chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# Required (OSS + Intelligence modes)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# ── License (unlocks paid Intelligence features such as durable memory) ──────
|
||||
# COPILOTKIT_LICENSE_TOKEN is required for either path below; pick ONE.
|
||||
#
|
||||
# MANAGED Intelligence (hosted — the eventual target for this demo):
|
||||
# Use a CopilotKit-ISSUED token (your account team, or `copilotkit license
|
||||
# -n banking-demo`) and point the INTELLIGENCE_* endpoints below at the managed
|
||||
# stack. Do NOT set BAKED_LICENSE_KEYS_JSON — managed/official images bake the
|
||||
# master public key as the root of trust and ignore a runtime baked key.
|
||||
#
|
||||
# SELF-HOSTED local dev (current): a locally-built Intelligence stack gates
|
||||
# memory behind a signed offline license. Generate one with
|
||||
# `pnpm mint-dev-license --write` (needs the private Intelligence source; see
|
||||
# scripts/mint-dev-license.mjs) — it fills in the three vars below for you.
|
||||
COPILOTKIT_LICENSE_TOKEN=
|
||||
# Self-hosted only — the trusted key that signed the dev license above.
|
||||
# Leave UNSET for managed Intelligence.
|
||||
# BAKED_LICENSE_KEYS_JSON=
|
||||
# Self-hosted only — main renamed the deployment-mode env (underscore value).
|
||||
# INTELLIGENCE_DEPLOYMENT_MODE=self_hosted
|
||||
|
||||
# Intelligence (memory) mode — set all three to enable durable cross-thread learning.
|
||||
# Ports/key match the vendored docker-compose.yml (see docs/superpowers/specs/2026-06-26-stack-decision.md).
|
||||
INTELLIGENCE_API_URL=http://localhost:7050
|
||||
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:7053
|
||||
INTELLIGENCE_API_KEY=cpk_sPRVSEED_seed0privat0longtoken00
|
||||
|
||||
# Leave INTELLIGENCE_USER_ID UNPINNED for the interactive demo so the sidebar
|
||||
# user switcher drives memory scope (Alex -> jordan-beamson, Maya -> morgan-fluxx;
|
||||
# see src/lib/intelligence/user-id.ts). Pin it only for a single-identity run
|
||||
# (CI/e2e already pins it in playwright.config.ts). Both survivors must map to a
|
||||
# SEEDED backend id — non-seeded ids 403 against the Intelligence stack.
|
||||
# INTELLIGENCE_USER_ID=jordan-beamson
|
||||
# INTELLIGENCE_USER_NAME=Jordan Beamson
|
||||
|
||||
# Glass Engine (advanced inspector). Unset = absent (public-host default).
|
||||
# Set to "true" for FDE/sales/conference deployments to expose the left-rail
|
||||
# telescope toggle. Distinct from the presenter's per-session on/off toggle.
|
||||
GLASS_ENGINE_AVAILABLE=
|
||||
|
||||
# Presenter/booth reset button (left sidebar). Unset = hidden AND the
|
||||
# /api/v1/dev/reset endpoint is disabled (403). Set to "true" for
|
||||
# FDE/sales/conference deployments so a presenter can reset demo state
|
||||
# (re-seed transactions + forget all durable memories) without curl.
|
||||
PRESENTER_RESET_ENABLED=
|
||||
|
||||
# Test-only: point the agent LLM at aimock for the deterministic E2E proof.
|
||||
# Leave unset in normal runs. (See e2e/memory-learning.spec.ts.)
|
||||
# OPENAI_BASE_URL=http://localhost:7099/v1
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
.next
|
||||
|
||||
# TypeScript incremental build cache
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
@@ -0,0 +1,326 @@
|
||||
# Northwind Finance — CopilotKit v2 Banking Demo
|
||||
|
||||
A customer-ready reference demo showing how to build a SaaS app with an embedded
|
||||
AI copilot on top of CopilotKit v2. The app — "Northwind Finance" — models a
|
||||
corporate banking dashboard where role-based users can view transactions,
|
||||
manage credit cards, and (for admins) manage team members. The copilot is
|
||||
wired into the same UI: it reads app context, calls typed tools to render
|
||||
generative UI, and asks the user to approve sensitive actions via
|
||||
human-in-the-loop.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||

|
||||
|
||||
While the officer demonstrates an action the copilot should learn from
|
||||
(approving a transaction, filing a policy exception), a soft violet vignette
|
||||
pulses around the canvas — the visible signal that the action is being
|
||||
recorded for the self-learning loop.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
pnpm install # from the repo root — this demo is a workspace package
|
||||
pnpm --filter demo-saas-copilot dev
|
||||
```
|
||||
|
||||
Then open <http://localhost:3000>.
|
||||
|
||||
The demo runs against the workspace versions of `@copilotkit/*` (see the root
|
||||
`pnpm-workspace.yaml`). The seed dataset lives in memory and resets every time
|
||||
the server restarts.
|
||||
|
||||
## Memory & durable self-learning (Intelligence mode)
|
||||
|
||||
By default the runtime is pure OSS: an SSE `CopilotRuntime` + `InMemoryAgentRunner`,
|
||||
with no external dependency. The agent runs locally against OpenAI and nothing is
|
||||
persisted. **This is the default and requires only `OPENAI_API_KEY`.**
|
||||
|
||||
The runtime in `src/app/api/copilotkit/[[...slug]]/route.ts` is **env-gated**: when
|
||||
the three `INTELLIGENCE_*` vars below are all present it builds the runtime in
|
||||
Intelligence mode (`CopilotKitIntelligence` + `CopilotRuntime({ intelligence,
|
||||
identifyUser, licenseToken, … })`). The local `bankingAgent` still executes here,
|
||||
but it gains durable long-term memory — the `recall_memory` / `save_memory` MCP
|
||||
tools auto-attach from the memory-enabled Intelligence backend. If any of the three
|
||||
is unset, the demo falls back to the exact OSS path above.
|
||||
|
||||
### What each mode recalls
|
||||
|
||||
- **OSS (default):** the teach-a-workflow loop works _within a single
|
||||
conversation_. Start a **new** thread and the agent no longer knows the
|
||||
procedure; nothing persists across threads or restarts. (Expected — it's the
|
||||
signal that durable recall needs Intelligence mode.)
|
||||
- **Intelligence:** durable long-term memory across three flavours, all via
|
||||
`save_memory` / `recall_memory`:
|
||||
- **Demonstrated over-limit procedure** — saved as a `project`-scoped,
|
||||
`procedural` memory and recalled at the start of any later over-limit
|
||||
request. A **brand-new thread — or a different user on the same team** —
|
||||
recalls the procedure and completes the approval unaided. This is the
|
||||
durable cross-thread + cross-user proof (FOR-149).
|
||||
- **General facts / preferences (`user` scope)** — arbitrary personal facts
|
||||
persist cross-thread but stay per-person. Try _"remember my favorite food
|
||||
is sushi"_, then ask _"what's my favorite food?"_ in a **new** thread and
|
||||
the copilot recalls it.
|
||||
- **Team-shared facts (`project` scope)** — facts flagged for the whole team
|
||||
persist cross-user, so a teammate recalls them in their own threads.
|
||||
- **Secrets are never stored.** Passwords, API keys, tokens, and full card or
|
||||
SSN numbers are never written to memory.
|
||||
|
||||
### 1. Start the memory-enabled stack (one command)
|
||||
|
||||
A self-contained Intelligence stack (postgres + pgvector, redis, minio, a TEI
|
||||
embedder, and the `app-api` + realtime-gateway composite) is vendored as
|
||||
`docker-compose.yml`:
|
||||
|
||||
**Recommended (one command, handles the embedder per-platform):**
|
||||
|
||||
```bash
|
||||
cd examples/showcases/banking
|
||||
export INTELLIGENCE_REPO=/path/to/Intelligence # composite image build context + dev-license signer
|
||||
./run-demo.sh
|
||||
```
|
||||
|
||||
`run-demo.sh` brings up the stack, picks the right embedder for your platform
|
||||
(native Metal TEI on Apple Silicon, the bundled docker `tei` on amd64/CI), mints
|
||||
a dev license if `.env` lacks one, then starts the Next.js dev server.
|
||||
|
||||
**Manual (if you prefer raw compose):** the bundled `tei` is gated behind the
|
||||
`cpu-fallback` profile, so a bare `docker compose up` **skips it** (see the Apple
|
||||
Silicon note below). On amd64/CI, opt it in:
|
||||
|
||||
```bash
|
||||
export INTELLIGENCE_REPO=/path/to/Intelligence
|
||||
docker compose --profile cpu-fallback up -d --wait # amd64/CI: bundled docker embedder
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Host ports: app-api **7050**, gateway **7053**, postgres 7156, redis 7158, minio
|
||||
7160/7161, tei 7167. (The deps use a `715x` range so a bare `docker compose up`
|
||||
coexists with a developer's own Intelligence dev stack on `705x`.) Seeded org
|
||||
`casa-de-erlang`, key `cpk_sPRVSEED_seed0privat0longtoken00`, users
|
||||
`jordan-beamson` / `morgan-fluxx`. The team is exactly two members, each mapped
|
||||
1:1 to a seeded backend identity — **Alex Morgan (Admin) → `jordan-beamson`** and
|
||||
**Maya Chen (Assistant) → `morgan-fluxx`** (see `src/lib/intelligence/user-id.ts`).
|
||||
That 1:1 mapping is what makes cross-user memory scope demonstrable through the
|
||||
sidebar user switcher. `SL_ENABLED=true` + a reachable embedder are
|
||||
required for the `save_memory`/`recall_memory` MCP tools to attach — both are set
|
||||
on the `intelligence` service in `docker-compose.yml`.
|
||||
|
||||
> **Apple Silicon:** the bundled `tei` image is amd64-only. Under emulation the
|
||||
> Candle/safetensors backend is unavailable, so TEI falls back to the ONNX/ORT
|
||||
> backend — which needs `onnx/model.onnx` files that `Qwen3-Embedding-0.6B` does
|
||||
> not publish (404), so the container **crash-loops**. `run-demo.sh` handles this
|
||||
> for you (native Metal TEI on `:7067`). To do it manually, run a native TEI on
|
||||
> the host and point the stack at it (the bundled `tei` is profile-gated, so a
|
||||
> bare `up` already skips it):
|
||||
>
|
||||
> ```bash
|
||||
> brew install text-embeddings-inference # one-time
|
||||
> text-embeddings-router --model-id Qwen/Qwen3-Embedding-0.6B --port 7067 --auto-truncate &
|
||||
> MEMORY_EMBEDDINGS_URL=http://host.docker.internal:7067 docker compose up -d --wait
|
||||
> ```
|
||||
>
|
||||
> Same TEI version (1.9.3) + model as the docker image → **byte-identical
|
||||
> embeddings**, and ~20× faster (Metal GPU vs CPU-under-emulation).
|
||||
|
||||
### 2. Point the demo at the stack
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill OPENAI_API_KEY; run `copilotkit license -n banking-demo`
|
||||
# .env (key lines):
|
||||
# INTELLIGENCE_API_URL=http://localhost:7050
|
||||
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:7053
|
||||
# INTELLIGENCE_API_KEY=cpk_sPRVSEED_seed0privat0longtoken00
|
||||
# # INTELLIGENCE_USER_ID — leave UNPINNED for the interactive demo (see below)
|
||||
pnpm --filter demo-saas-copilot dev
|
||||
```
|
||||
|
||||
The Next.js app needs only the three `INTELLIGENCE_*` vars (+ identity). The memory
|
||||
backend flags (`MEMORY_ENABLED`, `SL_ENABLED`, `MEMORY_EMBEDDINGS_URL`,
|
||||
`MEMORY_EMBEDDING_MODEL`) live on the `intelligence` service in `docker-compose.yml`.
|
||||
|
||||
Leave `INTELLIGENCE_USER_ID` **unpinned** for the interactive demo: with it unset,
|
||||
the sidebar user switcher drives which backend identity (and therefore which memory
|
||||
scope) is active, so you can walk through cross-user isolation live. It is pinned to
|
||||
a single identity only for CI/e2e (see `playwright.config.ts`).
|
||||
|
||||
### 3. The cross-thread payoff (FOR-149)
|
||||
|
||||
With the stack up and project memory empty:
|
||||
|
||||
1. **Thread A — teach.** Ask to approve an over-limit charge. The agent calls
|
||||
`recall_memory`, finds nothing, and offers to record. Demonstrate the policy
|
||||
exception on the dashboard, then click **Save workflow** — the agent calls
|
||||
`save_memory` (`scope:"project"`, `kind:"procedural"`).
|
||||
2. **Thread B — recall.** Open a **new** thread and ask to approve a _different_
|
||||
over-limit charge. The agent calls `recall_memory`, gets the procedure, files
|
||||
the exception with the learned code, and approves — **with no recording offer.**
|
||||
3. **Different persona.** Switch user (sidebar avatar) in a fresh thread and repeat
|
||||
— same unaided success, proving `project`-scope cross-user recall.
|
||||
|
||||
### 4. The memory-scope isolation demo (two personas)
|
||||
|
||||
The team is exactly **Alex Morgan (Admin)** and **Maya Chen (Assistant)**, mapped
|
||||
1:1 to the seeded `jordan-beamson` / `morgan-fluxx` backend identities. With
|
||||
`INTELLIGENCE_USER_ID` unpinned, the sidebar user switcher (bottom-left avatar)
|
||||
selects which identity is live, so scope isolation is visible end-to-end:
|
||||
|
||||
1. **Personal fact — save (as Alex).** Ask _"remember my favorite food is
|
||||
sushi."_ The copilot confirms it saved (`user` scope).
|
||||
2. **Personal fact — recall (as Alex, new thread).** Open a **new** thread and
|
||||
ask _"what's my favorite food?"_ — the copilot recalls **sushi** (cross-thread,
|
||||
same person).
|
||||
3. **Personal fact — isolated (switch to Maya, fresh thread).** Switch the sidebar
|
||||
user to Maya, open a fresh thread, and ask _"what's my favorite food?"_ — the
|
||||
copilot **does NOT know it.** `user`-scope memory is per-person.
|
||||
4. **Team fact — crosses users.** Back as Alex, say _"keep in mind, for the whole
|
||||
team: our fiscal year ends in March."_ Switch to Maya and ask _"when does our
|
||||
fiscal year end?"_ — the copilot recalls **March.** `project`-scope memory
|
||||
crosses users on the same team.
|
||||
|
||||
To replay the "fails first" beat, forget the saved procedure between runs
|
||||
(`DELETE http://localhost:7050/api/memories/:id`, or via the agent's
|
||||
`forget_memory` tool). Per-run reset for a repeatable public demo is a separate
|
||||
follow-up (user-scope memory, periodic DB reset, or a dashboard control).
|
||||
|
||||
### Testing
|
||||
|
||||
- **Deterministic E2E (CI gate):** `pnpm --filter demo-saas-copilot test:self-learning`
|
||||
runs `e2e/memory-learning.spec.ts`. The agent's LLM is served by
|
||||
[`@copilotkit/aimock`](https://github.com/CopilotKit/aimock) (fixtured
|
||||
`save_memory`/`recall_memory` tool calls) while the **real** local memory backend
|
||||
persists + recalls — so the full teach→save→fresh-thread-recall→unlock flow is
|
||||
deterministic. It asserts the fresh thread completes the unlock from recalled
|
||||
memory and never offers to record.
|
||||
- **Real-LLM drift smoke (manual, non-gating):**
|
||||
`node scripts/memory-drift-smoke.mjs` seeds the procedure via REST, then drives a
|
||||
fresh-thread over-limit request against a real OpenAI key and asserts the live
|
||||
model still emits `recall_memory` (the autonomous recall-first moment). aimock
|
||||
fixtures replay a fixed decision and cannot catch _behavioral drift_ after a
|
||||
prompt edit — this can. The save half is HITL-gated (not headless), so verify it
|
||||
via the manual walkthrough above + the aimock E2E. Run after editing the prompt or
|
||||
teach tools.
|
||||
|
||||
### Advanced mode — Glass Engine
|
||||
|
||||
Glass Engine is a docked inspector (desktop-only) that exposes the copilot's
|
||||
internals. It is gated twice:
|
||||
|
||||
- **Availability (deployment):** set `GLASS_ENGINE_AVAILABLE=true` to expose the
|
||||
left-rail telescope toggle. Leave it unset on public deployments — Glass Engine
|
||||
is then absent entirely and the `/api/memories*` routes return 404. (FDE/sales/
|
||||
conference deployments set it; one image, per-deployment env.)
|
||||
- **Activation (presenter):** when available, the telescope toggles the pane on/off
|
||||
per session (persisted in localStorage), so you can reveal it case-by-case during
|
||||
a talk.
|
||||
|
||||
Tabs:
|
||||
|
||||
- **Timeline** — every AG-UI protocol event of each run, live (works in any mode).
|
||||
- **Memory** — durable memory recall + semantic search (Intelligence mode only).
|
||||
The "Recalled memories" list is top-k semantic recall, not a full enumeration.
|
||||
- **Learning** — the over-limit teach→save→recall procedure and live recall
|
||||
activity (Intelligence mode only).
|
||||
|
||||
In OSS mode (no `INTELLIGENCE_*`) the Memory and Learning tabs show a "Requires
|
||||
Intelligence mode" hint.
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js 16, React 19, Tailwind v4) │
|
||||
│ CopilotKitProvider + CopilotPopup (@copilotkit/react-core/v2) │
|
||||
│ ├── useAgentContext → share user / page state with agent │
|
||||
│ ├── useFrontendTool → generative UI (showTransactions) │
|
||||
│ └── useHumanInTheLoop → approval flows (addNewCard, …) │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│ AG-UI over SSE
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Runtime (Hono, same Next process) │
|
||||
│ src/app/api/copilotkit/[[...slug]]/route.ts │
|
||||
│ BuiltInAgent + CopilotRuntime + createCopilotHonoHandler │
|
||||
│ (from @copilotkit/runtime/v2) │
|
||||
│ env-gated: OSS SSE + InMemoryAgentRunner by default; │
|
||||
│ CopilotKitIntelligence when INTELLIGENCE_* env is set │
|
||||
│ (see "Self-learning backend" below) │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Data layer │
|
||||
│ src/data/seed.json → seed cards, team, policies, txns │
|
||||
│ src/lib/store.ts → typed, in-memory store (resets) │
|
||||
│ src/app/api/v1/* → REST surface │
|
||||
│ (cards, transactions, │
|
||||
│ users, policies) │
|
||||
│ src/lib/identity.ts → Northwind branding strings │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key features and where to find them
|
||||
|
||||
### App-wide context for the copilot
|
||||
|
||||
`src/components/copilot-context.tsx` shares the current user and the current
|
||||
page with the agent via `useAgentContext`, so the LLM can adapt its responses
|
||||
to the logged-in role and the route the user is on. The Northwind brand and
|
||||
assistant greeting are centralized in `src/lib/identity.ts`.
|
||||
|
||||
Switch between users from the bottom-left avatar in the sidebar to see how
|
||||
role (Admin vs Assistant) changes what the copilot will agree to do.
|
||||
|
||||
### Generative UI — `showTransactions`
|
||||
|
||||
The cards landing page at `src/app/page.tsx` registers
|
||||
`useFrontendTool({ name: "showTransactions", render })`. When you ask the
|
||||
copilot something like _"Show me transactions for my card ending 4242"_, the
|
||||
LLM calls the tool and the rendered list IS the answer — there is no
|
||||
follow-up paragraph restating the data.
|
||||
|
||||
### Human-in-the-loop — `addNewCard` and `navigateToPageAndPerform`
|
||||
|
||||
- `useHumanInTheLoop({ name: "addNewCard", render })` in `src/app/page.tsx`
|
||||
shows the "add card" confirmation card directly in chat; the user clicks
|
||||
Approve / Cancel and the result is sent back to the agent. The team page
|
||||
(`src/app/team/page.tsx`) uses the same pattern for removing a member and
|
||||
changing a member's role or team (inviting a member is a UI-only dialog
|
||||
flow, not an agent tool).
|
||||
- `useHumanInTheLoop({ name: "navigateToPageAndPerform" })` in
|
||||
`src/components/copilot-context.tsx` is the cross-page fallback: if the user
|
||||
asks for an operation that lives on another page (e.g. "change my Visa PIN"
|
||||
from the team page), the copilot asks for permission to navigate, then
|
||||
redirects with an `?operation=…` query param so the destination page can
|
||||
open the right dialog.
|
||||
|
||||
### Role-based behaviour
|
||||
|
||||
Authorization is communicated to the agent through `useAgentContext` rather
|
||||
than enforced on the LLM by prompt alone. The REST handlers in
|
||||
`src/app/api/v1/*` enforce the same rules on the server side, so a curious
|
||||
user (or a hallucinating model) cannot bypass them.
|
||||
|
||||
## Backend & data
|
||||
|
||||
- All read/write goes through `src/lib/store.ts`, which exposes typed helpers
|
||||
— readers like `cards()`, `team()`, `policies()`, `transactions()` and
|
||||
mutators like `findCard`, `updateCardPin`, `assignPolicyToCard`,
|
||||
`updateTransaction` — over an in-memory copy of `src/data/seed.json`.
|
||||
- The REST endpoints under `src/app/api/v1/*` (cards, transactions, users,
|
||||
policies) are thin handlers around the store and are what the UI uses.
|
||||
- There is no database. State resets on every server restart — this keeps the
|
||||
demo deterministic for screenshots, e2e tests, and customer walkthroughs.
|
||||
|
||||
## Tests
|
||||
|
||||
End-to-end Playwright smoke tests live under `e2e/` and can be run with:
|
||||
|
||||
```bash
|
||||
pnpm --filter demo-saas-copilot test:e2e
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5bd11e83b0834a69671ce38efb29754b7a875856fcf84eb1dce6e41f7dbe3d7c
|
||||
size 284064
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f4c24ab7f7a2df4ae90026d7359073c09ad52b185bb29b02195c70265f66fdc
|
||||
size 205487
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48d83e0626c4047260ecf17ab096c70469134998367c72cb1b45267b0f191a74
|
||||
size 269361
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5c4f9f91804201ca6ce73eee4e388be1fe3df8b1513103ec4b195aa4d9c7aed9
|
||||
size 393611
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
# ============================================================================
|
||||
# Banking demo — memory-enabled CopilotKit Intelligence stack.
|
||||
#
|
||||
# Vendored from the proven `memory-chat` local recipe in the Intelligence
|
||||
# repo (docker-compose.deps.yml + docker-compose.yml + run-demo.sh). It stands
|
||||
# up everything the durable cross-thread memory feature needs:
|
||||
#
|
||||
# postgres (pgvector) :7156 app DB + halfvec memory store
|
||||
# redis :7158 session / realtime fan-out
|
||||
# minio :7160 realtime-gateway event archive (S3 API)
|
||||
# minio console :7161
|
||||
# tei :7167 Qwen3-Embedding-0.6B embeddings sidecar
|
||||
# intelligence :7050 app-api (REST /api/memories + gated /mcp)
|
||||
# :7053 realtime-gateway (thread/conversation state)
|
||||
#
|
||||
# `intelligence` is the single composite image (Dockerfile.composite) that
|
||||
# runs app-api + realtime-gateway + thread-culler + the db-migrations oneshot
|
||||
# under s6-overlay. The MEMORY_ENABLED / SL_ENABLED gates are compiled into
|
||||
# app-api, so the memory MCP tools and the /api/memories REST surface come
|
||||
# from the same binary the demo will eventually ship as a standalone app.
|
||||
#
|
||||
# cd examples/showcases/banking
|
||||
# docker compose up -d --wait
|
||||
#
|
||||
# The build context for the `intelligence` image is the Intelligence repo
|
||||
# checkout (it is NOT vendored into this repo — its Dockerfile.composite does
|
||||
# `COPY . .` over the whole Intelligence workspace). Point INTELLIGENCE_REPO
|
||||
# at your local checkout; it defaults to the sibling layout used on the
|
||||
# reference machine. Once built, the image is tagged `cpki/intelligence-composite`
|
||||
# and reused on subsequent `up`s.
|
||||
#
|
||||
# Seeded by the app-db-migrations seed.sql (run by the composite's migrations
|
||||
# oneshot before app-api starts):
|
||||
# org casa-de-erlang project elixir4days
|
||||
# key cpk_sPRVSEED_seed0privat0longtoken00
|
||||
# users jordan-beamson / morgan-fluxx
|
||||
# ============================================================================
|
||||
|
||||
name: banking-memory
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:0.8.2-pg16
|
||||
ports:
|
||||
# Banking-specific host-port range (715x) so a bare `docker compose up`
|
||||
# coexists with a developer's Intelligence dev deps (which use 705x).
|
||||
- "${POSTGRES_HOST_PORT:-7156}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: intelligence
|
||||
POSTGRES_PASSWORD: intelligence
|
||||
POSTGRES_DB: postgres
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
# Creates intelligence_app + intelligence_app_shadow on first boot
|
||||
# (the migrations oneshot and app-api connect to intelligence_app).
|
||||
- ./docker/app-postgres-init:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U intelligence -d intelligence_app"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "${REDIS_HOST_PORT:-7158}:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "${MINIO_HOST_PORT:-7160}:9000"
|
||||
- "${MINIO_CONSOLE_HOST_PORT:-7161}:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
# One-shot: create the bucket the realtime-gateway archives events into.
|
||||
# `mc ready local` in minio's healthcheck guarantees the server is up first,
|
||||
# but the embedded Docker DNS resolver can briefly fail to resolve the `minio`
|
||||
# service name at container start, so retry `mc alias set` until it resolves.
|
||||
# The `$$` escapes compose interpolation so the container shell sees `$`.
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
i=0
|
||||
until mc alias set local http://minio:9000 minioadmin minioadmin; do
|
||||
i=$$((i + 1))
|
||||
if [ "$$i" -ge 30 ]; then echo 'minio unreachable after 30 tries' >&2; exit 1; fi
|
||||
echo 'waiting for minio dns/health...'; sleep 2
|
||||
done
|
||||
mc mb --ignore-existing local/realtime-gateway-events
|
||||
echo 'minio bucket ready'
|
||||
restart: "no"
|
||||
|
||||
# OpenAI-compatible embeddings sidecar. The cpu-1.9.3 tag publishes a
|
||||
# linux/amd64 manifest ONLY (no arm64 build), so on Apple Silicon Docker runs
|
||||
# it under emulation, where the Candle/safetensors backend is unavailable and
|
||||
# TEI falls back to the ONNX/ORT backend — which needs onnx/model.onnx files
|
||||
# that Qwen3-Embedding-0.6B does not publish (404), so it crash-loops. On
|
||||
# amd64/CI this is native and works.
|
||||
#
|
||||
# Therefore this service is gated behind the `cpu-fallback` profile: a bare
|
||||
# `docker compose up` does NOT start it. Apple Silicon runs a native Metal TEI
|
||||
# on the host instead (see run-demo.sh / README), pointing app-api at it via
|
||||
# MEMORY_EMBEDDINGS_URL=http://host.docker.internal:7067 (same version 1.9.3,
|
||||
# same model, byte-identical embeddings). On amd64/CI, opt back in with
|
||||
# `docker compose --profile cpu-fallback up -d --wait`. `intelligence`'s
|
||||
# dependency on tei is `required: false`, so it starts fine without it.
|
||||
tei:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9.3
|
||||
profiles: ["cpu-fallback"]
|
||||
platform: linux/amd64
|
||||
# --auto-truncate is empirically required for the cpu-1.9.x image to serve
|
||||
# Qwen3-Embedding-0.6B (max_input_length 32768) cleanly; truncation is the
|
||||
# right behavior for memory content (capped at 8192 chars upstream).
|
||||
command:
|
||||
[
|
||||
"--model-id",
|
||||
"Qwen/Qwen3-Embedding-0.6B",
|
||||
"--port",
|
||||
"80",
|
||||
"--auto-truncate",
|
||||
"--max-batch-tokens",
|
||||
"${TEI_MAX_BATCH_TOKENS:-16384}",
|
||||
]
|
||||
ports:
|
||||
- "${TEI_HOST_PORT:-7167}:80"
|
||||
volumes:
|
||||
- tei-model-cache:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:80/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
# First boot downloads the model and runs a warmup forward pass; on CPU
|
||||
# (especially x86 under emulation) this can take several minutes, so give
|
||||
# it a generous grace before counting failures.
|
||||
start_period: 600s
|
||||
restart: unless-stopped
|
||||
|
||||
# app-api (:4201 -> host 7050) + realtime-gateway (:4401 -> host 7053) +
|
||||
# thread-culler + the db-migrations oneshot, all under s6-overlay. Built
|
||||
# from the Intelligence repo's Dockerfile.composite (memory/SL gates are
|
||||
# compiled in). The migrations oneshot runs graphile-migrate + seed.sql
|
||||
# against postgres before app-api/gateway start, so the seeded org/key/users
|
||||
# exist by the time the surface is healthy.
|
||||
intelligence:
|
||||
image: cpki/intelligence-composite:local
|
||||
build:
|
||||
context: ${INTELLIGENCE_REPO:-../../../../Intelligence}
|
||||
dockerfile: Dockerfile.composite
|
||||
ports:
|
||||
- "${APP_API_HOST_PORT:-7050}:4201"
|
||||
- "${GATEWAY_HOST_PORT:-7053}:4401"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://intelligence:intelligence@postgres:5432/intelligence_app
|
||||
REDIS_URL: redis://redis:6379
|
||||
MEMORY_ENABLED: "true"
|
||||
SL_ENABLED: "true" # REQUIRED — memory MCP tools attach by extending the SL /mcp server
|
||||
# Embedder is pluggable. Default = the bundled `tei` container (self-contained,
|
||||
# correct on amd64/CI/deploy). On a RAM-constrained Apple-Silicon dev box the
|
||||
# emulated TEI can OOM (exit 137); override to a host/native embedder, e.g.
|
||||
# MEMORY_EMBEDDINGS_URL=http://host.docker.internal:7067 docker compose up -d --wait \
|
||||
# postgres redis minio minio-init intelligence
|
||||
# (omits the bundled tei — its dependency below is required:false).
|
||||
MEMORY_EMBEDDINGS_URL: ${MEMORY_EMBEDDINGS_URL:-http://tei:80}
|
||||
MEMORY_EMBEDDING_MODEL: Qwen/Qwen3-Embedding-0.6B
|
||||
# NOTE (main migration): main dropped the legacy DEFAULT_ORGANIZATION_ID.
|
||||
# Org is resolved from the authenticated cpk key (seeded to casa-de-erlang);
|
||||
# the header default falls back to 'self_hosted' when unset.
|
||||
COPILOTKIT_LICENSE_TOKEN: "${COPILOTKIT_LICENSE_TOKEN:-}"
|
||||
# main migration: self-hosted memory is gated behind a signed offline
|
||||
# license carrying the `memory` feature (MEMORY_NOT_ENTITLED otherwise).
|
||||
# BAKED_LICENSE_KEYS_JSON bakes the public key the verifier trusts, so a
|
||||
# locally-minted dev enterprise license (scripts/mint-dev-license) unlocks
|
||||
# memory without any master-key attestation. Dev-only local values.
|
||||
BAKED_LICENSE_KEYS_JSON: "${BAKED_LICENSE_KEYS_JSON:-}"
|
||||
# Auth / runtime secrets (exactly as in the reference run-demo.sh; the
|
||||
# AUTH_SECRET must be >= 32 chars per auth-server's env schema). These
|
||||
# are dev-only local values.
|
||||
AUTH_SECRET: "local-dev-auth-secret-at-least-32-bytes-long-000"
|
||||
AUTH_TRUST_HOST: "true"
|
||||
# main renamed the deployment-mode env and uses an underscore value;
|
||||
# the legacy `DEPLOYMENT_MODE=self-hosted` is rejected (crash-loop).
|
||||
INTELLIGENCE_DEPLOYMENT_MODE: self_hosted
|
||||
RUNNER_AUTH_SECRET: dev-runner-secret
|
||||
SECRET_KEY_BASE: local-realtime-gateway-secret-key-base-at-least-64-bytes-long
|
||||
PHX_HOST: localhost
|
||||
# S3 (minio) wiring for the realtime-gateway event archive.
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_BUCKET: realtime-gateway-events
|
||||
S3_ACCESS_KEY_ID: minioadmin
|
||||
S3_SECRET_ACCESS_KEY: minioadmin
|
||||
S3_REGION: us-east-1
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
tei:
|
||||
condition: service_healthy
|
||||
# Optional: when an external embedder is supplied via MEMORY_EMBEDDINGS_URL,
|
||||
# bring the stack up without the bundled tei (`up ... intelligence` omitting tei).
|
||||
required: false
|
||||
healthcheck:
|
||||
# app-api answers /api/health on 4201; gateway listens on 4401.
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -fsS http://127.0.0.1:4201/api/health && nc -z 127.0.0.1 4401",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 90s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
minio-data:
|
||||
tei-model-cache:
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Ported verbatim from Intelligence infra/app-postgres/init/01-create-databases.sql.
|
||||
-- Runs once on the postgres container's first boot (docker-entrypoint-initdb.d).
|
||||
-- The composite image's migrations oneshot + app-api connect to intelligence_app;
|
||||
-- graphile-migrate uses intelligence_app_shadow for its shadow database.
|
||||
CREATE DATABASE intelligence_app;
|
||||
CREATE DATABASE intelligence_app_shadow;
|
||||
@@ -0,0 +1,128 @@
|
||||
# Northwind Finance — Design System ("Aurora")
|
||||
|
||||
A premium, airy, light-theme fintech aesthetic for the banking demo. This is a
|
||||
**visual layer**: it restyles the existing app without changing logic, data
|
||||
flow, REST calls, the CopilotKit provider/runtime, HITL approval flows, the
|
||||
threads drawer, auth/role gating, or the teachable policy-exception gate.
|
||||
|
||||
The system is token-driven. Tokens live in
|
||||
[`src/app/globals.css`](../src/app/globals.css) as CSS custom properties,
|
||||
surfaced to Tailwind v4 via `@theme inline` so utilities like `bg-surface`,
|
||||
`text-ink-muted`, `from-brand-violet`, `ring-brand`, `bg-positive` resolve
|
||||
everywhere. Both **light** (the primary showcase mode) and **dark** are fully
|
||||
supported via the class-based `.light` / `.dark` toggle on `<html>`.
|
||||
|
||||
## Design language
|
||||
|
||||
- **Mood** — premium, airy, modern. Generous whitespace, soft layered shadows,
|
||||
large radii, glassmorphism-lite surfaces.
|
||||
- **Canvas** — a soft lavender/lilac page background; content floats above it on
|
||||
white glass cards.
|
||||
- **Primary** — a violet→indigo gradient (`#7C5CFC` → `#5B3DF5`), reused on the
|
||||
primary button, the active nav icon, the credit-card face, progress bars, and
|
||||
CTAs (`.brand-gradient`).
|
||||
- **Semantics** — income/positive is emerald green with an up-and-right arrow;
|
||||
expense/negative is rose/red with a down-and-right arrow.
|
||||
- **Typography** — Inter (loaded via `next/font/google`, exposed as
|
||||
`--font-inter` → `--font-sans`). Very large bold balance numbers, small
|
||||
muted-grey labels, medium-weight violet section headings (`.section-heading`).
|
||||
- **Shape** — cards ~22px radius (`rounded-2xl` / `--radius`), buttons fully
|
||||
rounded pills (`rounded-full`), inputs/menus ~12–16px.
|
||||
|
||||
## Color tokens
|
||||
|
||||
All colors are HSL triplets behind `hsl(var(--token))`, exposed as Tailwind
|
||||
colors (`brand`, `brand-violet`, `brand-indigo`, `brand-soft`, `surface`,
|
||||
`surface-muted`, `canvas`, `ink`, `ink-muted`, `positive`, `positive-soft`,
|
||||
`negative`, `negative-soft`, `hairline`).
|
||||
|
||||
| Token | Light | Dark | Role |
|
||||
| ------------------- | ------------- | ------------- | --------------------------------------- |
|
||||
| `--canvas` | `255 60% 97%` | `252 30% 7%` | App background (lavender / deep indigo) |
|
||||
| `--surface` | `0 0% 100%` | `252 24% 11%` | Cards, sidebar, menus |
|
||||
| `--surface-muted` | `252 40% 98%` | `252 22% 14%` | Row hover, inset blocks |
|
||||
| `--ink` | `252 30% 14%` | `250 30% 96%` | Primary text / headings |
|
||||
| `--ink-muted` | `250 12% 46%` | `250 12% 66%` | Secondary / label text |
|
||||
| `--hairline` | `252 30% 92%` | `252 20% 22%` | Borders / dividers |
|
||||
| `--brand`/`-violet` | `252 83% 67%` | (shared) | Primary violet |
|
||||
| `--brand-indigo` | `248 84% 60%` | (shared) | Gradient end / heading color |
|
||||
| `--brand-soft` | `252 90% 96%` | `252 50% 18%` | Lilac chips, hover wash, avatar bg |
|
||||
| `--positive` | `152 62% 40%` | `152 56% 50%` | Income / available credit |
|
||||
| `--positive-soft` | `152 70% 95%` | `152 40% 16%` | Income chip background |
|
||||
| `--negative` | `349 78% 56%` | `349 80% 64%` | Expense / destructive |
|
||||
| `--negative-soft` | `349 90% 96%` | `349 40% 18%` | Expense chip background |
|
||||
|
||||
## Radius, shadow, font scale
|
||||
|
||||
| Token | Value |
|
||||
| --------------- | ---------------------------------------------- |
|
||||
| `--radius` (lg) | `1.375rem` (~22px) — card baseline |
|
||||
| `radius-xl/2xl` | `+6px` / `+12px` — sidebar, hero panels |
|
||||
| `radius-md/sm` | `-6px` / `-10px` |
|
||||
| `--shadow-soft` | resting card shadow (low, violet-tinted) |
|
||||
| `--shadow-lift` | hover / floating panels / menus |
|
||||
| `--shadow-glow` | violet glow under gradient CTAs |
|
||||
| `--font-sans` | `var(--font-inter), ui-sans-serif, system-ui…` |
|
||||
|
||||
## Helper classes (in `@layer components`)
|
||||
|
||||
- `.brand-gradient` — the 135° violet→indigo gradient (buttons, card, CTAs).
|
||||
- `.brand-text-gradient` — same gradient clipped to text.
|
||||
- `.glass-surface` — translucent surface + backdrop blur (floating sidebar, cards).
|
||||
- `.section-heading` — medium-weight violet/indigo section title.
|
||||
|
||||
## Layout
|
||||
|
||||
- **Floating icon rail** (`src/components/layout.tsx`) — ~72px, white glass,
|
||||
rounded, soft-shadowed, detached from the screen edge. Brand mark at top; nav
|
||||
icons (Dashboard / Credit Cards / Team) with a violet **gradient active
|
||||
state**; theme toggle, user switcher, and a help "?" pinned at the bottom.
|
||||
Role gating (Team only for admins) and the `useAgentContext` page readable are
|
||||
unchanged.
|
||||
- **Dashboard** (`src/app/dashboard/page.tsx`, mirrored by `/cards`) — two
|
||||
columns on a lavender canvas:
|
||||
- **Left**: "My Cards" (dashed add-card tile + a vivid gradient credit card,
|
||||
with a second card peeking behind) and "Recent Transactions" with a
|
||||
"View All" link, **underline** ALL / INCOME / EXPENSES tabs, a "TODAY"
|
||||
chip, and transaction rows (circular tinted badge + title + subtitle +
|
||||
colored amount).
|
||||
- **Right rail**: a tall rounded panel — **Balance** (large bold) + masked
|
||||
card number; an Income / Expenses split with colored arrows; a divider;
|
||||
**Last Payment Details**; a **Statistics** sparkline; and a gradient pill
|
||||
**New Transaction →** CTA.
|
||||
|
||||
## Statistics chart
|
||||
|
||||
`src/components/statistics-chart.tsx` is a **hand-rolled inline SVG** area+line
|
||||
sparkline — no charting dependency. It takes a numeric series (derived from real
|
||||
transaction data, bucketed by month; falls back to representative seeded points
|
||||
when there isn't enough data), draws a violet→indigo gradient stroke over a soft
|
||||
gradient area fill, emphasizes the latest point, and labels the axis with the
|
||||
final point highlighted in violet. It includes an `aria-label` and an
|
||||
`sr-only` numeric summary.
|
||||
|
||||
## Credit-card visual
|
||||
|
||||
`src/components/card-visual.tsx` exports `GradientCreditCard` (the vivid violet
|
||||
gradient face: EMV chip, masked `•••• •••• •••• last4`, holder, valid-thru, and a
|
||||
brand mark) plus `VisaWordmark` and the overlapping-circles `MastercardMark`. A
|
||||
`subtle` variant renders the dimmed card that peeks behind the active one.
|
||||
|
||||
## CopilotKit chat popup
|
||||
|
||||
The embedded `CopilotPopup` is themed **via CSS only** — no component props
|
||||
were changed. The v2 chat scopes its shadcn-style tokens to `[data-copilotkit]`;
|
||||
`globals.css` re-points `--primary` / `--primary-foreground` / `--ring` on that
|
||||
selector (and its dark variant) to the brand violet, so the send button, focus
|
||||
rings, and links match. This is additive and degrades gracefully if the SDK's
|
||||
internals change. All chat wiring (provider, threads, suggestions, HITL) is
|
||||
untouched.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Focus-visible rings (`ring-brand` with offset) on buttons, tabs, inputs, menu
|
||||
items, and the help/nav controls.
|
||||
- Semantic `<button>` / `<nav>` / `aria-current="page"` on the active nav item;
|
||||
`aria-label`s on icon-only controls; `role="img"` + `aria-label` on the chart.
|
||||
- Color pairings (ink on surface, brand-foreground on gradient, positive/negative
|
||||
on their soft backgrounds) target legible contrast in both themes.
|
||||
@@ -0,0 +1,548 @@
|
||||
# Learning-Track Plan — make the teach-mode loop actually learn in the banking demo
|
||||
|
||||
> **What this is.** A design doc (not an implementation) for closing the loop on the
|
||||
> banking teach-mode demo: human demonstrates the over-limit unlock → the demonstration is
|
||||
> recorded on the thread → distilled into `/knowledge` → a fresh agent performs the unlock
|
||||
> unaided. Plus the three pieces of teachable-demo UX (suggested prompt, inline HITL,
|
||||
> recording vignette) that make the loop legible on screen.
|
||||
>
|
||||
> **Owner:** jerel@copilotkit.ai · **Date:** 2026-06-05 · **Status:** Draft for review
|
||||
>
|
||||
> **Repos in play (read both):**
|
||||
>
|
||||
> - **Banking demo (canonical, OSS):** `CopilotKit/examples/showcases/banking` — Next.js App
|
||||
> Router, CopilotKit v2 hooks, `workspace:*` packages (react-core 1.59.2, **no recording
|
||||
> hook**). This is where the gate/unlock/framing/UX live and verify today.
|
||||
> - **Intelligence repo (the backend + the real hook):** `cpk-intelligence-banking` —
|
||||
> Nx monorepo with `apps/app-api`, `apps/realtime-gateway`, `apps/sl-worker`, and
|
||||
> `demos/{e-commerce,banking,…}`. Its root `package.json` pins
|
||||
> `@copilotkit/react-core@e103a19` (the build that **exports** the recording hook).
|
||||
> - **Cookbook contract:** `./README.md` (the 5-role teachable loop) + the recording seam at
|
||||
> `../../src/lib/record-user-action.ts` (a no-op shim today) + `./verify-teachable-gate.sh`
|
||||
> (the backend-independent REST proof).
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
The **left half** of the teachable loop (gate → symptom → agent framing → human unlock → a
|
||||
`recordUserAction(...)` call site) is built and verifiable in the banking demo today. The
|
||||
**right half** (recording actually streams → distill → `/knowledge` → fresh agent learns)
|
||||
is gated on two blockers:
|
||||
|
||||
- **Blocker 2a — the recording hook.** `useRecordUserActionInCurrentThread` does not exist
|
||||
in the OSS `@copilotkit/react-core/v2` build the banking demo currently resolves
|
||||
(`workspace:*` → 1.59.2). It exists at CopilotKit commit `e103a19`. Clearing it = pin that
|
||||
build + a one-line import swap of the `record-user-action.ts` shim.
|
||||
- **Blocker 2b — the Intelligence backend.** The runtime route is already env-gated on
|
||||
`INTELLIGENCE_API_URL` / `INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY`. Clearing
|
||||
it = stand up the Intelligence stack (`app-api` + `realtime-gateway` + `sl-worker` →
|
||||
`/knowledge`) locally or hosted, and point those three env vars at it.
|
||||
|
||||
The three teachable-demo UX pieces — **suggested prompt**, **inline HITL tool-call card**,
|
||||
**pulsating recording vignette** — can be built TODAY against the no-op shim and a local
|
||||
`recording` flag, because none of them needs the backend to render. They form the
|
||||
buildable-now track (FOR-148). The live learn/distill/retrieve loop is the blocked track
|
||||
(FOR-146/147/149). FOR-145 is the fresh-agent verification harness.
|
||||
|
||||
Recommended sequencing: **UX shell now (FOR-148)** in parallel with **standing up the
|
||||
backend (FOR-147)**; then **pin the hook + swap the import (FOR-146)**; then the
|
||||
**fresh-agent proof (FOR-145/149)** ties it together.
|
||||
|
||||
---
|
||||
|
||||
## 1. The loop, end to end — concretely, against THIS demo
|
||||
|
||||
The banking entities are: `transaction` (the gated write is _approve_), `expense-policy`
|
||||
(the limit that blocks it), `policy-exception` (the unlock record), and a
|
||||
`policy-exception-code` catalogue (justifying vs decoy vs invalid).
|
||||
|
||||
| Step | What happens | THIS demo's concrete entities + file |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1. Agent A tries the obvious write** | A fresh agent is asked to approve an over-limit transaction. It calls the approve write. | The agent prompt in `src/app/api/copilotkit/[[...slug]]/route.ts` (`bankingAgent`) lists the tools but withholds the unlock recipe. The approve path is the `showAndApproveTransactions` HITL in `src/app/page.tsx` → `changeTransactionStatus` (`src/app/actions.ts`) → `PUT /api/v1/transactions/[id]`. |
|
||||
| **2. Gate fails, symptom-only** | The PUT returns **422 `OVER_POLICY_LIMIT`**, message `"<policy> policy limit exceeded"`. It names the _problem_ (over limit), never the _fix_ (policy exception). | `src/app/api/v1/transactions/[id]/route.ts` (the `patch.status === "approved" && !isWithinPolicyLimit && !hasApprovedException` branch). Rules in `src/lib/store.ts`: `isWithinPolicyLimit` / `hasApprovedException` / `canApprove`. Seed: `t-1` (Google Ads, −5000, Marketing limit 5000/spent 500) ⇒ over limit. |
|
||||
| **3. Agent stops (framing holds)** | Per the **ACTION DISCIPLINE** clause, the agent does not improvise. It reports the failure and asks the human how to proceed. It does NOT fire a distractor (`sendSpendAlert` / `requestCardReplacement` / `flagForReview`). | Prompt + distractor tools in the runtime route and `src/app/page.tsx` (the three `useFrontendTool` no-op distractors). This is the **control**: pre-learning, a correctly-framed agent cannot pass. |
|
||||
| **4. Human demonstrates the unlock** | A human opens a policy exception under a **justifying** code (e.g. `EXC-BOARD-APPROVED`), finalizes it (auto-approves + links `activeExceptionId`), then re-approves — now **201**. | Today: `PolicyExceptionModal` (`src/components/policy-exception-modal.tsx`) opened from the over-limit row in `src/components/transactions-list.tsx`. Catalogue: `src/app/api/v1/policy-exception-codes.ts` (`JUSTIFYING_EXCEPTION_CODES` = BOARD-APPROVED / CONTRACTUAL-COMMITMENT / EMERGENCY-SPEND; decoys = WILL-REIMBURSE / ONE-TIME). REST: `exceptions/route.ts` + `exceptions/[id]/finalize/route.ts`. **This plan moves that flow inline into the chat — see §3.2.** |
|
||||
| **5. Each mutation is recorded on the thread** | After `open()` and after `finalize()`, the UI calls `recordUserAction({title, description, previousData, newData, metadata})`. `previousData` carries the gated flags (`approvePermitted: false`); `newData` the unlocked effect (flipped flags + the linking exception id); `metadata` the `transactionId`. | Two existing calls in `policy-exception-modal.tsx` (`policy_exception.opened`, `policy_exception.finalized`) + two in `transactions-list.tsx` (`transaction.approved`, `transaction.denied`). The import is the no-op shim `@/lib/record-user-action` **today** — see Blocker 2a. |
|
||||
| **6. Events stream to the gateway** | With the real hook + Intelligence runtime, every AG-UI event of the run (including the recorded user actions) streams over the Phoenix WebSocket to the Intelligence gateway, scoped to the current user + thread. | The env-gated `CopilotKitIntelligence({apiUrl,wsUrl,apiKey})` branch of `createRuntime()` in the runtime route; `identifyUser` maps `properties.userRole` → a stable `northwind-<role>` id so threads + knowledge are scoped consistently. Reference: `cpk-intelligence-banking/demos/e-commerce/bff/.../main.ts`. |
|
||||
| **7. Distilled into `/knowledge`** | The `sl-worker` sweeps the recorded actions and an LLM writer distills a reusable procedure: _"to approve an over-policy-limit transaction, open a policy exception under a justifying code (board-approved / contractual / emergency), finalize it, then approve."_ It lands in `/knowledge` (shared per org+project). | `apps/sl-worker` in the Intelligence repo (gated on `SL_ENABLED=true`); writes `cpki.knowledge_base_files` exposed to agents as `/knowledge`. |
|
||||
| **8. Agent B (fresh) learns + succeeds** | In a NEW thread with no memory of the human, the agent is asked the same over-limit approval. It greps `/knowledge` (via the `copilotkit_knowledge_base_shell` tool), discovers the procedure, files a _justifying_ exception, finalizes it, approves → **201** — no human help, nothing added to the prompt. | Same `bankingAgent` prompt (still recipe-free) reading `/knowledge`. This is the **proof of learning** — see §5. |
|
||||
|
||||
The contrast in step 5 (`previousData` gated flags vs `newData` unlocked flags) is the signal
|
||||
the distiller turns into the procedure — which is why the flag names must stay stable across
|
||||
`open → finalize`. This invariant is already honored in both call sites; do not break it.
|
||||
|
||||
---
|
||||
|
||||
## 2. The two blockers + how to clear them, in order
|
||||
|
||||
### 2a. The recording hook (FOR-146) — pin the build, swap one import
|
||||
|
||||
**Current state (verified).**
|
||||
|
||||
- Banking demo `package.json` pins CopilotKit packages as `workspace:*`; the installed
|
||||
`@copilotkit/react-core` is **1.59.2**, whose `v2` hooks index exports
|
||||
`useFrontendTool` / `useHumanInTheLoop` / `useAgent` / `useThreads` / `useComponent` /
|
||||
`useConfigureSuggestions` — but **not** `useRecordUserActionInCurrentThread`
|
||||
(`grep` of `node_modules/@copilotkit/react-core/dist/v2/` returns nothing).
|
||||
- So all four call sites import the **no-op shim** at `src/lib/record-user-action.ts`. The
|
||||
shim returns a `recordUserAction` that only `console.debug`s in dev and resolves — it
|
||||
records nothing. The call-site bodies are already byte-for-byte what the real hook expects.
|
||||
- The Intelligence repo's root `package.json` pins the hook-bearing build:
|
||||
`"@copilotkit/react-core": "https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/react-core@e103a19"`
|
||||
(and the matching `core` / `runtime` / `shared` / `sdk-js` at `e103a19`). The e-commerce
|
||||
demo there imports `useRecordUserActionInCurrentThread` directly from
|
||||
`@copilotkit/react-core/v2` and it resolves — proving `e103a19` exports it.
|
||||
|
||||
**The unblock, exactly.**
|
||||
|
||||
1. **Land/pin a react-core build that exports the hook.** Two options:
|
||||
- **(A) Pin the published pkg.pr.new build** (matches the Intelligence repo). In the
|
||||
banking demo `package.json`, replace the four `workspace:*` CopilotKit entries with the
|
||||
`e103a19` pins (at minimum `@copilotkit/react-core`, plus `@copilotkit/core`,
|
||||
`@copilotkit/runtime`, `@copilotkit/shared` to keep the runtime route's
|
||||
`CopilotKitIntelligence` / `BuiltInAgent` imports on the same line). Re-install.
|
||||
_Cost:_ the demo leaves the OSS monorepo `workspace:*` graph; lockfile churn. Best when
|
||||
the demo is being **vendored into the Intelligence repo** (where these pins already
|
||||
exist — see §6).
|
||||
- **(B) Land the recording hook into the OSS `react-core/v2` build** the demo's
|
||||
`workspace:*` already resolves, then bump. _Cost:_ a real OSS change; out of scope for
|
||||
this demo plan but the cleaner long-term home. Treat as a CopilotKit-core ticket.
|
||||
2. **The one-line import swap** at each of the **four** call sites. Change ONLY the import;
|
||||
every `recordUserAction({...})` body and the `UserActionRecord` type stay identical:
|
||||
|
||||
```ts
|
||||
// before — no-op shim (banking today):
|
||||
import { useRecordUserActionInCurrentThread } from "@/lib/record-user-action";
|
||||
// after — real hook (e-commerce already does this):
|
||||
import { useRecordUserActionInCurrentThread } from "@copilotkit/react-core/v2";
|
||||
```
|
||||
|
||||
Call sites to edit: `src/components/policy-exception-modal.tsx` (line 10),
|
||||
`src/components/transactions-list.tsx` (line 15) — plus the two inline-HITL components this
|
||||
plan introduces in §3.2 (which inherit the same import). **Recommended variant:** instead
|
||||
of editing imports, turn the shim into a **re-export** so zero call sites change:
|
||||
|
||||
```ts
|
||||
// src/lib/record-user-action.ts (after the hook ships):
|
||||
export { useRecordUserActionInCurrentThread } from "@copilotkit/react-core/v2";
|
||||
export type { UserActionRecord } from "@copilotkit/react-core/v2"; // if the type is exported there
|
||||
```
|
||||
|
||||
This is the smallest possible diff and keeps the "copy this file verbatim" cookbook story
|
||||
intact.
|
||||
|
||||
**Definition of done for 2a:** the real hook resolves; `recordUserAction(...)` calls stream
|
||||
to the runtime instead of `console.debug`; no call-site body changed.
|
||||
|
||||
### 2b. The Intelligence backend (FOR-147) — stand up record→distill→`/knowledge`
|
||||
|
||||
**Current state (verified).** The runtime route already env-gates the backend:
|
||||
|
||||
```ts
|
||||
const intelligenceEnabled = Boolean(
|
||||
INTELLIGENCE_API_URL && INTELLIGENCE_GATEWAY_WS_URL && INTELLIGENCE_API_KEY,
|
||||
);
|
||||
// enabled → new CopilotRuntime({ agents:{default:bankingAgent}, intelligence, identifyUser })
|
||||
// missing → new CopilotRuntime({ agents:{default:bankingAgent}, runner: new InMemoryAgentRunner() })
|
||||
```
|
||||
|
||||
So no route code changes to _turn on_ the backend — it is purely a deploy + env exercise.
|
||||
What each var points at:
|
||||
|
||||
| Env var | Points at | Local-dev value | Notes |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `INTELLIGENCE_API_URL` | `apps/app-api` HTTP — the `/knowledge` + threads + user_actions store | `http://localhost:7050` (`APP_API_PORT` default in `scripts/local-dev.sh`) | The durable backend the gateway writes to and `/knowledge` is read from. |
|
||||
| `INTELLIGENCE_GATEWAY_WS_URL` | `apps/realtime-gateway` Phoenix WebSocket — where AG-UI run events (incl. recorded actions) stream | `ws://localhost:7053` (`REALTIME_GATEWAY_PORT` default) | The live ingestion seam. |
|
||||
| `INTELLIGENCE_API_KEY` | the org/project key (scopes threads + `/knowledge`) | the seeded `cpk_…` key for `casa-de-erlang` (e-commerce uses `cpk_sPRVSEED_seed0privat0longtoken00`) | Must belong to an org whose `cpki.users` includes the demo identities `identifyUser` mints (`northwind-<role>`), or those users must be seeded. |
|
||||
| `COPILOTKIT_LICENSE_TOKEN` | optional, read automatically by the runtime | — | Only if the build requires it. |
|
||||
| `SL_ENABLED` | gate on the **`sl-worker`** distillation sweep (Intelligence side) | `true` | Without it the worker won't distill — recording streams but `/knowledge` never fills. |
|
||||
|
||||
**Standing it up — two paths.**
|
||||
|
||||
- **Local-dev (recommended for building the proof).** The Intelligence repo's
|
||||
`scripts/local-dev.sh` already boots the whole stack with `pnpm nx serve`:
|
||||
`app-api` (`:7050`), `realtime-gateway` (`:7053`), the per-demo BFFs, and — when
|
||||
`SL_ENABLED=true` — the `sl-worker` on a heartbeat. It **already registers a `banking`
|
||||
demo (demo 6: BFF `:7071`, web `:7072`)** alongside e-commerce. So the realistic path to a
|
||||
live banking loop is to run the demo **inside the Intelligence repo** (where the `e103a19`
|
||||
pins and the SL services already exist), not to point the standalone Next.js app at a
|
||||
hand-rolled stack. See §6 for that recommendation. If you keep the standalone Next.js app,
|
||||
set the three env vars to the local-dev URLs/key above and run `scripts/local-dev.sh` in the
|
||||
Intelligence repo to provide the backend.
|
||||
- **Hosted.** Point the three vars at a deployed Intelligence environment (app-api URL,
|
||||
gateway WSS URL, a real `cpk_…` key) — e.g. a Railway/staging deploy of the Intelligence
|
||||
apps. Same route code; only env differs. Ensure `SL_ENABLED=true` on that environment's
|
||||
`sl-worker` and that the key's org has the demo users seeded.
|
||||
|
||||
**Definition of done for 2b:** with 2a also done, a recorded human unlock results in a
|
||||
distilled procedure appearing in `/knowledge` (verifiable by grepping `/knowledge` via the
|
||||
agent or inspecting `cpki.knowledge_base_files`).
|
||||
|
||||
**Ordering.** 2a and 2b are independent to _build_ but both required for the live loop. Do
|
||||
2b's standup in parallel with the UX shell; 2a is the final flip. The cleanest single move is
|
||||
§6 (vendor into the Intelligence repo), which clears 2a and 2b together because the pins and
|
||||
services already live there.
|
||||
|
||||
---
|
||||
|
||||
## 3. The teachable-demo UX (first-class — explicit feedback)
|
||||
|
||||
These three make the loop legible. **All three are buildable today** against the no-op shim
|
||||
and a local `recording` flag (§4). Map below is to real files in
|
||||
`CopilotKit/examples/showcases/banking`.
|
||||
|
||||
### 3.1 Suggested prompt — a starter pill that kicks off the teachable scenario
|
||||
|
||||
**Goal.** The first thing a viewer sees offers a one-click path straight into the over-limit
|
||||
gate, so the "agent fails → human teaches → agent learns" arc starts without anyone having to
|
||||
know the domain.
|
||||
|
||||
**Where it plugs in.** `src/app/wrapper.tsx` already registers welcome-screen pills via
|
||||
`useConfigureSuggestions` (the v2 way — there is no `suggestions` prop on the v2 chat
|
||||
component). The `BankingSuggestions()` component calls it with
|
||||
`available: "before-first-message"` and three pills today.
|
||||
|
||||
**The change.** Add the teachable pill as the **first** suggestion so it leads. Exact copy:
|
||||
|
||||
```ts
|
||||
useConfigureSuggestions({
|
||||
available: "before-first-message",
|
||||
suggestions: [
|
||||
{
|
||||
title: "Approve the $5,000 Marketing transaction",
|
||||
message:
|
||||
"Approve the $5,000 Google Ads transaction on the Marketing policy.",
|
||||
},
|
||||
{ title: "View transactions", message: "Show me my recent transactions" },
|
||||
{ title: "Add a card", message: "Add a new credit card" },
|
||||
{
|
||||
title: "Assign a policy",
|
||||
message: "Assign a spending policy to one of my cards",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
- The `message` deliberately matches seed `t-1` (Google Ads, −$5,000, Marketing policy, limit
|
||||
$5,000 / spent $500), so approving it hits `OVER_POLICY_LIMIT` — the gate — every time.
|
||||
- Keep `title` human and benign ("Approve the $5,000 Marketing transaction"); it must **not**
|
||||
hint at the exception path (same symptom-only spirit as the gate).
|
||||
- Pre-learning the agent will fail this correctly (control). Post-learning it will succeed.
|
||||
The same pill demonstrates both halves of the arc.
|
||||
|
||||
**Touch-points:** `src/app/wrapper.tsx` (`BankingSuggestions`). No other file.
|
||||
|
||||
### 3.2 Inline HITL — approve/deny + file-exception rendered IN the chat
|
||||
|
||||
**Goal.** The human's whole demonstration — see the over-limit symptom, approve/deny, and
|
||||
_file a policy exception_ — happens **inline in the chat as a tool-call card**, not in a
|
||||
separate page modal. That's what makes the recorded demonstration feel like "the agent
|
||||
watched me do it right here."
|
||||
|
||||
**What exists today.**
|
||||
|
||||
- The approve/deny inline card is **already** an inline HITL: `showAndApproveTransactions`
|
||||
(`src/app/page.tsx`, ~line 405) renders `<TransactionsList showApprovalInterface>` inside
|
||||
the chat via `useHumanInTheLoop`'s `render`. That list shows the **over-limit symptom**
|
||||
("Over policy limit" badge) and a **"File policy exception"** button — but that button
|
||||
currently opens a **separate page modal** (`PolicyExceptionModal` mounted at the bottom of
|
||||
`transactions-list.tsx`, a shadcn `Dialog`). That modal is the one piece that breaks the
|
||||
"inline" story.
|
||||
- The standalone approve/deny buttons (`src/components/approval-buttons.tsx`) are a shared
|
||||
primitive reused across every HITL card.
|
||||
|
||||
**The design.**
|
||||
|
||||
1. **New inline component `PolicyExceptionInline`** (`src/components/policy-exception-inline.tsx`),
|
||||
modeled on `PolicyExceptionModal` but rendered as a chat card (no `Dialog` chrome — the
|
||||
same rounded `bg-surface` card the other HITL renders use). It shows:
|
||||
- the **symptom** ("This transaction is over its Marketing policy limit"),
|
||||
- the **code picker** (the existing `POLICY_EXCEPTION_CODES` select — labels for humans,
|
||||
codes persisted),
|
||||
- **File exception** (calls `openPolicyException` → `finalizePolicyException`, the same
|
||||
REST callers threaded from `useCreditCards` in `actions.ts`),
|
||||
- and on success a confirmation + the approve affordance.
|
||||
It carries the **same two `recordUserAction` calls** (`policy_exception.opened` →
|
||||
`policy_exception.finalized`) verbatim from the modal — the recording payloads do not
|
||||
change.
|
||||
2. **A new inline HITL tool `fileAndApproveOverLimit`** (a `useHumanInTheLoop` in
|
||||
`src/app/page.tsx`) whose `render` mounts `PolicyExceptionInline`. Description stays
|
||||
**neutral** (does not name the exception path or which codes justify — preserves the
|
||||
learning invariant), e.g. _"Resolve a blocked over-limit approval. Requires human
|
||||
approval."_ This becomes the inline surface the human uses to teach, and later the surface
|
||||
the learned agent's `openPolicyException` / `finalizePolicyException` calls render into.
|
||||
3. **Reuse vs replace.**
|
||||
- **Reuse** `approval-buttons.tsx` unchanged for approve/deny.
|
||||
- **Reuse** the existing `openPolicyException` / `finalizePolicyException` HITL tools
|
||||
(`src/app/page.tsx`, ~lines 492 / 549) — they already render inline approve cards; the
|
||||
learned agent drives the unlock through these. `PolicyExceptionInline` is the
|
||||
_human-initiated_ twin.
|
||||
- **Replace** the page-modal entrypoint: drop the `setExceptionTxnId` → `<PolicyExceptionModal>`
|
||||
branch in `transactions-list.tsx` in favor of rendering `PolicyExceptionInline` within the
|
||||
chat card. Keep `policy-exception-modal.tsx` only if a non-chat entry is still wanted;
|
||||
otherwise retire it (its recording payloads move into the inline component).
|
||||
|
||||
**Touch-points:** new `src/components/policy-exception-inline.tsx`; new HITL tool +
|
||||
`render` in `src/app/page.tsx`; edits to `src/components/transactions-list.tsx` (swap the
|
||||
modal mount for the inline card); reuse `src/components/approval-buttons.tsx`. Reference shape:
|
||||
`cpk-intelligence-banking/demos/e-commerce/.../incident-create-modal.tsx` (same
|
||||
open→record→finalize→record pattern).
|
||||
|
||||
### 3.3 Pulsating "recording" vignette — a violet edge glow while recording
|
||||
|
||||
**Goal.** While the agent is recording the human's demonstrated actions, a soft pulsating
|
||||
violet glow/vignette hugs the **canvas edges**, signaling "the agent is watching and recording
|
||||
this for future reference." It turns off when recording ends.
|
||||
|
||||
**Trigger / state.** Introduce a tiny **recording context** (`src/components/recording-context.tsx`)
|
||||
exposing `isRecording` + `beginRecording()` / `endRecording()` (or a ref-counted
|
||||
`withRecording()` wrapper). Wire it around the **`recordUserAction` calls**: each call site
|
||||
calls `beginRecording()` immediately before firing the record(s) and `endRecording()` when the
|
||||
demonstration step settles. Concretely:
|
||||
|
||||
- In `PolicyExceptionInline` (§3.2): `beginRecording()` at the start of `handleSubmit`, and
|
||||
`endRecording()` after the second (`finalized`) record resolves (or in `finally`).
|
||||
- In `transactions-list.tsx` approve/deny: wrap the `recordUserAction(...)` call the same way.
|
||||
- Use a small **debounce/min-duration** (e.g. keep it on ≥1200ms) so a fast fire-and-forget
|
||||
record still produces a visible pulse, and **ref-count** so overlapping records (open +
|
||||
finalize) don't flicker it off between steps.
|
||||
|
||||
> This flag is **independent of the backend**: it reflects "the UI is emitting a record right
|
||||
> now," which is true even against the no-op shim. So the vignette is fully demoable today
|
||||
> (FOR-148) and stays correct once the real hook streams (FOR-146).
|
||||
|
||||
**Visual treatment.**
|
||||
|
||||
- An **edge vignette**: a full-viewport overlay with `box-shadow: inset 0 0 0 …` /
|
||||
`radial-gradient` mask so color concentrates at the **edges** and fades to transparent in the
|
||||
center (content stays unobscured).
|
||||
- **On-brand violet:** use the existing tokens — `--brand-violet` (`hsl(252 83% 67%)`) /
|
||||
`--brand-indigo` (`hsl(248 84% 60%)`) and the `--shadow-glow` feel
|
||||
(`0 12px 30px hsl(252 83% 60% / 0.35)`). Keep alpha low (~0.25–0.4) so it reads as a glow,
|
||||
not a wash.
|
||||
- **Pulse animation:** a 2–3s `@keyframes` easing opacity/blur between two low values
|
||||
(e.g. 0.25 ↔ 0.45), `ease-in-out`, infinite while recording. Fade in/out over ~200ms on
|
||||
enter/leave so it doesn't snap.
|
||||
- **Non-blocking:** `position: fixed; inset: 0; pointer-events: none; z-index` above content
|
||||
but below modals/toasts. It must never intercept clicks.
|
||||
- **Reduced motion:** under `@media (prefers-reduced-motion: reduce)`, drop the pulse — show a
|
||||
**static** low-opacity violet edge glow instead (still communicates "recording", no
|
||||
animation).
|
||||
|
||||
**Where it mounts.** A top-level overlay so it frames the whole canvas. Mount
|
||||
`<RecordingVignette />` inside `CopilotKitWrapper` in `src/app/wrapper.tsx` (a sibling of
|
||||
`LayoutComponent` / `ChatPanel`, both inside the provider tree so it can read the recording
|
||||
context). The `RecordingProvider` wraps the same subtree. Add the keyframes + `.recording-vignette`
|
||||
styles to `src/app/globals.css` (which already owns the brand tokens and `.brand-gradient`).
|
||||
|
||||
**How it turns off.** When `endRecording()` drops the ref-count to zero (after the min-duration
|
||||
elapses), `isRecording` flips false; the overlay fades out over ~200ms and the animation stops.
|
||||
|
||||
**Touch-points:** new `src/components/recording-context.tsx`; new `src/components/recording-vignette.tsx`;
|
||||
keyframes/classes in `src/app/globals.css`; mount + provider in `src/app/wrapper.tsx`; `begin/endRecording`
|
||||
wrapping in `policy-exception-inline.tsx` and `transactions-list.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Buildable NOW vs blocked — the explicit split
|
||||
|
||||
| Piece | Ticket | Needs the real hook? | Needs the Intelligence backend? | Buildable today? |
|
||||
| ------------------------------------------------- | ------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Suggested prompt (§3.1) | FOR-148 | No | No | **Yes** — pure `useConfigureSuggestions` copy. |
|
||||
| Inline HITL card (§3.2) | FOR-148 | No (renders the demonstration UI; recording payloads already present via the shim) | No | **Yes** — renders + drives REST unlock; records via the shim. |
|
||||
| Recording vignette (§3.3) | FOR-148 | No (reads a local `recording` flag set around the record calls) | No | **Yes** — flag is true even against the no-op shim. |
|
||||
| Recording actually streams (role #3 beyond no-op) | FOR-146 | **Yes** (pin `e103a19` + import swap) | Indirectly (events have somewhere to go) | No — blocked on 2a. |
|
||||
| Distill → `/knowledge` (role #5) | FOR-147 | — | **Yes** (`app-api` + gateway + `sl-worker`, `SL_ENABLED=true`) | No — blocked on 2b. |
|
||||
| Fresh-agent learns + succeeds | FOR-149 | Yes | Yes | No — needs 2a + 2b. |
|
||||
| Fresh-agent verification harness | FOR-145 | The script half works today (§5); the learning half needs 2a+2b | Partial | The REST proof: **yes**. The learning proof: blocked. |
|
||||
|
||||
**Why the UX is safe to build first.** The three UX pieces only depend on (a) the v2 chat +
|
||||
HITL APIs the demo already uses, and (b) a local `recording` boolean. The no-op shim already
|
||||
keeps the `recordUserAction` call sites real and stable, so wrapping them in `begin/endRecording`
|
||||
and rendering inline cards is wiring that does not change when the real hook lands — at that
|
||||
point the same calls simply also stream. **No rework.**
|
||||
|
||||
### Recommended sequencing
|
||||
|
||||
1. **FOR-148 — UX shell (now, parallelizable).** Suggested prompt → inline HITL card →
|
||||
recording vignette. Verifiable visually + via `verify-teachable-gate.sh` (the REST contract
|
||||
is unaffected). Ship this regardless of backend timing — it makes the demo legible today.
|
||||
2. **FOR-147 — backend standup (now, parallel to FOR-148).** Bring up `app-api` + gateway +
|
||||
`sl-worker` (local-dev or hosted) and confirm the three env vars + `SL_ENABLED`. No demo
|
||||
code changes (the route is already gated).
|
||||
3. **FOR-146 — pin the hook + swap the import (after 147 so streaming has a destination).**
|
||||
Pin `e103a19` (or re-export shim) → recorded actions now stream.
|
||||
4. **FOR-149 / FOR-145 — fresh-agent proof.** With 146+147 live, run the learning proof (§5)
|
||||
and codify it in a harness.
|
||||
|
||||
**Strong recommendation (§6):** do FOR-146 + FOR-147 by **vendoring the demo into the
|
||||
Intelligence repo**, where both the `e103a19` pins and the SL services already exist — that
|
||||
collapses 2a+2b into "run the existing local-dev with the banking demo wired like e-commerce."
|
||||
|
||||
---
|
||||
|
||||
## 5. Verification strategy — proving learning, not scripting
|
||||
|
||||
### 5.1 Backend-independent REST proof (works TODAY) — roles #1 + #2 — FOR-145 (lower half)
|
||||
|
||||
`verify-teachable-gate.sh` already drives the real REST routes against a running banking dev
|
||||
server and asserts the full gate→unlock contract. Run it (point `BASE_URL` at the served port):
|
||||
|
||||
```bash
|
||||
BASE_URL=http://localhost:3000 ./verify-teachable-gate.sh # next dev defaults to :3000
|
||||
```
|
||||
|
||||
It asserts, in order:
|
||||
|
||||
- **A. GATE** — `PUT /api/v1/transactions/t-1 {"status":"approved"}` → **422
|
||||
`OVER_POLICY_LIMIT`**, and the body does **not** mention the exception/unlock path
|
||||
(symptom-only invariant).
|
||||
- **B. UNLOCK** — open `EXC-BOARD-APPROVED` on `t-1` → **201** → finalize → **200 approved** →
|
||||
re-approve `t-1` → **201** (gate lifted by a justifying code).
|
||||
- **C. DECOY** — `EXC-WILL-REIMBURSE` on `t-3` files + finalizes (**201/200**) but the approve
|
||||
stays **422** (decoy does not justify).
|
||||
- **D. CATALOGUE** — an invalid code → **422 `INVALID_EXCEPTION_CODE`**, body does **not**
|
||||
enumerate the catalogue (non-enumeration invariant).
|
||||
|
||||
This proves the gate is real and the unlock is discriminating — i.e. there is genuinely
|
||||
_something to learn_ — without any Intelligence backend. It is the control that the demo isn't
|
||||
faked. Re-run from a fresh server to reseed (in-memory store).
|
||||
|
||||
### 5.2 The fresh-agent proof (activates after 2a + 2b) — roles #3 + #5 — FOR-149
|
||||
|
||||
This is the proof the loop **learned**, not that REST works. Requires the real hook (2a) and
|
||||
the env-gated `CopilotKitIntelligence` backend with `SL_ENABLED=true` (2b).
|
||||
|
||||
1. **Baseline (control).** Fresh thread, ask: _"Approve the $5,000 Google Ads transaction on
|
||||
the Marketing policy."_ With the recipe-free prompt + ACTION DISCIPLINE intact, the agent
|
||||
hits the gate, has no procedure, and **reports the failure** instead of firing a distractor.
|
||||
_This failure is the control — record it._
|
||||
2. **Human teaches.** Open the inline policy-exception card (§3.2), pick a **justifying** code,
|
||||
file + finalize. Each step fires `recordUserAction(...)` on the current thread — now a real
|
||||
stream (2a), and the vignette (§3.3) confirms recording is live on screen.
|
||||
3. **Distill.** The `sl-worker` (2b, `SL_ENABLED=true`) distills the recorded actions into a
|
||||
reusable procedure in `/knowledge`. _Spot-check:_ grep `/knowledge` (via the agent or
|
||||
`cpki.knowledge_base_files`) and confirm the over-limit/policy-exception procedure exists.
|
||||
4. **Fresh agent succeeds unaided.** A **new** thread (and ideally a different seeded user, to
|
||||
prove cross-thread/cross-user transfer), same approval request. The agent greps `/knowledge`,
|
||||
files a _justifying_ exception, finalizes, approves → **201** — **no human help, nothing
|
||||
added to the prompt.**
|
||||
|
||||
**Pass criteria:** step 1 fails, step 4 succeeds, and the **only** thing that changed between
|
||||
them is the distilled `/knowledge`. That delta is the learning. Codify as the FOR-145 harness:
|
||||
the REST proof (§5.1) gates "is there something to learn," the fresh-agent run gates "did it
|
||||
learn it."
|
||||
|
||||
**Anti-cheat checks (keep the proof honest):**
|
||||
|
||||
- The agent prompt at step 4 is **byte-identical** to step 1 (recipe still withheld — diff the
|
||||
runtime route prompt).
|
||||
- A run that files a **decoy** code must still fail (the agent must learn _which_ codes justify,
|
||||
not just "file an exception").
|
||||
- Distractor tools must remain harmless no-ops (a "success" from `sendSpendAlert` must not be
|
||||
mistaken for clearing the gate).
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended path: vendor into the Intelligence repo (clears 2a + 2b together)
|
||||
|
||||
The standalone Next.js banking demo can _render_ the full UX today, but the **live learning
|
||||
loop's natural home is the Intelligence repo**, because:
|
||||
|
||||
- it already pins `@copilotkit/react-core@e103a19` (the hook-bearing build) — **2a is free
|
||||
there**;
|
||||
- it already runs `app-api` + `realtime-gateway` + `sl-worker` via `scripts/local-dev.sh`, and
|
||||
already registers a **`banking` demo (demo 6)** beside e-commerce — **2b is free there**;
|
||||
- the e-commerce demo there is a working reference for the exact wiring: `BuiltInAgent` +
|
||||
`CopilotKitIntelligence` in the BFF, `identifyUser` from a user header, domain tools in the
|
||||
browser via `useFrontendTool`, and the **real** `useRecordUserActionInCurrentThread` import.
|
||||
|
||||
This aligns with the existing vendor plan
|
||||
(`docs/superpowers/plans/2026-06-02-vendor-canonical-saas-demo-sl-threads.md`): port the
|
||||
banking React surface (pages, the inline HITL card, the vignette, suggestions) into
|
||||
`demos/banking/react`, keep the prompt + Intelligence wiring in `demos/banking/bff`, and the
|
||||
two blockers dissolve into "run the local-dev that's already there." The standalone Next.js
|
||||
app remains the OSS-verifiable artifact for roles #1/#2/#4 + the UX shell; the Intelligence
|
||||
copy is where roles #3/#5 actually run.
|
||||
|
||||
If vendoring is deferred, the standalone demo can still close the loop by (A) pinning `e103a19`
|
||||
in its own `package.json` and (B) pointing its three env vars at an Intelligence backend
|
||||
(local-dev in the Intelligence repo, or hosted) — but that hand-wires what the vendor path
|
||||
gives for free.
|
||||
|
||||
---
|
||||
|
||||
## 7. File / component touch-point summary
|
||||
|
||||
**Edit (UX shell, FOR-148 — buildable now):**
|
||||
|
||||
- `src/app/wrapper.tsx` — add the teachable suggestion pill (§3.1); mount `RecordingProvider`
|
||||
- `<RecordingVignette />` (§3.3).
|
||||
- `src/app/page.tsx` — add the `fileAndApproveOverLimit` inline HITL tool whose `render` mounts
|
||||
`PolicyExceptionInline` (§3.2).
|
||||
- `src/components/transactions-list.tsx` — swap the page-modal entry for the inline card; wrap
|
||||
the approve/deny `recordUserAction` calls in `begin/endRecording` (§3.2, §3.3).
|
||||
- `src/app/globals.css` — `.recording-vignette` + `@keyframes` + reduced-motion variant (§3.3).
|
||||
|
||||
**Create (UX shell, FOR-148):**
|
||||
|
||||
- `src/components/policy-exception-inline.tsx` — inline version of the file-exception flow
|
||||
(carries the two existing recording payloads verbatim).
|
||||
- `src/components/recording-context.tsx` — `isRecording` + `begin/endRecording` (ref-counted,
|
||||
min-duration).
|
||||
- `src/components/recording-vignette.tsx` — the edge-glow overlay.
|
||||
|
||||
**Edit (unblock the loop):**
|
||||
|
||||
- `package.json` — pin `@copilotkit/react-core` (+ core/runtime/shared) to `e103a19` (FOR-146,
|
||||
option A) — or do this via vendoring (§6).
|
||||
- `src/lib/record-user-action.ts` — turn into a re-export of the real hook (FOR-146) so no call
|
||||
site changes.
|
||||
- **Env only (no code):** set `INTELLIGENCE_API_URL` / `INTELLIGENCE_GATEWAY_WS_URL` /
|
||||
`INTELLIGENCE_API_KEY` (+ backend `SL_ENABLED=true`) (FOR-147). The runtime route
|
||||
(`src/app/api/copilotkit/[[...slug]]/route.ts`) already branches on these.
|
||||
|
||||
**Reuse unchanged:** `src/components/approval-buttons.tsx`; the existing `openPolicyException`
|
||||
/ `finalizePolicyException` HITL tools in `page.tsx`; the gate route; the catalogue; `store.ts`;
|
||||
`verify-teachable-gate.sh`.
|
||||
|
||||
**Reference (read-only):** `cpk-intelligence-banking/demos/e-commerce/{bff/.../main.ts,
|
||||
react/.../order-actions-bar.tsx, react/.../incident-create-modal.tsx}` and
|
||||
`scripts/local-dev.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks / open questions
|
||||
|
||||
- **Hook parity at `e103a19`.** Confirm the published `react-core@e103a19` `v2` index exports
|
||||
`useRecordUserActionInCurrentThread` _and_ the `UserActionRecord` type (e-commerce imports the
|
||||
hook; verify the type export before relying on it in the re-export shim — otherwise keep the
|
||||
local type).
|
||||
- **Pin drift vs `workspace:*`.** Pinning the demo to `e103a19` takes it off the OSS monorepo
|
||||
graph; the runtime route imports `CopilotKitIntelligence` / `BuiltInAgent` from
|
||||
`@copilotkit/runtime/v2`, so pin runtime to the same commit to avoid a split-brain build.
|
||||
Vendoring (§6) sidesteps this.
|
||||
- **Identity scoping.** `identifyUser` mints `northwind-<role>`; the `INTELLIGENCE_API_KEY`'s
|
||||
org must have those users seeded (e-commerce seeds four users in `casa-de-erlang`). Mismatch
|
||||
= threads/knowledge land under an unexpected scope and the fresh-agent retrieval misses.
|
||||
- **Writer non-determinism.** The LLM distiller may phrase `/knowledge` differently run to run;
|
||||
keep a deterministic fallback note for scripted live demos, and assert on _behavior_ (the 201) not on the knowledge text.
|
||||
- **Vignette over modals.** Ensure the overlay's `z-index` sits above page content but **below**
|
||||
HITL cards/toasts, and `pointer-events: none` everywhere, so it never blocks the approve
|
||||
buttons the human needs during recording.
|
||||
- **Inline modal retirement.** Decide whether to fully retire `policy-exception-modal.tsx` or
|
||||
keep it as a non-chat entry; if retired, ensure its recording payloads are preserved exactly
|
||||
in `policy-exception-inline.tsx` (the distiller depends on the stable flag names).
|
||||
|
||||
---
|
||||
|
||||
## 9. Ticket map (suggested)
|
||||
|
||||
| Ticket | Scope |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **FOR-145** | Verification harness: REST gate proof (works today) + fresh-agent learning proof (activates post-146/147). |
|
||||
| **FOR-146** | Recording hook unblock: pin `e103a19` (or re-export shim) + one-line import swap. |
|
||||
| **FOR-147** | Intelligence backend standup: `app-api` + gateway + `sl-worker` (`SL_ENABLED=true`); wire the three env vars (local-dev or hosted). |
|
||||
| **FOR-148** | Teachable-demo UX shell (buildable now): suggested prompt + inline HITL card + recording vignette. |
|
||||
| **FOR-149** | Close the loop: with 146+147 live, demonstrate fresh-agent success unaided and capture it in the harness. |
|
||||
@@ -0,0 +1,405 @@
|
||||
# teach-mode cookbook
|
||||
|
||||
A reusable recipe for building **self-learning, teachable** CopilotKit demos.
|
||||
|
||||
"Teach mode" is the loop where an agent **fails a task it was never told how to
|
||||
do**, a human **demonstrates** the workaround in the UI, that demonstration is
|
||||
**recorded → distilled → written to `/knowledge`**, and a **fresh agent then
|
||||
succeeds unaided**. The agent didn't have the recipe prompt-stuffed in; it
|
||||
_learned_ it from watching a person.
|
||||
|
||||
This loop is already implemented **identically** in two demos — only the domain
|
||||
entities differ. This cookbook documents the contract they share so a **third
|
||||
demo is a copy-and-reskin**, not a redesign.
|
||||
|
||||
| Demo | Path | Entities |
|
||||
| -------------------------- | ------------------------------------------- | ----------------------------------------------------- |
|
||||
| **Banking** (canonical) | `examples/showcases/banking` | `transaction` / `expense-policy` / `policy-exception` |
|
||||
| **E-commerce** (reference) | `cpk-intelligence-banking/demos/e-commerce` | `order` / `refund` / `incident-report` |
|
||||
|
||||
> Paths in this doc are repo-relative to `CopilotKit/` for banking, and to
|
||||
> `cpk-intelligence-banking/` for e-commerce.
|
||||
|
||||
---
|
||||
|
||||
## (a) What teach-mode is — the teachable loop
|
||||
|
||||
The whole demo turns on one asymmetry: **the agent is given the goal and the
|
||||
tools, but NOT the procedure.** A gate blocks the obvious write with a
|
||||
symptom-only error. A human knows the unlock and performs it in the UI. That
|
||||
human action is captured and distilled into knowledge. A later agent reads the
|
||||
knowledge and clears the same gate on its own.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Agent A (knows the goal + tools, NOT the │
|
||||
│ procedure) tries the obvious write │
|
||||
└───────────────────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
role #1 GATE ──► write FAILS with a SYMPTOM-ONLY error ─────────────┐
|
||||
("<policy> policy limit exceeded" — 422) │
|
||||
names the PROBLEM, never the FIX │
|
||||
▼
|
||||
role #4 AGENT FRAMING: prompt withholds the recipe + ships DISTRACTOR │
|
||||
tools + ACTION DISCIPLINE ──► agent CANNOT bluff its way past; │
|
||||
it stops and reports. │
|
||||
▼
|
||||
role #2 UNLOCK: a HUMAN performs the multi-step workaround in the UI │
|
||||
file a record under a JUSTIFYING code → finalize → link to entity │
|
||||
(DECOY codes file but don't justify; INVALID codes are rejected) │
|
||||
│
|
||||
┌─────────────────────────────────────────┘
|
||||
▼
|
||||
role #3 RECORDING: each human UI mutation is captured on the CURRENT
|
||||
thread via useRecordUserActionInCurrentThread()
|
||||
recordUserAction({ title, description, previousData, newData, metadata })
|
||||
previousData = the gated flags · newData = the unlocked effect
|
||||
│
|
||||
▼
|
||||
role #5 KNOWLEDGE BACKEND: writer agent DISTILLS the recorded actions
|
||||
────► /knowledge (a reusable "to clear this gate, do X" procedure)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Agent B (FRESH, no memory of A) retrieves │
|
||||
│ /knowledge and clears the SAME gate UNAIDED │ ◄── proof of LEARNING
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The left half (gate → symptom → framing → human unlock → recording call) works
|
||||
and is **verifiable today** with no Intelligence backend. The right half
|
||||
(distill → `/knowledge` → fresh agent) activates when the self-learning
|
||||
react-core + Intelligence runtime are wired — see role #5 and the honest
|
||||
backend-block note in **(e)**.
|
||||
|
||||
> **Banking's narrated dashboard variant (PR #5266).** On top of this contract,
|
||||
> the banking demo drives the unlock as an _agent-orchestrated, narrated_ loop.
|
||||
> When asked to approve an over-limit charge it has no saved procedure for, the
|
||||
> agent declines ("I don't have a saved way to approve an over-limit charge yet")
|
||||
> and offers to record (`offerWorkflowRecording`) — no approval card is shown.
|
||||
> The officer demonstrates the unlock on the real **/dashboard → Transactions →
|
||||
> Pending approval** view (file a justifying exception, then approve) while a
|
||||
> waiting card (`awaitDashboardDemonstration`) holds the chat; the agent then
|
||||
> summarizes and saves the procedure (`saveLearnedWorkflow`) and, on a later
|
||||
> request, applies it itself to a _different_ over-limit charge
|
||||
> (`openPolicyException` → `finalizePolicyException` → `approveTransaction`).
|
||||
> Because the demonstration happens on a different route, these teach/recall HITL
|
||||
> tools are registered **globally** in `src/components/copilot-context.tsx` (not
|
||||
> in a page component) so they survive navigation — a route-scoped registration
|
||||
> unmounts mid-run and the followUp never fires. Same-session recall works by
|
||||
> echoing the saved procedure back into the thread; the cross-thread `/knowledge`
|
||||
> proof still requires the backend (role #5).
|
||||
>
|
||||
> The waiting card ("Recording your workflow") stays **non-directional** — it
|
||||
> never lists the steps ("go ahead and do it yourself now and I'll watch and
|
||||
> learn"), since the point is the agent doesn't yet know how. The card embeds a
|
||||
> live **recorder feed** (`RecordingSteps` in `src/components/recording-feed.tsx`,
|
||||
> fed by `logStep` from the nav / tab / file-exception / approve call sites) that
|
||||
> narrates each officer action as it happens ("Opened Dashboard" → "Filed the
|
||||
> policy exception" → "Approved the charge"). It renders INSIDE the chat card (a
|
||||
> child component subscribed to the recording context, so it updates live without
|
||||
> a stale-closure dep), reading consistently with the other cards rather than as
|
||||
> a floating overlay. `saveLearnedWorkflow`'s tool result is
|
||||
> **directive** so the model renders the Save card instead of asking "should I
|
||||
> save this?" in prose (the failure that otherwise leaves the user nothing to
|
||||
> click). After saving, the agent treats the demonstrated charge as already
|
||||
> cleared and waits, rather than re-running the fresh procedure on it.
|
||||
|
||||
---
|
||||
|
||||
## (b) The 5-role contract (with load-bearing invariants)
|
||||
|
||||
State each role demo-agnostically. The **invariant** is the part you must not
|
||||
break when reskinning — it's what makes the demo _prove learning_ rather than
|
||||
merely _script a workflow_.
|
||||
|
||||
### 1. GATE — a write that fails with a SYMPTOM-ONLY error
|
||||
|
||||
A normal-looking write (approve, refund, …) is blocked when a domain rule isn't
|
||||
satisfied. The rejection **names the problem, never the fix**.
|
||||
|
||||
> **Invariant.** The error is symptom-only. It may say _"\<policy\> policy limit
|
||||
> exceeded"_; it must NEVER mention the policy-exception path (or whatever the
|
||||
> unlock is). Leaking the recipe in the error lets the agent derive it in one
|
||||
> round-trip and defeats the demo. The gate must also be _liftable_ — it passes
|
||||
> once the unlock is in place (`isWithinLimit(x) || hasApprovedException(x)`).
|
||||
|
||||
### 2. UNLOCK — a discriminating multi-step procedure that lifts the gate
|
||||
|
||||
A human (and, post-learning, the agent) lifts the gate by **filing a record
|
||||
under a JUSTIFYING code → finalizing it → linking it** to the entity. The
|
||||
catalogue mixes justifying codes with **decoys**, and unknown codes are
|
||||
**rejected without enumeration**.
|
||||
|
||||
> **Invariant.** The procedure is _discriminating_: only JUSTIFYING codes lift
|
||||
> the gate; DECOY codes file successfully (recorded for history) but do NOT
|
||||
> justify; INVALID codes are rejected _without listing the valid ones_. The
|
||||
> agent is **never told which codes justify** — it must learn that from observed
|
||||
> human flows. (If any code worked, or the catalogue were leaked, there'd be
|
||||
> nothing to learn.)
|
||||
|
||||
### 3. RECORDING surface — human UI mutations captured on the current thread
|
||||
|
||||
Every human mutation that advances the unlock is recorded on the current thread
|
||||
via `useRecordUserActionInCurrentThread()`, called as
|
||||
`recordUserAction({ title, description, previousData, newData, metadata }).catch(...)`.
|
||||
|
||||
> **Invariant.** The record shape is fixed and identical across demos:
|
||||
> `previousData` carries the **gated capability flags** (e.g.
|
||||
> `approvePermitted: false`), `newData` the **unlocked effect** (flipped flags +
|
||||
> linking ids), `metadata` the **domain ids**. `title` is a machine-ish dotted
|
||||
> event name (e.g. `policy_exception.opened`); `description` is one human
|
||||
> sentence. The contrast between `previousData` and `newData` is the signal the
|
||||
> distiller learns from — keep flag names stable across the open→finalize steps.
|
||||
|
||||
### 4. AGENT FRAMING — withhold the recipe, ship distractors, enforce discipline
|
||||
|
||||
The system prompt lists the unlock's tools but **never the procedure**, and
|
||||
ships **plausible distractor tools** that look helpful but don't lift the gate.
|
||||
An **ACTION DISCIPLINE** clause forbids improvising a substitute.
|
||||
|
||||
> **Invariant.** A successful unlock must prove **learning, not
|
||||
> prompt-stuffing**. So: (a) the prompt withholds the unlock recipe; (b) it
|
||||
> ships distractors (banking: `sendSpendAlert`, `requestCardReplacement`,
|
||||
> `flagForReview`) so "called a plausible tool" ≠ "cleared the gate"; (c) ACTION
|
||||
> DISCIPLINE makes the agent stop and report on failure rather than guess. Before
|
||||
> learning, the correctly-framed agent _cannot_ pass.
|
||||
|
||||
### 5. KNOWLEDGE BACKEND — record → distill → `/knowledge` → fresh agent learns
|
||||
|
||||
Recorded actions are distilled into `/knowledge`; a fresh agent retrieves it and
|
||||
succeeds unaided. The runtime is **env-gated**: OSS `InMemoryAgentRunner` by
|
||||
default, `CopilotKitIntelligence` when configured.
|
||||
|
||||
> **Invariant.** The backend is a **swappable seam**, and roles #1–#2 are proven
|
||||
> _without_ it. **Honest current block:** the OSS `@copilotkit/react-core/v2`
|
||||
> build does **not** yet export the recording hook, so the recording surface
|
||||
> (role #3) is a **no-op shim** today — gate/unlock/framing all work and verify,
|
||||
> but the distill→`/knowledge`→fresh-agent leg is deferred until the
|
||||
> self-learning react-core ships. The hook exists at CopilotKit commit
|
||||
> `e103a19` (pinned by the Intelligence repo); adoption is then a **one-line
|
||||
> import swap** (call sites don't change). See **(e)**.
|
||||
|
||||
---
|
||||
|
||||
## (c) Worked-example mapping table
|
||||
|
||||
Each role → the banking file → the e-commerce file → what you swap for a new demo.
|
||||
|
||||
| Role | Banking (`examples/showcases/banking`) | E-commerce (`cpk-intelligence-banking/demos/e-commerce`) | What you swap for a new demo |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **#1 GATE** | `src/app/api/v1/transactions/[id]/route.ts` — PUT returns **422 `OVER_POLICY_LIMIT`** when `status==="approved" && !isWithinPolicyLimit && !hasApprovedException`. Rule fns in `src/lib/store.ts`: `isWithinPolicyLimit` / `hasApprovedException` / `canApprove`. | `react/src/app/data/store.ts` — `processRefund` / `initiateReturn` throw **`REFUND_NOT_PERMITTED` / `RETURN_NOT_PERMITTED`** when `!isWithinRefundWindow && !hasApprovedActiveIncident`. | The gated write + its symptom-only error code. Pick your domain's "blocked action" (publish, ship, escalate…) and the rule that blocks it. |
|
||||
| **#2 UNLOCK** | Catalogue `src/app/api/v1/policy-exception-codes.ts` (`POLICY_EXCEPTION_CODES`, `JUSTIFYING_EXCEPTION_CODES`, `isValidExceptionCode`, `isJustifying`). REST `src/app/api/v1/exceptions/route.ts` (open, POST) + `src/app/api/v1/exceptions/[id]/finalize/route.ts` (finalize, POST). Store `openPolicyException` / `finalizePolicyException`. | Catalogue `react/src/app/data/incident-codes.ts` (`INCIDENT_CODES`, `REFUND_JUSTIFYING_CODES`, `isValidIncidentCode`). Store `openIncidentReport` / `finalizeIncidentReport`. | The record entity + its code catalogue. Keep 3 justifying + N decoys; keep open→finalize→link; keep the catalogue check that rejects unknown codes **without enumerating**. |
|
||||
| **#3 RECORDING** | `src/lib/record-user-action.ts` (**no-op shim**) → consumed in `src/components/policy-exception-modal.tsx` (two `recordUserAction` calls: `policy_exception.opened` then `.finalized`). | `@copilotkit/react-core/v2` (**real hook import**) → consumed in `react/src/app/components/incident-create-modal.tsx` (`incident_report.opened` / `.finalized`) and `order-actions-bar.tsx` (`order.refunded` / `order.return_initiated`). | Nothing in the seam itself — copy `record-user-action.ts` verbatim. Swap only the **payload values** (`title`/flags/`metadata`) for your domain. |
|
||||
| **#4 AGENT FRAMING** | `src/app/api/copilotkit/[[...slug]]/route.ts` — `BuiltInAgent` prompt withholds the unlock recipe; ships distractors `sendSpendAlert` / `requestCardReplacement` / `flagForReview`; has the **ACTION DISCIPLINE** clause. | Same role in the e-commerce runtime route (refund/return tools listed; distractors present; recipe withheld). | The prompt's tool list, your 3 distractor tools, and the ACTION DISCIPLINE clause (reuse the wording — it's domain-neutral). |
|
||||
| **#5 KNOWLEDGE BACKEND** | Same route — env-gated `CopilotKitIntelligence` (OSS `InMemoryAgentRunner` default) keyed on `INTELLIGENCE_API_URL` / `INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY`; `identifyUser` scopes threads by role. | Equivalent env-gated Intelligence runtime in the e-commerce app. | Nothing structural — reuse the env-gated `createRuntime()` pattern verbatim; only `agents: { default: <yourAgent> }` changes. |
|
||||
|
||||
---
|
||||
|
||||
## (d) Adoption checklist — add teach-mode to a new demo
|
||||
|
||||
Eight concrete steps. Assumes a CopilotKit demo with an in-memory store and a v2
|
||||
runtime route already scaffolded.
|
||||
|
||||
1. **Pick the gated write + symptom.** Choose the domain action to block
|
||||
(approve / refund / publish / ship …) and the rule that blocks it. Add the
|
||||
rule fns to your store (mirror `isWithinPolicyLimit` / `hasApprovedException`
|
||||
/ `canApprove`). Make the write's route return a **422 with a symptom-only
|
||||
error code** (mirror `OVER_POLICY_LIMIT` in
|
||||
`transactions/[id]/route.ts`). **Do not name the unlock in the error.**
|
||||
|
||||
2. **Author the code catalogue (role #2).** Create a `*-codes.ts` (mirror
|
||||
`policy-exception-codes.ts`): a `CODES` array (`{ code, label }`, label for
|
||||
humans only), a `JUSTIFYING_CODES` set (keep ~3), `isValid*Code`, and
|
||||
`isJustifying`. Include **decoy** codes that are valid-but-not-justifying.
|
||||
|
||||
3. **Add the unlock record + REST.** Add the record entity to your store with
|
||||
`open*` (validates code via `isValid*Code`, throws on unknown) and
|
||||
`finalize*` (auto-approves and links `active*Id` to the gated entity, which is
|
||||
what `hasApprovedException` checks). Expose them over REST (mirror
|
||||
`exceptions/route.ts` + `exceptions/[id]/finalize/route.ts`) — or as store
|
||||
calls if your demo is client-side like e-commerce.
|
||||
|
||||
4. **Copy the recording seam (role #3).** Copy
|
||||
[`record-user-action.ts`](../../src/lib/record-user-action.ts) into your `src/lib/`
|
||||
**verbatim**. It is domain-neutral; do not edit it.
|
||||
|
||||
5. **Wire the human UI to record (role #3).** In the modal/bar where the human
|
||||
performs the unlock, call `useRecordUserActionInCurrentThread()` and emit a
|
||||
record after each successful mutation. Follow the field convention exactly:
|
||||
`previousData` = gated flags (`{ approvePermitted: false }`), `newData` =
|
||||
unlocked effect (flipped flags + linking ids), `metadata` = domain ids,
|
||||
`title` = dotted event name, `description` = one sentence. Always
|
||||
`.catch(...)` (fire-and-forget). Mirror `policy-exception-modal.tsx`.
|
||||
|
||||
6. **Frame the agent (role #4).** In your runtime route's prompt: list the
|
||||
unlock's tools but **not the procedure**; add **3 distractor tools** that look
|
||||
plausible but don't lift the gate; paste the **ACTION DISCIPLINE** clause
|
||||
(reuse banking's wording). Implement the distractors as harmless no-ops/logs.
|
||||
|
||||
7. **Wire the env-gated backend (role #5).** Reuse banking's `createRuntime()`:
|
||||
build `CopilotKitIntelligence` when `INTELLIGENCE_API_URL` /
|
||||
`INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY` are all set, else fall
|
||||
back to `InMemoryAgentRunner`. Keep `identifyUser` to scope threads by role.
|
||||
|
||||
8. **Verify (role #1+#2 today; #5 when the backend lands).** Adapt
|
||||
[`verify-teachable-gate.sh`](./verify-teachable-gate.sh) to your entity ids
|
||||
and codes and run it against your dev server. It must show: gate blocks (422)
|
||||
→ justifying unlock succeeds → decoy stays blocked → invalid code rejected
|
||||
without leaking the catalogue. Add the fresh-agent learning proof (**(f)**)
|
||||
once the Intelligence backend is configured.
|
||||
|
||||
---
|
||||
|
||||
## (e) The RECORDING SEAM contract
|
||||
|
||||
The canonical primitive lives in
|
||||
[`record-user-action.ts`](../../src/lib/record-user-action.ts) — copy it once, never edit
|
||||
it.
|
||||
|
||||
### The `UserActionRecord` shape
|
||||
|
||||
```ts
|
||||
export type UserActionRecord = {
|
||||
title: string; // machine-ish dotted event name
|
||||
description: string; // one human sentence
|
||||
previousData?: unknown; // GATED state (flags that were false)
|
||||
newData?: unknown; // UNLOCKED effect (flipped flags + ids)
|
||||
metadata?: Record<string, unknown>; // domain ids ("which")
|
||||
};
|
||||
|
||||
export const useRecordUserActionInCurrentThread =
|
||||
() =>
|
||||
(record: UserActionRecord): Promise<void> => {
|
||||
/* … */
|
||||
};
|
||||
```
|
||||
|
||||
Call-site convention, verbatim from `policy-exception-modal.tsx` (the e-commerce
|
||||
modal is identical bar the entity names):
|
||||
|
||||
```ts
|
||||
const recordUserAction = useRecordUserActionInCurrentThread();
|
||||
// ...after open() succeeds...
|
||||
recordUserAction({
|
||||
title: "policy_exception.opened",
|
||||
description: "Opened a policy exception from the transactions view.",
|
||||
previousData: { transactionActiveExceptionId: null, approvePermitted: false },
|
||||
newData: { exceptionId, exceptionStatus: "draft", exceptionCode: code },
|
||||
metadata: { transactionId: props.transactionId },
|
||||
}).catch(console.error);
|
||||
// ...then after finalize() succeeds, a second record flips the flags to the unlocked state.
|
||||
```
|
||||
|
||||
### The no-op shim (current state)
|
||||
|
||||
Today the shim **records nothing** — it only `console.debug`s in dev — because
|
||||
the OSS `@copilotkit/react-core/v2` build does **not** export
|
||||
`useRecordUserActionInCurrentThread`. Its hooks index exports only
|
||||
`useFrontendTool` / `useHumanInTheLoop` / `useAgent` / `useThreads` / etc. The
|
||||
shim exists purely to keep the call sites real and stable.
|
||||
|
||||
### The one-line swap to the real hook
|
||||
|
||||
When a react-core build exporting the hook is one you can depend on, change
|
||||
**only the import** at each call site:
|
||||
|
||||
```ts
|
||||
// before — no-op shim (banking today):
|
||||
import { useRecordUserActionInCurrentThread } from "@/lib/record-user-action";
|
||||
|
||||
// after — real hook (e-commerce already does this):
|
||||
import { useRecordUserActionInCurrentThread } from "@copilotkit/react-core/v2";
|
||||
```
|
||||
|
||||
The `UserActionRecord` type and every call body stay byte-for-byte identical.
|
||||
(Alternatively, make `record-user-action.ts` re-export the real hook so not even
|
||||
imports change.)
|
||||
|
||||
### Honest backend-block note
|
||||
|
||||
- **Works today, no backend:** roles #1 (gate), #2 (unlock + decoy + catalogue),
|
||||
#4 (framing). Provable via `verify-teachable-gate.sh`.
|
||||
- **Deferred until the self-learning react-core + Intelligence runtime are
|
||||
wired:** the recording actually streaming (role #3 beyond the no-op) and the
|
||||
distill → `/knowledge` → fresh-agent-learns leg (role #5).
|
||||
- **Known landing point:** the recording hook exists at CopilotKit commit
|
||||
`e103a19`, which the Intelligence repo pins. The banking demo points its
|
||||
import at the shim; the e-commerce demo already imports the hook from
|
||||
`@copilotkit/react-core/v2` — that single import line is the entire difference
|
||||
between "backend pending" and "backend wired".
|
||||
|
||||
---
|
||||
|
||||
## (f) Verification recipe
|
||||
|
||||
### Backend-independent proof (works TODAY) — roles #1 + #2
|
||||
|
||||
Run the bundled script against a running banking dev server. It drives the real
|
||||
REST routes and asserts the full gate→unlock contract.
|
||||
|
||||
```bash
|
||||
# default base URL is http://localhost:3939 (next dev defaults to :3000 —
|
||||
# point BASE_URL at whatever port you actually serve)
|
||||
./verify-teachable-gate.sh
|
||||
BASE_URL=http://localhost:3000 ./verify-teachable-gate.sh
|
||||
```
|
||||
|
||||
What it asserts (each step commented in the script with the role it exercises):
|
||||
|
||||
- **A. GATE (#1)** — `PUT /api/v1/transactions/t-1 {"status":"approved"}` →
|
||||
**422 `OVER_POLICY_LIMIT`**, and the body does **not** mention the
|
||||
exception/unlock path (symptom-only invariant).
|
||||
- **B. UNLOCK (#2)** — `POST /api/v1/exceptions {transactionId:"t-1",
|
||||
code:"EXC-BOARD-APPROVED"}` → **201** → `POST
|
||||
/api/v1/exceptions/{id}/finalize` → **200 approved** → re-`PUT` approve `t-1`
|
||||
→ **201** (gate lifted).
|
||||
- **C. DECOY (#2)** — same flow on `t-3` with `EXC-WILL-REIMBURSE` files +
|
||||
finalizes (**201/200**) but the approve stays **422 `OVER_POLICY_LIMIT`**.
|
||||
- **D. CATALOGUE (#2)** — `POST /api/v1/exceptions {code:"EXC-…NOT-REAL"}` →
|
||||
**422 `INVALID_EXCEPTION_CODE`**, and the body does **not** enumerate any real
|
||||
catalogue codes (non-enumeration invariant).
|
||||
|
||||
> The store is in-memory and seeded from `src/data/seed.json`. Each scenario uses
|
||||
> a different seeded over-limit transaction (`t-1`, `t-3`, `t-2`), so one run
|
||||
> needs no reset. To re-run from scratch, **restart the dev server** to reseed.
|
||||
|
||||
Minimal manual equivalent of the gate→unlock payoff:
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:3939/api/v1
|
||||
# A. gate blocks
|
||||
curl -s -X PUT "$BASE/transactions/t-1" -H 'content-type: application/json' \
|
||||
-d '{"status":"approved"}' # -> 422 OVER_POLICY_LIMIT
|
||||
# B. unlock
|
||||
EXC=$(curl -s -X POST "$BASE/exceptions" -H 'content-type: application/json' \
|
||||
-d '{"transactionId":"t-1","code":"EXC-BOARD-APPROVED"}' | jq -r .id)
|
||||
curl -s -X POST "$BASE/exceptions/$EXC/finalize" # -> 200 approved
|
||||
curl -s -X PUT "$BASE/transactions/t-1" -H 'content-type: application/json' \
|
||||
-d '{"status":"approved"}' # -> 201 (now allowed)
|
||||
```
|
||||
|
||||
### Fresh-agent learning proof (activates once the backend lands) — roles #3 + #5
|
||||
|
||||
This is the proof that the loop _learned_, not that the REST works. It requires
|
||||
the recording hook (real, not the shim) and the env-gated `CopilotKitIntelligence`
|
||||
runtime configured (`INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`,
|
||||
`INTELLIGENCE_API_KEY`).
|
||||
|
||||
1. **Baseline (no knowledge).** In a fresh thread, ask the agent to approve an
|
||||
over-limit transaction. With role #4 framing intact it **fails correctly**:
|
||||
it hits the gate, has no procedure, and (per ACTION DISCIPLINE) reports the
|
||||
failure instead of firing a distractor. _This failure is the control._
|
||||
2. **Human teaches.** A human opens the policy-exception modal and performs the
|
||||
unlock (justifying code → finalize). Each step fires `recordUserAction(...)`
|
||||
on the current thread (now a real stream, not a no-op).
|
||||
3. **Distill.** The Intelligence writer agent distills those recorded actions
|
||||
into a reusable procedure in `/knowledge`.
|
||||
4. **Fresh agent succeeds unaided.** In a **new** thread (no memory of the
|
||||
human's session), ask the same over-limit approval. The agent retrieves
|
||||
`/knowledge`, files a _justifying_ exception, finalizes it, and the approval
|
||||
now returns **201** — with **no human help and nothing added to the prompt**.
|
||||
|
||||
Pass criteria: step 1 fails, step 4 succeeds, and the only thing that changed
|
||||
between them is the distilled `/knowledge`. That delta _is_ the learning.
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-teachable-gate.sh — proves the teach-mode GATE → UNLOCK contract end to
|
||||
# end against the BANKING demo's real REST routes. This is the BACKEND-INDEPENDENT
|
||||
# proof: it exercises roles #1 (GATE), #2 (UNLOCK + DECOY + catalogue check) of
|
||||
# the 5-role contract entirely over HTTP, with NO Intelligence stack required.
|
||||
# (Roles #3 RECORDING / #4 AGENT FRAMING / #5 KNOWLEDGE BACKEND are proven
|
||||
# separately — see the README "Verification" section for the fresh-agent learning
|
||||
# proof that activates once the backend lands.)
|
||||
#
|
||||
# It demonstrates, in order:
|
||||
# A. GATE — approving an over-policy-limit transaction is blocked (422
|
||||
# OVER_POLICY_LIMIT, symptom-only: it names the problem, never
|
||||
# the fix).
|
||||
# B. UNLOCK — filing a JUSTIFYING policy exception (open → finalize → link)
|
||||
# lifts the gate; the same approval now succeeds.
|
||||
# C. DECOY — a NON-justifying catalogue code files fine but does NOT lift
|
||||
# the gate; approval stays blocked (422).
|
||||
# D. CATALOGUE — an INVALID code is rejected (422 INVALID_EXCEPTION_CODE)
|
||||
# WITHOUT the response enumerating the valid catalogue.
|
||||
#
|
||||
# USAGE
|
||||
# ./verify-teachable-gate.sh # against http://localhost:3939
|
||||
# BASE_URL=http://localhost:3000 ./verify-teachable-gate.sh
|
||||
# ./verify-teachable-gate.sh http://localhost:3000 # positional override
|
||||
#
|
||||
# PREREQUISITES
|
||||
# - The banking demo running locally (its dev server, e.g. `pnpm dev`). NOTE:
|
||||
# `next dev` defaults to :3000; this script defaults to :3939 to match the
|
||||
# project convention — point BASE_URL at whatever port you actually serve.
|
||||
# - `curl` and `jq` on PATH.
|
||||
# - A FRESH server process (or one not yet mutated by a prior run). The store is
|
||||
# in-memory and seeded from `src/data/seed.json`; restart the dev server to
|
||||
# reset. Each scenario below uses a DIFFERENT seeded transaction so a single
|
||||
# run does not need a reset between scenarios.
|
||||
#
|
||||
# SEED FACTS THIS SCRIPT RELIES ON (src/data/seed.json):
|
||||
# - t-1 "Google Ads" amount -5000 policy Marketing (limit 5000, spent 500)
|
||||
# → approve needs 500+5000=5500 > 5000 ⇒ OVER LIMIT (scenario A/B)
|
||||
# - t-3 "Microsoft 365" amount -10000 policy Executive (limit 10000, spent 1000)
|
||||
# → approve needs 1000+10000=11000 > 10000 ⇒ OVER LIMIT (scenario C)
|
||||
# - t-2 "AWS" amount -15000 policy Engineering(limit 15000, spent 1500)
|
||||
# → approve needs 1500+15000=16500 > 15000 ⇒ OVER LIMIT (scenario D)
|
||||
#
|
||||
# JUSTIFYING vs DECOY codes (src/app/api/v1/policy-exception-codes.ts):
|
||||
# JUSTIFYING (lift the gate): EXC-BOARD-APPROVED, EXC-CONTRACTUAL-COMMITMENT,
|
||||
# EXC-EMERGENCY-SPEND
|
||||
# DECOY (filed for history, do NOT lift the gate): EXC-WILL-REIMBURSE, EXC-ONE-TIME
|
||||
# INVALID (not in the catalogue at all): anything else, e.g. EXC-MADE-UP
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# --- Config ----------------------------------------------------------------
|
||||
BASE_URL="${1:-${BASE_URL:-http://localhost:3939}}"
|
||||
API="${BASE_URL%/}/api/v1"
|
||||
|
||||
# Seeded over-limit transactions (one per scenario so they don't interfere).
|
||||
TXN_GATE="t-1" # scenarios A + B
|
||||
TXN_DECOY="t-3" # scenario C
|
||||
TXN_INVALID="t-2" # scenario D
|
||||
|
||||
JUSTIFYING_CODE="EXC-BOARD-APPROVED" # lifts the gate (role #2 UNLOCK)
|
||||
DECOY_CODE="EXC-WILL-REIMBURSE" # valid catalogue code, does NOT justify
|
||||
INVALID_CODE="EXC-DEFINITELY-NOT-REAL" # not in the catalogue at all
|
||||
|
||||
# --- Pretty helpers --------------------------------------------------------
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
if ! have curl; then echo "ERROR: curl not found on PATH" >&2; exit 1; fi
|
||||
if ! have jq; then echo "ERROR: jq not found on PATH (used to parse JSON)" >&2; exit 1; fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
|
||||
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# status_of METHOD URL [JSON_BODY] -> prints HTTP status, stashes body in $BODY
|
||||
BODY=""
|
||||
status_of() {
|
||||
local method="$1" url="$2" data="${3:-}"
|
||||
local resp code
|
||||
if [[ -n "$data" ]]; then
|
||||
resp="$(curl -sS -o /tmp/tm_body.$$ -w '%{http_code}' \
|
||||
-X "$method" "$url" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "$data")"
|
||||
else
|
||||
resp="$(curl -sS -o /tmp/tm_body.$$ -w '%{http_code}' -X "$method" "$url")"
|
||||
fi
|
||||
code="$resp"
|
||||
BODY="$(cat /tmp/tm_body.$$ 2>/dev/null || true)"
|
||||
rm -f /tmp/tm_body.$$
|
||||
printf '%s' "$code"
|
||||
}
|
||||
|
||||
approve() { status_of PUT "$API/transactions/$1" '{"status":"approved"}'; }
|
||||
open_exception() { status_of POST "$API/exceptions" "{\"transactionId\":\"$1\",\"code\":\"$2\"}"; }
|
||||
finalize() { status_of POST "$API/exceptions/$1/finalize"; }
|
||||
|
||||
printf 'teach-mode gate→unlock verification\n'
|
||||
printf 'BASE_URL = %s\n' "$BASE_URL"
|
||||
|
||||
# ===========================================================================
|
||||
# Scenario A — GATE (role #1): approving an over-limit txn is blocked, 422,
|
||||
# symptom-only. The error names the over-limit POLICY but NEVER the
|
||||
# policy-exception path that would lift it.
|
||||
# Route: PUT /api/v1/transactions/[id] (src/app/api/v1/transactions/[id]/route.ts)
|
||||
# ===========================================================================
|
||||
section "A. GATE — over-limit approval is blocked (role #1)"
|
||||
code="$(approve "$TXN_GATE")"
|
||||
if [[ "$code" == "422" && "$(jq -r '.error' <<<"$BODY")" == "OVER_POLICY_LIMIT" ]]; then
|
||||
ok "approve $TXN_GATE → 422 OVER_POLICY_LIMIT"
|
||||
else
|
||||
bad "expected 422 OVER_POLICY_LIMIT, got HTTP $code body=$BODY"
|
||||
fi
|
||||
# Symptom-only invariant: the rejection must NOT leak the unlock recipe.
|
||||
if grep -qiE 'exception|EXC-|finalize|override|knowledge' <<<"$BODY"; then
|
||||
bad "GATE error leaked the fix (mentions the exception/unlock path): $BODY"
|
||||
else
|
||||
ok "GATE error is symptom-only (no mention of the exception/unlock recipe)"
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# Scenario B — UNLOCK (role #2): file a JUSTIFYING exception, then approve.
|
||||
# open → POST /api/v1/exceptions (src/app/api/v1/exceptions/route.ts)
|
||||
# final → POST /api/v1/exceptions/[id]/finalize
|
||||
# (src/app/api/v1/exceptions/[id]/finalize/route.ts)
|
||||
# The finalize step auto-approves the exception AND links it to the
|
||||
# transaction's activeExceptionId — which is what lifts the gate, but ONLY
|
||||
# because the code is justifying (store.hasApprovedException + isJustifying).
|
||||
# ===========================================================================
|
||||
section "B. UNLOCK — justifying exception lifts the gate (role #2)"
|
||||
code="$(open_exception "$TXN_GATE" "$JUSTIFYING_CODE")"
|
||||
if [[ "$code" == "201" ]]; then
|
||||
EXC_ID="$(jq -r '.id' <<<"$BODY")"
|
||||
ok "open exception ($JUSTIFYING_CODE) on $TXN_GATE → 201 id=$EXC_ID"
|
||||
else
|
||||
bad "expected 201 opening exception, got HTTP $code body=$BODY"; EXC_ID=""
|
||||
fi
|
||||
|
||||
if [[ -n "${EXC_ID:-}" ]]; then
|
||||
code="$(finalize "$EXC_ID")"
|
||||
if [[ "$code" == "200" && "$(jq -r '.status' <<<"$BODY")" == "approved" ]]; then
|
||||
ok "finalize $EXC_ID → 200 status=approved (linked to $TXN_GATE)"
|
||||
else
|
||||
bad "expected 200 approved finalizing, got HTTP $code body=$BODY"
|
||||
fi
|
||||
fi
|
||||
|
||||
# The payoff: the SAME approval that was blocked in A now succeeds.
|
||||
code="$(approve "$TXN_GATE")"
|
||||
if [[ "$code" == "201" ]]; then
|
||||
ok "approve $TXN_GATE again → 201 (gate lifted by justifying exception)"
|
||||
else
|
||||
bad "expected 201 after justifying unlock, got HTTP $code body=$BODY"
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# Scenario C — DECOY (role #2 invariant): a NON-justifying catalogue code is a
|
||||
# valid code (files + finalizes fine, recorded for history) but does NOT lift
|
||||
# the gate. Approval must stay 422. This is what forces the agent to LEARN
|
||||
# WHICH codes justify — it can file every code and still fail.
|
||||
# ===========================================================================
|
||||
section "C. DECOY — non-justifying code files but does NOT unlock (role #2)"
|
||||
code="$(open_exception "$TXN_DECOY" "$DECOY_CODE")"
|
||||
if [[ "$code" == "201" ]]; then
|
||||
DECOY_ID="$(jq -r '.id' <<<"$BODY")"
|
||||
ok "open exception ($DECOY_CODE) on $TXN_DECOY → 201 id=$DECOY_ID (accepted: valid code)"
|
||||
else
|
||||
bad "expected 201 opening decoy exception, got HTTP $code body=$BODY"; DECOY_ID=""
|
||||
fi
|
||||
|
||||
if [[ -n "${DECOY_ID:-}" ]]; then
|
||||
code="$(finalize "$DECOY_ID")"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
ok "finalize $DECOY_ID → 200 (filed for history)"
|
||||
else
|
||||
bad "expected 200 finalizing decoy, got HTTP $code body=$BODY"
|
||||
fi
|
||||
fi
|
||||
|
||||
code="$(approve "$TXN_DECOY")"
|
||||
if [[ "$code" == "422" && "$(jq -r '.error' <<<"$BODY")" == "OVER_POLICY_LIMIT" ]]; then
|
||||
ok "approve $TXN_DECOY → 422 OVER_POLICY_LIMIT (decoy did NOT lift the gate)"
|
||||
else
|
||||
bad "expected 422 OVER_POLICY_LIMIT (decoy must not unlock), got HTTP $code body=$BODY"
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# Scenario D — CATALOGUE CHECK (role #2 invariant): an INVALID code is rejected
|
||||
# (422 INVALID_EXCEPTION_CODE) WITHOUT enumerating the valid catalogue. Leaking
|
||||
# the list would itself be a hint; the agent must discover the catalogue via
|
||||
# /knowledge. Rejection happens at open time, so nothing to finalize/approve.
|
||||
# ===========================================================================
|
||||
section "D. CATALOGUE — invalid code rejected without leaking the list (role #2)"
|
||||
code="$(open_exception "$TXN_INVALID" "$INVALID_CODE")"
|
||||
if [[ "$code" == "422" && "$(jq -r '.error' <<<"$BODY")" == "INVALID_EXCEPTION_CODE" ]]; then
|
||||
ok "open exception ($INVALID_CODE) → 422 INVALID_EXCEPTION_CODE"
|
||||
else
|
||||
bad "expected 422 INVALID_EXCEPTION_CODE, got HTTP $code body=$BODY"
|
||||
fi
|
||||
# Non-enumeration invariant: the rejection must not list any real catalogue codes.
|
||||
if grep -qE 'EXC-BOARD-APPROVED|EXC-CONTRACTUAL-COMMITMENT|EXC-EMERGENCY-SPEND|EXC-WILL-REIMBURSE|EXC-ONE-TIME' <<<"$BODY"; then
|
||||
bad "INVALID_EXCEPTION_CODE response leaked catalogue codes: $BODY"
|
||||
else
|
||||
ok "rejection does not enumerate the catalogue (no valid codes leaked)"
|
||||
fi
|
||||
|
||||
# --- Summary ---------------------------------------------------------------
|
||||
section "Summary"
|
||||
printf ' passed: %d failed: %d\n' "$PASS" "$FAIL"
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
printf '\n\033[31mGATE→UNLOCK contract NOT satisfied.\033[0m If failures are 404s or\n'
|
||||
printf 'unexpected 201s on scenario A/C, you are likely re-running against a server\n'
|
||||
printf 'whose store was already mutated — restart the dev server to reseed.\n'
|
||||
exit 1
|
||||
fi
|
||||
printf '\n\033[32mGATE→UNLOCK contract verified (roles #1 and #2).\033[0m\n'
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* A2UI report canvas E2E.
|
||||
*
|
||||
* Proves the render_report → a2ui-surface → full-screen canvas handoff: an
|
||||
* assistant turn calls the backend `render_report` tool with a small selection
|
||||
* (which KPIs/charts); the tool builds A2UI ops, the A2UIMiddleware turns them
|
||||
* into an `a2ui-surface` activity, the chat shows a "rendered on the canvas"
|
||||
* pill, and LayoutComponent swaps the dashboard body out for <ReportCanvas/>
|
||||
* (data-testid="a2ui-surface"), which reads the ops from the agent message
|
||||
* stream and renders them against the banking catalog with live client data.
|
||||
*
|
||||
* ── HOW render_report BECOMES AN a2ui-surface ACTIVITY (verified) ────────────────
|
||||
* The runtime sets `a2ui: { injectA2UITool: false }` on both variants
|
||||
* (src/app/api/copilotkit/[[...slug]]/route.ts) and registers a backend tool
|
||||
* `render_report` on the BuiltInAgent whose `execute` returns
|
||||
* `{ a2ui_operations: [...] }` (built deterministically by
|
||||
* src/a2ui/build-report-ops.ts). With injectA2UITool:false the middleware
|
||||
* detects that `a2ui_operations` container in the tool result
|
||||
* (tryParseA2UIOperations) and emits the a2ui-surface activity. The model only
|
||||
* emits the tiny render_report selection — it never authors component JSON.
|
||||
*
|
||||
* ── REQUIRED aimock FIXTURE (not yet wired — see test.fixme below) ───────────────
|
||||
* aimock mocks ONLY the LLM, so the fixture just emits the render_report tool
|
||||
* call; the real backend `execute` runs and builds the ops. It must be loaded by
|
||||
* the aimock server (e2e/aimock-server.mjs), which currently hardcodes only
|
||||
* fixtures/memory-learning.fixtures.json — wiring this is the remaining step:
|
||||
*
|
||||
* {
|
||||
* "match": { "userMessage": "spend report on the canvas", "turnIndex": 0 },
|
||||
* "response": { "toolCalls": [{
|
||||
* "name": "render_report",
|
||||
* "arguments": {
|
||||
* "title": "Spend report",
|
||||
* "kpis": ["totalSpend", "pendingCount"],
|
||||
* "charts": ["spendingTrend"],
|
||||
* "transactions": "pending"
|
||||
* }
|
||||
* }] }
|
||||
* },
|
||||
* {
|
||||
* "match": { "userMessage": "spend report on the canvas", "turnIndex": 1 },
|
||||
* "response": { "content": "I put a spend report on the canvas." }
|
||||
* }
|
||||
*
|
||||
* (memory-learning.fixtures.json uses `sequenceIndex`; render-a2ui fixtures under
|
||||
* showcase/aimock use `turnIndex` + `hasToolResult`. Both are accepted by
|
||||
* loadFixtureFile — confirm the matching key on first run.)
|
||||
*
|
||||
* ── WHY test.fixme (headless CI honesty) ─────────────────────────────────────────
|
||||
* This gate needs the aimock stack running with the fixture above loaded
|
||||
* (Playwright webServer[0] currently loads only the memory fixtures) plus the
|
||||
* banking dev server. The fixture wiring and a first green run have not been
|
||||
* confirmed here, so — per the plan, where the aimock E2E is optional and MUST
|
||||
* NOT block the feature — it is marked test.fixme so it never reds the suite.
|
||||
* Remove `.fixme` once e2e/aimock-server.mjs loads this fixture and it passes
|
||||
* locally. (The render_report path itself is verified manually against a live
|
||||
* LLM: the report prompt paints KPIs + charts on the canvas.)
|
||||
*/
|
||||
|
||||
const REPORT_PROMPT =
|
||||
"Build a spend report on the canvas: show the spending trend.";
|
||||
|
||||
test.describe("A2UI report canvas", () => {
|
||||
test.fixme("a report prompt renders the A2UI surface on the canvas with a handoff pill", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// The dashboard body (credit-cards page) is visible before any surface is
|
||||
// active — this is the "before" state we assert disappears.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Credit Cards", level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
// Open the docked chat (starts closed; v2 launcher has this testid).
|
||||
await page.getByTestId("copilot-chat-toggle").click();
|
||||
|
||||
// Send a report prompt. "spend report on the canvas" is the aimock match key.
|
||||
const input = page.getByPlaceholder(/type a message/i);
|
||||
await input.fill(REPORT_PROMPT);
|
||||
await input.press("Enter");
|
||||
|
||||
// The chat shows the handoff pill (status-only a2ui-surface renderer in
|
||||
// wrapper.tsx).
|
||||
await expect(page.getByText(/rendered on the canvas/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The surface renders on the full-screen canvas (report-canvas.tsx).
|
||||
await expect(page.getByTestId("a2ui-surface")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// While the surface is active the normal dashboard body is swapped out for
|
||||
// <ReportCanvas/> (layout.tsx: activeSurfaceId ? <ReportCanvas/> : children).
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Credit Cards", level: 1 }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* aimock launcher for the deterministic banking E2Es.
|
||||
*
|
||||
* Starts an aimock server on $AIMOCK_PORT (default 7099) so the banking dev server
|
||||
* can point OPENAI_BASE_URL at it and get deterministic agent tool calls. The
|
||||
* fixture file is $AIMOCK_FIXTURES (a path relative to cwd, or absolute); when
|
||||
* unset it defaults to fixtures/memory-learning.fixtures.json. The OGUI routing
|
||||
* suite (playwright.ogui.config.ts) sets AIMOCK_FIXTURES=fixtures/ogui-routing...
|
||||
* on its own aimock instance (port 7098).
|
||||
*
|
||||
* Used as a Playwright webServer entry — Playwright waits on the readiness URL
|
||||
* before starting the dev server.
|
||||
*
|
||||
* VERIFY ON FIRST GREEN RUN (unverified API assumptions):
|
||||
* - The exact @copilotkit/aimock programmatic API. This uses the documented
|
||||
* `loadFixtureFile` + a server factory. If the named exports differ, the
|
||||
* simplest robust fallback is the bundled CLI instead of this script, e.g.:
|
||||
* pnpm exec aimock --port 7099 --validate-on-load e2e/fixtures/memory-learning.fixtures.json
|
||||
* (confirm the CLI's fixture-path flag; `aimock --help`). If you switch to the
|
||||
* CLI, set playwright.config webServer[0].command accordingly and delete this file.
|
||||
* - The readiness endpoint path (this assumes GET /health returns 200 once ready).
|
||||
*/
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PORT = Number(process.env.AIMOCK_PORT ?? 7099);
|
||||
const FIXTURES = process.env.AIMOCK_FIXTURES
|
||||
? process.env.AIMOCK_FIXTURES.startsWith("/")
|
||||
? process.env.AIMOCK_FIXTURES
|
||||
: join(process.cwd(), process.env.AIMOCK_FIXTURES)
|
||||
: join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"fixtures",
|
||||
"memory-learning.fixtures.json",
|
||||
);
|
||||
|
||||
const mod = await import("@copilotkit/aimock");
|
||||
|
||||
// Preferred path: load + validate the fixture file, then start a server bound to it.
|
||||
// The exact factory name is the main thing to confirm; we try the documented ones.
|
||||
// Confirmed exports in @copilotkit/aimock@1.19.1: LLMock, loadFixtureFile,
|
||||
// validateFixtures, createServer. LLMock is the OpenAI-shape mock server.
|
||||
const loadFixtureFile = mod.loadFixtureFile ?? mod.default?.loadFixtureFile;
|
||||
const validateFixtures = mod.validateFixtures ?? mod.default?.validateFixtures;
|
||||
const ServerCtor = mod.LLMock ?? mod.default?.LLMock;
|
||||
|
||||
if (!ServerCtor) {
|
||||
console.error(
|
||||
"[aimock-server] Could not find a server constructor in @copilotkit/aimock.\n" +
|
||||
"Use the bundled CLI instead (see header): pnpm exec aimock --port " +
|
||||
PORT +
|
||||
" --validate-on-load " +
|
||||
FIXTURES,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// loadFixtureFile returns a ready-to-use array of converted Fixture objects
|
||||
// (it parses the {fixtures:[...]} file and applies entryToFixture).
|
||||
const loadedFixtures = loadFixtureFile
|
||||
? loadFixtureFile(FIXTURES)
|
||||
: (JSON.parse(
|
||||
await (await import("node:fs/promises")).readFile(FIXTURES, "utf8"),
|
||||
).fixtures ?? []);
|
||||
if (validateFixtures) validateFixtures(loadedFixtures);
|
||||
|
||||
// NOTE: LLMock's constructor does NOT read `options.fixtures` — fixtures passed
|
||||
// that way are silently dropped and the server starts with zero fixtures (so
|
||||
// every LLM turn 404s "No fixture matched" and the agent never advances). They
|
||||
// MUST be registered via addFixtures()/addFixture(). (Verified against
|
||||
// @copilotkit/aimock@1.19.1: `new LLMock({fixtures}).getFixtures().length === 0`.)
|
||||
const server = new ServerCtor({ port: PORT });
|
||||
server.addFixtures(loadedFixtures);
|
||||
await server.start();
|
||||
console.log(
|
||||
`[aimock-server] listening on :${PORT} with ${server.getFixtures().length} fixtures`,
|
||||
);
|
||||
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
await server.stop?.();
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"_comment": [
|
||||
"aimock fixtures for the deterministic cross-thread memory proof (Task 7 / FOR-149).",
|
||||
"These pin the agent's LLM decisions so the test exercises the REAL local memory",
|
||||
"backend (recall ranking + tenant scoping) without LLM nondeterminism.",
|
||||
"",
|
||||
"STRATEGY (recall-half determinism, per the plan's Task 7 option 2): the spec SEEDS the",
|
||||
"over-limit procedure as a project/operational memory via REST, then drives a FRESH",
|
||||
"thread. With the procedure already in memory, the agent must (1) recall_memory, then",
|
||||
"apply the recalled code by (2) openPolicyException -> (3) finalizePolicyException ->",
|
||||
"(4) approveTransaction, and never offer to record. Each step is one fixture, matched in",
|
||||
"order via sequenceIndex (the same user message drives the whole run).",
|
||||
"",
|
||||
"VERIFY ON FIRST GREEN RUN (these are the unverified assumptions):",
|
||||
"- Fixture file schema: top-level {fixtures:[...]}, each {match:{userMessage, sequenceIndex?}, response:{content|toolCalls}}.",
|
||||
" Confirm against `loadFixtureFile`/`validateFixtures` from @copilotkit/aimock and showcase/aimock/*.json.",
|
||||
"- Multi-turn ordering key: this uses `sequenceIndex`. If aimock uses a different key",
|
||||
" (e.g. turnIndex / hasToolResult), rename accordingly (see showcase/GOTCHAS.md).",
|
||||
"- Tool names/argument shapes must match the banking tools (openPolicyException,",
|
||||
" finalizePolicyException, approveTransaction) and the agent's recall_memory call.",
|
||||
"- EXC-BOARD-APPROVED must stay the seeded justifying code (see the spec + over-limit-gate-smoke)."
|
||||
],
|
||||
"fixtures": [
|
||||
{
|
||||
"match": { "userMessage": "over-limit", "sequenceIndex": 0 },
|
||||
"response": {
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "recall_memory",
|
||||
"arguments": {
|
||||
"query": "how to approve an over-limit charge / policy exception procedure"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": { "userMessage": "over-limit", "sequenceIndex": 1 },
|
||||
"response": {
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "openPolicyException",
|
||||
"arguments": {
|
||||
"transactionId": "t-3",
|
||||
"code": "EXC-BOARD-APPROVED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": { "userMessage": "over-limit", "sequenceIndex": 2 },
|
||||
"response": {
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "finalizePolicyException",
|
||||
"arguments": {
|
||||
"transactionId": "t-3",
|
||||
"code": "EXC-BOARD-APPROVED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": { "userMessage": "over-limit", "sequenceIndex": 3 },
|
||||
"response": {
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "approveTransaction",
|
||||
"arguments": { "transactionId": "t-3" }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": { "userMessage": "over-limit", "sequenceIndex": 4 },
|
||||
"response": {
|
||||
"content": "Done — I recalled the saved procedure, filed an EXC-BOARD-APPROVED policy exception, and approved the over-limit charge."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"_comment": [
|
||||
"aimock fixtures for the deterministic OGUI routing test.",
|
||||
"Single-turn: each pill message maps to the tool the agent must pick.",
|
||||
"Guards BOTH boundary sides — curated pills must NOT route to generateSandboxedUi,",
|
||||
"OGUI pills MUST. userMessage values are copied byte-for-byte from wrapper.tsx pill messages."
|
||||
],
|
||||
"fixtures": [
|
||||
{
|
||||
"match": { "userMessage": "Show me the spending trend over time." },
|
||||
"response": {
|
||||
"toolCalls": [{ "name": "showSpendingTrend", "arguments": {} }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"userMessage": "Show me budget usage by policy — which ones are close to or over their limit?"
|
||||
},
|
||||
"response": {
|
||||
"toolCalls": [{ "name": "showBudgetUsage", "arguments": {} }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"userMessage": "Where is the money going? Break down spend by team."
|
||||
},
|
||||
"response": {
|
||||
"toolCalls": [{ "name": "showSpendBreakdown", "arguments": {} }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"userMessage": "Compare our income vs expenses — how is our cash flow?"
|
||||
},
|
||||
"response": {
|
||||
"toolCalls": [{ "name": "showIncomeVsExpenses", "arguments": {} }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"userMessage": "Build a full spend report on the canvas: KPIs, the spending trend, budget usage, and a spend breakdown by team."
|
||||
},
|
||||
"response": {
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "render_report",
|
||||
"arguments": {
|
||||
"title": "Spend report",
|
||||
"kpis": ["totalSpend"],
|
||||
"charts": ["spendingTrend"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"userMessage": "Build an interactive spend explorer I can filter and play with — pull the real transactions and policies."
|
||||
},
|
||||
"response": {
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "generateSandboxedUi",
|
||||
"arguments": {
|
||||
"initialHeight": 320,
|
||||
"html": "<div id=\"root\">Spend explorer</div>",
|
||||
"jsFunctions": "async function load(){ const t = await window.Websandbox.connection.remote.getTransactions(); document.getElementById('root').textContent = 'txns: ' + t.length; } load();"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"userMessage": "Prototype an interactive what-if calculator for our cash flow using our real income and expense figures."
|
||||
},
|
||||
"response": {
|
||||
"toolCalls": [
|
||||
{
|
||||
"name": "generateSandboxedUi",
|
||||
"arguments": {
|
||||
"initialHeight": 320,
|
||||
"html": "<div id=\"calc\">What-if calculator</div>",
|
||||
"jsFunctions": "async function load(){ const k = await window.Websandbox.connection.remote.getKpis(); document.getElementById('calc').textContent = 'spend: ' + k.totalSpend; } load();"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Deterministic cross-thread memory proof (Task 7 / FOR-149).
|
||||
*
|
||||
* Proves the RECALL half deterministically: with the over-limit procedure already
|
||||
* in project memory, a FRESH thread recalls it and completes the unlock unaided —
|
||||
* never offering to record. The agent's LLM is served by aimock (fixtures pin the
|
||||
* recall_memory -> openPolicyException -> finalizePolicyException ->
|
||||
* approveTransaction sequence), while the REAL local Intelligence backend does the
|
||||
* actual recall ranking + tenant scoping.
|
||||
*
|
||||
* The SAVE half is HITL+LLM (Option A) and is covered by the manual walkthrough
|
||||
* (README) + scripts/memory-drift-smoke.mjs — not this gate.
|
||||
*
|
||||
* ── PRECONDITIONS (the gate assumes these are already running) ──────────────────
|
||||
* 1. The memory stack is up and healthy (docker compose; see README). app-api on
|
||||
* $APP_API_URL (default http://localhost:7050) with the seeded org/key. IMPORTANT:
|
||||
* "container healthy" is not sufficient — the sl-mcp memory worker can throw an
|
||||
* UnhandledPromiseRejection during boot and briefly drop /mcp connections. If this
|
||||
* test fails with "No fixture matched" plus app-side [MCPMiddleware] "Failed to list
|
||||
* tools" / "other side closed" / ECONNRESET on :7050, that is the backend startup
|
||||
* window, NOT a fixture or demo bug — wait until `POST /mcp initialize` returns 200
|
||||
* (see scripts/memory-*-smoke.mjs readiness gate) and re-run. When the memory tools
|
||||
* fail to attach, the agent skips recall_memory and its LLM-call sequence diverges
|
||||
* from the sequenced fixtures, so the mismatch surfaces on a later call.
|
||||
* 2. Playwright starts aimock (webServer[0]) + the dev server in Intelligence mode
|
||||
* with OPENAI_BASE_URL pointed at aimock (see playwright.config.ts). Runs are
|
||||
* sequenced against one aimock server; parallel workers can interleave the
|
||||
* over-limit fixture group's shared counter, so run this spec with --workers=1.
|
||||
*
|
||||
* ── VERIFY ON FIRST GREEN RUN (unverified assumptions to shake out) ─────────────
|
||||
* - Chat input + send selectors (getByPlaceholder/getByRole below) match the
|
||||
* CopilotSidebar markup.
|
||||
* - The recall-path HITL cards (openPolicyException / finalizePolicyException /
|
||||
* approveTransaction) render approve buttons; this clicks any visible
|
||||
* approve/confirm button to advance. Adjust the APPROVE_LABELS list to the real
|
||||
* ApprovalButtons label(s).
|
||||
* - The over-limit seed txn id (DRIFT/GATE txn) is t-3 and renders a status that
|
||||
* reads "cleared"/"approved" after the flow.
|
||||
* - The fixtures' multi-turn ordering key (sequenceIndex) matches aimock.
|
||||
*/
|
||||
|
||||
const APP_API_URL = process.env.APP_API_URL ?? "http://localhost:7050";
|
||||
const KEY =
|
||||
process.env.INTELLIGENCE_API_KEY ?? "cpk_sPRVSEED_seed0privat0longtoken00";
|
||||
const USER_ID = process.env.CPKI_USER_ID ?? "jordan-beamson";
|
||||
const TXN_ID = process.env.GATE_TXN_ID ?? "t-3";
|
||||
const SEED_CODE = "EXC-BOARD-APPROVED";
|
||||
const APPROVE_LABELS = [
|
||||
/^approve$/i,
|
||||
/^confirm$/i,
|
||||
/^yes$/i,
|
||||
/^approve transaction$/i,
|
||||
];
|
||||
|
||||
const memHeaders = {
|
||||
Authorization: `Bearer ${KEY}`,
|
||||
"X-Cpki-User-Id": USER_ID,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
async function recallProcedureIds(): Promise<string[]> {
|
||||
const res = await fetch(`${APP_API_URL}/api/memories/recall`, {
|
||||
method: "POST",
|
||||
headers: memHeaders,
|
||||
body: JSON.stringify({
|
||||
query: "over-limit approval procedure",
|
||||
scope: "project",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { memories?: { id: string }[] };
|
||||
return (body.memories ?? []).map((m) => m.id);
|
||||
}
|
||||
|
||||
/** Arrange a clean slate, then seed exactly one project/operational procedure. */
|
||||
async function resetAndSeedProcedure(): Promise<void> {
|
||||
for (const id of await recallProcedureIds()) {
|
||||
await fetch(`${APP_API_URL}/api/memories/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: memHeaders,
|
||||
});
|
||||
}
|
||||
const res = await fetch(`${APP_API_URL}/api/memories`, {
|
||||
method: "POST",
|
||||
headers: memHeaders,
|
||||
body: JSON.stringify({
|
||||
content:
|
||||
`To approve an over-limit charge, open a policy exception with code ${SEED_CODE} ` +
|
||||
`against the charge and finalize it, then approve the transaction.`,
|
||||
scope: "project",
|
||||
kind: "operational",
|
||||
}),
|
||||
});
|
||||
expect(res.status, "seed procedure memory").toBe(201);
|
||||
}
|
||||
|
||||
test.describe("durable cross-thread memory recall (FOR-149)", () => {
|
||||
test.beforeAll(async () => {
|
||||
await resetAndSeedProcedure();
|
||||
});
|
||||
|
||||
test("a fresh thread recalls the procedure and unlocks the over-limit charge unaided", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// The docked chat starts closed (clean-dashboard first impression); open it
|
||||
// before interacting. CopilotSidebar's launcher is labelled "Open chat".
|
||||
const openChat = page.getByRole("button", { name: /open chat/i });
|
||||
if (await openChat.count()) await openChat.first().click();
|
||||
|
||||
// Fresh thread so there is NO in-thread context — recall is the only way the
|
||||
// agent can know the procedure.
|
||||
const newThread = page.getByRole("button", {
|
||||
name: /new (thread|conversation|chat)/i,
|
||||
});
|
||||
if (await newThread.count()) await newThread.first().click();
|
||||
|
||||
// Send an over-limit approval request. "over-limit" matches the aimock fixtures.
|
||||
const input = page.getByPlaceholder(/type a message/i);
|
||||
await input.fill(`Please approve the over-limit charge ${TXN_ID}.`);
|
||||
await input.press("Enter");
|
||||
|
||||
// The agent (via aimock) recalls then drives openPolicyException ->
|
||||
// finalizePolicyException -> approveTransaction, each as a HITL approval card.
|
||||
// Click through them; assert the recording offer never appears.
|
||||
const recordOffer = page.getByText(/record a workflow\?/i);
|
||||
|
||||
for (let step = 0; step < 4; step++) {
|
||||
// The "Record a workflow?" card must never appear on the recall path.
|
||||
await expect(recordOffer).toHaveCount(0);
|
||||
// Advance whichever approval card is currently shown.
|
||||
const approve = page.getByRole("button", { name: APPROVE_LABELS[0] });
|
||||
await approve
|
||||
.first()
|
||||
.click({ timeout: 30_000 })
|
||||
.catch(async () => {
|
||||
for (const label of APPROVE_LABELS.slice(1)) {
|
||||
const b = page.getByRole("button", { name: label });
|
||||
if (await b.count()) return b.first().click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Outcome: the recording offer never appeared, and the charge is cleared. Prefer
|
||||
// a server assertion (robust) — the over-limit gate is now lifted for TXN_ID.
|
||||
await expect(recordOffer).toHaveCount(0);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await fetch(
|
||||
`http://localhost:3000/api/v1/transactions/${TXN_ID}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
},
|
||||
);
|
||||
return res.status;
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
message:
|
||||
"over-limit charge should be approvable after recalled unlock",
|
||||
},
|
||||
)
|
||||
// 201 = approve succeeded (gate lifted); 409/200 = already approved by the run.
|
||||
.not.toBe(422);
|
||||
});
|
||||
|
||||
test("Glass Engine inspector streams events and shows the learned procedure", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open advanced mode from the left rail (availability gate is set in
|
||||
// playwright.config.ts via GLASS_ENGINE_AVAILABLE=true).
|
||||
await page
|
||||
.getByRole("button", { name: /glass engine/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The inspector pane is visible with its three tabs.
|
||||
await expect(page.getByText(/live protocol inspector/i)).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /timeline/i })).toBeVisible();
|
||||
|
||||
// Open the docked chat (starts closed) and drive a run so the Timeline
|
||||
// receives AG-UI events.
|
||||
const openChat = page.getByRole("button", { name: /open chat/i });
|
||||
if (await openChat.count()) await openChat.first().click();
|
||||
const input = page.getByPlaceholder(/type a message/i);
|
||||
await input.fill(`Please approve the over-limit charge ${TXN_ID}.`);
|
||||
await input.press("Enter");
|
||||
|
||||
// Timeline shows at least one streamed card (the empty-state copy is gone).
|
||||
await expect(page.getByText(/every AG-UI protocol event/i)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Learning tab reflects the seeded project procedure as "learned".
|
||||
await page.getByRole("tab", { name: /learning/i }).click();
|
||||
await expect(page.getByText(/over-limit procedure — learned/i)).toBeVisible(
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Memory tab lists the seeded procedure content under "Recalled memories".
|
||||
await page.getByRole("tab", { name: /memory/i }).click();
|
||||
await expect(
|
||||
page.getByText(/policy exception with code/i).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// Deterministic (aimock-backed) routing guard for the adjacency set — the pills
|
||||
// OGUI could plausibly steal. Curated pills must NOT open an OGUI iframe; OGUI
|
||||
// pills must. Runs in OSS mode via playwright.ogui.config.ts (isolated ports).
|
||||
//
|
||||
// NUANCE: clicking a pill sends its `message` (not its `title`); aimock matches
|
||||
// on userMessage = that message (see e2e/fixtures/ogui-routing.fixtures.json).
|
||||
// Here we click by the pill TITLE (what the user sees). Keep these straight.
|
||||
|
||||
async function openChatAndClick(page: Page, pillText: string) {
|
||||
await page.goto("/");
|
||||
// The docked chat starts closed; CopilotSidebar's launcher is "Open chat".
|
||||
const launcher = page.getByRole("button", { name: /open chat/i });
|
||||
if (await launcher.count()) await launcher.first().click();
|
||||
// Wait for the chat to hydrate (input present) before clicking a pill, else
|
||||
// the click can land before React wires the pill's onClick and is dropped.
|
||||
await expect(page.getByPlaceholder(/type a message/i)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const pill = page
|
||||
.getByTestId("copilot-suggestion")
|
||||
.filter({ hasText: pillText })
|
||||
.first();
|
||||
// The docked chat panel is pinned to the right edge, so its suggestion pills
|
||||
// land outside the viewport where Playwright's click (even force:true)
|
||||
// refuses to fire. Call the element's native click() — this drives React's
|
||||
// onClick (which sends the pill's `message` to the agent) regardless of
|
||||
// viewport position. Retry until the send registers (a user message bubble
|
||||
// appears), covering the race where an early click is dropped before
|
||||
// hydration completes.
|
||||
await expect(async () => {
|
||||
await pill.evaluate((el) => (el as HTMLElement).click());
|
||||
await expect(page.getByTestId("copilot-user-message").first()).toBeVisible({
|
||||
timeout: 2_000,
|
||||
});
|
||||
}).toPass({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
const CURATED = [
|
||||
{ pill: "Show the spending trend", heading: /spending trend/i },
|
||||
{ pill: "Budgets near their limit?", heading: /budget usage/i },
|
||||
{ pill: "Where is the money going?", heading: /spend breakdown/i },
|
||||
{ pill: "How's our cash flow?", heading: /income vs expenses/i },
|
||||
];
|
||||
|
||||
test.describe("OGUI routing — adjacency set", () => {
|
||||
for (const { pill, heading } of CURATED) {
|
||||
test(`curated pill "${pill}" renders its chart, not an OGUI iframe`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openChatAndClick(page, pill);
|
||||
// The curated chart renders inside an assistant message as an <h3> card
|
||||
// title. Scope to the transcript's assistant messages (not the pill row,
|
||||
// whose titles also contain these words). Match on the <h3> text rather
|
||||
// than role="heading": CopilotKit paints rendered tool output with
|
||||
// pointer-events/aria affordances that can drop the heading from the
|
||||
// accessibility tree, so getByRole("heading") is unreliable here.
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("copilot-assistant-message")
|
||||
.locator("h3")
|
||||
.filter({ hasText: heading })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("ogui-surface")).toHaveCount(0);
|
||||
await expect(page.locator("iframe")).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('boundary: "Build a spend report on the canvas" routes to render_report, not OGUI', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openChatAndClick(page, "Build a spend report on the canvas");
|
||||
await expect(page.getByTestId("a2ui-surface")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("ogui-surface")).toHaveCount(0);
|
||||
});
|
||||
|
||||
const OGUI = [
|
||||
"Build an interactive spend explorer",
|
||||
"Prototype a cash-flow what-if calculator",
|
||||
];
|
||||
for (const pill of OGUI) {
|
||||
test(`OGUI pill "${pill}" renders on the canvas`, async ({ page }) => {
|
||||
await openChatAndClick(page, pill);
|
||||
const surface = page.getByTestId("ogui-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30_000 });
|
||||
await expect(surface.locator("iframe").first()).toBeVisible();
|
||||
// Two identical handoff pills appear on OGUI turns in REPLAY (this uses
|
||||
// .first() to tolerate that). Cause: generateSandboxedUi is a frontend tool
|
||||
// with followUp:true, so after it runs the agent takes a follow-up turn with
|
||||
// the SAME (unchanged) userMessage. aimock matches only on that userMessage,
|
||||
// so on the follow-up it RE-SERVES the same generateSandboxedUi fixture → a
|
||||
// second OGUI activity → a second pill. It is a replay-only artifact:
|
||||
// interactively a real LLM replies with prose on the follow-up, so a user sees
|
||||
// ONE pill, and the canvas renders one surface either way (latest-id
|
||||
// arbitration). NOT cross-exchange accumulation — each test does a fresh
|
||||
// page.goto("/").
|
||||
//
|
||||
// A terminating follow-up fixture (sequenceIndex 0 = the tool, sequenceIndex 1
|
||||
// = prose) was attempted to make replay show a single pill, but it destabilized
|
||||
// this suite: the runtime issues a "Generate a short title for this
|
||||
// conversation" request whose body EMBEDS the pill text, and aimock matches
|
||||
// userMessage by substring — so those title-gen requests also match the OGUI
|
||||
// fixtures and race ahead to consume the sequenceIndex counter, leaving the
|
||||
// real leg-1 turn to fall through to prose (no tool → no surface renders). So
|
||||
// we keep the .first() guard; the canvas already renders exactly one surface.
|
||||
await expect(
|
||||
page.getByText(/rendered on the canvas/i).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { ConsoleMessage, Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* CI-safe smoke test for the Northwind banking showcase.
|
||||
*
|
||||
* What this covers:
|
||||
* 1. App boots and the dashboard renders credible content (brand + cards UI).
|
||||
* 2. The CopilotKit v2 popup launcher opens the modal.
|
||||
* 3. The arc-leading suggestion pills (incl. the OGUI pill) are visible.
|
||||
*
|
||||
* What this intentionally does NOT do:
|
||||
* - Send any chat message
|
||||
* - Click any suggestion (which would invoke the agent / hit OpenAI)
|
||||
* - Exercise any tool
|
||||
*
|
||||
* That keeps the test runnable in CI without secrets.
|
||||
*/
|
||||
|
||||
// Console-error filtering: the page may legitimately log network errors for
|
||||
// the /api/copilotkit endpoint (e.g. if the popup pings it on open and the
|
||||
// runtime fails because OPENAI_API_KEY is a dummy value), and Next.js dev mode
|
||||
// prints various non-fatal warnings. We only fail on genuine page/script
|
||||
// errors that indicate the app itself is broken.
|
||||
const IGNORED_ERROR_PATTERNS: RegExp[] = [
|
||||
/favicon/i,
|
||||
/\/api\/copilotkit/i,
|
||||
/Failed to load resource/i,
|
||||
/net::ERR_/i,
|
||||
/Download the React DevTools/i,
|
||||
];
|
||||
|
||||
function isIgnoredError(message: ConsoleMessage): boolean {
|
||||
if (message.type() !== "error") return true;
|
||||
const text = message.text();
|
||||
return IGNORED_ERROR_PATTERNS.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
function collectConsoleErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error" && !isIgnoredError(msg)) {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
function collectPageErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (err) => {
|
||||
errors.push(err.stack ?? err.message);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
test.describe("banking showcase smoke", () => {
|
||||
test("dashboard renders and popup opens with suggestions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleErrors = collectConsoleErrors(page);
|
||||
const pageErrors = collectPageErrors(page);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Brand assertion: the document title is the Northwind Finance brand.
|
||||
await expect(page).toHaveTitle(/Northwind/);
|
||||
|
||||
// The credit-cards page renders an h1 "Credit Cards" and an "Add Card"
|
||||
// dropdown — that's our credible-content check that the dashboard is up.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Credit Cards", level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
// Open the CopilotKit popup. v2 renders a fixed launcher button with
|
||||
// data-testid="copilot-chat-toggle" (see CopilotChatToggleButton.tsx).
|
||||
const launcher = page.getByTestId("copilot-chat-toggle");
|
||||
await expect(launcher).toBeVisible();
|
||||
await launcher.click();
|
||||
|
||||
// The popup modal exposes the configured assistant title as its aria-label
|
||||
// (modalHeaderTitle = IDENTITY.assistant = "Northwind Copilot"). We assert
|
||||
// on the dialog rather than visible text since the title may be rendered
|
||||
// as aria-only.
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Northwind Copilot/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Pills configured by BankingSuggestions render as buttons with
|
||||
// data-testid="copilot-suggestion" (CopilotChatSuggestionPill.tsx). Assert
|
||||
// the arc-leading pills are present rather than a brittle exact count —
|
||||
// the pill catalog grows over time (curated charts, OGUI, cross-page ops).
|
||||
const suggestions = page.getByTestId("copilot-suggestion");
|
||||
await expect(suggestions.first()).toBeVisible();
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Approve the $5,000 Google Ads charge" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Review my pending transactions" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
suggestions.filter({ hasText: "Build an interactive spend explorer" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Make sure nothing genuinely broken showed up in the console while the
|
||||
// page rendered and the popup opened.
|
||||
expect(
|
||||
consoleErrors,
|
||||
`Unexpected console errors:\n${consoleErrors.join("\n")}`,
|
||||
).toEqual([]);
|
||||
|
||||
// Uncaught exceptions don't always surface as console.error — pageerror is
|
||||
// the canonical "did the app crash?" hook. Any uncaught exception fails
|
||||
// the smoke test (no filtering).
|
||||
expect(
|
||||
pageErrors,
|
||||
`Unexpected uncaught page errors:\n${pageErrors.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypescript from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = [
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"playwright-report/**",
|
||||
"test-results/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// React StrictMode intentionally double-invokes mount effects in DEV ONLY.
|
||||
// For CopilotKit's AG-UI human-in-the-loop flow that double-mount tears the
|
||||
// in-flight run down the instant the agent emits a HITL tool call — before
|
||||
// the approval card can render and return its result — which orphans the tool
|
||||
// call ("Tool result is missing for tool call …") and then poisons the thread
|
||||
// so every later message fails. Production builds never double-invoke, so the
|
||||
// bug is purely a `next dev` artifact; disabling StrictMode makes the live
|
||||
// HITL chat (and the teach-mode demonstration arc) work when the demo is run
|
||||
// via `next dev`. No effect on production behavior or correctness.
|
||||
reactStrictMode: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "demo-saas-copilot",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ogui": "playwright test -c playwright.ogui.config.ts",
|
||||
"test:self-learning": "playwright test e2e/memory-learning.spec.ts",
|
||||
"test:unit": "vitest run",
|
||||
"mint-dev-license": "node scripts/mint-dev-license.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/a2ui-renderer": "workspace:*",
|
||||
"@copilotkit/core": "workspace:*",
|
||||
"@copilotkit/react-core": "workspace:*",
|
||||
"@copilotkit/runtime": "workspace:*",
|
||||
"@copilotkit/shared": "workspace:*",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.11.4",
|
||||
"lucide-react": "^0.447.0",
|
||||
"next": "^16.0.10",
|
||||
"openai": "^4.96.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@copilotkit/aimock": "1.19.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16.0.10",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: /ogui-routing\.spec\.ts/,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: "list",
|
||||
use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
|
||||
// Two servers: aimock (deterministic LLM) must be up before the dev server so the
|
||||
// runtime's OPENAI_BASE_URL resolves. The memory-learning E2E additionally needs
|
||||
// the docker memory stack already running (see README / e2e/memory-learning.spec).
|
||||
webServer: [
|
||||
{
|
||||
// Deterministic LLM for the memory E2E. See e2e/aimock-server.mjs for the
|
||||
// fixture wiring + the CLI fallback if the programmatic API differs.
|
||||
command: "node e2e/aimock-server.mjs",
|
||||
url: `http://localhost:${process.env.AIMOCK_PORT ?? "7099"}/health`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
{
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
// Existing LLM-free smokes only need *some* OPENAI_API_KEY so the route's
|
||||
// BuiltInAgent import doesn't crash at boot. The memory E2E additionally
|
||||
// points the agent at aimock and runs the runtime in Intelligence mode.
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "test",
|
||||
OPENAI_BASE_URL:
|
||||
process.env.OPENAI_BASE_URL ??
|
||||
`http://localhost:${process.env.AIMOCK_PORT ?? "7099"}/v1`,
|
||||
INTELLIGENCE_API_URL:
|
||||
process.env.INTELLIGENCE_API_URL ?? "http://localhost:7050",
|
||||
INTELLIGENCE_GATEWAY_WS_URL:
|
||||
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:7053",
|
||||
INTELLIGENCE_API_KEY:
|
||||
process.env.INTELLIGENCE_API_KEY ??
|
||||
"cpk_sPRVSEED_seed0privat0longtoken00",
|
||||
INTELLIGENCE_USER_ID:
|
||||
process.env.INTELLIGENCE_USER_ID ?? "jordan-beamson",
|
||||
// Glass Engine must be available for the inspector test to render the
|
||||
// left-rail toggle (the deployment availability gate; see lib/glass-engine).
|
||||
GLASS_ENGINE_AVAILABLE: "true",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Dedicated config for the OGUI routing test. Isolated ports (dev :3002, aimock
|
||||
// :7098) and OSS mode (no INTELLIGENCE_* env) so it never collides with the
|
||||
// Intelligence-mode suite in playwright.config.ts and needs no docker stack.
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: /ogui-routing\.spec\.ts/,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3002",
|
||||
trace: "on-first-retry",
|
||||
// Wide enough that the full suggestion-pill row stays on-screen — the pills
|
||||
// overflow a default 1280px viewport, landing off-screen where clicks fail.
|
||||
viewport: { width: 1680, height: 900 },
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command:
|
||||
"AIMOCK_FIXTURES=e2e/fixtures/ogui-routing.fixtures.json AIMOCK_PORT=7098 node e2e/aimock-server.mjs",
|
||||
url: "http://localhost:7098/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
{
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3002",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
PORT: "3002",
|
||||
OPENAI_API_KEY: "test",
|
||||
OPENAI_BASE_URL: "http://localhost:7098/v1",
|
||||
// OSS mode: deliberately NO INTELLIGENCE_* vars → the runtime uses the
|
||||
// InMemoryAgentRunner path (no docker, no gateway).
|
||||
GLASS_ENGINE_AVAILABLE: "false",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# run-demo.sh — one-command cold start for the banking demo (self-hosted mode).
|
||||
#
|
||||
# cd examples/showcases/banking && ./run-demo.sh
|
||||
#
|
||||
# Brings up the memory-enabled Intelligence stack + a working embedder, mints a
|
||||
# dev license if needed, then starts the Next.js dev server. Idempotent: safe to
|
||||
# re-run — it reuses anything already up.
|
||||
#
|
||||
# Embedder platform split (this is the whole point of the script):
|
||||
# - Apple Silicon (arm64): the bundled docker `tei` image is amd64-only and
|
||||
# crash-loops under emulation (Candle backend unavailable -> ONNX/ORT ->
|
||||
# 404). So we run a NATIVE Metal TEI on the host (:7067) and point app-api
|
||||
# at it. Same TEI version + model => byte-identical embeddings, ~20x faster.
|
||||
# - amd64 / Linux / CI: use the bundled docker `tei` via its `cpu-fallback`
|
||||
# compose profile (native there, no emulation).
|
||||
#
|
||||
# Managed Intelligence users: you do NOT need this script — set the
|
||||
# INTELLIGENCE_* endpoints + a CopilotKit-issued COPILOTKIT_LICENSE_TOKEN in
|
||||
# .env and run `pnpm dev` directly. This script is the self-hosted local path.
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$DEMO_DIR"
|
||||
LOG_DIR="${TMPDIR:-/tmp}/banking-demo"; mkdir -p "$LOG_DIR"
|
||||
|
||||
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||
ok() { printf ' \033[1;32m✓\033[0m %s\n' "$*"; }
|
||||
die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
wait_http() { # url label maxsecs
|
||||
local url="$1" label="$2" max="${3:-120}" i=0 code
|
||||
while [ "$i" -lt "$max" ]; do
|
||||
code="$(curl -s -m3 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
|
||||
[ "$code" != "000" ] && { ok "$label ready ($code)"; return 0; }
|
||||
sleep 3; i=$((i+3))
|
||||
done
|
||||
die "$label did not come up at $url within ${max}s (see $LOG_DIR)"
|
||||
}
|
||||
|
||||
# --- Preflight --------------------------------------------------------------
|
||||
say "Preflight"
|
||||
docker info >/dev/null 2>&1 || die "Docker is not running. Start Docker Desktop and re-run."
|
||||
[ -f .env ] || die ".env missing. Copy .env.example to .env and set OPENAI_API_KEY."
|
||||
grep -q '^OPENAI_API_KEY=.\+' .env || die "OPENAI_API_KEY not set in .env (the agent needs it)."
|
||||
# The composite image build context + the dev-license signer both need the
|
||||
# (private) Intelligence source. Default to the sibling checkout the compose uses.
|
||||
export INTELLIGENCE_REPO="${INTELLIGENCE_REPO:-$(cd "$DEMO_DIR/../../../../Intelligence" 2>/dev/null && pwd || true)}"
|
||||
[ -n "$INTELLIGENCE_REPO" ] && [ -d "$INTELLIGENCE_REPO" ] \
|
||||
|| die "INTELLIGENCE_REPO not found. Point it at your Intelligence checkout (self-hosted mode needs the source to build the image + mint a dev license)."
|
||||
ok "docker running, .env present, INTELLIGENCE_REPO=$INTELLIGENCE_REPO"
|
||||
|
||||
# --- Dev license ------------------------------------------------------------
|
||||
# Self-hosted memory is gated behind a signed offline license. Mint one only if
|
||||
# .env doesn't already carry a token, so re-runs don't churn the baked key.
|
||||
if grep -q '^COPILOTKIT_LICENSE_TOKEN=.\+' .env; then
|
||||
ok "dev license already present in .env"
|
||||
else
|
||||
say "Minting a dev license (features.memory=true) into .env"
|
||||
node scripts/mint-dev-license.mjs --write
|
||||
fi
|
||||
|
||||
# --- Embedder + stack (platform split) --------------------------------------
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then
|
||||
say "Apple Silicon ($ARCH): native Metal TEI on :7067 (bundled amd64 tei is skipped)"
|
||||
if [ "$(curl -s -m3 -o /dev/null -w '%{http_code}' http://localhost:7067/health 2>/dev/null)" != "200" ]; then
|
||||
command -v text-embeddings-router >/dev/null 2>&1 \
|
||||
|| die "native TEI missing. Install it: brew install text-embeddings-inference"
|
||||
say " starting native Metal TEI (Qwen/Qwen3-Embedding-0.6B)"
|
||||
# --max-batch-tokens caps the warmup forward pass. TEI's default (16384) can
|
||||
# fault the Metal backend during warmup on some Apple Silicon setups: the
|
||||
# process either deadlocks (all threads parked, 0% CPU) or dies silently
|
||||
# without a panic — a GPU-level abort — so :7067 never binds and the health
|
||||
# wait below times out. A small warmup batch clears warmup reliably. It only
|
||||
# bounds per-request tokens (memory texts are short), not the embedding
|
||||
# vectors themselves, so recall stays byte-identical.
|
||||
nohup text-embeddings-router --model-id 'Qwen/Qwen3-Embedding-0.6B' \
|
||||
--port 7067 --auto-truncate --max-batch-tokens 512 \
|
||||
> "$LOG_DIR/tei-metal.log" 2>&1 & disown
|
||||
fi
|
||||
wait_http "http://localhost:7067/health" "native Metal TEI" 300
|
||||
# Warm the model so the first real recall isn't a cold forward pass.
|
||||
curl -s -m30 -o /dev/null -X POST http://localhost:7067/embed \
|
||||
-H 'Content-Type: application/json' -d '{"inputs":"warmup"}' 2>/dev/null || true
|
||||
export MEMORY_EMBEDDINGS_URL="http://host.docker.internal:7067"
|
||||
say "Bringing up the stack (embedder = native host TEI; docker tei stays off)"
|
||||
docker compose up -d --wait
|
||||
else
|
||||
say "amd64/Linux ($ARCH): bundled docker tei via the cpu-fallback profile"
|
||||
docker compose --profile cpu-fallback up -d --wait
|
||||
fi
|
||||
ok "stack healthy: app-api :7050, gateway :7053"
|
||||
|
||||
# --- App --------------------------------------------------------------------
|
||||
say "Starting the Next.js dev server (http://localhost:3000)"
|
||||
say " (stack is up; Ctrl-C stops only the dev server, not the docker stack)"
|
||||
exec pnpm dev
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-LLM memory drift smoke — NON-GATING, run manually.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* The deterministic CI gate (e2e/memory-learning.spec.ts) serves the agent's
|
||||
* LLM from aimock fixtures, so it proves the WIRING (prompt -> tool call -> memory
|
||||
* backend -> cross-thread recall -> unlock) but is BLIND to behavioral drift: a
|
||||
* fixture replays a fixed decision, so if a prompt edit makes the real model stop
|
||||
* calling its memory tools, the aimock test keeps passing. This script closes that
|
||||
* gap with a REAL OpenAI call.
|
||||
*
|
||||
* WHAT IT CHECKS (headless half)
|
||||
* It seeds the over-limit procedure as a project/operational memory via REST, then
|
||||
* drives a FRESH-THREAD over-limit approval request through the live runtime and
|
||||
* asserts the run's event stream contains a `recall_memory` tool call — i.e. the
|
||||
* live model still RECALLS-FIRST (the autonomous, load-bearing moment). It also
|
||||
* asserts that NO `save_memory` fires on that over-limit request turn (rule 9:
|
||||
* GENERAL MEMORY must defer during a procedure). The mid-demonstration turns are
|
||||
* HITL/headless-unreachable, so that coverage lives in the aimock e2e + the manual
|
||||
* walkthrough.
|
||||
*
|
||||
* WHAT IT DOES NOT CHECK
|
||||
* The SAVE half (the agent emitting `save_memory` after the teach arc) is gated
|
||||
* behind the human-in-the-loop teach cards (offerWorkflowRecording ->
|
||||
* awaitDashboardDemonstration -> saveLearnedWorkflow) and cannot be driven
|
||||
* headlessly. Verify the save path via the manual UI walkthrough (README step 3)
|
||||
* and the aimock E2E.
|
||||
*
|
||||
* REQUIREMENTS
|
||||
* - The memory-enabled stack is up (docker compose; see README) and reachable.
|
||||
* - The demo dev server is running in Intelligence mode (the three INTELLIGENCE_*
|
||||
* env vars set) with a real OPENAI_API_KEY.
|
||||
*
|
||||
* READINESS GATE
|
||||
* The Intelligence sl-mcp worker can throw an UnhandledPromiseRejection during boot
|
||||
* and briefly drop /mcp connections even after `docker compose up --wait` reports the
|
||||
* container healthy. This smoke first polls `POST /mcp initialize` until it returns 200,
|
||||
* so it only runs once memory is actually serving — a booting/down backend fails fast
|
||||
* with a clear message instead of masquerading as recall drift.
|
||||
*
|
||||
* USAGE
|
||||
* node scripts/memory-drift-smoke.mjs
|
||||
* ENV (optional)
|
||||
* DEMO_URL default http://localhost:3000
|
||||
* APP_API_URL default http://localhost:7050
|
||||
* INTELLIGENCE_API_KEY default cpk_sPRVSEED_seed0privat0longtoken00
|
||||
* CPKI_USER_ID default jordan-beamson
|
||||
* DRIFT_TXN_ID default t-3 (an over-limit pending seed txn)
|
||||
*
|
||||
* NOTE: the run is posted to the AG-UI run endpoint
|
||||
* `${DEMO_URL}/api/copilotkit/agent/default/run` with a minimal RunAgentInput. If a
|
||||
* future runtime version changes that path or body shape, that POST is the one spot
|
||||
* to adjust — the seed/recall REST calls and the stream scan are stable.
|
||||
*/
|
||||
|
||||
const DEMO_URL = process.env.DEMO_URL ?? "http://localhost:3000";
|
||||
const APP_API_URL = process.env.APP_API_URL ?? "http://localhost:7050";
|
||||
const KEY =
|
||||
process.env.INTELLIGENCE_API_KEY ?? "cpk_sPRVSEED_seed0privat0longtoken00";
|
||||
const USER_ID = process.env.CPKI_USER_ID ?? "jordan-beamson";
|
||||
const TXN_ID = process.env.DRIFT_TXN_ID ?? "t-3";
|
||||
|
||||
const PROCEDURE = (code) =>
|
||||
`To approve an over-limit charge, open a policy exception with code ${code} ` +
|
||||
`against the charge and finalize it, then approve the transaction.`;
|
||||
const SEED_CODE = "EXC-BOARD-APPROVED";
|
||||
|
||||
function log(ok, msg) {
|
||||
console.log(`${ok ? "✓" : "✗"} ${msg}`);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Poll POST /mcp `initialize` until the sl-mcp worker answers 200 and the SSE body
|
||||
// completes without a reset. Guards against the Intelligence backend's boot window,
|
||||
// where the worker throws an UnhandledPromiseRejection and drops /mcp connections even
|
||||
// though the container reports healthy — running against that window makes the agent
|
||||
// lose recall_memory mid-run and looks like drift. Fails fast so a booting backend
|
||||
// never masquerades as a prompt regression.
|
||||
async function waitForMcpReady({ retries = 30, delayMs = 1000 } = {}) {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "smoke-preflight", version: "1" },
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(`${APP_API_URL}/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${KEY}`,
|
||||
"X-Cpki-User-Id": USER_ID,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-protocol-version": "2025-11-25",
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (res.ok) {
|
||||
await res.text(); // reading the body catches a mid-stream reset
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// connection refused / reset during boot — keep polling
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
throw new Error(
|
||||
`MCP not ready after ${retries * delayMs}ms — the Intelligence sl-mcp worker never ` +
|
||||
`stabilized at POST ${APP_API_URL}/mcp (initialize). Bring up / restart the stack ` +
|
||||
`(docker compose up -d --wait) and confirm 'docker logs' shows no boot-time ` +
|
||||
`UnhandledPromiseRejection, then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm the demo dev server (pnpm dev) is up at DEMO_URL. The /mcp gate only covers
|
||||
// the backend (:7050); the over-limit run below hits the app (:3000). Any HTTP response
|
||||
// means it is serving; only a connection error means it is down.
|
||||
async function waitForDemoServer({ retries = 20, delayMs = 1000 } = {}) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await fetch(DEMO_URL, { method: "GET" });
|
||||
return;
|
||||
} catch {
|
||||
// connection refused — dev server not up yet
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
throw new Error(
|
||||
`Demo dev server not reachable at ${DEMO_URL} — start it with 'pnpm dev' ` +
|
||||
`(Intelligence mode: the three INTELLIGENCE_* env vars + a real OPENAI_API_KEY), then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function seedProcedureMemory() {
|
||||
const res = await fetch(`${APP_API_URL}/api/memories`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${KEY}`,
|
||||
"X-Cpki-User-Id": USER_ID,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: PROCEDURE(SEED_CODE),
|
||||
scope: "project",
|
||||
kind: "operational",
|
||||
}),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(
|
||||
`seed memory failed: HTTP ${res.status} ${await res.text()}`,
|
||||
);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return body;
|
||||
}
|
||||
|
||||
async function runOverLimitTurn() {
|
||||
// Minimal AG-UI RunAgentInput. A fresh UUID threadId guarantees no in-thread context
|
||||
// (the only way the agent can know the procedure is by calling recall_memory) and
|
||||
// satisfies the Intelligence backend's UUID validation (a custom "drift-..." id 400s).
|
||||
const threadId = crypto.randomUUID();
|
||||
const body = {
|
||||
threadId,
|
||||
runId: crypto.randomUUID(),
|
||||
state: {},
|
||||
// Alex -> jordan-beamson (the id we seed the procedure under). Makes the
|
||||
// smoke identity-self-sufficient so it passes against the unpinned live demo.
|
||||
properties: { userId: "9g5h2j1k4l", userRole: "Admin" },
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
content: `Please approve the over-limit charge ${TXN_ID}.`,
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
};
|
||||
const res = await fetch(`${DEMO_URL}/api/copilotkit/agent/default/run`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`run POST failed: HTTP ${res.status} ${await res.text().catch(() => "")}\n` +
|
||||
"hint: confirm the demo is running in Intelligence mode and the run endpoint " +
|
||||
"path/body shape matches this runtime version (see header NOTE).",
|
||||
);
|
||||
}
|
||||
// Drain the FULL turn's SSE (until the run ends or the deadline) before scanning.
|
||||
// We must not early-return on the first recall_memory frame: because RECALL FIRST
|
||||
// makes recall stream before anything else, a spurious general save_memory (rule 9
|
||||
// leak) streams AFTER it — cancelling the reader on recall would cut that frame off
|
||||
// and make the negative-save assertion a false negative. The turn ends after the
|
||||
// agent emits its tool calls (the HITL cards are answered on the NEXT turn, which
|
||||
// we never send), so draining terminates naturally; the deadline is a backstop.
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
}
|
||||
reader.cancel().catch(() => {});
|
||||
return {
|
||||
recalled: /recall_memory/.test(buf),
|
||||
sawSave: /save_memory/.test(buf),
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
`memory drift smoke (REAL LLM) — demo ${DEMO_URL}, app-api ${APP_API_URL}, txn ${TXN_ID}\n`,
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForMcpReady();
|
||||
log(true, "preflight: /mcp initialize is serving (memory tools ready)");
|
||||
await waitForDemoServer();
|
||||
log(true, `preflight: demo dev server reachable at ${DEMO_URL}`);
|
||||
|
||||
const seeded = await seedProcedureMemory();
|
||||
log(
|
||||
true,
|
||||
`seeded project/operational procedure memory (${seeded.absorbed ? "absorbed" : "created"})`,
|
||||
);
|
||||
|
||||
const { recalled, sawSave } = await runOverLimitTurn();
|
||||
log(
|
||||
recalled,
|
||||
recalled
|
||||
? "PASS: live model emitted recall_memory on a fresh-thread over-limit request"
|
||||
: "DRIFT: live model did NOT emit recall_memory — the recall-first prompt may have regressed",
|
||||
);
|
||||
// Rule 9 (DEFER DURING PROCEDURES): the over-limit request turn must NOT emit a
|
||||
// general save. A spurious save_memory here means the GENERAL MEMORY block is
|
||||
// firing inside the teach flow.
|
||||
log(
|
||||
!sawSave,
|
||||
sawSave
|
||||
? "DRIFT: a save_memory fired on the over-limit request turn — GENERAL MEMORY leaked into the procedure (rule 9)"
|
||||
: "PASS: no spurious save_memory on the over-limit request turn",
|
||||
);
|
||||
process.exit(recalled && !sawSave ? 0 : 1);
|
||||
} catch (err) {
|
||||
log(false, `error: ${String(err).slice(0, 400)}`);
|
||||
process.exit(2);
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-LLM general-memory smoke — NON-GATING, run manually.
|
||||
* Mirrors memory-drift-smoke.mjs. Asserts the GENERAL MEMORY prompt behavior:
|
||||
* SAVE — a personal fact triggers save_memory(kind:"topical", scope:"user")
|
||||
* NO-SAVE — a secret is NOT saved
|
||||
* RECALL — a seeded user-scoped fact is recalled on a fresh thread
|
||||
* ISOLATION — a user-scoped fact does NOT cross to a different seeded user;
|
||||
* a project-scoped fact DOES (REST-level, no LLM ranking dependency)
|
||||
*
|
||||
* REQUIREMENTS: the memory stack is up and the demo runs in Intelligence mode
|
||||
* with a real OPENAI_API_KEY (see README). Uses two seeded users:
|
||||
* ALEX_ID -> jordan-beamson, MAYA_ID -> morgan-fluxx.
|
||||
*
|
||||
* READINESS GATE: the Intelligence sl-mcp worker can throw an UnhandledPromiseRejection
|
||||
* during boot and briefly drop /mcp connections even after `docker compose up --wait`
|
||||
* reports the container healthy. Running against that window makes the agent lose its
|
||||
* memory tools mid-run and surfaces as unrelated failures. This smoke first polls
|
||||
* `POST /mcp initialize` until it returns 200, so it only runs once memory is actually
|
||||
* serving. If you see connection resets or "no fixture matched" style flakes, it is the
|
||||
* backend startup window, not this script.
|
||||
*/
|
||||
const DEMO_URL = process.env.DEMO_URL ?? "http://localhost:3000";
|
||||
const APP_API_URL = process.env.APP_API_URL ?? "http://localhost:7050";
|
||||
const KEY =
|
||||
process.env.INTELLIGENCE_API_KEY ?? "cpk_sPRVSEED_seed0privat0longtoken00";
|
||||
const ALEX = {
|
||||
memberId: "9g5h2j1k4l",
|
||||
role: "Admin",
|
||||
userId: "jordan-beamson",
|
||||
};
|
||||
const MAYA = { userId: "morgan-fluxx" };
|
||||
|
||||
function log(ok, msg) {
|
||||
console.log(`${ok ? "✓" : "✗"} ${msg}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Poll POST /mcp `initialize` until the sl-mcp worker answers 200 and the SSE body
|
||||
// completes without a reset. Guards against the Intelligence backend's boot window,
|
||||
// where the worker throws an UnhandledPromiseRejection and drops /mcp connections
|
||||
// even though the container reports healthy. Fails fast with a clear message so a
|
||||
// down/booting backend never masquerades as a feature regression.
|
||||
async function waitForMcpReady({ retries = 30, delayMs = 1000 } = {}) {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "smoke-preflight", version: "1" },
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(`${APP_API_URL}/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${KEY}`,
|
||||
"X-Cpki-User-Id": ALEX.userId,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-protocol-version": "2025-11-25",
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (res.ok) {
|
||||
await res.text();
|
||||
return;
|
||||
} // reading the body catches a mid-stream reset
|
||||
} catch {
|
||||
// connection refused / reset during boot — keep polling
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
throw new Error(
|
||||
`MCP not ready after ${retries * delayMs}ms — the Intelligence sl-mcp worker never ` +
|
||||
`stabilized at POST ${APP_API_URL}/mcp (initialize). Bring up / restart the stack ` +
|
||||
`(docker compose up -d --wait) and confirm 'docker logs' shows no boot-time ` +
|
||||
`UnhandledPromiseRejection, then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function restSave(userId, content, kind, scope) {
|
||||
const res = await fetch(`${APP_API_URL}/api/memories`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${KEY}`,
|
||||
"X-Cpki-User-Id": userId,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content, kind, scope }),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`save failed HTTP ${res.status} ${await res.text()}`);
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
async function restRecall(userId, query, scope) {
|
||||
const res = await fetch(`${APP_API_URL}/api/memories/recall`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${KEY}`,
|
||||
"X-Cpki-User-Id": userId,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(scope ? { query, scope } : { query }),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`recall failed HTTP ${res.status} ${await res.text()}`);
|
||||
const body = await res.json().catch(() => ({ memories: [] }));
|
||||
return body.memories ?? [];
|
||||
}
|
||||
|
||||
// Confirm the demo dev server (pnpm dev) is actually up at DEMO_URL. The /mcp gate
|
||||
// only covers the backend (:7050); the chat turns below hit the app (:3000). Any HTTP
|
||||
// response (even 404) means it is serving; only a connection error means it is down.
|
||||
async function waitForDemoServer({ retries = 20, delayMs = 1000 } = {}) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await fetch(DEMO_URL, { method: "GET" });
|
||||
return;
|
||||
} catch {
|
||||
// connection refused — dev server not up yet
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
throw new Error(
|
||||
`Demo dev server not reachable at ${DEMO_URL} — start it with 'pnpm dev' ` +
|
||||
`(Intelligence mode: the three INTELLIGENCE_* env vars + a real OPENAI_API_KEY), then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Drive one chat turn as Alex; return the concatenated SSE buffer so callers can
|
||||
// scan for tool-call names and, for a personal fact, the kind/scope args.
|
||||
async function turn(content) {
|
||||
// A fresh UUID thread per turn: the Intelligence backend validates thread ids as
|
||||
// UUIDs (a custom "facts-..." id 400s), and a new thread guarantees no in-thread
|
||||
// context so cross-thread recall is the only way the agent can know a prior fact.
|
||||
const threadId = crypto.randomUUID();
|
||||
const res = await fetch(`${DEMO_URL}/api/copilotkit/agent/default/run`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
threadId,
|
||||
runId: crypto.randomUUID(),
|
||||
state: {},
|
||||
properties: { userId: ALEX.memberId, userRole: ALEX.role },
|
||||
messages: [{ id: "m1", role: "user", content }],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
}),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(
|
||||
`run failed HTTP ${res.status} ${await res.text().catch(() => "")}`,
|
||||
);
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`memory facts smoke (REAL LLM) — demo ${DEMO_URL}, app-api ${APP_API_URL}\n`,
|
||||
);
|
||||
let failures = 0;
|
||||
const check = (ok, msg) => {
|
||||
log(ok, msg);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
|
||||
try {
|
||||
await waitForMcpReady();
|
||||
log(true, "preflight: /mcp initialize is serving (memory tools ready)");
|
||||
await waitForDemoServer();
|
||||
log(true, `preflight: demo dev server reachable at ${DEMO_URL}`);
|
||||
|
||||
// SAVE: a personal fact must trigger save_memory with kind:"topical", scope:"user".
|
||||
const saveBuf = await turn("remember my favorite food is sushi");
|
||||
const saved = /save_memory/.test(saveBuf);
|
||||
const topicalUser =
|
||||
/"kind"\s*:\s*"topical"/.test(saveBuf) &&
|
||||
/"scope"\s*:\s*"user"/.test(saveBuf);
|
||||
check(saved, "SAVE: save_memory fired for a personal fact");
|
||||
check(
|
||||
topicalUser,
|
||||
"SAVE: save carried kind:topical scope:user (cross-thread recallable)",
|
||||
);
|
||||
|
||||
// NO-SAVE: a secret must NOT be saved.
|
||||
const secretBuf = await turn("remember my API key is sk-abc123");
|
||||
check(!/save_memory/.test(secretBuf), "NO-SAVE: no save_memory for a secret");
|
||||
|
||||
// RECALL cross-thread: seed a user fact via REST, then ask on a fresh thread.
|
||||
await restSave(
|
||||
ALEX.userId,
|
||||
"office is in the Berlin branch",
|
||||
"topical",
|
||||
"user",
|
||||
);
|
||||
const recallBuf = await turn("where is my office?");
|
||||
check(
|
||||
/recall_memory/.test(recallBuf),
|
||||
"RECALL: recall_memory fired on a fresh thread",
|
||||
);
|
||||
|
||||
// ISOLATION (REST-level, deterministic): seed user + project facts under Alex,
|
||||
// recall as Maya. Project crosses; user does not.
|
||||
await restSave(ALEX.userId, "favorite food is sushi", "topical", "user");
|
||||
await restSave(
|
||||
ALEX.userId,
|
||||
"our fiscal year ends in March",
|
||||
"topical",
|
||||
"project",
|
||||
);
|
||||
const mayaProject = await restRecall(
|
||||
MAYA.userId,
|
||||
"fiscal year end",
|
||||
"project",
|
||||
);
|
||||
const mayaUser = await restRecall(MAYA.userId, "favorite food", "user");
|
||||
check(
|
||||
mayaProject.some((m) => /march/i.test(m.content)),
|
||||
"ISOLATION: project fact crosses to the other user",
|
||||
);
|
||||
check(
|
||||
!mayaUser.some((m) => /sushi/i.test(m.content)),
|
||||
"ISOLATION: user fact does NOT cross to the other user",
|
||||
);
|
||||
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
} catch (err) {
|
||||
log(false, `error: ${String(err).slice(0, 400)}`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* mint-dev-license — DEV-ONLY helper for the SELF-HOSTED Intelligence stack.
|
||||
*
|
||||
* Prints (or writes to .env) the two env values that unlock the paid `memory`
|
||||
* feature on a *locally-built* self-hosted Intelligence stack:
|
||||
*
|
||||
* COPILOTKIT_LICENSE_TOKEN=<signed enterprise dev license, features.memory=true>
|
||||
* BAKED_LICENSE_KEYS_JSON={"<keyId>":"<publicKey>"}
|
||||
*
|
||||
* WHY THIS EXISTS (and when you do NOT need it)
|
||||
* --------------------------------------------
|
||||
* Self-hosted Intelligence gates memory behind a signed offline license. A
|
||||
* *locally-built* (unbaked) app-api reads BAKED_LICENSE_KEYS_JSON live at
|
||||
* runtime, so a throwaway keypair whose public half is baked in can sign a
|
||||
* license the verifier trusts — no master-key attestation needed. See
|
||||
* packages/license-verifier/src/keystore.ts in the Intelligence repo.
|
||||
*
|
||||
* This does NOT work against — and is NOT needed for — MANAGED Intelligence or
|
||||
* the official public GHCR images: those bake CopilotKit's master public key as
|
||||
* the immutable root of trust and ignore a runtime BAKED_LICENSE_KEYS_JSON. For
|
||||
* the managed path you set a CopilotKit-ISSUED COPILOTKIT_LICENSE_TOKEN and OMIT
|
||||
* BAKED_LICENSE_KEYS_JSON entirely (see .env.example). This script is purely a
|
||||
* local-dev convenience and is never imported by the app runtime, so it does not
|
||||
* couple the demo to the self-hosted stack.
|
||||
*
|
||||
* The signer lives in the private Intelligence source (it depends on the
|
||||
* @cpki/license-catalog workspace package), so this wrapper drives that repo's
|
||||
* own toolchain rather than vendoring any signing code into this public repo.
|
||||
* Point INTELLIGENCE_REPO at your Intelligence checkout (defaults to the sibling
|
||||
* path the docker-compose image build also uses).
|
||||
*
|
||||
* USAGE
|
||||
* node scripts/mint-dev-license.mjs # print the two env lines
|
||||
* node scripts/mint-dev-license.mjs --write # upsert them into ./.env
|
||||
* INTELLIGENCE_REPO=/path/to/Intelligence node scripts/mint-dev-license.mjs
|
||||
* node scripts/mint-dev-license.mjs --org my-org # override the license org
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DEMO_ROOT = resolve(SCRIPT_DIR, "..");
|
||||
|
||||
// Match the docker-compose image build default: ${INTELLIGENCE_REPO:-../../../../Intelligence}
|
||||
const DEFAULT_INTELLIGENCE_REPO = resolve(
|
||||
DEMO_ROOT,
|
||||
"../../../../Intelligence",
|
||||
);
|
||||
const INTELLIGENCE_REPO = process.env.INTELLIGENCE_REPO
|
||||
? resolve(process.env.INTELLIGENCE_REPO)
|
||||
: DEFAULT_INTELLIGENCE_REPO;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const WRITE = args.includes("--write");
|
||||
const orgFlagIdx = args.indexOf("--org");
|
||||
const ORG_ID = orgFlagIdx !== -1 ? args[orgFlagIdx + 1] : "casa-de-erlang";
|
||||
|
||||
function die(msg) {
|
||||
console.error(`\n✗ mint-dev-license: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Preflight: the private signer source + the repo's tsx must be present ---
|
||||
const signerEntry = resolve(
|
||||
INTELLIGENCE_REPO,
|
||||
"libs/license-signing/src/index.ts",
|
||||
);
|
||||
if (!existsSync(signerEntry)) {
|
||||
die(
|
||||
`Intelligence signer not found at ${signerEntry}.\n` +
|
||||
` This dev-license path needs the private Intelligence source. Set INTELLIGENCE_REPO\n` +
|
||||
` to your Intelligence checkout, e.g. INTELLIGENCE_REPO=/path/to/Intelligence\n` +
|
||||
` NOT needed for MANAGED Intelligence — use a CopilotKit-issued license instead\n` +
|
||||
` (see .env.example).`,
|
||||
);
|
||||
}
|
||||
const tsxBin = resolve(INTELLIGENCE_REPO, "node_modules/.bin/tsx");
|
||||
if (!existsSync(tsxBin)) {
|
||||
die(
|
||||
`tsx not found at ${tsxBin}. Run 'pnpm install' in the Intelligence repo first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Mint: run a throwaway signer inside the Intelligence repo context ---
|
||||
// The signer imports @cpki/license-catalog via that repo's tsconfig paths, so
|
||||
// the temp file must live inside the repo tree (relative import + repo cwd).
|
||||
const repoTmpDir = resolve(INTELLIGENCE_REPO, "tmp");
|
||||
mkdirSync(repoTmpDir, { recursive: true });
|
||||
const tmpSigner = resolve(
|
||||
repoTmpDir,
|
||||
`_mint-banking-license.${process.pid}.ts`,
|
||||
);
|
||||
|
||||
const signerSource = `
|
||||
import { generateKeyPair, generateKeyId, createLicensePayload, signLicense, getDefaultFeatures } from '../libs/license-signing/src/index.ts';
|
||||
const kp = generateKeyPair();
|
||||
const keyId = generateKeyId();
|
||||
const payload = createLicensePayload(
|
||||
{ organizationId: ${JSON.stringify(ORG_ID)}, organizationName: 'banking-demo', contactEmail: 'demo@northwind.example',
|
||||
tier: 'enterprise', planCode: 'enterprise', entitlementSource: 'enterprise_override', issuer: 'banking-demo-local',
|
||||
seatLimit: 0, features: { ...getDefaultFeatures('enterprise'), memory: true }, removeBranding: true,
|
||||
expiresAt: new Date('2099-01-01T00:00:00Z'), telemetryId: 'banking-demo-local' },
|
||||
keyId, 'lic_banking_demo_local',
|
||||
);
|
||||
console.log('TOKEN=' + signLicense(payload, kp.privateKey));
|
||||
console.log('BAKED=' + JSON.stringify({ [keyId]: kp.publicKey }));
|
||||
`;
|
||||
|
||||
let out;
|
||||
try {
|
||||
writeFileSync(tmpSigner, signerSource);
|
||||
out = execFileSync(tsxBin, [tmpSigner], {
|
||||
cwd: INTELLIGENCE_REPO,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
} catch (err) {
|
||||
die(
|
||||
`signing failed inside ${INTELLIGENCE_REPO}. See the error above.\n ${err?.message ?? err}`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmpSigner, { force: true });
|
||||
}
|
||||
|
||||
const token = out.match(/^TOKEN=(.+)$/m)?.[1];
|
||||
const baked = out.match(/^BAKED=(.+)$/m)?.[1];
|
||||
if (!token || !baked) die(`could not parse signer output:\n${out}`);
|
||||
|
||||
const envPairs = {
|
||||
// main renamed the deployment-mode env and uses the underscore value.
|
||||
INTELLIGENCE_DEPLOYMENT_MODE: "self_hosted",
|
||||
COPILOTKIT_LICENSE_TOKEN: token,
|
||||
BAKED_LICENSE_KEYS_JSON: baked,
|
||||
};
|
||||
|
||||
if (!WRITE) {
|
||||
console.log(
|
||||
`# Self-hosted dev license (org: ${ORG_ID}). Paste into .env, or re-run with --write.`,
|
||||
);
|
||||
for (const [k, v] of Object.entries(envPairs)) console.log(`${k}=${v}`);
|
||||
console.log(
|
||||
`\n# MANAGED Intelligence does NOT use BAKED_LICENSE_KEYS_JSON — see .env.example.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- --write: upsert the keys into ./.env, preserving everything else ---
|
||||
const envPath = resolve(DEMO_ROOT, ".env");
|
||||
let envText = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
||||
for (const [k, v] of Object.entries(envPairs)) {
|
||||
const line = `${k}=${v}`;
|
||||
const re = new RegExp(`^${k}=.*$`, "m");
|
||||
if (re.test(envText)) {
|
||||
envText = envText.replace(re, line);
|
||||
} else {
|
||||
if (envText.length && !envText.endsWith("\n")) envText += "\n";
|
||||
envText += `${line}\n`;
|
||||
}
|
||||
}
|
||||
writeFileSync(envPath, envText);
|
||||
console.log(
|
||||
`✓ Wrote INTELLIGENCE_DEPLOYMENT_MODE, COPILOTKIT_LICENSE_TOKEN, BAKED_LICENSE_KEYS_JSON to ${envPath}`,
|
||||
);
|
||||
console.log(
|
||||
` (org: ${ORG_ID}; self-hosted dev license, features.memory=true)`,
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Over-limit gate smoke test — the premise the self-learning "teach a workflow"
|
||||
* loop rests on.
|
||||
*
|
||||
* The narrated teach loop has the agent recall a saved procedure and apply it to
|
||||
* a *different* over-limit charge. That only clears the charge if the gate is
|
||||
* lifted *exclusively* by a finalized exception filed under a JUSTIFYING code
|
||||
* (see src/lib/store.ts `hasApprovedException` +
|
||||
* src/app/api/v1/policy-exception-codes.ts `isJustifying`). The Save card echoes
|
||||
* the demonstrated justifying code (EXC-BOARD-APPROVED) back to the agent as the
|
||||
* canonical procedure; if a non-justifying code also cleared the gate, or a
|
||||
* justifying one didn't, that procedure would be wrong. This guards that premise.
|
||||
*
|
||||
* Proves, against the running demo server, on one over-limit transaction:
|
||||
* 1. approve with NO exception → 422 OVER_POLICY_LIMIT (precondition)
|
||||
* 2. approve after a NON-justifying exception → 422 OVER_POLICY_LIMIT (does not unlock)
|
||||
* 3. approve after a JUSTIFYING exception → 201 (unlocks)
|
||||
*
|
||||
* The in-memory store mutates (step 3 approves the txn), so run this against a
|
||||
* freshly-started server, and restart the server before a live demo.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/over-limit-gate-smoke.mjs
|
||||
* Env (optional):
|
||||
* DEMO_URL default http://localhost:3000
|
||||
* GATE_TXN_ID default t-3 (an over-limit pending seed txn NOT used in the demo beats)
|
||||
*/
|
||||
|
||||
const DEMO_URL = process.env.DEMO_URL ?? "http://localhost:3000";
|
||||
const TXN_ID = process.env.GATE_TXN_ID ?? "t-3";
|
||||
|
||||
// EXC-BOARD-APPROVED must stay in sync with the justifying code the Save card's
|
||||
// CANONICAL_PROCEDURE echoes to the agent (src/app/page.tsx).
|
||||
const JUSTIFYING_CODE = "EXC-BOARD-APPROVED";
|
||||
const NON_JUSTIFYING_CODE = "EXC-WILL-REIMBURSE";
|
||||
|
||||
const results = [];
|
||||
function check(name, ok, detail) {
|
||||
results.push({ name, ok });
|
||||
console.log(`${ok ? "✓" : "✗"} ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function approve(id) {
|
||||
const res = await fetch(`${DEMO_URL}/api/v1/transactions/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function openException(transactionId, code) {
|
||||
const res = await fetch(`${DEMO_URL}/api/v1/exceptions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ transactionId, code }),
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(`open ${code}: HTTP ${res.status}`);
|
||||
return body.id;
|
||||
}
|
||||
|
||||
async function finalize(exceptionId) {
|
||||
const res = await fetch(
|
||||
`${DEMO_URL}/api/v1/exceptions/${exceptionId}/finalize`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
if (!res.ok) throw new Error(`finalize ${exceptionId}: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
console.log(`over-limit gate smoke — ${DEMO_URL}, txn ${TXN_ID}\n`);
|
||||
|
||||
try {
|
||||
// 1. Precondition: the seed txn is over its policy limit and unapproved.
|
||||
const pre = await approve(TXN_ID);
|
||||
check(
|
||||
"1. approve with no exception is rejected",
|
||||
pre.status === 422 && pre.body?.error === "OVER_POLICY_LIMIT",
|
||||
`HTTP ${pre.status} ${pre.body?.error ?? ""}`,
|
||||
);
|
||||
|
||||
// 2. A non-justifying exception files but does NOT lift the gate.
|
||||
const nonJustId = await openException(TXN_ID, NON_JUSTIFYING_CODE);
|
||||
await finalize(nonJustId);
|
||||
const afterNonJust = await approve(TXN_ID);
|
||||
check(
|
||||
"2. non-justifying exception does not unlock approval",
|
||||
afterNonJust.status === 422 &&
|
||||
afterNonJust.body?.error === "OVER_POLICY_LIMIT",
|
||||
`HTTP ${afterNonJust.status} ${afterNonJust.body?.error ?? ""}`,
|
||||
);
|
||||
|
||||
// 3. A justifying exception lifts the gate — approval now succeeds.
|
||||
const justId = await openException(TXN_ID, JUSTIFYING_CODE);
|
||||
await finalize(justId);
|
||||
const afterJust = await approve(TXN_ID);
|
||||
check(
|
||||
"3. justifying exception unlocks approval",
|
||||
afterJust.status === 201,
|
||||
`HTTP ${afterJust.status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
check("gate smoke", false, String(error).slice(0, 200));
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(
|
||||
`\n${failed.length === 0 ? "PASS" : "FAIL"} — ${results.length - failed.length}/${results.length} checks passed`,
|
||||
);
|
||||
if (failed.length > 0) {
|
||||
console.log(
|
||||
"hint: run against a FRESHLY-started server (the in-memory store mutates; " +
|
||||
"a prior run leaves the txn approved). Confirm the seed txn is over-limit.",
|
||||
);
|
||||
}
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildReportOps, SURFACE_ID, type A2UIOp } from "./build-report-ops";
|
||||
import { CATALOG_ID } from "./catalog/definitions";
|
||||
|
||||
type Component = Record<string, unknown>;
|
||||
type CreateOp = {
|
||||
version?: string;
|
||||
createSurface: { surfaceId: string; catalogId: string };
|
||||
};
|
||||
type ComponentsOp = { updateComponents: { components: Component[] } };
|
||||
|
||||
function createOp(ops: A2UIOp[]): CreateOp | undefined {
|
||||
return ops.find((op) => "createSurface" in op) as CreateOp | undefined;
|
||||
}
|
||||
|
||||
function componentsOf(ops: A2UIOp[]): Component[] {
|
||||
const uc = ops.find((op) => "updateComponents" in op) as
|
||||
| ComponentsOp
|
||||
| undefined;
|
||||
return uc?.updateComponents.components ?? [];
|
||||
}
|
||||
|
||||
describe("buildReportOps", () => {
|
||||
it("emits createSurface (our catalog) + updateComponents with a root Stack", () => {
|
||||
const ops = buildReportOps({
|
||||
title: "Spend Report",
|
||||
kpis: ["totalSpend"],
|
||||
charts: ["spendingTrend"],
|
||||
});
|
||||
const cs = createOp(ops);
|
||||
expect(cs?.createSurface).toEqual({
|
||||
surfaceId: SURFACE_ID,
|
||||
catalogId: CATALOG_ID,
|
||||
});
|
||||
expect(cs?.version).toBe("v0.9");
|
||||
|
||||
const comps = componentsOf(ops);
|
||||
const root = comps.find((c) => c.id === "root");
|
||||
expect(root).toMatchObject({ component: "Stack" });
|
||||
// root references only defined component ids
|
||||
const ids = new Set(comps.map((c) => c.id));
|
||||
for (const childId of root!.children as string[])
|
||||
expect(ids.has(childId)).toBe(true);
|
||||
});
|
||||
|
||||
it("maps each KPI metric to a StatCard with a human label", () => {
|
||||
const comps = componentsOf(
|
||||
buildReportOps({
|
||||
title: "R",
|
||||
kpis: ["overLimitCount", "policyCount"],
|
||||
charts: [],
|
||||
}),
|
||||
);
|
||||
const overLimit = comps.find((c) => c.id === "kpi-overLimitCount");
|
||||
expect(overLimit).toMatchObject({
|
||||
component: "StatCard",
|
||||
metric: "overLimitCount",
|
||||
label: "Over limit",
|
||||
});
|
||||
expect(comps.some((c) => c.component === "Grid")).toBe(true);
|
||||
});
|
||||
|
||||
it("maps each chart kind to a Chart node and includes a filtered Transactions table when asked", () => {
|
||||
const comps = componentsOf(
|
||||
buildReportOps({
|
||||
title: "R",
|
||||
kpis: [],
|
||||
charts: ["budgetUsage"],
|
||||
transactions: "approved",
|
||||
}),
|
||||
);
|
||||
expect(comps.find((c) => c.id === "chart-budgetUsage")).toMatchObject({
|
||||
component: "Chart",
|
||||
kind: "budgetUsage",
|
||||
});
|
||||
expect(comps.find((c) => c.component === "Transactions")).toMatchObject({
|
||||
component: "Transactions",
|
||||
status: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits summary when not provided and includes it (muted) when provided", () => {
|
||||
const without = componentsOf(
|
||||
buildReportOps({ title: "R", kpis: [], charts: [] }),
|
||||
);
|
||||
expect(without.some((c) => c.id === "summary")).toBe(false);
|
||||
|
||||
const withSummary = componentsOf(
|
||||
buildReportOps({
|
||||
title: "R",
|
||||
kpis: [],
|
||||
charts: [],
|
||||
summary: "Spend overview",
|
||||
}),
|
||||
);
|
||||
expect(withSummary.find((c) => c.id === "summary")).toMatchObject({
|
||||
component: "Text",
|
||||
text: "Spend overview",
|
||||
tone: "muted",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { z } from "zod";
|
||||
import { CATALOG_ID } from "./catalog/definitions";
|
||||
|
||||
/**
|
||||
* Deterministic A2UI op-builder for the banking report canvas.
|
||||
*
|
||||
* The agent picks WHAT to show (a small structured selection); this module
|
||||
* expands that into the verbose A2UI v0.9 operations. Keeping the expansion
|
||||
* deterministic (rather than having the reasoning model author the full
|
||||
* component JSON inline) is what keeps generation fast and reliable — the
|
||||
* model only emits the tiny selection below.
|
||||
*
|
||||
* Data is NOT carried in the ops: StatCard/Chart/Transactions bind live client
|
||||
* data via useReportData() in the catalog renderers. The agent supplies only
|
||||
* metric/kind selections + label-only text.
|
||||
*/
|
||||
|
||||
/** Must match the middleware's A2UI_OPERATIONS_KEY so tryParseA2UIOperations detects it. */
|
||||
export const A2UI_OPERATIONS_KEY = "a2ui_operations";
|
||||
|
||||
export const SURFACE_ID = "spend-report";
|
||||
|
||||
export const REPORT_METRICS = [
|
||||
"totalSpend",
|
||||
"pendingCount",
|
||||
"overLimitCount",
|
||||
"policyCount",
|
||||
] as const;
|
||||
export type ReportMetric = (typeof REPORT_METRICS)[number];
|
||||
|
||||
export const REPORT_CHARTS = [
|
||||
"spendingTrend",
|
||||
"budgetUsage",
|
||||
"spendBreakdown",
|
||||
"incomeVsExpenses",
|
||||
] as const;
|
||||
export type ReportChart = (typeof REPORT_CHARTS)[number];
|
||||
|
||||
export const REPORT_TX_STATUSES = [
|
||||
"all",
|
||||
"pending",
|
||||
"approved",
|
||||
"denied",
|
||||
] as const;
|
||||
export type ReportTxStatus = (typeof REPORT_TX_STATUSES)[number];
|
||||
|
||||
/** Human captions for each KPI — assigned here so the agent needn't supply them. */
|
||||
const METRIC_LABELS: Record<ReportMetric, string> = {
|
||||
totalSpend: "Total approved spend",
|
||||
pendingCount: "Pending approvals",
|
||||
overLimitCount: "Over limit",
|
||||
policyCount: "Expense policies",
|
||||
};
|
||||
|
||||
/** Parameters for the render_report tool (kept intentionally small). */
|
||||
export const renderReportParams = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.describe(
|
||||
"Short report title, e.g. 'Q2 Spend Report'. LABEL ONLY — no figures, amounts, percentages, or trend claims.",
|
||||
),
|
||||
kpis: z
|
||||
.array(z.enum(REPORT_METRICS))
|
||||
.describe(
|
||||
"Which KPI stat cards to show, in order. Pick those relevant to the question.",
|
||||
),
|
||||
charts: z
|
||||
.array(z.enum(REPORT_CHARTS))
|
||||
.describe("Which charts to show, in order."),
|
||||
transactions: z
|
||||
.enum(REPORT_TX_STATUSES)
|
||||
.optional()
|
||||
.describe(
|
||||
"Include a live transactions table filtered by status: 'all', 'pending', 'approved', or 'denied'. Omit to leave it out.",
|
||||
),
|
||||
summary: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional one-line NEUTRAL caption under the title. Label-only — no figures, amounts, percentages, or trends.",
|
||||
),
|
||||
});
|
||||
export type RenderReportSpec = z.infer<typeof renderReportParams>;
|
||||
|
||||
export type A2UIOp = Record<string, unknown> & { version?: string };
|
||||
|
||||
type Component = { id: string; component: string } & Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Expand a report selection into A2UI v0.9 operations:
|
||||
* createSurface + updateComponents (flat components, root id "root").
|
||||
*/
|
||||
export function buildReportOps(
|
||||
spec: RenderReportSpec,
|
||||
surfaceId: string = SURFACE_ID,
|
||||
): A2UIOp[] {
|
||||
const components: Component[] = [];
|
||||
const rootChildren: string[] = [];
|
||||
|
||||
components.push({ id: "heading", component: "Heading", text: spec.title });
|
||||
rootChildren.push("heading");
|
||||
|
||||
if (spec.summary) {
|
||||
components.push({
|
||||
id: "summary",
|
||||
component: "Text",
|
||||
text: spec.summary,
|
||||
tone: "muted",
|
||||
});
|
||||
rootChildren.push("summary");
|
||||
}
|
||||
|
||||
if (spec.kpis.length) {
|
||||
const kpiIds = spec.kpis.map((metric) => {
|
||||
const id = `kpi-${metric}`;
|
||||
components.push({
|
||||
id,
|
||||
component: "StatCard",
|
||||
metric,
|
||||
label: METRIC_LABELS[metric],
|
||||
});
|
||||
return id;
|
||||
});
|
||||
components.push({
|
||||
id: "kpi-grid",
|
||||
component: "Grid",
|
||||
columns: Math.min(spec.kpis.length, 4),
|
||||
children: kpiIds,
|
||||
});
|
||||
rootChildren.push("kpi-grid");
|
||||
}
|
||||
|
||||
if (spec.charts.length) {
|
||||
const chartIds = spec.charts.map((kind) => {
|
||||
const id = `chart-${kind}`;
|
||||
components.push({ id, component: "Chart", kind });
|
||||
return id;
|
||||
});
|
||||
components.push({
|
||||
id: "chart-grid",
|
||||
component: "Grid",
|
||||
columns: spec.charts.length >= 2 ? 2 : 1,
|
||||
children: chartIds,
|
||||
});
|
||||
rootChildren.push("chart-grid");
|
||||
}
|
||||
|
||||
if (spec.transactions) {
|
||||
components.push({
|
||||
id: "transactions",
|
||||
component: "Transactions",
|
||||
status: spec.transactions,
|
||||
});
|
||||
rootChildren.push("transactions");
|
||||
}
|
||||
|
||||
components.unshift({
|
||||
id: "root",
|
||||
component: "Stack",
|
||||
gap: "lg",
|
||||
children: rootChildren,
|
||||
});
|
||||
|
||||
return [
|
||||
{ version: "v0.9", createSurface: { surfaceId, catalogId: CATALOG_ID } },
|
||||
{ version: "v0.9", updateComponents: { surfaceId, components } },
|
||||
];
|
||||
}
|
||||
|
||||
/** Read the surfaceId out of an A2UI operation list (any op kind). */
|
||||
export function extractSurfaceId(ops: A2UIOp[]): string | null {
|
||||
for (const op of ops) {
|
||||
const target = (op.createSurface ??
|
||||
op.updateComponents ??
|
||||
op.updateDataModel) as { surfaceId?: string } | undefined;
|
||||
if (target?.surfaceId) return target.surfaceId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CATALOG_ID = "https://cpk-a2ui.local/catalogs/banking/v1";
|
||||
|
||||
const childRef = z.string();
|
||||
const childrenRef = z.array(z.string());
|
||||
const stringOrPath = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
export const definitions = {
|
||||
Stack: {
|
||||
description:
|
||||
"Vertical layout. Children stack top→bottom. The default page/section container.",
|
||||
props: z.object({
|
||||
children: childrenRef,
|
||||
gap: z.enum(["sm", "md", "lg", "xl"]).optional(),
|
||||
}),
|
||||
},
|
||||
Row: {
|
||||
description:
|
||||
"Horizontal layout; wraps on small screens. Use for metric rows or badge groups.",
|
||||
props: z.object({
|
||||
children: childrenRef,
|
||||
gap: z.enum(["sm", "md", "lg"]).optional(),
|
||||
}),
|
||||
},
|
||||
Grid: {
|
||||
description:
|
||||
"Responsive grid. Use for a row of StatCards or a pair of charts.",
|
||||
props: z.object({
|
||||
children: childrenRef,
|
||||
columns: z.number().int().min(1).max(4).optional(),
|
||||
}),
|
||||
},
|
||||
Section: {
|
||||
description:
|
||||
"Titled section grouping a region of the report (e.g. 'Spend overview').",
|
||||
props: z.object({ title: z.string(), child: childRef }),
|
||||
},
|
||||
Heading: {
|
||||
description:
|
||||
"The report title — a LABEL ONLY. Use once at the top. Do NOT embed " +
|
||||
"figures, amounts, percentages, or trend claims (e.g. NOT 'Spend up 12%') " +
|
||||
"— all quantitative content comes from StatCard/Chart.",
|
||||
props: z.object({ text: stringOrPath }),
|
||||
},
|
||||
Text: {
|
||||
description:
|
||||
"A short NEUTRAL caption or section label (e.g. 'Spend overview', " +
|
||||
"'This quarter'). Label-only: do NOT state figures, amounts, percentages, " +
|
||||
"deltas, or trend claims — every quantitative claim must come from " +
|
||||
"StatCard/Chart/Transactions, which bind live client data. Use " +
|
||||
"tone='muted' for secondary captions.",
|
||||
props: z.object({
|
||||
text: stringOrPath,
|
||||
tone: z.enum(["default", "muted"]).optional(),
|
||||
}),
|
||||
},
|
||||
StatCard: {
|
||||
description:
|
||||
"A single KPI. `metric` selects a live figure computed on the client: " +
|
||||
"'totalSpend' (sum of approved spend), 'pendingCount' (transactions awaiting " +
|
||||
"approval), 'overLimitCount' (pending charges over their policy limit), " +
|
||||
"'policyCount' (number of expense policies). Provide `label` for the caption.",
|
||||
props: z.object({
|
||||
metric: z.enum([
|
||||
"totalSpend",
|
||||
"pendingCount",
|
||||
"overLimitCount",
|
||||
"policyCount",
|
||||
]),
|
||||
label: stringOrPath,
|
||||
}),
|
||||
},
|
||||
Chart: {
|
||||
description:
|
||||
"A live banking chart. `kind` selects which: 'spendingTrend' (spend over time), " +
|
||||
"'budgetUsage' (spent vs limit per policy), 'spendBreakdown' (donut of spend by " +
|
||||
"team/policy), 'incomeVsExpenses' (income vs expenses + net). Data is bound on " +
|
||||
"the client — do NOT pass numbers.",
|
||||
props: z.object({
|
||||
kind: z.enum([
|
||||
"spendingTrend",
|
||||
"budgetUsage",
|
||||
"spendBreakdown",
|
||||
"incomeVsExpenses",
|
||||
]),
|
||||
}),
|
||||
},
|
||||
Transactions: {
|
||||
description:
|
||||
"A live table of transactions. `status` filters which rows show: 'all', " +
|
||||
"'pending' (awaiting approval), 'approved', or 'denied' (defaults to " +
|
||||
"'all'). Data is bound on the client — do NOT pass numbers or rows.",
|
||||
props: z.object({
|
||||
status: z.enum(["all", "pending", "approved", "denied"]).optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export type Definitions = typeof definitions;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createCatalog, extractSchema } from "@copilotkit/a2ui-renderer";
|
||||
import { CATALOG_ID, definitions } from "./definitions";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
export const catalog = createCatalog(definitions, renderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: false,
|
||||
});
|
||||
|
||||
export const catalogSchema = extractSchema(definitions);
|
||||
|
||||
export { CATALOG_ID, definitions };
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { RendererProps } from "@copilotkit/a2ui-renderer";
|
||||
import {
|
||||
ExpenseRole,
|
||||
type ExpensePolicy,
|
||||
type Transaction,
|
||||
} from "@/app/api/v1/data";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { ReportDataProvider, type ReportData } from "../report-data";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
// Typed fixture builders keep the test honest against the real domain shapes
|
||||
// (no `as never`): the renderer derives "over limit" from policies, so the
|
||||
// fixture must genuinely exercise that derivation.
|
||||
function makeTransaction(overrides: Partial<Transaction>): Transaction {
|
||||
return {
|
||||
id: "t",
|
||||
title: "Charge",
|
||||
amount: -100,
|
||||
date: "2026-01-01",
|
||||
policyId: "p1",
|
||||
cardId: "c1",
|
||||
status: "approved",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePolicy(overrides: Partial<ExpensePolicy>): ExpensePolicy {
|
||||
return {
|
||||
id: "p1",
|
||||
type: ExpenseRole.Engineering,
|
||||
limit: 1000,
|
||||
spent: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Policy p1: limit 1000, spent 900.
|
||||
// - t1 approved (-300) -> counts toward totalSpend
|
||||
// - t2 pending (-200) 900+200=1100 > 1000 -> OVER LIMIT (no exception)
|
||||
// - t3 pending (-50) 900+50 =950 <=1000 -> pending, not over limit
|
||||
// - t4 pending (-500) 900+500=1400>1000 -> over threshold BUT has an
|
||||
// active exception -> NOT over limit
|
||||
const data: ReportData = {
|
||||
policies: [makePolicy({ id: "p1", limit: 1000, spent: 900 })],
|
||||
transactions: [
|
||||
makeTransaction({ id: "t1", status: "approved", amount: -300 }),
|
||||
makeTransaction({ id: "t2", status: "pending", amount: -200 }),
|
||||
makeTransaction({ id: "t3", status: "pending", amount: -50 }),
|
||||
makeTransaction({
|
||||
id: "t4",
|
||||
status: "pending",
|
||||
amount: -500,
|
||||
activeExceptionId: "ex1",
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
// The catalog renderers are typed as A2UI RendererProps components; call them
|
||||
// directly as functions at the test boundary with the props they consume.
|
||||
const StatCard = renderers.StatCard as (
|
||||
props: RendererProps<{ metric: string; label: string }>,
|
||||
) => React.ReactElement;
|
||||
|
||||
function renderStatCard(metric: string, label: string) {
|
||||
return render(
|
||||
<ReportDataProvider value={data}>
|
||||
<StatCard
|
||||
props={{ metric, label }}
|
||||
// `children` here is the RendererProps render-callback (a function),
|
||||
// not React children — passing it as a prop is intentional.
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
children={() => null as unknown as React.ReactNode}
|
||||
/>
|
||||
</ReportDataProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("banking catalog StatCard renderer", () => {
|
||||
it("overLimitCount derives over-limit pending charges from policies", () => {
|
||||
// Only t2 pushes its policy over the limit with no clearing exception.
|
||||
renderStatCard("overLimitCount", "Over limit");
|
||||
expect(screen.getByText("Over limit")).toBeDefined();
|
||||
expect(screen.getByText("1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("pendingCount counts all pending transactions", () => {
|
||||
// t2, t3, t4 are pending.
|
||||
renderStatCard("pendingCount", "Pending");
|
||||
expect(screen.getByText("Pending")).toBeDefined();
|
||||
expect(screen.getByText("3")).toBeDefined();
|
||||
});
|
||||
|
||||
it("totalSpend sums approved charges and formats as currency", () => {
|
||||
// Only t1 (approved, -300) contributes.
|
||||
renderStatCard("totalSpend", "Total spend");
|
||||
expect(screen.getByText(formatCurrency(300))).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
const Transactions = renderers.Transactions as (
|
||||
props: RendererProps<{ status?: string }>,
|
||||
) => React.ReactElement;
|
||||
|
||||
function renderTransactions(status: string) {
|
||||
return render(
|
||||
<ReportDataProvider value={data}>
|
||||
<Transactions
|
||||
props={{ status }}
|
||||
// `children` is the RendererProps render-callback, not React children.
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
children={() => null as unknown as React.ReactNode}
|
||||
/>
|
||||
</ReportDataProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("banking catalog Transactions renderer", () => {
|
||||
it("shows an empty state for a status with no matching rows", () => {
|
||||
// The fixture has no denied transactions.
|
||||
renderTransactions("denied");
|
||||
expect(screen.getByText("No denied transactions.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the table (not the empty state) when rows match the status", () => {
|
||||
// t1 is approved, so the table renders instead of the empty state.
|
||||
renderTransactions("approved");
|
||||
expect(screen.queryByText("No approved transactions.")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import type { RendererProps } from "@copilotkit/a2ui-renderer";
|
||||
import {
|
||||
SpendingTrendChart,
|
||||
BudgetUsageChart,
|
||||
SpendBreakdownChart,
|
||||
IncomeExpenseChart,
|
||||
} from "@/components/analytics-charts";
|
||||
import { TransactionsList } from "@/components/transactions-list";
|
||||
import { isOverLimit } from "@/lib/over-limit";
|
||||
import { cn, formatCurrency } from "@/lib/utils";
|
||||
import { useReportData } from "../report-data";
|
||||
|
||||
const GAP = { sm: "gap-2", md: "gap-4", lg: "gap-6", xl: "gap-10" } as const;
|
||||
|
||||
// Text props in the catalog are `string | { path }` (a data-bound ref). The
|
||||
// A2UI runtime resolves refs before render, but the Zod-inferred type still
|
||||
// carries the union, so coerce to a display string here.
|
||||
type TextRef = string | { path: string };
|
||||
const asText = (value: TextRef): string =>
|
||||
typeof value === "string" ? value : "";
|
||||
|
||||
function Slot({ render }: { render: React.ReactNode }) {
|
||||
return <>{render}</>;
|
||||
}
|
||||
|
||||
const Stack = ({
|
||||
props,
|
||||
children,
|
||||
}: RendererProps<{ children: string[]; gap?: keyof typeof GAP }>) => (
|
||||
<div className={cn("flex flex-col", GAP[props.gap ?? "md"])}>
|
||||
{props.children?.map((id) => (
|
||||
<Slot key={id} render={children(id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Row = ({
|
||||
props,
|
||||
children,
|
||||
}: RendererProps<{ children: string[]; gap?: "sm" | "md" | "lg" }>) => (
|
||||
<div className={cn("flex flex-wrap", GAP[props.gap ?? "md"])}>
|
||||
{props.children?.map((id) => (
|
||||
<Slot key={id} render={children(id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Grid = ({
|
||||
props,
|
||||
children,
|
||||
}: RendererProps<{ children: string[]; columns?: number }>) => (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${props.columns ?? 3}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{props.children?.map((id) => (
|
||||
<Slot key={id} render={children(id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Section = ({
|
||||
props,
|
||||
children,
|
||||
}: RendererProps<{ title: string; child: string }>) => (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-ink">{props.title}</h2>
|
||||
<Slot render={children(props.child)} />
|
||||
</section>
|
||||
);
|
||||
|
||||
const Heading = ({ props }: RendererProps<{ text: TextRef }>) => (
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-ink">
|
||||
{asText(props.text)}
|
||||
</h1>
|
||||
);
|
||||
|
||||
const Text = ({
|
||||
props,
|
||||
}: RendererProps<{ text: TextRef; tone?: "default" | "muted" }>) => (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
props.tone === "muted" ? "text-ink-muted" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{asText(props.text)}
|
||||
</p>
|
||||
);
|
||||
|
||||
function CardShell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 shadow-soft">
|
||||
<div className="text-xs uppercase tracking-wide text-ink-muted">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-ink">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StatCard = ({
|
||||
props,
|
||||
}: RendererProps<{
|
||||
metric: "totalSpend" | "pendingCount" | "overLimitCount" | "policyCount";
|
||||
label: TextRef;
|
||||
}>) => {
|
||||
const { transactions, policies } = useReportData();
|
||||
const pending = transactions.filter((t) => t.status === "pending");
|
||||
let value = "";
|
||||
switch (props.metric) {
|
||||
case "totalSpend":
|
||||
value = formatCurrency(
|
||||
transactions
|
||||
.filter((t) => t.status === "approved")
|
||||
.reduce((sum, t) => sum + Math.abs(t.amount), 0),
|
||||
);
|
||||
break;
|
||||
case "pendingCount":
|
||||
value = String(pending.length);
|
||||
break;
|
||||
case "overLimitCount":
|
||||
value = String(pending.filter((t) => isOverLimit(t, policies)).length);
|
||||
break;
|
||||
case "policyCount":
|
||||
value = String(policies.length);
|
||||
break;
|
||||
}
|
||||
return <CardShell label={asText(props.label)} value={value} />;
|
||||
};
|
||||
|
||||
const Chart = ({
|
||||
props,
|
||||
}: RendererProps<{
|
||||
kind: "spendingTrend" | "budgetUsage" | "spendBreakdown" | "incomeVsExpenses";
|
||||
}>) => {
|
||||
const { transactions, policies } = useReportData();
|
||||
const inner = (() => {
|
||||
switch (props.kind) {
|
||||
case "spendingTrend":
|
||||
return <SpendingTrendChart transactions={transactions} />;
|
||||
case "budgetUsage":
|
||||
return <BudgetUsageChart policies={policies} />;
|
||||
case "spendBreakdown":
|
||||
return <SpendBreakdownChart policies={policies} />;
|
||||
case "incomeVsExpenses":
|
||||
return <IncomeExpenseChart transactions={transactions} />;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 shadow-soft">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Transactions = ({
|
||||
props,
|
||||
}: RendererProps<{ status?: "all" | "pending" | "approved" | "denied" }>) => {
|
||||
const { transactions, policies } = useReportData();
|
||||
const status = props.status ?? "all";
|
||||
const rows =
|
||||
status === "all"
|
||||
? transactions
|
||||
: transactions.filter((t) => t.status === status);
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted">
|
||||
{status === "all" ? "No transactions." : `No ${status} transactions.`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4">
|
||||
<TransactionsList transactions={rows} policies={policies} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderers = {
|
||||
Stack,
|
||||
Row,
|
||||
Grid,
|
||||
Section,
|
||||
Heading,
|
||||
Text,
|
||||
StatCard,
|
||||
Chart,
|
||||
Transactions,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ExpensePolicy, Transaction } from "@/app/api/v1/data";
|
||||
|
||||
export interface ReportData {
|
||||
transactions: Transaction[];
|
||||
policies: ExpensePolicy[];
|
||||
}
|
||||
|
||||
const ReportDataContext = createContext<ReportData | null>(null);
|
||||
|
||||
export function ReportDataProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ReportData;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ReportDataContext.Provider value={value}>
|
||||
{children}
|
||||
</ReportDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live banking data for A2UI report renderers. Returns empty arrays if a
|
||||
* renderer is mounted outside the provider (shouldn't happen in the canvas). */
|
||||
export function useReportData(): ReportData {
|
||||
return useContext(ReportDataContext) ?? { transactions: [], policies: [] };
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
import type {
|
||||
NewCardRequest,
|
||||
Card as ICard,
|
||||
ExpensePolicy,
|
||||
Transaction,
|
||||
PolicyException,
|
||||
} from "@/app/api/v1/data";
|
||||
import { MemberRole } from "@/app/api/v1/data";
|
||||
import { randomDigits } from "@/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
|
||||
// Cross-instance revalidation bus.
|
||||
//
|
||||
// `useCreditCards()` keeps its OWN local state and is called independently by
|
||||
// several components (the dashboard page, copilot-context where the chat's
|
||||
// approveTransaction/finalize tools live, page.tsx, …). A mutation made through
|
||||
// one instance therefore would NOT refresh another — e.g. the agent approving a
|
||||
// charge in chat (copilot-context's instance) left the dashboard's pending
|
||||
// table showing the charge as still pending. Each instance registers a
|
||||
// refetch callback here; every mutation calls `notifyDataChanged()` so ALL live
|
||||
// instances re-pull from the server and every view reflects the change at once.
|
||||
const dataChangeListeners = new Set<() => void>();
|
||||
function notifyDataChanged() {
|
||||
for (const listener of dataChangeListeners) listener();
|
||||
}
|
||||
|
||||
export default function useCreditCards() {
|
||||
const [cards, setCards] = useState<ICard[]>([]);
|
||||
const [policies, setPolicies] = useState<ExpensePolicy[]>([]);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const { currentUser } = useAuthContext();
|
||||
|
||||
const changePin = async ({
|
||||
cardId,
|
||||
pin,
|
||||
}: {
|
||||
cardId: string;
|
||||
pin: string;
|
||||
}) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/cards/${cardId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ pin }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to change PIN");
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Error changing PIN:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCards = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/cards");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const data = await response.json();
|
||||
setCards(data);
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPolicies = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/policies");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const data = await response.json();
|
||||
setPolicies(data);
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/transactions");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const data = await response.json();
|
||||
setTransactions(data);
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const refetchAll = () => {
|
||||
void Promise.all([fetchCards(), fetchPolicies(), fetchTransactions()]);
|
||||
};
|
||||
refetchAll();
|
||||
// Refresh this instance whenever ANY instance reports a mutation, so the
|
||||
// dashboard reflects chat-driven approvals (and vice-versa) immediately.
|
||||
dataChangeListeners.add(refetchAll);
|
||||
return () => {
|
||||
dataChangeListeners.delete(refetchAll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addNewCard = async ({ type, color, pin }: NewCardRequest) => {
|
||||
const reqBody = {
|
||||
// random 4 digits
|
||||
last4: randomDigits(4).toString(),
|
||||
// 5 years from now in format MM/YY
|
||||
expiry:
|
||||
new Date(new Date().setFullYear(new Date().getFullYear() + 5))
|
||||
.toISOString()
|
||||
.split("-")[1] +
|
||||
"/" +
|
||||
new Date().toISOString().split("-")[0].slice(2),
|
||||
type: type,
|
||||
color: color,
|
||||
pin: pin,
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/api/v1/cards", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(reqBody),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to add new card");
|
||||
}
|
||||
notifyDataChanged();
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Error adding new card:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const assignPolicyToCard = async ({
|
||||
policyId,
|
||||
cardId,
|
||||
}: {
|
||||
policyId: string;
|
||||
cardId: string;
|
||||
}) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/cards/${cardId}/policy`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ policyId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to assign policy");
|
||||
}
|
||||
notifyDataChanged();
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Error assigning policy:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const addNoteToTransaction = async ({
|
||||
transactionId,
|
||||
content,
|
||||
}: {
|
||||
transactionId: string;
|
||||
content: string;
|
||||
}) => {
|
||||
const reqBody = {
|
||||
content,
|
||||
userId: currentUser.id,
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`/api/v1/transactions/${transactionId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(reqBody),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to add new card");
|
||||
}
|
||||
notifyDataChanged();
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Error adding new card:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const changeTransactionStatus = async ({
|
||||
id,
|
||||
status,
|
||||
}: {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "denied";
|
||||
}): Promise<{ ok: boolean; error?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/transactions/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
// Always refresh from the server so the UI reflects the real state
|
||||
// whether the write succeeded or was rejected (e.g. the over-limit gate).
|
||||
notifyDataChanged();
|
||||
if (!response.ok) {
|
||||
// Surface the server's symptom-only message (e.g. "<team> policy limit
|
||||
// exceeded") so the agent + UI can learn the failure instead of
|
||||
// silently reporting a false success.
|
||||
const body = await response.json().catch(() => null);
|
||||
return {
|
||||
ok: false,
|
||||
error: body?.message ?? "Failed to change transaction status",
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.error("Error changing transaction status:", error);
|
||||
return { ok: false, error: "Network error" };
|
||||
}
|
||||
};
|
||||
|
||||
const openPolicyException = async ({
|
||||
transactionId,
|
||||
code,
|
||||
}: {
|
||||
transactionId: string;
|
||||
code: string;
|
||||
}): Promise<{ ok: boolean; data?: PolicyException; error?: string }> => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/exceptions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ transactionId, code }),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
notifyDataChanged();
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: body?.message ?? "Failed to open policy exception",
|
||||
};
|
||||
}
|
||||
return { ok: true, data: body as PolicyException };
|
||||
} catch (error) {
|
||||
console.error("Error opening policy exception:", error);
|
||||
return { ok: false, error: "Network error" };
|
||||
}
|
||||
};
|
||||
|
||||
const finalizePolicyException = async ({
|
||||
exceptionId,
|
||||
}: {
|
||||
exceptionId: string;
|
||||
}): Promise<{ ok: boolean; data?: PolicyException; error?: string }> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/exceptions/${exceptionId}/finalize`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => null);
|
||||
notifyDataChanged();
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: body?.message ?? "Failed to finalize policy exception",
|
||||
};
|
||||
}
|
||||
return { ok: true, data: body as PolicyException };
|
||||
} catch (error) {
|
||||
console.error("Error finalizing policy exception:", error);
|
||||
return { ok: false, error: "Network error" };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cards:
|
||||
currentUser.role === MemberRole.Admin
|
||||
? cards
|
||||
: cards.filter((card) => {
|
||||
const policy = policies.find(
|
||||
(policy) => policy.id === card.expensePolicyId,
|
||||
);
|
||||
return policy?.type === currentUser.team;
|
||||
}),
|
||||
policies,
|
||||
transactions,
|
||||
changePin,
|
||||
addNewCard,
|
||||
addNoteToTransaction,
|
||||
assignPolicyToCard,
|
||||
changeTransactionStatus,
|
||||
openPolicyException,
|
||||
finalizePolicyException,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotHonoHandler,
|
||||
InMemoryAgentRunner,
|
||||
BuiltInAgent,
|
||||
CopilotKitIntelligence,
|
||||
defineTool,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { IdentifyUserCallback } from "@copilotkit/runtime/v2";
|
||||
import { handle } from "hono/vercel";
|
||||
import { resolveUserId, resolveUserName } from "@/lib/intelligence/user-id";
|
||||
import {
|
||||
renderReportParams,
|
||||
buildReportOps,
|
||||
A2UI_OPERATIONS_KEY,
|
||||
SURFACE_ID,
|
||||
} from "@/a2ui/build-report-ops";
|
||||
|
||||
/**
|
||||
* Backend tool: render a spend report on the canvas. The agent supplies only a
|
||||
* small selection (title + which KPIs/charts); this handler deterministically
|
||||
* expands it into A2UI operations and returns them wrapped in
|
||||
* `a2ui_operations`, which the A2UI middleware detects (injectA2UITool:false)
|
||||
* and turns into an `a2ui-surface` activity the ReportCanvas renders. Running
|
||||
* server-side keeps the emission in the same run, and building the ops in code
|
||||
* (rather than having the reasoning model author the full component JSON
|
||||
* inline) is what keeps it fast and reliable.
|
||||
*/
|
||||
const renderReportTool = defineTool({
|
||||
name: "render_report",
|
||||
description:
|
||||
"Render a multi-widget spend report on the CANVAS (the app's main content " +
|
||||
"area, outside the chat). Choose which KPIs and charts to include; the " +
|
||||
"client renders live banking figures — you never pass numbers. Use for a " +
|
||||
"report/overview/dashboard/analysis request or 'show it on the canvas', " +
|
||||
"NOT for a single inline chart.",
|
||||
parameters: renderReportParams,
|
||||
execute: async (spec) => ({
|
||||
// Unique surfaceId per report so dismissing one report never suppresses a
|
||||
// later one (the canvas tracks the dismissed surfaceId).
|
||||
[A2UI_OPERATIONS_KEY]: buildReportOps(
|
||||
spec,
|
||||
`${SURFACE_ID}-${Date.now().toString(36)}`,
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const bankingAgent = new BuiltInAgent({
|
||||
// Full gpt-5.4 (not -mini): the teach-flow's multi-step tool routing
|
||||
// (recall_memory → offerWorkflowRecording → awaitDashboardDemonstration →
|
||||
// saveLearnedWorkflow) is more reliable on the non-mini model. `openai/gpt-5.4`
|
||||
// is the alias used across the repo.
|
||||
model: "openai/gpt-5.4",
|
||||
prompt: `You are the Northwind Copilot, an assistant embedded in a corporate
|
||||
banking dashboard. You help users view transactions, manage credit cards,
|
||||
assign expense policies, and navigate the app. Use the provided tools. Respect
|
||||
the user's role: if a tool is unavailable to the current user, explain that
|
||||
they lack permission rather than attempting it.
|
||||
|
||||
When you call the showTransactions tool, the rendered list is the single
|
||||
source of truth for the user. Do NOT restate transaction counts, totals,
|
||||
or per-row details in prose — the list already shows them. Keep any
|
||||
accompanying message to at most one short sentence (e.g. "Here are your
|
||||
recent transactions.") and let the rendered list speak for itself.
|
||||
|
||||
When the user asks what is pending, what needs approval, or to review the
|
||||
approval queue, call showPendingApprovals — it renders the interactive queue in
|
||||
the chat. Do not list pending charges in prose. But when the user asks you to
|
||||
APPROVE or CLEAR one specific charge, do NOT call showPendingApprovals — follow
|
||||
the over-limit handling rule below (recall first, then offer to record).
|
||||
|
||||
You can also visualize data directly in the chat. Prefer rendering the chart or
|
||||
diagram over describing the numbers in prose:
|
||||
- showSpendingTrend — spending over time / trend / history questions.
|
||||
- showBudgetUsage — budget, limit, or utilization questions ("how's our budget?").
|
||||
- showSpendBreakdown — "where is the money going?" / spend-by-team breakdowns.
|
||||
- showIncomeVsExpenses — income vs expenses / cash-flow / net-position questions.
|
||||
- showApprovalFlow — ONLY when the user asks how clearing an over-limit charge works (a static explainer). Never in response to a request to approve or clear a charge.
|
||||
|
||||
Tools available to you:
|
||||
- showTransactions — show a filtered list of transactions in the chat.
|
||||
- showPendingApprovals — show the interactive queue of pending transactions. Call when the user asks what is pending or to review approvals — NOT as the response when they ask you to approve one specific charge.
|
||||
- showSpendingTrend — chart of spending over time.
|
||||
- showBudgetUsage — chart of budget usage (spent vs limit) per policy.
|
||||
- showSpendBreakdown — donut chart of spend by team/policy.
|
||||
- showIncomeVsExpenses — chart comparing income vs expenses.
|
||||
- showApprovalFlow — a static explainer diagram of the clearing process. Call ONLY when the user explicitly asks how clearing an over-limit charge works (e.g. "how does this work?"). NEVER call it when the user asks you to approve or clear a specific charge — that path is recall_memory → offerWorkflowRecording.
|
||||
- addNewCard — request a new expense card. Requires human approval.
|
||||
- setCardPin — change the PIN on an existing card. Requires human approval.
|
||||
- assignPolicyToCard — assign an expense policy to a card. Requires human approval.
|
||||
- selectCard — render a visual card picker (brand + last 4 digits) for the user to choose a card. Requires human selection.
|
||||
- addNoteToTransaction — attach a note to a transaction. Requires human approval.
|
||||
- approveTransaction — approve a single transaction. Only valid once a charge can actually be approved (within its limit, or its over-limit gate already lifted). Requires human approval.
|
||||
- openPolicyException — open a draft policy exception against a transaction. Requires human approval.
|
||||
- finalizePolicyException — finalize a policy exception. Requires human approval.
|
||||
- sendSpendAlert — send a spend alert notification for a card.
|
||||
- requestCardReplacement — request a replacement card for an existing card.
|
||||
- flagForReview — flag a transaction for manual review.
|
||||
- offerWorkflowRecording — offer to record how the user handles a situation you have no saved procedure for. Requires human approval.
|
||||
- awaitDashboardDemonstration — wait while the user demonstrates the fix on the dashboard so you can learn it. Requires human approval.
|
||||
- saveLearnedWorkflow — summarize the demonstrated procedure and ask the user to save it. Requires human approval.
|
||||
- recall_memory — search durable long-term memory for a saved procedure, fact, or preference. See the memory rules below for when to call it.
|
||||
- save_memory — persist a durable procedure, fact, or preference. Choose kind and scope per the memory rules below; do NOT hardcode operational/project.
|
||||
|
||||
When you need the user to choose which card to act on (for example before
|
||||
assigning a policy or changing a PIN), call selectCard to render a visual card
|
||||
picker rather than listing the cards as text. Wait for the user's selection,
|
||||
then continue with the chosen card.
|
||||
|
||||
ACTION DISCIPLINE: Only invoke a write tool when the user has explicitly asked
|
||||
for that specific action. Do not chain or substitute actions on your own
|
||||
initiative. If you do not have a known procedure that covers what is being
|
||||
asked, do NOT improvise a substitute action or guess at parameter values.
|
||||
|
||||
When the user asks you to approve a charge that is over its policy limit
|
||||
(overLimit: true in the transactions context) and you do NOT already hold a
|
||||
saved procedure for over-limit charges: do NOT call approveTransaction,
|
||||
showApprovalFlow, showPendingApprovals, or open any approval card — none of
|
||||
those approve the charge, and the explainer/queue cards are NOT a substitute
|
||||
for offering to learn the procedure. Instead, in the SAME turn: (1) briefly say you do not have a
|
||||
saved way to approve an over-limit charge yet, and (2) IMMEDIATELY call
|
||||
offerWorkflowRecording with that charge's id to offer to learn how the user
|
||||
handles it. Never stop after only explaining — always make that offer in the
|
||||
same turn (see TEACH & RECALL). For any other failure you have no procedure for,
|
||||
report exactly what you tried and why it failed, then ask the user how they
|
||||
would like to proceed.
|
||||
|
||||
TEACH & RECALL (durable self-learning via long-term memory):
|
||||
You have long-term memory tools: recall_memory, save_memory. They persist across
|
||||
threads and across users on this team (project scope).
|
||||
|
||||
RECALL FIRST. Whenever the user asks you to approve an over-limit charge
|
||||
(overLimit: true), BEFORE doing anything else call
|
||||
recall_memory({ query: "how to approve an over-limit charge / policy exception procedure" }).
|
||||
- If recall returns a procedure, APPLY IT step by step (file the policy exception
|
||||
with the specified code, then approveTransaction). Do NOT offer to record and do
|
||||
NOT guess a code — use only the code the recalled procedure specifies.
|
||||
- If recall returns nothing, you have no saved procedure: say so briefly and, in the
|
||||
SAME turn, call offerWorkflowRecording with that charge's id.
|
||||
|
||||
LEARN BY WATCHING. If offerWorkflowRecording returns "started", call
|
||||
awaitDashboardDemonstration with the same transaction id and watch — do not direct
|
||||
the user. It reports back the exception code they used.
|
||||
|
||||
SAVE THE PROCEDURE. After awaitDashboardDemonstration reports a filed exception,
|
||||
call saveLearnedWorkflow with that transaction id and the exact code to ask the
|
||||
user to save it. Once saveLearnedWorkflow returns a result whose status is "saved",
|
||||
call save_memory with:
|
||||
scope: "project",
|
||||
kind: "operational",
|
||||
content: "To approve an over-limit charge, open a policy exception with code <CODE>
|
||||
against the charge and finalize it, then approve the transaction."
|
||||
(substitute the exact demonstrated code from the saveLearnedWorkflow result). Save
|
||||
this procedure AT MOST ONCE. If save_memory returns status "near_duplicates" or
|
||||
"absorbed", the procedure is already stored — do not save again; just continue.
|
||||
|
||||
The charge the user demonstrated on is already cleared by that demonstration — do not
|
||||
re-approve it. Apply the saved procedure only to OTHER over-limit charges afterwards.
|
||||
|
||||
GENERAL MEMORY (durable facts & preferences — separate from the over-limit procedure):
|
||||
Beyond the over-limit procedure above, you can remember arbitrary facts and
|
||||
preferences with the same recall_memory / save_memory tools.
|
||||
|
||||
1. RECALL FIRST (general). Before answering anything that could depend on who this
|
||||
person is or how they like things done — and on a fresh thread's first relevant
|
||||
turn — call recall_memory with a short query. A new thread has no visible
|
||||
history, so rely on recall, not the chat log.
|
||||
|
||||
2. SAVE DURABLE FACTS — REQUIRED. When the user asks you to remember something
|
||||
("remember that…", "note that…", "keep in mind…", "fyi…") OR states a durable
|
||||
personal fact/preference/constraint/role/schedule, call save_memory in the SAME
|
||||
turn, before replying. Acknowledging in prose ("Got it, I'll remember…") WITHOUT
|
||||
calling save_memory is a FAILURE — nothing is stored and the fact is lost on the
|
||||
next thread.
|
||||
|
||||
3. SAVE ≠ RECALL. Recalling to check for a duplicate does not satisfy the save;
|
||||
when the user gives a new fact, emit BOTH calls in the same turn.
|
||||
|
||||
4. CLASSIFY. kind: "topical" for a stable fact/preference ("favorite food is
|
||||
sushi", "prefers spend reports by team"); "episodic" for a dated one-off; the
|
||||
over-limit procedure uses "operational" (handled by TEACH & RECALL, not here).
|
||||
scope: "user" for personal facts (the default for "about me"); "project" for
|
||||
team-shared facts.
|
||||
|
||||
5. ASK WHEN AMBIGUOUS. If a fact is genuinely dual-use (could be personal or
|
||||
team-wide), ask one short question — "Just for you, or the whole team?" — before
|
||||
saving. Otherwise infer per (4).
|
||||
|
||||
6. SAVE ONCE / DEDUP. Save each fact at most once per turn. OMIT the "supersedes"
|
||||
parameter entirely on a normal save — only include it when the user is
|
||||
correcting a specific earlier fact AND you have that memory's exact id from a
|
||||
recall_memory result. "supersedes" must be a real memory UUID; never pass an
|
||||
empty string, a placeholder, the content, or a guessed value (the tool rejects
|
||||
a non-UUID and the save fails). On a "near_duplicates" status: if it's already
|
||||
known, just continue; if the user is correcting it, re-save once with
|
||||
"supersedes" set to the recalled memory's id. On "absorbed": continue. Never
|
||||
re-issue the same save.
|
||||
|
||||
7. SECRETS EXCLUSION. NEVER store passwords, API keys, tokens, or full card/SSN
|
||||
numbers, even on an explicit "remember". Ordinary facts (office, schedule,
|
||||
dietary preference, report preferences) ARE saved.
|
||||
|
||||
8. VOICE. Speak about memories like a person ("earlier you mentioned…"); never
|
||||
name the tools or memory ids to the user.
|
||||
|
||||
9. DEFER DURING PROCEDURES. While an over-limit approval / teach-flow is in
|
||||
progress (from the first recall_memory for the over-limit procedure through the
|
||||
saveLearnedWorkflow save), TEACH & RECALL owns ALL memory calls. Suspend this
|
||||
GENERAL MEMORY save rule for the duration: do NOT save_memory facts/roles the
|
||||
user states while demonstrating (e.g. "we file travel overages under TRAVEL-01",
|
||||
"I'm the finance manager"), and do NOT emit an "I'll remember that" line
|
||||
mid-procedure. The only save during the procedure is the operational one. Resume
|
||||
general save/recall once the procedure completes.
|
||||
|
||||
You can render a full multi-widget report on the CANVAS (the app's main content
|
||||
area, outside the chat). Pick by intent:
|
||||
|
||||
- REPORT / ANALYSIS / OVERVIEW / DASHBOARD, or "show it on the canvas" -> call render_report. Choose which KPIs (kpis) and charts to include, and set transactions to a status when a transactions table is relevant. The canvas binds live figures on the client — you only pick which widgets to show and a label-only title/summary.
|
||||
- A SINGLE named chart or metric -> use the existing in-chat chart tool instead (renders inline in the conversation). Do NOT open the canvas for these.
|
||||
|
||||
Examples:
|
||||
- "build me a spend report" / "give me an overview of our spending" / "show it on the canvas" -> render_report (canvas).
|
||||
- "show the spending trend" / "what's our budget usage?" -> in-chat chart tool (inline).
|
||||
|
||||
render_report inputs: kpis is any of totalSpend | pendingCount | overLimitCount | policyCount; charts is any of spendingTrend | budgetUsage | spendBreakdown | incomeVsExpenses; transactions (optional) is one of all | pending | approved | denied. title and summary are LABELS ONLY — never put figures, amounts, percentages, or trend claims in them; every number comes from the selected KPIs/charts, which bind live data on the client.
|
||||
|
||||
OPEN GENERATIVE UI (generateSandboxedUi): You can also author a custom, sandboxed
|
||||
interactive UI on demand with the built-in generateSandboxedUi tool. Use it ONLY
|
||||
for something the standard charts and the render_report canvas cannot express: an
|
||||
interactive tool, calculator, explorer, what-if/scenario simulator, playground,
|
||||
prototype, or a custom/novel visualization (e.g. a treemap, heatmap, sankey, 3D
|
||||
view, or a specific chart library like Chart.js / D3 / Three.js).
|
||||
- NOT for a report, overview, dashboard, or a standard chart — those ALWAYS use
|
||||
render_report or the in-chat chart tools, EVEN WHEN the user says "build" or
|
||||
"make" (e.g. "build a spend report on the canvas" -> render_report, never
|
||||
generateSandboxedUi).
|
||||
- When you build such a UI you MUST obtain every figure by calling the exposed
|
||||
sandbox functions (getTransactions, getPolicies, getCards, getKpis) from inside
|
||||
the generated JavaScript. NEVER invent, inline, or hardcode numbers.
|
||||
- generateSandboxedUi is NEVER part of the over-limit approval / teach-recall arc,
|
||||
the approvals queue, or the standard chart/report responses.`,
|
||||
tools: [renderReportTool],
|
||||
// Temperature 0 for consistent tool routing — the teach-flow sequencing
|
||||
// (recall_memory → offerWorkflowRecording on an over-limit approve) needs the
|
||||
// agent to pick the same path every time, not sample alternatives.
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Self-learning backend (Phase C), env-gated.
|
||||
*
|
||||
* When the three Intelligence env vars below are all set, the runtime is built
|
||||
* in Intelligence mode: the local `bankingAgent` still executes here (calling
|
||||
* OpenAI), but every AG-UI event of every run is streamed over a Phoenix
|
||||
* WebSocket to the Intelligence gateway for durable threads + self-learning
|
||||
* ingestion (the `IntelligenceAgentRunner` does both — see
|
||||
* packages/runtime/src/v2/runtime/runner/intelligence.ts). Officer actions the
|
||||
* gateway later distills into `/knowledge` are what a fresh agent reads back to
|
||||
* learn the over-limit unlock unaided.
|
||||
*
|
||||
* When ANY of the three is missing, the runtime falls back to the exact OSS
|
||||
* path: a pure SSE `CopilotRuntime` + `InMemoryAgentRunner`, with no network
|
||||
* dependency on an Intelligence stack. This is the default and must not regress.
|
||||
*
|
||||
* INTELLIGENCE_API_URL e.g. http://localhost:4201
|
||||
* INTELLIGENCE_GATEWAY_WS_URL e.g. ws://localhost:4401
|
||||
* INTELLIGENCE_API_KEY e.g. cpk_...
|
||||
* COPILOTKIT_LICENSE_TOKEN (optional) read automatically by the runtime
|
||||
*/
|
||||
const intelligenceApiUrl = process.env.INTELLIGENCE_API_URL;
|
||||
const intelligenceWsUrl = process.env.INTELLIGENCE_GATEWAY_WS_URL;
|
||||
const intelligenceApiKey = process.env.INTELLIGENCE_API_KEY;
|
||||
|
||||
const intelligenceEnabled = Boolean(
|
||||
intelligenceApiUrl && intelligenceWsUrl && intelligenceApiKey,
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve a stable end-user identity for Intelligence requests.
|
||||
*
|
||||
* The client sends the active member's role via CopilotKit `properties`
|
||||
* ({ userRole }), which the runtime forwards in the request body. We map it to
|
||||
* a stable per-role user id so threads and distilled knowledge are scoped
|
||||
* consistently across runs. If the role can't be read, fall back to a single
|
||||
* stable demo identity rather than minting a random id (random ids would
|
||||
* fragment thread history).
|
||||
*
|
||||
* Some backends verify that the asserted user is a live member of the org
|
||||
* (e.g. a local Intelligence stack with seeded fixture users). For those, pin
|
||||
* the identity with INTELLIGENCE_USER_ID / INTELLIGENCE_USER_NAME instead of
|
||||
* the derived per-role id.
|
||||
*
|
||||
* The id/name derivation lives in `@/lib/intelligence/user-id` so the Memory
|
||||
* panel proxy (api/memories) resolves the exact same per-user scope this
|
||||
* runtime asserts — the inspector and the agent stay one source of truth.
|
||||
*/
|
||||
const identifyUser: IdentifyUserCallback = async (request: Request) => {
|
||||
let role: string | undefined;
|
||||
let memberId: string | undefined;
|
||||
try {
|
||||
const cloned = request.clone();
|
||||
const body = (await cloned.json()) as {
|
||||
properties?: { userRole?: string; userId?: string };
|
||||
} | null;
|
||||
role = body?.properties?.userRole;
|
||||
memberId = body?.properties?.userId;
|
||||
} catch {
|
||||
// Non-JSON body (e.g. GET /info) — fall through to the default identity.
|
||||
}
|
||||
return {
|
||||
id: resolveUserId({ memberId, role }),
|
||||
name: resolveUserName({ memberId, role }),
|
||||
};
|
||||
};
|
||||
|
||||
function createRuntime(): CopilotRuntime {
|
||||
if (intelligenceEnabled) {
|
||||
const intelligence = new CopilotKitIntelligence({
|
||||
apiUrl: intelligenceApiUrl!,
|
||||
wsUrl: intelligenceWsUrl!,
|
||||
apiKey: intelligenceApiKey!,
|
||||
// Required for the durable-memory demo: the platform's recall_memory /
|
||||
// save_memory tools live at `${apiUrl}/mcp` and are attached to the local
|
||||
// BuiltInAgent run via MCP middleware ONLY when this opt-in flag is set
|
||||
// (see attachIntelligenceEnterpriseLearning in
|
||||
// packages/runtime/.../handlers/shared/agent-utils.ts). Without it the
|
||||
// agent has no memory tools and re-offers to record every over-limit charge.
|
||||
enableEnterpriseLearning: true,
|
||||
});
|
||||
|
||||
return new CopilotRuntime({
|
||||
agents: { default: bankingAgent },
|
||||
intelligence,
|
||||
identifyUser,
|
||||
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
|
||||
lockTtlSeconds: 30,
|
||||
lockKeyPrefix: "northwind-lock",
|
||||
lockHeartbeatIntervalSeconds: 12,
|
||||
generateThreadNames: true,
|
||||
a2ui: { injectA2UITool: false },
|
||||
openGenerativeUI: { agents: ["default"] },
|
||||
});
|
||||
}
|
||||
|
||||
// OSS default — pure SSE, no external Intelligence dependency.
|
||||
return new CopilotRuntime({
|
||||
agents: { default: bankingAgent },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
a2ui: { injectA2UITool: false },
|
||||
openGenerativeUI: { agents: ["default"] },
|
||||
});
|
||||
}
|
||||
|
||||
const runtime = createRuntime();
|
||||
|
||||
const app = createCopilotHonoHandler({ runtime, basePath: "/api/copilotkit" });
|
||||
|
||||
export const GET = handle(app);
|
||||
export const POST = handle(app);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { glassEngineAvailable } from "@/lib/glass-engine";
|
||||
import { intelligenceEnabled, recallMemories } from "@/lib/intelligence/memory";
|
||||
import { resolveUserId } from "@/lib/intelligence/user-id";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
if (!glassEngineAvailable()) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (!intelligenceEnabled()) {
|
||||
return Response.json({ error: "intelligence_disabled" }, { status: 503 });
|
||||
}
|
||||
let body: { query?: unknown; scope?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid_json" }, { status: 400 });
|
||||
}
|
||||
const query = typeof body.query === "string" ? body.query.trim() : "";
|
||||
if (!query) return Response.json({ memories: [] });
|
||||
const scope = typeof body.scope === "string" ? body.scope : undefined;
|
||||
const identity = {
|
||||
memberId: request.headers.get("x-northwind-user-id") ?? undefined,
|
||||
role: request.headers.get("x-northwind-role") ?? undefined,
|
||||
};
|
||||
try {
|
||||
const memories = await recallMemories(
|
||||
resolveUserId(identity),
|
||||
query,
|
||||
scope,
|
||||
);
|
||||
return Response.json({ memories });
|
||||
} catch (err) {
|
||||
console.error("[api/memories/recall] failed:", err);
|
||||
return Response.json({ error: "recall_failed" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as memoryLib from "@/lib/intelligence/memory";
|
||||
import { GET } from "./route";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("GET /api/memories", () => {
|
||||
it("404s when Glass Engine is unavailable (public-host default)", async () => {
|
||||
vi.stubEnv("GLASS_ENGINE_AVAILABLE", "");
|
||||
// Even with Intelligence configured, an unavailable deployment exposes nothing.
|
||||
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
|
||||
vi.stubEnv("INTELLIGENCE_GATEWAY_WS_URL", "ws://localhost:7053");
|
||||
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_x");
|
||||
const res = await GET(new Request("http://localhost:3000/api/memories"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 503 intelligence_disabled when available but in OSS mode", async () => {
|
||||
vi.stubEnv("GLASS_ENGINE_AVAILABLE", "true");
|
||||
vi.stubEnv("INTELLIGENCE_API_URL", "");
|
||||
vi.stubEnv("INTELLIGENCE_GATEWAY_WS_URL", "");
|
||||
vi.stubEnv("INTELLIGENCE_API_KEY", "");
|
||||
const res = await GET(new Request("http://localhost:3000/api/memories"));
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toEqual({ error: "intelligence_disabled" });
|
||||
});
|
||||
|
||||
it("resolves identity from the x-northwind-user-id header (member id)", async () => {
|
||||
vi.stubEnv("GLASS_ENGINE_AVAILABLE", "true");
|
||||
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
|
||||
vi.stubEnv("INTELLIGENCE_GATEWAY_WS_URL", "ws://localhost:7053");
|
||||
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_test");
|
||||
vi.stubEnv("INTELLIGENCE_USER_ID", ""); // unpinned so the map is exercised
|
||||
const spy = vi
|
||||
.spyOn(memoryLib, "listRecalledMemories")
|
||||
.mockResolvedValue([]);
|
||||
const req = new Request("http://localhost:3000/api/memories", {
|
||||
headers: { "x-northwind-user-id": "2b3c4d5e6f" }, // Maya -> morgan-fluxx
|
||||
});
|
||||
await GET(req);
|
||||
expect(spy).toHaveBeenCalledWith("morgan-fluxx");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { glassEngineAvailable } from "@/lib/glass-engine";
|
||||
import {
|
||||
intelligenceEnabled,
|
||||
listRecalledMemories,
|
||||
} from "@/lib/intelligence/memory";
|
||||
import { resolveUserId } from "@/lib/intelligence/user-id";
|
||||
|
||||
/** The active member the panel passes so the proxy resolves the same identity
|
||||
* the runtime asserts (ignored when INTELLIGENCE_USER_ID is pinned). */
|
||||
function identityFrom(request: Request) {
|
||||
return {
|
||||
memberId: request.headers.get("x-northwind-user-id") ?? undefined,
|
||||
role: request.headers.get("x-northwind-role") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
// Security boundary: an un-opted-in deployment exposes no memory surface.
|
||||
if (!glassEngineAvailable()) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (!intelligenceEnabled()) {
|
||||
return Response.json({ error: "intelligence_disabled" }, { status: 503 });
|
||||
}
|
||||
try {
|
||||
const memories = await listRecalledMemories(
|
||||
resolveUserId(identityFrom(request)),
|
||||
);
|
||||
return Response.json({ memories });
|
||||
} catch (err) {
|
||||
console.error("[api/memories] recall failed:", err);
|
||||
return Response.json({ error: "recall_failed" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as store from "@/lib/store";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Get policy per card
|
||||
export const GET = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { id } = await params;
|
||||
const card = store.findCard(id);
|
||||
if (!card) {
|
||||
return new Response(JSON.stringify({ error: "Card not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
const policy = card.expensePolicyId
|
||||
? store.findPolicy(card.expensePolicyId)
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(policy), { status: 200 });
|
||||
};
|
||||
|
||||
// Assign policy
|
||||
export const POST = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const card = store.findCard(id);
|
||||
if (!card) {
|
||||
return new Response(JSON.stringify({ error: "Card not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
const body = await req.json();
|
||||
const { policyId } = body;
|
||||
const updated = store.assignPolicyToCard(id, policyId);
|
||||
return new Response(JSON.stringify(updated), { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("POST Request error", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
export const PUT = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id: cardId } = await params;
|
||||
// Handle pin or card limit change
|
||||
const body = await req.json();
|
||||
const { pin } = body;
|
||||
if (!pin) {
|
||||
const card = store.findCard(cardId);
|
||||
if (!card) {
|
||||
return new Response(JSON.stringify({ error: "Card not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(card), { status: 200 });
|
||||
}
|
||||
const updated = store.updateCardPin(cardId, pin);
|
||||
if (!updated) {
|
||||
return new Response(JSON.stringify({ error: "Card not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(updated), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("PUT Request error", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { Card } from "../data";
|
||||
import { generateUniqueId } from "../data";
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
export const GET = async () => {
|
||||
return new Response(JSON.stringify(store.cards()), { status: 200 });
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
// Handle new card creation
|
||||
const body = await req.json();
|
||||
const { last4, expiry, type, color, pin } = body;
|
||||
const newCard: Card = {
|
||||
id: generateUniqueId(),
|
||||
last4,
|
||||
expiry,
|
||||
type,
|
||||
color,
|
||||
pin,
|
||||
expensePolicyId: "8r5c3m4n5o",
|
||||
}; // Ensure all required fields are included
|
||||
store.addCard(newCard);
|
||||
return new Response(JSON.stringify(newCard), { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("POST Request error", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
export enum CardBrand {
|
||||
Visa = "Visa",
|
||||
MasterCard = "MasterCard",
|
||||
}
|
||||
|
||||
export const CARD_COLORS = {
|
||||
[CardBrand.Visa]: "bg-blue-500",
|
||||
[CardBrand.MasterCard]: "bg-red-500",
|
||||
};
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
last4: string;
|
||||
expiry: string;
|
||||
type: CardBrand;
|
||||
color: string;
|
||||
pin: string;
|
||||
expensePolicyId?: string;
|
||||
}
|
||||
|
||||
export enum MemberRole {
|
||||
Admin = "Admin",
|
||||
Assistant = "Assistant",
|
||||
Member = "Member",
|
||||
}
|
||||
|
||||
export enum ExpenseRole {
|
||||
Marketing = "Marketing",
|
||||
Engineering = "Engineering",
|
||||
Executive = "Executive",
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: MemberRole;
|
||||
team: ExpenseRole;
|
||||
}
|
||||
|
||||
export interface ExpensePolicy {
|
||||
id: string;
|
||||
type: ExpenseRole;
|
||||
limit: number;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export interface TransactionNote {
|
||||
content: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface PolicyException {
|
||||
id: string;
|
||||
transactionId: string;
|
||||
code: string;
|
||||
status: "draft" | "approved";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
title: string;
|
||||
note?: TransactionNote;
|
||||
amount: number;
|
||||
date: string;
|
||||
policyId: string;
|
||||
cardId: string;
|
||||
status: "pending" | "denied" | "approved";
|
||||
activeExceptionId?: string | null;
|
||||
}
|
||||
|
||||
export interface NewCardRequest {
|
||||
type: CardBrand;
|
||||
color: string;
|
||||
pin: string;
|
||||
}
|
||||
|
||||
// A copilot-generated report artifact, filed in the dashboard's Reports tab.
|
||||
// Narrative fields come from the agent; id/createdAt are server-set.
|
||||
export interface Report {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
highlights: string[];
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export function generateUniqueId() {
|
||||
return Math.random().toString(36).slice(2, 15);
|
||||
}
|
||||
|
||||
// The domain data store has moved to `@/lib/store`. This module is now the
|
||||
// single source of truth for shared types/enums only. Routes and server
|
||||
// actions should read/write through the store's typed accessors.
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/store", () => ({ reset: vi.fn() }));
|
||||
vi.mock("@/lib/intelligence/forget-memories", () => ({
|
||||
forgetAllMemories: vi.fn().mockResolvedValue(2),
|
||||
}));
|
||||
|
||||
import * as store from "@/lib/store";
|
||||
import { forgetAllMemories } from "@/lib/intelligence/forget-memories";
|
||||
import { POST } from "./route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/v1/dev/reset", () => {
|
||||
it("403s when presenter reset is disabled and does not touch state", async () => {
|
||||
vi.stubEnv("PRESENTER_RESET_ENABLED", "");
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(403);
|
||||
expect(store.reset).not.toHaveBeenCalled();
|
||||
expect(forgetAllMemories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the store only when Intelligence is unconfigured", async () => {
|
||||
vi.stubEnv("PRESENTER_RESET_ENABLED", "true");
|
||||
vi.stubEnv("INTELLIGENCE_API_URL", "");
|
||||
vi.stubEnv("INTELLIGENCE_API_KEY", "");
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true, reset: ["store"] });
|
||||
expect(store.reset).toHaveBeenCalledTimes(1);
|
||||
expect(forgetAllMemories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forgets every seeded persona when Intelligence is configured", async () => {
|
||||
vi.stubEnv("PRESENTER_RESET_ENABLED", "true");
|
||||
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
|
||||
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_test");
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.reset).toHaveBeenCalledTimes(1);
|
||||
expect(forgetAllMemories).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "jordan-beamson" }),
|
||||
);
|
||||
expect(forgetAllMemories).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "morgan-fluxx" }),
|
||||
);
|
||||
expect(await res.json()).toEqual({
|
||||
ok: true,
|
||||
reset: ["store", "memory"],
|
||||
forgot: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports partial progress on a mid-loop memory failure", async () => {
|
||||
vi.stubEnv("PRESENTER_RESET_ENABLED", "true");
|
||||
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
|
||||
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_test");
|
||||
// First persona succeeds (2 forgotten), second persona throws.
|
||||
vi.mocked(forgetAllMemories)
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(502);
|
||||
expect(await res.json()).toMatchObject({
|
||||
ok: false,
|
||||
reset: ["store", "memory"],
|
||||
forgot: 2,
|
||||
memoryError: "boom",
|
||||
});
|
||||
expect(store.reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as store from "@/lib/store";
|
||||
import { forgetAllMemories } from "@/lib/intelligence/forget-memories";
|
||||
import { presenterResetEnabled } from "@/lib/presenter";
|
||||
import { SEEDED_USER_IDS } from "@/lib/intelligence/user-id";
|
||||
|
||||
/**
|
||||
* Presenter/booth reset: restore the demo to a fresh "teachable" state.
|
||||
* Gated by PRESENTER_RESET_ENABLED (same flag the sidebar button checks), so a
|
||||
* publicly-hosted deployment is safe-off by default.
|
||||
* 1. Re-seed the in-memory transaction store (over-limit charges back to pending).
|
||||
* 2. If Intelligence is configured, forget durable memory for EVERY seeded
|
||||
* persona (a bare list enumerates user + project scope, so the first persona
|
||||
* also clears project-scoped rows; the rest clear their own user scope).
|
||||
*/
|
||||
export const POST = async () => {
|
||||
if (!presenterResetEnabled()) {
|
||||
return new Response(JSON.stringify({ error: "presenter reset disabled" }), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
store.reset();
|
||||
|
||||
const apiUrl = process.env.INTELLIGENCE_API_URL;
|
||||
const apiKey = process.env.INTELLIGENCE_API_KEY;
|
||||
if (!apiUrl || !apiKey) {
|
||||
return new Response(JSON.stringify({ ok: true, reset: ["store"] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Declared outside the try so the catch can report partial progress: a
|
||||
// mid-loop failure can leave the store reset AND some personas already
|
||||
// forgotten, so the error body must not read as "memory untouched".
|
||||
let forgot = 0;
|
||||
try {
|
||||
for (const userId of SEEDED_USER_IDS) {
|
||||
forgot += await forgetAllMemories({ apiUrl, apiKey, userId });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, reset: ["store", "memory"], forgot }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
reset: forgot > 0 ? ["store", "memory"] : ["store"],
|
||||
forgot,
|
||||
memoryError: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
{ status: 502, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as store from "@/lib/store";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Finalize (auto-approve) a draft policy exception, linking it to its
|
||||
// transaction so the policy-limit gate is lifted when the code justifies it.
|
||||
export const POST = async (
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const approved = store.finalizePolicyException(id);
|
||||
return new Response(JSON.stringify(approved), { status: 200 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (message === "NOT_FOUND") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "NOT_FOUND", message: "Exception not found" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (message === "ALREADY_FINALIZED") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "BAD_REQUEST",
|
||||
message: "Exception already finalized.",
|
||||
}),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
console.error("POST Request error", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "BAD_REQUEST", message: "Bad request" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
// Open a draft policy exception against a transaction.
|
||||
export const POST = async (req: NextRequest) => {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
const body = await req.json();
|
||||
code = body?.code;
|
||||
const exception = store.openPolicyException(
|
||||
body?.transactionId,
|
||||
code as string,
|
||||
);
|
||||
return new Response(JSON.stringify(exception), { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (message === "NOT_FOUND") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "NOT_FOUND",
|
||||
message: "Transaction not found",
|
||||
}),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (message === "INVALID_EXCEPTION_CODE") {
|
||||
// Surface-level rejection. We deliberately do NOT enumerate the valid
|
||||
// codes here — that would itself be a hint. The agent has to find the
|
||||
// catalogue on its own via `/knowledge`.
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "INVALID_EXCEPTION_CODE",
|
||||
message: `"${code}" is not a recognized policy exception code.`,
|
||||
}),
|
||||
{ status: 422, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
console.error("POST Request error", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "BAD_REQUEST", message: "Bad request" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MemberRole } from "@/app/api/v1/data";
|
||||
|
||||
const AdminAndAssistants = [MemberRole.Admin, MemberRole.Assistant];
|
||||
const All = [MemberRole.Admin, MemberRole.Assistant, MemberRole.Member];
|
||||
const AdminOnly = [MemberRole.Admin];
|
||||
|
||||
export const PERMISSIONS = {
|
||||
ADD_CARD: AdminOnly,
|
||||
ADD_POLICY: AdminAndAssistants,
|
||||
ADD_NOTE: All,
|
||||
SHOW_TRANSACTIONS: All,
|
||||
SET_PIN: All,
|
||||
APPROVE_TRANSACTION: AdminAndAssistants,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
// Get policy per card
|
||||
export const GET = async () => {
|
||||
return new Response(JSON.stringify(store.policies()), { status: 200 });
|
||||
};
|
||||
|
||||
// Assign policy
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { policyId, type, limit } = body;
|
||||
if (!policyId || !type || !limit) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required fields" }),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// Handle new policy creation
|
||||
const newPolicy = { id: policyId, type, limit, spent: 0 };
|
||||
store.addPolicy(newPolicy);
|
||||
return new Response(JSON.stringify(newPolicy), { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("POST Request error", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Policy-exception-code catalogue for the banking demo.
|
||||
*
|
||||
* The human UI displays the `label` so a compliance officer knows what
|
||||
* each code means; everything stored in the exception record and returned
|
||||
* by the agent tools carries only the `code`. The agent has no static
|
||||
* mapping from code -> meaning — it must discover via `/knowledge` which
|
||||
* codes actually justify an approval. That's the whole point of this
|
||||
* surface for the SL demo: the writer agent watches successful officer
|
||||
* flows and learns that, e.g., `EXC-BOARD-APPROVED` is the
|
||||
* approval-justifying lever for policy overrides, while the other codes
|
||||
* are filed for recordkeeping but do not constitute a standing
|
||||
* justification.
|
||||
*
|
||||
* Adding a new code? Append it here. Adding a NEW justifying code?
|
||||
* Also list it in `JUSTIFYING_EXCEPTION_CODES` below.
|
||||
*/
|
||||
|
||||
export interface PolicyExceptionCodeMeta {
|
||||
readonly code: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalogue shown to the human in the New exception modal. First three
|
||||
* are the justifying codes. The rest are plausible exception reasons
|
||||
* that get filed for the record but do not constitute a standing policy
|
||||
* justification.
|
||||
*/
|
||||
export const POLICY_EXCEPTION_CODES: ReadonlyArray<PolicyExceptionCodeMeta> = [
|
||||
// Justifying — these codes support approval of the policy exception.
|
||||
{ code: "EXC-BOARD-APPROVED", label: "Board-approved spend" },
|
||||
{ code: "EXC-CONTRACTUAL-COMMITMENT", label: "Contractual commitment" },
|
||||
{ code: "EXC-EMERGENCY-SPEND", label: "Emergency spend" },
|
||||
// Non-justifying. Recorded for history; do not constitute a standing justification.
|
||||
{ code: "EXC-WILL-REIMBURSE", label: "Employee will reimburse" },
|
||||
{ code: "EXC-ONE-TIME", label: "One-time exception" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The three codes that, when filed and finalized, justify approval of
|
||||
* a policy exception. Anything else is recorded but does not provide
|
||||
* a standing justification.
|
||||
*/
|
||||
export const JUSTIFYING_EXCEPTION_CODES: ReadonlySet<string> = new Set<string>([
|
||||
"EXC-BOARD-APPROVED",
|
||||
"EXC-CONTRACTUAL-COMMITMENT",
|
||||
"EXC-EMERGENCY-SPEND",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Human-friendly label for a policy exception code. Falls back to the
|
||||
* raw code for unknown values (e.g. legacy records from older seeds).
|
||||
*/
|
||||
export const labelForExceptionCode = (code: string): string =>
|
||||
POLICY_EXCEPTION_CODES.find((c) => c.code === code)?.label ?? code;
|
||||
|
||||
const VALID_EXCEPTION_CODES: ReadonlySet<string> = new Set(
|
||||
POLICY_EXCEPTION_CODES.map((c) => c.code),
|
||||
);
|
||||
|
||||
/**
|
||||
* True iff `code` belongs to the published catalogue. The store rejects
|
||||
* `openPolicyException` calls that pass an unknown code, forcing the
|
||||
* agent to learn the catalogue via `/knowledge` rather than inventing
|
||||
* plausible-looking strings.
|
||||
*/
|
||||
export const isValidExceptionCode = (code: string): boolean =>
|
||||
VALID_EXCEPTION_CODES.has(code);
|
||||
|
||||
/**
|
||||
* True iff `code` is one of the three justifying exception codes.
|
||||
* Non-justifying codes are filed for history but do not support approval.
|
||||
*/
|
||||
export const isJustifying = (code: string): boolean =>
|
||||
JUSTIFYING_EXCEPTION_CODES.has(code);
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
// Get all reports (newest first — the store unshifts on file)
|
||||
export const GET = async () => {
|
||||
return new Response(JSON.stringify(store.reports()), { status: 200 });
|
||||
};
|
||||
|
||||
// File a new copilot-generated report
|
||||
export const POST = async (req: Request) => {
|
||||
const body = await req.json().catch(() => null);
|
||||
const title = typeof body?.title === "string" ? body.title.trim() : "";
|
||||
const summary = typeof body?.summary === "string" ? body.summary.trim() : "";
|
||||
const highlights = Array.isArray(body?.highlights)
|
||||
? body.highlights.filter((h: unknown): h is string => typeof h === "string")
|
||||
: [];
|
||||
const createdBy =
|
||||
typeof body?.createdBy === "string" && body.createdBy.trim()
|
||||
? body.createdBy.trim()
|
||||
: "Copilot";
|
||||
|
||||
if (!title || !summary) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "title and summary are required" }),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const report = store.addReport({ title, summary, highlights, createdBy });
|
||||
return new Response(JSON.stringify(report), { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as store from "@/lib/store";
|
||||
import type { Transaction } from "../../data";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Add note to transaction / update status
|
||||
export const PUT = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const existing = store.findTransaction(id);
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ error: "Transaction not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
const body = await req.json();
|
||||
const { content, userId, ...rest } = body;
|
||||
const patch: Partial<Transaction> = { ...rest };
|
||||
if (content && userId) {
|
||||
patch.note = {
|
||||
content,
|
||||
userId,
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
};
|
||||
}
|
||||
// Policy-limit gate. `status` arrives inside `rest`, so apply the patch
|
||||
// to a merged view before checking. The rejection names ONLY the
|
||||
// symptom (the policy is over its limit) — never the policy-exception
|
||||
// path that lifts the gate. The agent has to learn that recipe from
|
||||
// `/knowledge`; leaking it here would defeat the SL demo.
|
||||
const merged = { ...existing, ...patch };
|
||||
if (
|
||||
patch.status === "approved" &&
|
||||
!store.isWithinPolicyLimit(merged) &&
|
||||
!store.hasApprovedException(merged)
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "OVER_POLICY_LIMIT",
|
||||
message: `${store.findPolicy(existing.policyId)?.type} policy limit exceeded`,
|
||||
}),
|
||||
{ status: 422, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const updated = store.updateTransaction(id, patch);
|
||||
return new Response(JSON.stringify(updated), { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("PUT Request error", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
// Get all transactions
|
||||
export const GET = async () => {
|
||||
return new Response(JSON.stringify(store.transactions()), { status: 200 });
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
export const PUT = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
// Handle role/team change
|
||||
const body = await req.json();
|
||||
const { team, role } = body;
|
||||
const updated = store.updateMember(id, { team, role });
|
||||
if (!updated) {
|
||||
return new Response(JSON.stringify({ error: "User not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(updated), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("PUT Request error", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE = async (
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const remaining = store.removeMember(id);
|
||||
if (!remaining) {
|
||||
return new Response(JSON.stringify({ error: "User not found" }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(remaining), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("DELETE Request error", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { generateUniqueId } from "../data";
|
||||
import * as store from "@/lib/store";
|
||||
|
||||
export const GET = async () => {
|
||||
return new Response(JSON.stringify(store.team()), { status: 200 });
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
// Handle new item creation
|
||||
const body = await req.json();
|
||||
const { name, email, role, team } = body;
|
||||
const newUser = { id: generateUniqueId(), name, email, role, team };
|
||||
store.addMember(newUser);
|
||||
return new Response(JSON.stringify(newUser), { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("POST Request error", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
// The `/cards` route mirrors the dashboard overview (it shares the same
|
||||
// `useCreditCards` data). Re-export the redesigned dashboard so both routes
|
||||
// stay visually identical without duplicating the layout.
|
||||
export { default } from "@/app/dashboard/page";
|
||||
@@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
import useCreditCards from "@/app/actions";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Transaction } from "@/app/api/v1/data";
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, ArrowRight } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TransactionsList } from "@/components/transactions-list";
|
||||
import { GradientCreditCard } from "@/components/card-visual";
|
||||
import { StatisticsChart } from "@/components/statistics-chart";
|
||||
import { AnalyticsView } from "@/components/wow/analytics-view";
|
||||
import { ReportsView } from "@/components/wow/reports-view";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import { useRecording } from "@/components/recording-context";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
const {
|
||||
cards,
|
||||
policies,
|
||||
transactions,
|
||||
changeTransactionStatus,
|
||||
openPolicyException,
|
||||
finalizePolicyException,
|
||||
} = useCreditCards();
|
||||
const { currentUser } = useAuthContext();
|
||||
const { logStep } = useRecording();
|
||||
// Top-level dashboard tab; the "View All" link jumps straight to Transactions.
|
||||
const [tab, setTab] = useState("overview");
|
||||
|
||||
// Switch the top-level tab and narrate it into the recorder HUD (only while a
|
||||
// workflow is being recorded). Used by both the tab strip and the "View All"
|
||||
// shortcut so either path records the same "Opened Transactions" step.
|
||||
const selectTab = (value: string) => {
|
||||
setTab(value);
|
||||
if (value === "transactions") logStep("Opened Transactions");
|
||||
};
|
||||
|
||||
// Charges awaiting approval. Over-limit ones surface the "File policy
|
||||
// exception" affordance inside TransactionsList; the approve only takes
|
||||
// effect once a justifying exception is finalized (the gate in store.ts).
|
||||
const pendingTransactions = transactions.filter(
|
||||
(t) => t.status === "pending",
|
||||
);
|
||||
|
||||
// Run the REST mutation and report whether it actually took effect, so
|
||||
// TransactionsList only records the human action when the server accepted it
|
||||
// (a blocked over-limit approval must not be recorded as an approval).
|
||||
const handleChangeTransactionStatus = async ({
|
||||
id,
|
||||
status,
|
||||
}: {
|
||||
id: string;
|
||||
status: Transaction["status"];
|
||||
}): Promise<boolean> => {
|
||||
const { ok } = await changeTransactionStatus({ id, status });
|
||||
return ok;
|
||||
};
|
||||
|
||||
const { balance, income, expenses, limit, lastPayment, stats, statLabels } =
|
||||
useMemo(() => {
|
||||
const { balance, limit } = policies.reduce(
|
||||
(acc, policy) => ({
|
||||
balance: acc.balance + policy.spent,
|
||||
limit: {
|
||||
used: acc.limit.used + policy.spent,
|
||||
total: acc.limit.total + policy.limit,
|
||||
},
|
||||
}),
|
||||
{ balance: 0, limit: { used: 0, total: 0 } },
|
||||
);
|
||||
|
||||
const income = transactions
|
||||
.filter((t) => t.amount > 0)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
const expenses = transactions
|
||||
.filter((t) => t.amount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
||||
|
||||
// Most recent transaction by date = "last payment".
|
||||
const lastPayment = [...transactions].sort(
|
||||
(a, b) => +new Date(b.date) - +new Date(a.date),
|
||||
)[0];
|
||||
|
||||
// Statistics: bucket expense magnitude by calendar month (oldest→newest).
|
||||
// Falls back to representative seeded points when there isn't enough data.
|
||||
const byMonth = new Map<string, number>();
|
||||
for (const t of transactions) {
|
||||
if (t.amount >= 0) continue;
|
||||
const d = new Date(t.date);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
byMonth.set(key, (byMonth.get(key) ?? 0) + Math.abs(t.amount));
|
||||
}
|
||||
const monthFmt = new Intl.DateTimeFormat("en-US", { month: "short" });
|
||||
const sortedMonths = [...byMonth.entries()].sort(([a], [b]) => {
|
||||
const [ay, am] = a.split("-").map(Number);
|
||||
const [by, bm] = b.split("-").map(Number);
|
||||
return ay - by || am - bm;
|
||||
});
|
||||
|
||||
let stats: number[];
|
||||
let statLabels: string[];
|
||||
if (sortedMonths.length >= 3) {
|
||||
stats = sortedMonths.map(([, v]) => v);
|
||||
statLabels = sortedMonths.map(([k]) => {
|
||||
const [y, m] = k.split("-").map(Number);
|
||||
return monthFmt.format(new Date(y, m, 1));
|
||||
});
|
||||
} else {
|
||||
stats = [3200, 4100, 3600, 5200, 4800, 6400];
|
||||
statLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
income,
|
||||
expenses,
|
||||
limit: {
|
||||
total: limit.total,
|
||||
usagePercentage: limit.total ? (limit.used / limit.total) * 100 : 0,
|
||||
},
|
||||
lastPayment,
|
||||
stats,
|
||||
statLabels,
|
||||
};
|
||||
}, [policies, transactions]);
|
||||
|
||||
const holderName = currentUser?.name?.toUpperCase() ?? "CARD HOLDER";
|
||||
const primaryCard = cards[0];
|
||||
const secondaryCard = cards[1];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-2 pb-4 md:px-4">
|
||||
<Tabs value={tab} onValueChange={selectTab} className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold tracking-tight text-ink">
|
||||
Dashboard
|
||||
</h2>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="transactions">Transactions</TabsTrigger>
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
<TabsTrigger value="reports">Reports</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="overview"
|
||||
className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_360px]"
|
||||
>
|
||||
{/* ── LEFT column ──────────────────────────────────────────── */}
|
||||
<div className="space-y-6">
|
||||
{/* My Cards */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="section-heading text-base">My Cards</h3>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium text-brand-indigo hover:underline dark:text-brand-violet"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex gap-5 overflow-x-auto pb-2">
|
||||
{primaryCard && (
|
||||
<div className="relative w-[300px] flex-shrink-0">
|
||||
{/* A second card peeking behind */}
|
||||
{secondaryCard && (
|
||||
<div className="absolute -right-3 top-3 w-full scale-[0.96] opacity-60 blur-[1px]">
|
||||
<GradientCreditCard
|
||||
card={secondaryCard}
|
||||
holder={holderName}
|
||||
subtle
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<GradientCreditCard
|
||||
card={primaryCard}
|
||||
holder={holderName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add-card tile (rendered last) */}
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Add a new card"
|
||||
className="flex aspect-[1.586/1] w-[300px] flex-shrink-0 flex-col items-center justify-center gap-2 rounded-[22px] border-2 border-dashed border-brand/40 text-ink-muted transition-colors hover:border-brand hover:bg-brand-soft/50 hover:text-brand-indigo"
|
||||
>
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-soft text-brand-indigo">
|
||||
<Plus className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="text-sm font-medium">Add new card</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
<section className="rounded-2xl border border-hairline bg-surface p-5 shadow-soft">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="section-heading text-base">
|
||||
Recent Transactions
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("transactions")}
|
||||
className="text-sm font-medium text-brand-indigo hover:underline dark:text-brand-violet"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all">
|
||||
<TabsList variant="underline" className="mb-2">
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="income">Income</TabsTrigger>
|
||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-4 mb-1">
|
||||
<span className="inline-flex items-center rounded-full bg-brand-soft px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-wide text-brand-indigo dark:text-brand-violet">
|
||||
Today
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all">
|
||||
<TransactionsList transactions={transactions} />
|
||||
</TabsContent>
|
||||
<TabsContent value="income">
|
||||
<TransactionsList
|
||||
transactions={transactions.filter((t) => t.amount > 0)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="expenses">
|
||||
<TransactionsList
|
||||
transactions={transactions.filter((t) => t.amount < 0)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT rail ──────────────────────────────────────────── */}
|
||||
<aside className="flex flex-col gap-5 rounded-[26px] border border-hairline bg-surface p-6 shadow-soft">
|
||||
<div>
|
||||
<p className="text-sm text-ink-muted">Balance</p>
|
||||
<p className="mt-1 text-4xl font-bold tracking-tight text-ink">
|
||||
{formatCurrency(balance)}
|
||||
</p>
|
||||
{primaryCard && (
|
||||
<p className="mt-2 font-mono text-xs tracking-widest text-ink-muted">
|
||||
•••• •••• •••• {primaryCard.last4}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Income / Expenses split */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl bg-positive-soft/60 p-3">
|
||||
<div className="flex items-center gap-1.5 text-positive">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Income</span>
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-bold text-ink">
|
||||
{formatCurrency(income)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-negative-soft/60 p-3">
|
||||
<div className="flex items-center gap-1.5 text-negative">
|
||||
<ArrowDownRight className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Expenses</span>
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-bold text-ink">
|
||||
{formatCurrency(expenses)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px w-full bg-hairline" />
|
||||
|
||||
{/* Last payment */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
Last Payment Details
|
||||
</p>
|
||||
{lastPayment ? (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink">
|
||||
{lastPayment.title}
|
||||
</p>
|
||||
<p className="text-xs text-ink-muted">{lastPayment.date}</p>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
lastPayment.amount > 0
|
||||
? "font-semibold text-positive"
|
||||
: "font-semibold text-negative"
|
||||
}
|
||||
>
|
||||
{lastPayment.amount > 0 ? "+" : ""}
|
||||
{formatCurrency(lastPayment.amount)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-ink-muted">No payments yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
Statistics
|
||||
</p>
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
{formatCurrency(limit.total)} limit ·{" "}
|
||||
{limit.usagePercentage.toFixed(0)}% used
|
||||
</span>
|
||||
</div>
|
||||
<StatisticsChart data={stats} labels={statLabels} />
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href="/"
|
||||
className="brand-gradient mt-1 flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold text-white shadow-[0_10px_24px_hsl(252_83%_60%/0.35)] transition-all hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
New Transaction
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</aside>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transactions">
|
||||
<section className="rounded-2xl border border-hairline bg-surface p-5 shadow-soft">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="section-heading text-base">All Transactions</h3>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="all"
|
||||
onValueChange={(value) => {
|
||||
if (value === "pending") logStep("Opened Pending approval");
|
||||
}}
|
||||
>
|
||||
<TabsList variant="underline" className="mb-2">
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending approval</TabsTrigger>
|
||||
<TabsTrigger value="income">Income</TabsTrigger>
|
||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all">
|
||||
<TransactionsList transactions={transactions} />
|
||||
</TabsContent>
|
||||
<TabsContent value="pending">
|
||||
{pendingTransactions.length ? (
|
||||
<TransactionsList
|
||||
transactions={pendingTransactions}
|
||||
policies={policies}
|
||||
openPolicyException={openPolicyException}
|
||||
finalizePolicyException={finalizePolicyException}
|
||||
showApprovalInterface
|
||||
approvalInterfaceProps={{
|
||||
onApprove: (id) =>
|
||||
handleChangeTransactionStatus({
|
||||
id,
|
||||
status: "approved",
|
||||
}),
|
||||
onDeny: (id) =>
|
||||
handleChangeTransactionStatus({ id, status: "denied" }),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="px-3 py-8 text-center text-sm text-ink-muted">
|
||||
No transactions are pending approval.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="income">
|
||||
<TransactionsList
|
||||
transactions={transactions.filter((t) => t.amount > 0)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="expenses">
|
||||
<TransactionsList
|
||||
transactions={transactions.filter((t) => t.amount < 0)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
<AnalyticsView />
|
||||
</TabsContent>
|
||||
<TabsContent value="reports">
|
||||
<ReportsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,278 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* Banking app uses a class-based dark mode (`.dark` / `.light` on <html>). In
|
||||
Tailwind v4 the default dark variant is `prefers-color-scheme`; the
|
||||
`@custom-variant` directive below re-enables the class-based behavior so
|
||||
utilities like `dark:bg-neutral-800` continue to work. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
DESIGN TOKENS — "Aurora" premium light-theme fintech language.
|
||||
|
||||
Mood: airy, premium, modern. Soft lavender canvas, white glass cards, a
|
||||
violet→indigo primary gradient, emerald (positive) / rose (negative)
|
||||
semantics, large radii and soft shadows. See docs/DESIGN.md.
|
||||
|
||||
The legacy shadcn `--background` / `--foreground` HSL pairs are preserved
|
||||
(Tailwind utilities such as `bg-background` still resolve through `@theme
|
||||
inline` below) so nothing that referenced them breaks. New work should reach
|
||||
for the named brand tokens (`--brand-*`, `--surface`, `--positive`,
|
||||
`--negative`) and the helper utility classes at the bottom of this file.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
@theme inline {
|
||||
/* Legacy shadcn bridges (kept for back-compat with existing utilities). */
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
/* Brand palette, exposed as Tailwind colors so `bg-brand`, `text-brand`,
|
||||
`from-brand-violet`, `to-brand-indigo`, `ring-brand` all work. */
|
||||
--color-brand: hsl(var(--brand));
|
||||
--color-brand-violet: hsl(var(--brand-violet));
|
||||
--color-brand-indigo: hsl(var(--brand-indigo));
|
||||
--color-brand-foreground: hsl(var(--brand-foreground));
|
||||
--color-brand-soft: hsl(var(--brand-soft));
|
||||
--color-surface: hsl(var(--surface));
|
||||
--color-surface-muted: hsl(var(--surface-muted));
|
||||
--color-canvas: hsl(var(--canvas));
|
||||
--color-ink: hsl(var(--ink));
|
||||
--color-ink-muted: hsl(var(--ink-muted));
|
||||
--color-positive: hsl(var(--positive));
|
||||
--color-positive-soft: hsl(var(--positive-soft));
|
||||
--color-negative: hsl(var(--negative));
|
||||
--color-negative-soft: hsl(var(--negative-soft));
|
||||
--color-hairline: hsl(var(--hairline));
|
||||
|
||||
/* Radius scale — large and friendly. `--radius` is the card baseline. */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 6px);
|
||||
--radius-sm: calc(var(--radius) - 10px);
|
||||
--radius-xl: calc(var(--radius) + 6px);
|
||||
--radius-2xl: calc(var(--radius) + 12px);
|
||||
|
||||
/* Soft, layered shadows. */
|
||||
--shadow-soft:
|
||||
0 1px 2px hsl(252 60% 30% / 0.04), 0 8px 24px hsl(252 50% 30% / 0.06);
|
||||
--shadow-lift:
|
||||
0 2px 6px hsl(252 60% 30% / 0.06), 0 18px 48px hsl(252 50% 30% / 0.12);
|
||||
--shadow-glow: 0 12px 30px hsl(252 83% 60% / 0.35);
|
||||
|
||||
/* Headings pick up the brand font (Inter via next/font), body too. */
|
||||
--font-sans:
|
||||
var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
var(--font-inter),
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
background-color: hsl(var(--canvas));
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Card baseline radius (≈22px). */
|
||||
--radius: 1.375rem;
|
||||
|
||||
/* Brand violet→indigo. violet-600 ≈ #7C5CFC, indigo-600 ≈ #5B3DF5. */
|
||||
--brand: 252 83% 67%; /* primary violet (#7C5CFC-ish) */
|
||||
--brand-violet: 252 83% 67%;
|
||||
--brand-indigo: 248 84% 60%; /* indigo (#5B3DF5-ish) */
|
||||
--brand-foreground: 0 0% 100%;
|
||||
--brand-soft: 252 90% 96%; /* lilac chip / hover wash */
|
||||
}
|
||||
|
||||
/* LIGHT (primary showcase mode). */
|
||||
.light {
|
||||
--background: 255 60% 99%;
|
||||
--foreground: 252 30% 14%;
|
||||
color-scheme: light;
|
||||
|
||||
--canvas: 255 60% 97%; /* soft lavender page background */
|
||||
--surface: 0 0% 100%; /* white cards */
|
||||
--surface-muted: 252 40% 98%;
|
||||
--ink: 252 30% 14%; /* near-black headings */
|
||||
--ink-muted: 250 12% 46%; /* muted grey labels */
|
||||
--hairline: 252 30% 92%; /* soft borders */
|
||||
|
||||
--positive: 152 62% 40%; /* emerald */
|
||||
--positive-soft: 152 70% 95%;
|
||||
--negative: 349 78% 56%; /* rose / red */
|
||||
--negative-soft: 349 90% 96%;
|
||||
}
|
||||
|
||||
/* DARK (kept fully working — deep indigo-tinted neutrals). */
|
||||
.dark {
|
||||
--background: 252 30% 7%;
|
||||
--foreground: 250 30% 96%;
|
||||
color-scheme: dark;
|
||||
|
||||
--canvas: 252 30% 7%;
|
||||
--surface: 252 24% 11%;
|
||||
--surface-muted: 252 22% 14%;
|
||||
--ink: 250 30% 96%;
|
||||
--ink-muted: 250 12% 66%;
|
||||
--hairline: 252 20% 22%;
|
||||
|
||||
--brand-soft: 252 50% 18%;
|
||||
|
||||
--positive: 152 56% 50%;
|
||||
--positive-soft: 152 40% 16%;
|
||||
--negative: 349 80% 64%;
|
||||
--negative-soft: 349 40% 18%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reusable brand helpers ─────────────────────────────────────────────── */
|
||||
@layer components {
|
||||
/* The signature violet→indigo gradient, reused on the primary button, the
|
||||
credit card, and CTAs. */
|
||||
.brand-gradient {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--brand-violet)),
|
||||
hsl(var(--brand-indigo))
|
||||
);
|
||||
}
|
||||
|
||||
.brand-text-gradient {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--brand-violet)),
|
||||
hsl(var(--brand-indigo))
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Glassmorphism-lite surface used by cards and the floating sidebar. */
|
||||
.glass-surface {
|
||||
background-color: hsl(var(--surface) / 0.85);
|
||||
backdrop-filter: blur(16px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
||||
}
|
||||
|
||||
/* Section heading — medium-weight violet. */
|
||||
.section-heading {
|
||||
color: hsl(var(--brand-indigo));
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.dark .section-heading {
|
||||
color: hsl(var(--brand-violet));
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
.prefix-arrow::before {
|
||||
content: "↑ ";
|
||||
}
|
||||
|
||||
/* ── CopilotKit chat popup accent ───────────────────────────────────────────
|
||||
The v2 chat scopes its shadcn-style tokens to `[data-copilotkit]` (neutral
|
||||
greys by default). We re-point only the accent-ish tokens to the brand
|
||||
violet so the send button, focus rings and links match — purely additive,
|
||||
no component props touched, and it degrades gracefully if the SDK changes. */
|
||||
[data-copilotkit] {
|
||||
--primary: oklch(58% 0.214 277);
|
||||
--primary-foreground: oklch(98.5% 0 0);
|
||||
--ring: oklch(58% 0.214 277);
|
||||
}
|
||||
.dark [data-copilotkit],
|
||||
[data-copilotkit].dark {
|
||||
--primary: oklch(70% 0.17 280);
|
||||
--primary-foreground: oklch(20.5% 0 0);
|
||||
--ring: oklch(70% 0.17 280);
|
||||
}
|
||||
|
||||
/* ── Spacing between stacked chat action cards ──────────────────────────────
|
||||
HITL / tool-render cards (openPolicyException, approveTransaction, the
|
||||
teach-loop cards, …) render as bordered blocks inside assistant messages.
|
||||
CopilotKit lays the message list out as a flex column with no row-gap and
|
||||
gives messages no margin, so a recall chain (open → finalize → approve)
|
||||
renders several cards sharing an edge — both across consecutive assistant
|
||||
messages and stacked within one. A uniform top gap on every card separates
|
||||
them in both cases (the first card's gap is harmless against the chat top). */
|
||||
.copilotKitMessages .rounded-2xl.border-hairline {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Legacy dark-mode overrides, retargeted to the new tokens ───────────── */
|
||||
.dark body {
|
||||
background-color: hsl(var(--canvas));
|
||||
color: hsl(var(--ink));
|
||||
}
|
||||
|
||||
.dark header,
|
||||
.dark .border-b,
|
||||
.dark .border-r {
|
||||
border-color: hsl(var(--hairline));
|
||||
}
|
||||
|
||||
/* ── Self-learning "recording" vignette ─────────────────────────────────────
|
||||
A soft pulsating violet glow that hugs the canvas edges while the agent is
|
||||
recording a demonstrated officer action (see <RecordingVignette/> +
|
||||
recording-context.tsx). Non-blocking overlay: fixed, full-viewport,
|
||||
pointer-events:none, driven by the `data-recording` attribute. */
|
||||
@keyframes recording-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-vignette {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above the docked chat panel (z-1200) so the glow frames the WHOLE canvas —
|
||||
including over the panel — reading as "the entire app is being recorded".
|
||||
pointer-events:none keeps it from ever intercepting clicks. */
|
||||
z-index: 1300;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease-in-out;
|
||||
box-shadow:
|
||||
inset 0 0 0 3px hsl(var(--brand-violet) / 0.6),
|
||||
inset 0 0 70px 2px hsl(var(--brand-violet) / 0.45),
|
||||
inset 0 0 170px 36px hsl(var(--brand-indigo) / 0.22);
|
||||
}
|
||||
|
||||
.recording-vignette[data-recording="true"] {
|
||||
opacity: 1;
|
||||
/* Delay the pulse by the fade-in duration so opacity eases 0 → 1 first, then
|
||||
breathes; the keyframe starts at opacity 1 to avoid a snap. */
|
||||
animation: recording-pulse 2.4s ease-in-out 0.22s infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.recording-vignette[data-recording="true"] {
|
||||
animation: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* Radix popper overlays (Select, DropdownMenu, Popover, Tooltip) portal to
|
||||
<body> at z-50, so when opened inside the docked chat sidebar (z-1200) they
|
||||
render BEHIND the panel and look like nothing happened. Lift every Radix
|
||||
popper above the panel — same 1300 "above the chat panel" value used by the
|
||||
recording vignette above. One rule fixes the whole class of overlay. */
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1300 !important;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { Inter } from "next/font/google";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
import "./globals.css";
|
||||
import { AuthContextProvider } from "@/components/auth-context";
|
||||
import { CopilotKitWrapper } from "./wrapper";
|
||||
import { IDENTITY } from "@/lib/identity";
|
||||
import { glassEngineAvailable } from "@/lib/glass-engine";
|
||||
import { presenterResetEnabled } from "@/lib/presenter";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
// Inter is the body + heading typeface for the premium fintech look. Loaded
|
||||
// via next/font/google (part of Next — no new dependency). Exposed as
|
||||
// `--font-inter`, which globals.css maps onto `--font-sans`.
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: IDENTITY.brand,
|
||||
description: "Collaborative finance for 21st century teams",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="light" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${inter.variable} ${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<AuthContextProvider>
|
||||
{/* Read the deployment gate server-side (non-NEXT_PUBLIC_ env) and
|
||||
thread it to the client as a prop — one image, per-deploy env. */}
|
||||
<CopilotKitWrapper
|
||||
glassAvailable={glassEngineAvailable()}
|
||||
resetEnabled={presenterResetEnabled()}
|
||||
>
|
||||
{children}
|
||||
</CopilotKitWrapper>
|
||||
</AuthContextProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useReducer } from "react";
|
||||
import {
|
||||
useHumanInTheLoop,
|
||||
useComponent,
|
||||
useFrontendTool,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
import type { NewCardRequest } from "@/app/api/v1/data";
|
||||
import { CARD_COLORS, CardBrand } from "@/app/api/v1/data";
|
||||
import { CreditCardDetails } from "@/components/credit-card-details";
|
||||
import { CardPicker } from "@/components/card-picker";
|
||||
import type { PartialBy } from "@/lib/type-helpers";
|
||||
import {
|
||||
filterTransactionByTitle,
|
||||
filterTransactionsByCardLast4,
|
||||
filterTransactionsByPolicyId,
|
||||
randomDigits,
|
||||
} from "@/lib/utils";
|
||||
import useCreditCards from "@/app/actions";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import { AddCardDropdown } from "@/components/add-card-dropdown";
|
||||
import { TransactionsList } from "@/components/transactions-list";
|
||||
import { ChangePinDialog } from "@/components/change-pin-dialog";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { CardsPageOperations } from "@/components/copilot-context";
|
||||
import { PERMISSIONS } from "@/app/api/v1/permissions";
|
||||
import { ApprovalButtons } from "@/components/approval-buttons";
|
||||
|
||||
interface ChangePinState {
|
||||
newPin: string;
|
||||
dialogOpen: boolean;
|
||||
cardId: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const changePinReducer = (
|
||||
state: ChangePinState,
|
||||
payload: Partial<ChangePinState>,
|
||||
): ChangePinState => ({
|
||||
...state,
|
||||
...payload,
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const { currentUser } = useAuthContext();
|
||||
const searchParams = useSearchParams();
|
||||
const operation = searchParams.get("operation") as CardsPageOperations | null;
|
||||
const [state, dispatch] = useReducer(changePinReducer, {
|
||||
newPin: "",
|
||||
dialogOpen: false,
|
||||
cardId: null,
|
||||
loading: false,
|
||||
});
|
||||
const {
|
||||
cards,
|
||||
policies,
|
||||
transactions,
|
||||
addNewCard,
|
||||
changePin,
|
||||
assignPolicyToCard,
|
||||
addNoteToTransaction,
|
||||
} = useCreditCards();
|
||||
|
||||
useEffect(() => {
|
||||
const operationNameToMethod: Partial<
|
||||
Record<CardsPageOperations, () => void>
|
||||
> = {
|
||||
[CardsPageOperations.ChangePin]: () => dispatch({ dialogOpen: true }),
|
||||
};
|
||||
|
||||
if (!operation || !Object.values(CardsPageOperations).includes(operation))
|
||||
return;
|
||||
operationNameToMethod[operation]?.();
|
||||
}, [operation]);
|
||||
|
||||
const handleChangePinSubmit = async ({
|
||||
pin,
|
||||
cardId,
|
||||
}: {
|
||||
pin?: string;
|
||||
cardId?: string;
|
||||
}) => {
|
||||
dispatch({ loading: true });
|
||||
await changePin({
|
||||
pin: pin ?? state.newPin,
|
||||
cardId: cardId ?? state.cardId!,
|
||||
});
|
||||
dispatch({ loading: false, newPin: "", cardId: null, dialogOpen: false });
|
||||
};
|
||||
|
||||
const handleAddCard = async (
|
||||
cardRequest: PartialBy<NewCardRequest, "color" | "pin">,
|
||||
) => {
|
||||
void addNewCard({
|
||||
...cardRequest,
|
||||
color: CARD_COLORS[cardRequest.type],
|
||||
pin: randomDigits(4).toString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Enable add new card with co pilot (human-in-the-loop)
|
||||
useHumanInTheLoop({
|
||||
followUp: false,
|
||||
name: "addNewCard",
|
||||
description:
|
||||
"Add new credit card. You MUST ask the user for the PIN (4 digits) before calling this action. Once you have all required info, call this action immediately without asking for additional confirmation - the approval UI will handle that.",
|
||||
available: PERMISSIONS.ADD_CARD.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
type: z
|
||||
.string()
|
||||
.describe("The type of the card (set by user), Visa or Mastercard"),
|
||||
color: z
|
||||
.string()
|
||||
.describe(
|
||||
"The color of the card (generated by copilot, bg-blue-500 for visa, bg-red-500 for mastercard)",
|
||||
),
|
||||
pin: z
|
||||
.string()
|
||||
.describe("The pin code of the card (set by user), 4 digits"),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { type, color, pin } = args;
|
||||
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">New Card Request</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-10 items-center justify-center rounded-md border border-hairline bg-surface p-1">
|
||||
{type === CardBrand.Visa ? (
|
||||
<svg
|
||||
className="h-5"
|
||||
viewBox="0 0 780 500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M293.2 348.7l33.4-195.8h53.4l-33.4 195.8zM540.7 157.2c-10.6-4-27.2-8.3-47.9-8.3-52.8 0-90 26.6-90.2 64.6-.3 28.1 26.5 43.8 46.8 53.2 20.8 9.6 27.8 15.7 27.7 24.3-.1 13.1-16.6 19.1-32 19.1-21.4 0-32.7-3-50.3-10.2l-6.9-3.1-7.5 43.8c12.5 5.5 35.6 10.2 59.6 10.5 56.2 0 92.6-26.3 93-66.8.2-22.3-14-39.2-44.8-53.2-18.6-9.1-30.1-15.1-30-24.3 0-8.1 9.7-16.8 30.6-16.8 17.4-.3 30.1 3.5 39.9 7.5l4.8 2.3 7.2-42.7zM676.3 152.9h-41.3c-12.8 0-22.4 3.5-28 16.3l-79.4 179.5h56.2s9.2-24.2 11.3-29.5c6.1 0 60.8.1 68.6.1 1.6 6.9 6.5 29.4 6.5 29.4h49.7l-43.6-195.8zm-65.8 126.3c4.4-11.3 21.4-54.8 21.4-54.8-.3.5 4.4-11.4 7.1-18.8l3.6 17s10.3 47 12.4 56.6h-44.5zM232.2 152.9L180 283.6l-5.6-27c-9.7-31.2-39.9-65-73.7-81.9l47.9 173.8h56.6l84.2-195.6h-57.2"
|
||||
fill="#1a1f71"
|
||||
/>
|
||||
<path
|
||||
d="M131.9 152.9H46.3l-.7 3.8c67.1 16.2 111.5 55.4 129.9 102.5L157.2 169c-3.2-12.5-12.7-15.7-25.3-16.1"
|
||||
fill="#f7a600"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5"
|
||||
viewBox="0 0 780 500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="312" cy="250" r="200" fill="#eb001b" />
|
||||
<circle cx="468" cy="250" r="200" fill="#f79e1b" />
|
||||
<path
|
||||
d="M390 100.2c-49.7 38.3-81.6 98.1-81.6 165.8s31.9 127.5 81.6 165.8c49.7-38.3 81.6-98.1 81.6-165.8S439.7 138.5 390 100.2z"
|
||||
fill="#ff5f00"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{type}</p>
|
||||
<p className="text-sm text-ink-muted">PIN: {pin}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
await addNewCard({ type, color, pin } as NewCardRequest);
|
||||
respond?.("Card created successfully");
|
||||
}}
|
||||
onDeny={() => respond?.("Card creation denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useHumanInTheLoop(
|
||||
{
|
||||
followUp: false,
|
||||
name: "assignPolicyToCard",
|
||||
description:
|
||||
"Assign a policy to a card. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
|
||||
available: PERMISSIONS.ADD_POLICY.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
cardId: z
|
||||
.string()
|
||||
.describe("The card (from existing) to assign policy to"),
|
||||
policyType: z.string().describe("The type of the policy to use"),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { cardId, policyType } = args;
|
||||
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const card = cards.find((c) => c.id === cardId);
|
||||
const policy = policies.find((p) => p.type === policyType);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Assign Policy to Card
|
||||
</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Card:</span>{" "}
|
||||
{card ? `${card.type} ending in ${card.last4}` : cardId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">Policy:</span> {policyType}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
const policyId = policy?.id;
|
||||
if (!cardId || !policyId) {
|
||||
respond?.("Could not find matching policy to assign");
|
||||
return;
|
||||
}
|
||||
await assignPolicyToCard({ cardId, policyId });
|
||||
respond?.("Policy assigned successfully");
|
||||
}}
|
||||
onDeny={() => respond?.("Policy assignment denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Re-register when cards/policies load; otherwise this render (mount-keyed
|
||||
// effect) resolves the card/policy against the EMPTY initial arrays and
|
||||
// shows raw ids. Mirrors selectCard / showTransactions.
|
||||
},
|
||||
[cards, policies],
|
||||
);
|
||||
|
||||
// Visual card picker (human-in-the-loop). Instead of listing the user's
|
||||
// cards as text, the agent calls this to render a tappable picker (brand +
|
||||
// last 4 digits); the human's pick is returned to the agent so it can
|
||||
// continue (e.g. then ask which policy and call assignPolicyToCard). Pure
|
||||
// selection UI, so it's available to every role — the actual mutating action
|
||||
// it precedes stays independently permission-gated.
|
||||
useHumanInTheLoop(
|
||||
{
|
||||
followUp: false,
|
||||
name: "selectCard",
|
||||
description:
|
||||
"Render a visual picker of the user's cards (brand + last 4 digits) and let them choose one. Call this whenever you need the user to choose which card to act on — for example before assigning a policy or changing a PIN. Do NOT list the cards as text; call this tool so the user can pick one directly. After the user picks, you receive the chosen card's id, type and last 4 digits.",
|
||||
available: true,
|
||||
parameters: z.object({
|
||||
purpose: z
|
||||
.string()
|
||||
.describe(
|
||||
"Short reason shown as the picker heading, e.g. 'Select a card to assign the Marketing policy'.",
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardPicker
|
||||
cards={cards}
|
||||
policies={policies}
|
||||
heading={args.purpose || "Select a card"}
|
||||
onSelect={(card) =>
|
||||
respond?.(
|
||||
`User selected the ${card.type} card ending in ${card.last4} (cardId: ${card.id}).`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Re-register the renderer once the cards/policies load; otherwise the
|
||||
// render closure captures the initial EMPTY `cards` (registration runs in a
|
||||
// mount effect keyed on these deps) and the picker shows "No cards
|
||||
// available". Mirrors the deps array on the showTransactions useComponent.
|
||||
},
|
||||
[cards, policies],
|
||||
);
|
||||
|
||||
useHumanInTheLoop(
|
||||
{
|
||||
followUp: false,
|
||||
name: "addNoteToTransaction",
|
||||
description:
|
||||
"Add note to transaction. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
|
||||
available: PERMISSIONS.ADD_NOTE.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
transactionId: z
|
||||
.string()
|
||||
.describe("The transaction to add note to (ID provided by copilot)"),
|
||||
content: z.string().describe("The content of the note"),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { transactionId, content } = args;
|
||||
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const transaction = transactions.find((t) => t.id === transactionId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Add Note to Transaction
|
||||
</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Transaction:</span>{" "}
|
||||
{transaction?.title ?? transactionId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">Note:</span> {content}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!transactionId || !content) {
|
||||
respond?.("Missing transaction or note content");
|
||||
return;
|
||||
}
|
||||
await addNoteToTransaction({ transactionId, content });
|
||||
respond?.("Note added successfully");
|
||||
}}
|
||||
onDeny={() => respond?.("Note addition denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Re-register when transactions load; otherwise this render (mount-keyed
|
||||
// effect) resolves the transaction title against the EMPTY initial array.
|
||||
// Mirrors selectCard / showTransactions.
|
||||
},
|
||||
[transactions],
|
||||
);
|
||||
|
||||
// Showcase usage of generative UI. Display-only components use `useComponent`
|
||||
// (not `useFrontendTool`): its render is unconditional, so the rendered card
|
||||
// persists in the transcript after the tool call completes. A handler-driven
|
||||
// `useFrontendTool` render only shows transiently while executing.
|
||||
useComponent(
|
||||
{
|
||||
name: "showTransactions",
|
||||
description:
|
||||
"Displays a list of transactions upon request. At least one parameter is required per request",
|
||||
parameters: z.object({
|
||||
card4Digits: z
|
||||
.string()
|
||||
.describe("the last 4 digits of the card")
|
||||
.optional(),
|
||||
policyId: z
|
||||
.string()
|
||||
.describe("the id of the policy (figured out by copilot)")
|
||||
.optional(),
|
||||
transactionTitle: z
|
||||
.string()
|
||||
.describe("the title of the transaction")
|
||||
.optional(),
|
||||
}),
|
||||
render: ({ card4Digits, policyId, transactionTitle }) => {
|
||||
let filteredTransactions = transactions;
|
||||
if (card4Digits) {
|
||||
filteredTransactions = filterTransactionsByCardLast4(
|
||||
transactions,
|
||||
cards,
|
||||
card4Digits,
|
||||
);
|
||||
} else if (policyId) {
|
||||
filteredTransactions = filterTransactionsByPolicyId(
|
||||
transactions,
|
||||
policyId,
|
||||
);
|
||||
} else if (transactionTitle) {
|
||||
filteredTransactions = filterTransactionByTitle(
|
||||
transactions,
|
||||
transactionTitle,
|
||||
);
|
||||
}
|
||||
|
||||
if (!filteredTransactions) {
|
||||
return <>Problem fetching transactions</>;
|
||||
}
|
||||
return <TransactionsList transactions={filteredTransactions} compact />;
|
||||
},
|
||||
},
|
||||
// Re-register the renderer when the underlying data loads/changes; otherwise
|
||||
// the closure captures the initial empty `transactions`/`cards`.
|
||||
[transactions, cards],
|
||||
);
|
||||
|
||||
// Enable pin changing with co pilot
|
||||
useHumanInTheLoop(
|
||||
{
|
||||
followUp: false,
|
||||
name: "setCardPin",
|
||||
description:
|
||||
"Set the pin code of an existing card. Ask the user for the new 4-digit PIN, then call this action immediately. Do NOT ask for additional confirmation - the approval UI will handle that.",
|
||||
available: PERMISSIONS.SET_PIN.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
cardId: z.string().describe("The id of the card (provided by copilot)"),
|
||||
pin: z
|
||||
.string()
|
||||
.describe("The new 4-digit PIN code (provided by the user)"),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { cardId, pin } = args;
|
||||
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const card = cards.find((c) => c.id === cardId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">Change Card PIN</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Card:</span>{" "}
|
||||
{card ? `${card.type} ending in ${card.last4}` : cardId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">New PIN:</span> {pin}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!pin || !cardId) {
|
||||
respond?.("Missing PIN or card information");
|
||||
return;
|
||||
}
|
||||
await changePin({ pin, cardId });
|
||||
respond?.("PIN changed successfully");
|
||||
}}
|
||||
onDeny={() => respond?.("PIN change denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Re-register when cards load; otherwise this render (mount-keyed effect)
|
||||
// resolves the card against the EMPTY initial array. Mirrors selectCard /
|
||||
// showTransactions.
|
||||
},
|
||||
[cards],
|
||||
);
|
||||
|
||||
// Distractors. Plausible card/transaction actions that do NOT solve the
|
||||
// over-limit block. No render — they run immediately and return a
|
||||
// non-erroring success whose `note` makes clear it changed nothing about
|
||||
// the transaction or policy. Their job is to widen the option space.
|
||||
useFrontendTool({
|
||||
name: "sendSpendAlert",
|
||||
description: "Send a spend alert notification for a card.",
|
||||
parameters: z.object({
|
||||
cardId: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
handler: async ({ cardId }) => ({
|
||||
ok: true,
|
||||
cardId,
|
||||
note: "Spend alert sent. Informational only; does not modify any transaction or policy.",
|
||||
}),
|
||||
});
|
||||
|
||||
useFrontendTool({
|
||||
name: "requestCardReplacement",
|
||||
description: "Request a replacement card for an existing card.",
|
||||
parameters: z.object({
|
||||
cardId: z.string(),
|
||||
}),
|
||||
handler: async ({ cardId }) => ({
|
||||
ok: true,
|
||||
cardId,
|
||||
note: "Replacement card requested. Does not affect pending transactions or policy limits.",
|
||||
}),
|
||||
});
|
||||
|
||||
useFrontendTool({
|
||||
name: "flagForReview",
|
||||
description: "Flag a transaction for manual review.",
|
||||
parameters: z.object({
|
||||
transactionId: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
handler: async ({ transactionId }) => ({
|
||||
ok: true,
|
||||
transactionId,
|
||||
note: "Flagged for manual review. Does not approve or change the transaction.",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!cards || !policies) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-2 pb-4 md:px-4">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-ink">
|
||||
Credit Cards
|
||||
</h1>
|
||||
<p className="text-sm text-ink-muted">
|
||||
Manage cards and spending policies for your team.
|
||||
</p>
|
||||
</div>
|
||||
<AddCardDropdown
|
||||
handleAddCard={handleAddCard}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</div>
|
||||
{/* Cards never squish: a horizontal-scroll row keeps each card at its
|
||||
natural width (min 300px) and lets them grow to fill on wide screens.
|
||||
When the chat panel docks open and narrows this area the row scrolls
|
||||
horizontally instead of compressing the cards — which would otherwise
|
||||
wrap the holder / valid-thru text. Mirrors the dashboard "My Cards".
|
||||
`overflow-x-auto` also forces overflow-y to `auto`, so the matching
|
||||
negative-margin / padding pairs (-mt-6/pt-6, -mx-2/px-2) keep the cards
|
||||
in place while giving their soft drop shadows room inside the scroll
|
||||
viewport instead of clipping them at the top/sides; pb-8 clears the
|
||||
(downward) shadow at the bottom. */}
|
||||
<div className="-mx-2 -mt-6 flex gap-6 overflow-x-auto px-2 pb-8 pt-6">
|
||||
{cards.length ? (
|
||||
cards.map((card) => (
|
||||
<div key={card.id} className="min-w-[300px] max-w-[420px] flex-1">
|
||||
<CreditCardDetails
|
||||
card={card}
|
||||
holder={currentUser.name}
|
||||
policy={policies.find((p) => p.id === card.expensePolicyId)}
|
||||
onChangePinModalOpen={() =>
|
||||
dispatch({ dialogOpen: true, cardId: card.id })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="w-full rounded-2xl border border-dashed border-hairline bg-surface/60 p-10 text-center text-ink-muted">
|
||||
No cards found for {currentUser.team} team
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePinDialog
|
||||
dialogOpen={state.dialogOpen}
|
||||
onSubmit={({ pin, cardId }) => handleChangePinSubmit({ pin, cardId })}
|
||||
loading={state.loading}
|
||||
onDialogOpenChange={(open) => dispatch({ dialogOpen: open })}
|
||||
cards={cards}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ExpenseRole, Member, MemberRole } from "@/app/api/v1/data";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useTeam() {
|
||||
const [team, setTeam] = useState<Member[]>([]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/users");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const data = await response.json();
|
||||
setTeam(data);
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/users/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const inviteMember = async (
|
||||
email: string,
|
||||
role: MemberRole,
|
||||
team: ExpenseRole,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, role, team }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
await response.json();
|
||||
void fetchUsers();
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const changeMemberRole = async (id: string, role: MemberRole) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/users/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
await response.json();
|
||||
void fetchUsers();
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const changeMemberTeam = async (id: string, team: ExpenseRole) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/users/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ team }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
await response.json();
|
||||
void fetchUsers();
|
||||
} catch (error) {
|
||||
console.error("There was a problem with the fetch operation:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchUsers();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
team,
|
||||
inviteMember,
|
||||
removeMember,
|
||||
changeMemberRole,
|
||||
changeMemberTeam,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
import useTeam from "@/app/team/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import type { ExpenseRole } from "@/app/api/v1/data";
|
||||
import { MemberRole } from "@/app/api/v1/data";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { TeamPageOperations } from "@/components/copilot-context";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAgentContext, useHumanInTheLoop } from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
import type { DialogState } from "@/components/add-or-edit-member-dialog";
|
||||
import {
|
||||
AddOrEditMemberDialog,
|
||||
defaultDialogState,
|
||||
} from "@/components/add-or-edit-member-dialog";
|
||||
import { RemoveMemberConfirmationDialog } from "@/components/remove-member-dialog";
|
||||
import { ApprovalButtons } from "@/components/approval-buttons";
|
||||
|
||||
const dialogStateReducer = (
|
||||
state: DialogState,
|
||||
payload: Partial<DialogState>,
|
||||
): DialogState => ({
|
||||
...state,
|
||||
...payload,
|
||||
});
|
||||
|
||||
export default function Team() {
|
||||
const { currentUser } = useAuthContext();
|
||||
const {
|
||||
team,
|
||||
inviteMember,
|
||||
removeMember,
|
||||
changeMemberRole,
|
||||
changeMemberTeam,
|
||||
} = useTeam();
|
||||
const searchParams = useSearchParams();
|
||||
const operation = searchParams.get("operation") as TeamPageOperations | null;
|
||||
|
||||
useAgentContext({
|
||||
description: "The available users of the system.",
|
||||
value: JSON.stringify(team),
|
||||
});
|
||||
|
||||
useHumanInTheLoop({
|
||||
followUp: false,
|
||||
name: "removeMember",
|
||||
description:
|
||||
"Remove a team member. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the member to remove (provided by copilot, ask questions to figure out the member)",
|
||||
),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { id } = args;
|
||||
if (status === "inProgress")
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
const member = team.find((m) => m.id === id);
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="font-semibold text-lg">Remove Team Member</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Member:</span>{" "}
|
||||
{member?.name ?? id}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">Role:</span> {member?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!id) {
|
||||
respond?.("Missing member information");
|
||||
return;
|
||||
}
|
||||
await removeMember(id);
|
||||
respond?.("Member removed successfully");
|
||||
}}
|
||||
onDeny={() => respond?.("Member removal denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useHumanInTheLoop({
|
||||
followUp: false,
|
||||
name: "changeMemberRole",
|
||||
description:
|
||||
"Change the role of a team member. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
|
||||
parameters: z.object({
|
||||
id: z.string().describe("The ID of the member to change the role of"),
|
||||
role: z.string().describe("The new role of the member"),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { id, role } = args;
|
||||
if (status === "inProgress")
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
const member = team.find((m) => m.id === id);
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="font-semibold text-lg">Change Member Role</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Member:</span>{" "}
|
||||
{member?.name ?? id}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">Current Role:</span>{" "}
|
||||
{member?.role}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">New Role:</span> {role}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!id || !role) {
|
||||
respond?.("Missing member or role information");
|
||||
return;
|
||||
}
|
||||
await changeMemberRole(id, role as MemberRole);
|
||||
respond?.("Role changed successfully");
|
||||
}}
|
||||
onDeny={() => respond?.("Role change denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useHumanInTheLoop({
|
||||
followUp: false,
|
||||
name: "changeMemberTeam",
|
||||
description:
|
||||
"Change the team of a team member. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
|
||||
parameters: z.object({
|
||||
id: z.string().describe("The ID of the member to change the team of"),
|
||||
team: z.string().describe("The new team of the member"),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { id, team: newTeam } = args;
|
||||
if (status === "inProgress")
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
const member = team.find((m) => m.id === id);
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="font-semibold text-lg">Change Member Team</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Member:</span>{" "}
|
||||
{member?.name ?? id}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">Current Team:</span>{" "}
|
||||
{member?.team}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">New Team:</span> {newTeam}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!id || !newTeam) {
|
||||
respond?.("Missing member or team information");
|
||||
return;
|
||||
}
|
||||
await changeMemberTeam(id, newTeam as ExpenseRole);
|
||||
respond?.("Team changed successfully");
|
||||
}}
|
||||
onDeny={() => respond?.("Team change denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [dialogState, dispatchDialogState] = useReducer(
|
||||
dialogStateReducer,
|
||||
defaultDialogState,
|
||||
);
|
||||
|
||||
const handleAddMemberSubmit = () => {
|
||||
dispatchDialogState({ loading: true });
|
||||
void inviteMember(dialogState.email, dialogState.role, dialogState.team);
|
||||
dispatchDialogState({ dialogOpen: false, loading: false });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const operationNameToMethod: Partial<
|
||||
Record<TeamPageOperations, () => void>
|
||||
> = {
|
||||
[TeamPageOperations.InviteMember]: () =>
|
||||
dispatchDialogState({ dialogOpen: true }),
|
||||
};
|
||||
|
||||
if (!operation || !Object.values(TeamPageOperations).includes(operation))
|
||||
return;
|
||||
operationNameToMethod[operation]?.();
|
||||
}, [operation]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-2 pb-4 md:px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight text-ink">
|
||||
Team Management
|
||||
</h2>
|
||||
<p className="text-sm text-ink-muted">
|
||||
Invite teammates and manage roles & departments.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
dispatchDialogState({ dialogOpen: true, action: "add" })
|
||||
}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" /> Invite Team Member
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{team.map((member) => (
|
||||
<Card key={member.id} className="p-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-ink">{member.name}</CardTitle>
|
||||
<CardDescription>{member.email}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-ink-muted">Role</span>
|
||||
<span className="inline-flex items-center rounded-full bg-brand-soft px-2.5 py-0.5 text-xs font-semibold text-brand-indigo dark:text-brand-violet">
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
{currentUser.role === MemberRole.Admin ? (
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
dispatchDialogState({
|
||||
memberId: member.id,
|
||||
dialogOpen: true,
|
||||
action: "edit",
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
dispatchDialogState({
|
||||
memberId: member.id,
|
||||
dialogOpen: true,
|
||||
action: "remove",
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<AddOrEditMemberDialog
|
||||
dialogState={dialogState}
|
||||
onStateChange={dispatchDialogState}
|
||||
onSubmit={handleAddMemberSubmit}
|
||||
/>
|
||||
{dialogState.action === "remove" && (
|
||||
<RemoveMemberConfirmationDialog
|
||||
dialogState={dialogState}
|
||||
onStateChange={dispatchDialogState}
|
||||
onSubmit={async () => {
|
||||
await removeMember(dialogState.memberId!);
|
||||
dispatchDialogState(defaultDialogState);
|
||||
}}
|
||||
members={team}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
import { LayoutComponent } from "@/components/layout";
|
||||
import {
|
||||
CopilotChatConfigurationProvider,
|
||||
CopilotKitProvider,
|
||||
useConfigureSuggestions,
|
||||
type ReactActivityMessageRenderer,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
import { catalog } from "@/a2ui/catalog";
|
||||
import { CanvasProvider } from "@/components/canvas/canvas-context";
|
||||
import CopilotContext from "@/components/copilot-context";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import { useThreadSelection } from "@/components/threads/use-thread-selection";
|
||||
import { ChatInboxProvider } from "@/components/chat/chat-inbox-context";
|
||||
import { ChatPanel } from "@/components/chat/chat-panel";
|
||||
import { RecordingProvider } from "@/components/recording-context";
|
||||
import { RecordingVignette } from "@/components/recording-vignette";
|
||||
import { ProactiveNotice } from "@/components/wow/proactive-notice";
|
||||
import { ReportCopilotTools } from "@/components/wow/report-tool";
|
||||
import { GlassEngineProvider } from "@/components/glass-engine-context";
|
||||
import { InspectorStoreProvider } from "@/lib/inspector/store";
|
||||
import { InspectorPane } from "@/components/inspector/inspector-pane";
|
||||
import { sandboxFunctions } from "@/opengen/sandbox-functions";
|
||||
import { SandboxDataSync } from "@/opengen/sandbox-data-sync";
|
||||
|
||||
// The agent's render_report tool result becomes an `a2ui-surface` activity that
|
||||
// <ReportCanvas/> renders full-screen (it reads the ops from the agent message
|
||||
// stream). In the chat we leave only a small handoff pill in place of the
|
||||
// built-in inline surface renderer. Module-level so the array reference stays
|
||||
// stable across renders (CopilotKitProvider requires a stable
|
||||
// `renderActivityMessages` array).
|
||||
function ReportHandoffPill() {
|
||||
return (
|
||||
<div className="my-1.5 inline-flex max-w-fit items-center gap-2 rounded-full border border-hairline bg-surface px-3 py-2 text-xs font-medium text-ink">
|
||||
<span className="h-2 w-2 rounded-full bg-brand" />
|
||||
<span className="uppercase tracking-wide text-ink-muted">report</span>
|
||||
<span aria-hidden className="text-ink-muted">
|
||||
→
|
||||
</span>
|
||||
<span>rendered on the canvas</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Same handoff treatment for Open Generative UI: the sandboxed iframe renders
|
||||
// full-region on the canvas (see <ReportCanvas/>), so the chat shows only this
|
||||
// pill. Registering it OVERRIDES the built-in inline OGUI renderer (user-provided
|
||||
// renderers take precedence).
|
||||
function OguiHandoffPill() {
|
||||
return (
|
||||
<div className="my-1.5 inline-flex max-w-fit items-center gap-2 rounded-full border border-hairline bg-surface px-3 py-2 text-xs font-medium text-ink">
|
||||
<span className="h-2 w-2 rounded-full bg-brand" />
|
||||
<span className="uppercase tracking-wide text-ink-muted">
|
||||
interactive
|
||||
</span>
|
||||
<span aria-hidden className="text-ink-muted">
|
||||
→
|
||||
</span>
|
||||
<span>rendered on the canvas</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const A2UI_RENDERERS: ReactActivityMessageRenderer<unknown>[] = [
|
||||
{ activityType: "a2ui-surface", content: z.any(), render: ReportHandoffPill },
|
||||
{
|
||||
activityType: "open-generative-ui",
|
||||
content: z.any(),
|
||||
render: OguiHandoffPill,
|
||||
},
|
||||
];
|
||||
|
||||
// Static suggestion pills — the demo's full use-case catalog, available at
|
||||
// ALL times (`available: "always"`), not just the welcome screen: the demo
|
||||
// must stay fully click-drivable after every exchange, with zero typing.
|
||||
// In v2, suggestions are registered via `useConfigureSuggestions` rather than a
|
||||
// prop on the chat component (the v1 `suggestions` prop does not exist on the
|
||||
// v2 component — it's omitted from `CopilotChatProps` and supplied via the hook
|
||||
// instead). See packages/react-core/src/v2/hooks/use-configure-suggestions.tsx
|
||||
// and packages/react-core/src/v2/components/chat/CopilotChat.tsx (line 41–46).
|
||||
//
|
||||
// The first three pills are sequenced to drive the self-learning demo arc
|
||||
// (FOR-137). The agent prompt (api/copilotkit/[[...slug]]/route.ts) deliberately
|
||||
// never explains the over-policy-limit unlock, and the agent only ever sees
|
||||
// exception *codes*, never their human labels — so a fresh agent must LEARN,
|
||||
// by watching an officer file a justifying exception, both that over-limit
|
||||
// charges can be unlocked and which codes justify it.
|
||||
// 1. Teach-ask — hits the gate (agent correctly stalls before learning).
|
||||
// 2. Teach-setup — surfaces the pending charges so the officer can
|
||||
// demonstrate the unlock inline (that demonstration is what gets recorded).
|
||||
// 3. Recall — on a FRESH thread after distill, the agent applies the
|
||||
// learned procedure to a DIFFERENT over-limit charge it was never taught.
|
||||
// Titles stay symptom-only: they must NOT hint at the exception path the agent
|
||||
// is meant to learn on its own.
|
||||
//
|
||||
// The remaining pills cover every other use case the demo ships: gen-UI
|
||||
// charts, the approvals explainer, report artifacts, and the cross-page
|
||||
// operations (PIN change, team invite) that ride navigateToPageAndPerform.
|
||||
// Page-scoped fire-and-forget tools (spend alert, card replacement, flag for
|
||||
// review) are deliberately NOT pilled: they only exist on the home route, so
|
||||
// a global pill for them would be a broken promise on every other page.
|
||||
|
||||
// Design brief handed to generateSandboxedUi so generated UIs match the demo's
|
||||
// look instead of the generic DEFAULT_DESIGN_SKILL. Dark-mode aware, brand accent,
|
||||
// glass surfaces, Geist type — the same language as the curated cards.
|
||||
const NORTHWIND_DESIGN_SKILL = `You are designing UI for Northwind Finance, a
|
||||
corporate banking dashboard. Match its aesthetic:
|
||||
- Surfaces: rounded-2xl cards, subtle hairline borders, soft shadow, a translucent
|
||||
"glass" surface over the page background. Generous padding.
|
||||
- Type: the Geist sans-serif family; clear hierarchy (semibold headings, muted
|
||||
secondary text). Currency in USD with thousands separators.
|
||||
- Color: a restrained neutral base with a single indigo/violet brand accent for
|
||||
emphasis, positive = green, negative/over-limit = red. Never rainbow palettes.
|
||||
- Dark-mode aware: read colors from CSS variables / prefers-color-scheme; never
|
||||
hardcode white backgrounds.
|
||||
- Keep it calm, precise, and enterprise-appropriate — this is a finance tool.`;
|
||||
|
||||
function BankingSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
available: "always",
|
||||
suggestions: [
|
||||
{
|
||||
// BEAT 1 — the teachable ask. Seed t-1: Google Ads, -$5,000, Marketing
|
||||
// policy (limit $5,000 / spent $500 → approving always trips
|
||||
// OVER_POLICY_LIMIT). Pre-learning the agent stalls here correctly;
|
||||
// post-learning the same ask succeeds. Leads the welcome screen.
|
||||
title: "Approve the $5,000 Google Ads charge",
|
||||
message:
|
||||
"Approve the $5,000 Google Ads charge on the Marketing policy.",
|
||||
},
|
||||
{
|
||||
// BEAT 2 setup — surfaces the pending charges (all three are over their
|
||||
// policy limit) via showTransactions, the unconditional-render tool
|
||||
// that is reliable in every mode. The officer files a policy exception
|
||||
// inline on a row to demonstrate the unlock; that is the recorded
|
||||
// demonstration the writer agent distills into /knowledge.
|
||||
title: "Review my pending transactions",
|
||||
message: "Show me my pending transactions.",
|
||||
},
|
||||
{
|
||||
// BEAT 3 — recall / generalization. On a fresh thread after the
|
||||
// demonstration is distilled, the agent should apply the learned
|
||||
// procedure to a DIFFERENT over-limit charge it was never explicitly
|
||||
// taught (seed t-2: AWS, -$15,000, Engineering policy, limit $15,000 /
|
||||
// spent $1,500), proving transferable memory, not per-row memorization.
|
||||
title: "How should I handle the $15,000 AWS charge?",
|
||||
message:
|
||||
"The $15,000 AWS charge is over its policy limit. How should I handle it?",
|
||||
},
|
||||
{
|
||||
// Breadth — a clean non-arc capability (the generative-UI card flow).
|
||||
title: "Add an expense card",
|
||||
message: "Add a new expense card",
|
||||
},
|
||||
// ── A2UI report canvas ───────────────────────────────────────────────
|
||||
{
|
||||
title: "Build a spend report on the canvas",
|
||||
message:
|
||||
"Build a full spend report on the canvas: KPIs, the spending trend, budget usage, and a spend breakdown by team.",
|
||||
},
|
||||
// ── Gen-UI charts ────────────────────────────────────────────────────
|
||||
{
|
||||
title: "Show the spending trend",
|
||||
message: "Show me the spending trend over time.",
|
||||
},
|
||||
{
|
||||
title: "Budgets near their limit?",
|
||||
message:
|
||||
"Show me budget usage by policy — which ones are close to or over their limit?",
|
||||
},
|
||||
{
|
||||
title: "Where is the money going?",
|
||||
message: "Where is the money going? Break down spend by team.",
|
||||
},
|
||||
{
|
||||
title: "How's our cash flow?",
|
||||
message: "Compare our income vs expenses — how is our cash flow?",
|
||||
},
|
||||
// ── Open Generative UI (custom interactive surfaces) ─────────────────
|
||||
{
|
||||
// OGUI-only: an interactive explorer the fixed catalog can't express.
|
||||
// "interactive"/"explorer" (not "build") is what routes this to
|
||||
// generateSandboxedUi; figures come from the sandbox functions.
|
||||
title: "Build an interactive spend explorer",
|
||||
message:
|
||||
"Build an interactive spend explorer I can filter and play with — pull the real transactions and policies.",
|
||||
},
|
||||
{
|
||||
title: "Prototype a cash-flow what-if calculator",
|
||||
message:
|
||||
"Prototype an interactive what-if calculator for our cash flow using our real income and expense figures.",
|
||||
},
|
||||
{
|
||||
title: "How do approvals work?",
|
||||
message: "Explain how an over-limit charge gets cleared and approved.",
|
||||
},
|
||||
// ── Work product ─────────────────────────────────────────────────────
|
||||
{
|
||||
title: "Prep the Q2 spend report",
|
||||
message:
|
||||
"Prepare a Q2 spend report for the board: summarize spend against budgets, call out anything over limit or pending, and file it as a report.",
|
||||
},
|
||||
// ── Cross-page operations (navigateToPageAndPerform fallbacks) ──────
|
||||
{
|
||||
title: "Change my card PIN",
|
||||
message: "I want to change the PIN on my Visa card.",
|
||||
},
|
||||
{
|
||||
title: "Invite a team member",
|
||||
message: "Invite a new member to my team.",
|
||||
},
|
||||
],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CopilotKitWrapper({
|
||||
children,
|
||||
glassAvailable = false,
|
||||
resetEnabled = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
glassAvailable?: boolean;
|
||||
resetEnabled?: boolean;
|
||||
}) {
|
||||
const { currentUser } = useAuthContext();
|
||||
const { threadId, selectThread, createThread } = useThreadSelection();
|
||||
|
||||
return (
|
||||
<CopilotKitProvider
|
||||
runtimeUrl="/api/copilotkit"
|
||||
// The runtime route is the multi-endpoint REST handler
|
||||
// (createCopilotHonoHandler at api/copilotkit/[[...slug]]: /info,
|
||||
// /agent/{id}/run, /agent/{id}/connect). The single-endpoint transport
|
||||
// POSTs to /api/copilotkit and 404s against this handler, so stay in REST
|
||||
// (multi-route) mode.
|
||||
useSingleEndpoint={false}
|
||||
properties={{ userRole: currentUser?.role, userId: currentUser?.id }}
|
||||
// A2UI report canvas. The agent calls the backend render_report tool,
|
||||
// whose ops the A2UI middleware turns into an `a2ui-surface` activity; the
|
||||
// banking catalog here lets the client render those ops. The agent selects
|
||||
// widgets via render_report's typed params, so it does NOT need the raw
|
||||
// A2UI component schema (no includeSchema). `renderActivityMessages`
|
||||
// replaces the built-in inline surface renderer with a small handoff pill —
|
||||
// the surface itself renders full-screen in <ReportCanvas/>.
|
||||
a2ui={{ catalog }}
|
||||
renderActivityMessages={A2UI_RENDERERS}
|
||||
openGenerativeUI={{
|
||||
sandboxFunctions,
|
||||
designSkill: NORTHWIND_DESIGN_SKILL,
|
||||
}}
|
||||
// Use the v2-native CopilotKitProvider, NOT the v1 `CopilotKit`
|
||||
// compatibility bridge. The bridge wraps the chat in a heavier stack (its
|
||||
// own ThreadsProvider + a second CopilotChatConfigurationProvider +
|
||||
// listeners); that extra churn re-fires CopilotChat's /connect effect
|
||||
// cleanup mid-run, and the cleanup calls AG-UI `detachActiveRun()` — which
|
||||
// silently tears down the in-flight run the instant the agent emits a
|
||||
// frontend (HITL) tool call, so the tool's render() never mounts and the
|
||||
// chat appears "stuck". CopilotKitProvider is the lean stack the working
|
||||
// e-commerce reference uses; our inbox's `useThreads` (from /v2) reads
|
||||
// CopilotKitProvider's own context, so the inbox keeps working.
|
||||
showDevConsole={false}
|
||||
>
|
||||
{/*
|
||||
Anchor the whole chat surface to the actively-selected thread. The
|
||||
agentId is "default" — the runtime registers `agents: { default: ... }`
|
||||
and the SDK's default agentId is also "default" (NOT "bankingAgent").
|
||||
We pass `threadId` and let the provider infer explicit-thread mode
|
||||
(CopilotChatConfigurationProvider treats a supplied threadId as
|
||||
explicit), matching the working e-commerce reference. This outer
|
||||
provider also stays in sync with the docked panel's open/close state
|
||||
(the sidebar's modal state propagates upward), which the inbox overlay
|
||||
reads to know when the panel is showing.
|
||||
*/}
|
||||
<CopilotChatConfigurationProvider agentId="default" threadId={threadId}>
|
||||
<BankingSuggestions />
|
||||
<SandboxDataSync />
|
||||
{/*
|
||||
ChatInboxProvider carries the inbox open/closed state + the thread
|
||||
actions (select/create) so the panel header and the inbox rows can
|
||||
switch or start conversations and collapse the inbox in one place.
|
||||
The docked panel starts CLOSED for a clean first impression; a
|
||||
floating toggle button (bottom-right) opens it.
|
||||
*/}
|
||||
<ChatInboxProvider
|
||||
selectedThreadId={threadId}
|
||||
onSelectThread={selectThread}
|
||||
onCreateThread={createThread}
|
||||
>
|
||||
{/*
|
||||
RecordingProvider exposes the teach-mode `isRecording` flag; the
|
||||
<RecordingVignette/> reads it to pulse a soft violet glow around the
|
||||
canvas while an officer demonstration is being recorded. It wraps
|
||||
BOTH the page content and the chat panel so every demonstration
|
||||
call site (the transactions list approve/deny, the inline policy
|
||||
exception card) is inside it.
|
||||
*/}
|
||||
<GlassEngineProvider available={glassAvailable}>
|
||||
<InspectorStoreProvider>
|
||||
<RecordingProvider>
|
||||
{/*
|
||||
CanvasProvider derives whether a report surface is active from
|
||||
the agent message stream (+ a local dismiss for the "← Back"
|
||||
control). It must be an ancestor of LayoutComponent, which calls
|
||||
useCanvas() to render <ReportCanvas/> in place of the page body.
|
||||
*/}
|
||||
<CanvasProvider>
|
||||
<LayoutComponent resetEnabled={resetEnabled}>
|
||||
<CopilotContext>{children}</CopilotContext>
|
||||
</LayoutComponent>
|
||||
<ChatPanel threadId={threadId} />
|
||||
<ProactiveNotice />
|
||||
<ReportCopilotTools />
|
||||
</CanvasProvider>
|
||||
{/* Mount the pane (and its AG-UI subscription) ONLY where the
|
||||
deployment opted in. Public hosts never subscribe. */}
|
||||
{glassAvailable && <InspectorPane />}
|
||||
<RecordingVignette />
|
||||
</RecordingProvider>
|
||||
</InspectorStoreProvider>
|
||||
</GlassEngineProvider>
|
||||
</ChatInboxProvider>
|
||||
</CopilotChatConfigurationProvider>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { CardBrand, MemberRole } from "@/app/api/v1/data";
|
||||
import { PERMISSIONS } from "@/app/api/v1/permissions";
|
||||
|
||||
interface AddCardDropdownProps {
|
||||
currentUser: {
|
||||
role: MemberRole;
|
||||
};
|
||||
handleAddCard: (params: { type: CardBrand }) => void;
|
||||
}
|
||||
|
||||
export function AddCardDropdown({
|
||||
currentUser,
|
||||
handleAddCard,
|
||||
}: AddCardDropdownProps) {
|
||||
const hasPermission = PERMISSIONS.ADD_CARD.includes(currentUser.role);
|
||||
|
||||
const dropdownButton = (
|
||||
<Button disabled={!hasPermission}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add New Card{" "}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
{hasPermission ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{dropdownButton}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAddCard({ type: CardBrand.Visa })}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-8 mr-2"
|
||||
viewBox="0 0 780 500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M293.2 348.7l33.4-195.8h53.4l-33.4 195.8zM540.7 157.2c-10.6-4-27.2-8.3-47.9-8.3-52.8 0-90 26.6-90.2 64.6-.3 28.1 26.5 43.8 46.8 53.2 20.8 9.6 27.8 15.7 27.7 24.3-.1 13.1-16.6 19.1-32 19.1-21.4 0-32.7-3-50.3-10.2l-6.9-3.1-7.5 43.8c12.5 5.5 35.6 10.2 59.6 10.5 56.2 0 92.6-26.3 93-66.8.2-22.3-14-39.2-44.8-53.2-18.6-9.1-30.1-15.1-30-24.3 0-8.1 9.7-16.8 30.6-16.8 17.4-.3 30.1 3.5 39.9 7.5l4.8 2.3 7.2-42.7zM676.3 152.9h-41.3c-12.8 0-22.4 3.5-28 16.3l-79.4 179.5h56.2s9.2-24.2 11.3-29.5c6.1 0 60.8.1 68.6.1 1.6 6.9 6.5 29.4 6.5 29.4h49.7l-43.6-195.8zm-65.8 126.3c4.4-11.3 21.4-54.8 21.4-54.8-.3.5 4.4-11.4 7.1-18.8l3.6 17s10.3 47 12.4 56.6h-44.5zM232.2 152.9L180 283.6l-5.6-27c-9.7-31.2-39.9-65-73.7-81.9l47.9 173.8h56.6l84.2-195.6h-57.2"
|
||||
fill="#1a1f71"
|
||||
/>
|
||||
<path
|
||||
d="M131.9 152.9H46.3l-.7 3.8c67.1 16.2 111.5 55.4 129.9 102.5L157.2 169c-3.2-12.5-12.7-15.7-25.3-16.1"
|
||||
fill="#f7a600"
|
||||
/>
|
||||
</svg>
|
||||
Add Visa Card
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleAddCard({ type: CardBrand.MasterCard })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-8 mr-2"
|
||||
viewBox="0 0 780 500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="312" cy="250" r="200" fill="#eb001b" />
|
||||
<circle cx="468" cy="250" r="200" fill="#f79e1b" />
|
||||
<path
|
||||
d="M390 100.2c-49.7 38.3-81.6 98.1-81.6 165.8s31.9 127.5 81.6 165.8c49.7-38.3 81.6-98.1 81.6-165.8S439.7 138.5 390 100.2z"
|
||||
fill="#ff5f00"
|
||||
/>
|
||||
</svg>
|
||||
Add Mastercard
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
dropdownButton
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!hasPermission && (
|
||||
<TooltipContent>
|
||||
<p>Only admins can add new cards</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ExpenseRole, MemberRole } from "@/app/api/v1/data";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export interface DialogState {
|
||||
email: string;
|
||||
role: MemberRole;
|
||||
team: ExpenseRole;
|
||||
loading: boolean;
|
||||
dialogOpen: boolean;
|
||||
memberId: string | null;
|
||||
action: "add" | "edit" | "remove";
|
||||
}
|
||||
|
||||
export const defaultDialogState = {
|
||||
email: "",
|
||||
role: MemberRole.Member,
|
||||
team: ExpenseRole.Marketing,
|
||||
loading: false,
|
||||
dialogOpen: false,
|
||||
memberId: null,
|
||||
action: "add" as DialogState["action"],
|
||||
};
|
||||
|
||||
export interface MemberDialogProps {
|
||||
onStateChange: (payload: Partial<DialogState>) => void;
|
||||
onSubmit: () => void;
|
||||
dialogState: DialogState;
|
||||
}
|
||||
|
||||
export function AddOrEditMemberDialog({
|
||||
onStateChange,
|
||||
onSubmit,
|
||||
dialogState,
|
||||
}: MemberDialogProps) {
|
||||
const isEdit = dialogState.action === "edit";
|
||||
return (
|
||||
<Dialog
|
||||
open={
|
||||
dialogState.dialogOpen &&
|
||||
(isEdit ? !!dialogState.memberId : !dialogState.memberId)
|
||||
}
|
||||
onOpenChange={(open) =>
|
||||
onStateChange(open ? { dialogOpen: open } : defaultDialogState)
|
||||
}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Member" : "Invite Member"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Edit the team or role of a member"
|
||||
: "Enter the email, role, and team for the new member."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{!isEdit && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="email" className="text-right">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={dialogState.email}
|
||||
onChange={(e) => onStateChange({ email: e.target.value })}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="role" className="text-right">
|
||||
Role
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
onStateChange({ role: value as MemberRole })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={MemberRole.Admin}>Admin</SelectItem>
|
||||
<SelectItem value={MemberRole.Member}>Member</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="team" className="text-right">
|
||||
Team
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
onStateChange({ team: value as ExpenseRole })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select a team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={ExpenseRole.Marketing}>
|
||||
Marketing
|
||||
</SelectItem>
|
||||
<SelectItem value={ExpenseRole.Engineering}>
|
||||
Engineering
|
||||
</SelectItem>
|
||||
<SelectItem value={ExpenseRole.Executive}>
|
||||
Executive
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onSubmit()}>
|
||||
{dialogState.loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : isEdit ? (
|
||||
"Edit Member"
|
||||
) : (
|
||||
"Invite Member"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { ExpensePolicy, Transaction } from "@/app/api/v1/data";
|
||||
import { cn, formatCurrency } from "@/lib/utils";
|
||||
import { StatisticsChart } from "@/components/statistics-chart";
|
||||
|
||||
// Brand-leading palette for multi-series charts (violet → indigo → supporting
|
||||
// hues). Hand-picked to sit on the lavender surface and read distinctly in the
|
||||
// donut legend. No charting dependency — every chart here is plain SVG/CSS,
|
||||
// matching StatisticsChart's "lightweight, hand-rolled" approach.
|
||||
const PALETTE = [
|
||||
"hsl(252 83% 67%)", // brand violet
|
||||
"hsl(248 84% 60%)", // brand indigo
|
||||
"hsl(199 89% 56%)", // sky
|
||||
"hsl(160 70% 45%)", // emerald
|
||||
"hsl(38 92% 55%)", // amber
|
||||
"hsl(330 75% 60%)", // pink
|
||||
];
|
||||
|
||||
/**
|
||||
* Spend-over-time trend. Buckets expenses by calendar month (oldest → newest)
|
||||
* and feeds StatisticsChart, falling back to representative seeded points when
|
||||
* there isn't enough history — mirrors the dashboard's Statistics rail so the
|
||||
* chat trend and the dashboard trend tell the same story.
|
||||
*/
|
||||
export function SpendingTrendChart({
|
||||
transactions,
|
||||
}: {
|
||||
transactions: Transaction[];
|
||||
}) {
|
||||
const { stats, labels } = useMemo(() => {
|
||||
const byMonth = new Map<string, number>();
|
||||
for (const t of transactions) {
|
||||
if (t.amount >= 0) continue;
|
||||
const d = new Date(t.date);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
byMonth.set(
|
||||
`${d.getFullYear()}-${d.getMonth()}`,
|
||||
(byMonth.get(`${d.getFullYear()}-${d.getMonth()}`) ?? 0) +
|
||||
Math.abs(t.amount),
|
||||
);
|
||||
}
|
||||
const monthFmt = new Intl.DateTimeFormat("en-US", { month: "short" });
|
||||
const sorted = [...byMonth.entries()].sort(([a], [b]) => {
|
||||
const [ay, am] = a.split("-").map(Number);
|
||||
const [by, bm] = b.split("-").map(Number);
|
||||
return ay - by || am - bm;
|
||||
});
|
||||
if (sorted.length >= 3) {
|
||||
return {
|
||||
stats: sorted.map(([, v]) => v),
|
||||
labels: sorted.map(([k]) => {
|
||||
const [y, m] = k.split("-").map(Number);
|
||||
return monthFmt.format(new Date(y, m, 1));
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
stats: [3200, 4100, 3600, 5200, 4800, 6400],
|
||||
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
|
||||
};
|
||||
}, [transactions]);
|
||||
|
||||
return <StatisticsChart data={stats} labels={labels} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget usage per expense policy — a horizontal bar of `spent / limit` for
|
||||
* each team's policy. Bars use the brand gradient; a policy already past its
|
||||
* limit turns red and calls out the overage. This is "how's our budget?" at a
|
||||
* glance.
|
||||
*/
|
||||
export function BudgetUsageChart({ policies }: { policies: ExpensePolicy[] }) {
|
||||
if (!policies.length) {
|
||||
return (
|
||||
<p className="text-sm text-ink-muted">No expense policies to show.</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3.5">
|
||||
{policies.map((policy) => {
|
||||
const pct = policy.limit > 0 ? (policy.spent / policy.limit) * 100 : 0;
|
||||
const over = policy.spent > policy.limit;
|
||||
return (
|
||||
<div key={policy.id} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span className="font-medium text-ink">{policy.type}</span>
|
||||
<span className="tabular-nums text-ink-muted">
|
||||
{formatCurrency(policy.spent)}
|
||||
<span className="text-ink-muted/60">
|
||||
{" "}
|
||||
/ {formatCurrency(policy.limit)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-surface-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
over ? "bg-negative" : "brand-gradient",
|
||||
)}
|
||||
style={{ width: `${Math.min(100, Math.max(2, pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
{over && (
|
||||
<p className="text-xs font-medium text-negative">
|
||||
Over limit by {formatCurrency(policy.spent - policy.limit)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend breakdown — a donut of each policy's spend as a share of total, with a
|
||||
* legend. Built with stroke-dasharray segments on a rotated circle (no arc
|
||||
* math, no dependency); the total sits in the center hole.
|
||||
*/
|
||||
export function SpendBreakdownChart({
|
||||
policies,
|
||||
}: {
|
||||
policies: ExpensePolicy[];
|
||||
}) {
|
||||
const segments = policies
|
||||
.filter((p) => p.spent > 0)
|
||||
.map((p, i) => ({
|
||||
label: p.type,
|
||||
value: p.spent,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
}));
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
if (!total) {
|
||||
return (
|
||||
<p className="text-sm text-ink-muted">No spend to break down yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
const radius = 42;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
// Precompute each arc's length and its start offset (cumulative length of the
|
||||
// preceding arcs) without a mutable accumulator, so the render stays pure.
|
||||
const arcs = segments.map((s, i) => ({
|
||||
...s,
|
||||
len: (s.value / total) * circumference,
|
||||
offset: segments
|
||||
.slice(0, i)
|
||||
.reduce((sum, x) => sum + (x.value / total) * circumference, 0),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative h-32 w-32 flex-none">
|
||||
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--hairline))"
|
||||
strokeWidth="12"
|
||||
/>
|
||||
{arcs.map((s) => (
|
||||
<circle
|
||||
key={s.label}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth="12"
|
||||
strokeDasharray={`${s.len} ${circumference - s.len}`}
|
||||
strokeDashoffset={-s.offset}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-[0.65rem] uppercase tracking-wide text-ink-muted">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-ink">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-1.5 text-sm">
|
||||
{segments.map((s) => (
|
||||
<li key={s.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-none rounded-full"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
<span className="truncate text-ink">{s.label}</span>
|
||||
<span className="ml-auto tabular-nums text-ink-muted">
|
||||
{Math.round((s.value / total) * 100)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A single labelled meter bar (label · value · proportional fill). Module-level
|
||||
// so it isn't re-created on every IncomeExpenseChart render.
|
||||
function MeterRow({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
fill,
|
||||
text,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
max: number;
|
||||
fill: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span className="text-ink-muted">{label}</span>
|
||||
<span className={cn("font-semibold tabular-nums", text)}>
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-surface-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full", fill)}
|
||||
style={{ width: `${Math.max(2, (value / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Income vs expenses — two proportional bars (incoming green, outgoing red)
|
||||
* plus the net. Summed straight from the transaction amounts.
|
||||
*/
|
||||
export function IncomeExpenseChart({
|
||||
transactions,
|
||||
}: {
|
||||
transactions: Transaction[];
|
||||
}) {
|
||||
const income = transactions
|
||||
.filter((t) => t.amount > 0)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
const expenses = transactions
|
||||
.filter((t) => t.amount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
||||
const max = Math.max(income, expenses, 1);
|
||||
const net = income - expenses;
|
||||
|
||||
return (
|
||||
<div className="space-y-3.5">
|
||||
<MeterRow
|
||||
label="Income"
|
||||
value={income}
|
||||
max={max}
|
||||
fill="bg-positive"
|
||||
text="text-positive"
|
||||
/>
|
||||
<MeterRow
|
||||
label="Expenses"
|
||||
value={expenses}
|
||||
max={max}
|
||||
fill="bg-negative"
|
||||
text="text-negative"
|
||||
/>
|
||||
<div className="flex items-baseline justify-between border-t border-hairline pt-2.5 text-sm">
|
||||
<span className="font-medium text-ink">Net</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
net >= 0 ? "text-positive" : "text-negative",
|
||||
)}
|
||||
>
|
||||
{net >= 0 ? "+" : "−"}
|
||||
{formatCurrency(Math.abs(net))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function ApprovalButtons({
|
||||
onApprove,
|
||||
onDeny,
|
||||
approveLabel = "Approve",
|
||||
denyLabel = "Deny",
|
||||
}: {
|
||||
onApprove: () => Promise<void> | void;
|
||||
onDeny: () => void;
|
||||
approveLabel?: string;
|
||||
denyLabel?: string;
|
||||
}) {
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
if (responded) {
|
||||
return <p className="text-sm italic text-ink-muted">Response submitted.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="brand-gradient flex-1 rounded-full px-4 py-2 text-sm font-medium text-white shadow-[0_6px_16px_hsl(252_83%_60%/0.3)] transition-all hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
onClick={async () => {
|
||||
setResponded(true);
|
||||
await onApprove();
|
||||
}}
|
||||
>
|
||||
{approveLabel}
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 rounded-full bg-surface-muted px-4 py-2 text-sm font-medium text-ink transition-colors hover:bg-brand-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
onClick={() => {
|
||||
setResponded(true);
|
||||
onDeny();
|
||||
}}
|
||||
>
|
||||
{denyLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApprovalButtons;
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Check, FileText, ShieldCheck } from "lucide-react";
|
||||
|
||||
// The four beats of clearing an over-limit charge, as a vertical stepper. This
|
||||
// is the explainer twin of the teach/recall loop: it shows the user (or the
|
||||
// agent, when asked "how does this work?") the same open → finalize → approve
|
||||
// procedure the copilot performs, without naming a specific exception code.
|
||||
const STEPS = [
|
||||
{
|
||||
icon: AlertTriangle,
|
||||
title: "Over-limit charge",
|
||||
desc: "The charge exceeds its policy limit, so it can't be approved directly.",
|
||||
tone: "text-negative bg-negative-soft",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "File a policy exception",
|
||||
desc: "Open an exception against the charge under a justifying code.",
|
||||
tone: "text-brand-indigo bg-brand-soft dark:text-brand-violet",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: "Finalize the exception",
|
||||
desc: "Finalizing links it to the charge and lifts the policy-limit gate.",
|
||||
tone: "text-brand-indigo bg-brand-soft dark:text-brand-violet",
|
||||
},
|
||||
{
|
||||
icon: Check,
|
||||
title: "Approve the charge",
|
||||
desc: "With the gate lifted, the approval goes through and the charge clears.",
|
||||
tone: "text-positive bg-positive-soft",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Vertical step diagram of how an over-limit charge gets cleared. Static and
|
||||
* presentational — numbered/iconed nodes joined by a connector line, in the
|
||||
* demo's brand style. Renders well in the narrow chat panel.
|
||||
*/
|
||||
export function ApprovalFlowDiagram() {
|
||||
return (
|
||||
<ol className="space-y-0">
|
||||
{STEPS.map((step, i) => {
|
||||
const Icon = step.icon;
|
||||
const isLast = i === STEPS.length - 1;
|
||||
return (
|
||||
<li key={step.title} className="relative flex gap-3 pb-5 last:pb-0">
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[17px] top-9 h-[calc(100%-1.25rem)] w-px bg-hairline"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`relative flex h-9 w-9 flex-none items-center justify-center rounded-full ${step.tone}`}
|
||||
>
|
||||
<Icon className="h-4.5 w-4.5" />
|
||||
</span>
|
||||
<div className="pt-1">
|
||||
<p className="text-sm font-semibold leading-tight text-ink">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-snug text-ink-muted">
|
||||
{step.desc}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import type { Member } from "@/app/api/v1/data";
|
||||
import useTeam from "@/app/team/actions";
|
||||
|
||||
interface AuthContextType {
|
||||
users: Member[];
|
||||
currentUser: Member;
|
||||
setCurrentUser: (user: Member) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuthContext = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAuthContext must be used within an AuthContextProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthContextProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { team } = useTeam();
|
||||
const [selectedUser, setSelectedUser] = useState<Member | undefined>();
|
||||
const currentUser = selectedUser ?? team[0];
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ users: team, currentUser, setCurrentUser: setSelectedUser }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import { useReportSurface } from "./use-report-surface";
|
||||
|
||||
type SurfaceKind = "report" | "ogui";
|
||||
|
||||
interface CanvasValue {
|
||||
activeSurfaceKind: SurfaceKind | null;
|
||||
activeSurfaceId: string | null;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const CanvasContext = createContext<CanvasValue>({
|
||||
activeSurfaceKind: null,
|
||||
activeSurfaceId: null,
|
||||
clear: () => {},
|
||||
});
|
||||
|
||||
/** Minimal shape of an activity message in the agent's message list. */
|
||||
type MaybeActivityMessage = {
|
||||
id?: string;
|
||||
role?: string;
|
||||
activityType?: string;
|
||||
};
|
||||
|
||||
/** The latest canvas surface (report or OGUI) in the stream, whichever is most recent. */
|
||||
function useLatestCanvasSurface(): {
|
||||
kind: SurfaceKind | null;
|
||||
surfaceId: string | null;
|
||||
} {
|
||||
const { agent } = useAgent();
|
||||
const { surfaceId: reportId } = useReportSurface();
|
||||
const messages = agent?.messages as MaybeActivityMessage[] | undefined;
|
||||
if (messages) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m?.role !== "activity") continue;
|
||||
if (m.activityType === "a2ui-surface")
|
||||
return { kind: "report", surfaceId: reportId };
|
||||
if (m.activityType === "open-generative-ui")
|
||||
return { kind: "ogui", surfaceId: m.id ?? null };
|
||||
}
|
||||
}
|
||||
return { kind: null, surfaceId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether a surface (A2UI report or OGUI) should occupy the content
|
||||
* region, and which KIND. Derives from the latest surface activity in the
|
||||
* agent's message stream and layers a local dismiss for the "← Back" control.
|
||||
* Unique per-surface ids mean dismissing one never suppresses a later one.
|
||||
*/
|
||||
export function CanvasProvider({ children }: { children: React.ReactNode }) {
|
||||
const { kind, surfaceId } = useLatestCanvasSurface();
|
||||
const [dismissedId, setDismissedId] = useState<string | null>(null);
|
||||
|
||||
const active = !!surfaceId && surfaceId !== dismissedId;
|
||||
const activeSurfaceId = active ? surfaceId : null;
|
||||
const activeSurfaceKind = active ? kind : null;
|
||||
|
||||
const clear = () => setDismissedId(surfaceId);
|
||||
|
||||
return (
|
||||
<CanvasContext.Provider
|
||||
value={{ activeSurfaceKind, activeSurfaceId, clear }}
|
||||
>
|
||||
{children}
|
||||
</CanvasContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanvas(): CanvasValue {
|
||||
return useContext(CanvasContext);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
A2UIProvider,
|
||||
A2UIRenderer,
|
||||
useA2UIActions,
|
||||
} from "@copilotkit/a2ui-renderer";
|
||||
import { OpenGenerativeUIActivityRenderer } from "@copilotkit/react-core/v2";
|
||||
import useCreditCards from "@/app/actions";
|
||||
import { catalog } from "@/a2ui/catalog";
|
||||
import { ReportDataProvider } from "@/a2ui/report-data";
|
||||
import type { A2UIOp } from "@/a2ui/build-report-ops";
|
||||
import { useReportSurface } from "./use-report-surface";
|
||||
import { useOguiSurface } from "./use-ogui-surface";
|
||||
import { useCanvas } from "./canvas-context";
|
||||
|
||||
export function ReportCanvas() {
|
||||
const { activeSurfaceKind } = useCanvas();
|
||||
if (activeSurfaceKind === "ogui") return <OguiCanvas />;
|
||||
return <ReportSurfaceCanvas />;
|
||||
}
|
||||
|
||||
/** OGUI surfaces render their sandboxed iframe full-region on the canvas. */
|
||||
function OguiCanvas() {
|
||||
const { content } = useOguiSurface();
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6 md:p-8" data-testid="ogui-surface">
|
||||
{/* message/agent are required by the renderer's prop type but only
|
||||
`content` is read; pass null to satisfy the type. */}
|
||||
<OpenGenerativeUIActivityRenderer
|
||||
activityType="open-generative-ui"
|
||||
content={content}
|
||||
message={null}
|
||||
agent={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportSurfaceCanvas() {
|
||||
const { transactions, policies } = useCreditCards();
|
||||
return (
|
||||
<ReportDataProvider value={{ transactions, policies }}>
|
||||
<A2UIProvider catalog={catalog}>
|
||||
<CanvasInner />
|
||||
</A2UIProvider>
|
||||
</ReportDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasInner() {
|
||||
const { operations, surfaceId } = useReportSurface();
|
||||
const hasContent = operations.length > 0 && !!surfaceId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{surfaceId ? (
|
||||
<SurfaceMessageProcessor
|
||||
operations={operations}
|
||||
surfaceId={surfaceId}
|
||||
/>
|
||||
) : null}
|
||||
{hasContent ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="a2ui-surface p-6 md:p-8" data-testid="a2ui-surface">
|
||||
<A2UIRenderer surfaceId={surfaceId} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the surface's operations into the A2UI provider. The activity content
|
||||
* carries the FULL operation list on each snapshot, so we strip a duplicate
|
||||
* createSurface once the surface exists (the MessageProcessor throws on it) and
|
||||
* skip re-processing identical op lists. Mirrors the framework's built-in
|
||||
* SurfaceMessageProcessor.
|
||||
*/
|
||||
function SurfaceMessageProcessor({
|
||||
operations,
|
||||
surfaceId,
|
||||
}: {
|
||||
operations: A2UIOp[];
|
||||
surfaceId: string;
|
||||
}) {
|
||||
const { processMessages, getSurface } = useA2UIActions();
|
||||
const lastHashRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!operations.length) return;
|
||||
const hash = JSON.stringify(operations);
|
||||
if (hash === lastHashRef.current) return;
|
||||
lastHashRef.current = hash;
|
||||
|
||||
const isExisting = !!getSurface(surfaceId);
|
||||
const ops = isExisting
|
||||
? operations.filter((op) => !("createSurface" in op))
|
||||
: operations;
|
||||
if (!ops.length) return;
|
||||
try {
|
||||
processMessages(ops as Array<Record<string, unknown>>);
|
||||
} catch (err) {
|
||||
console.warn("[report-canvas] processMessages threw:", err);
|
||||
}
|
||||
}, [operations, processMessages, getSurface, surfaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import type { OpenGenerativeUIContent } from "@copilotkit/react-core/v2";
|
||||
|
||||
const OGUI_ACTIVITY_TYPE = "open-generative-ui";
|
||||
|
||||
/** Minimal shape of an OGUI activity message in the agent's message list. */
|
||||
type MaybeActivityMessage = {
|
||||
id?: string;
|
||||
role?: string;
|
||||
activityType?: string;
|
||||
content?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest Open Generative UI surface in the agent's message stream.
|
||||
*
|
||||
* The OpenGenerativeUIMiddleware turns a `generateSandboxedUi` call into an
|
||||
* `open-generative-ui` activity message whose content is the streamed
|
||||
* css/html/js. We read it straight from `agent.messages`, mirroring
|
||||
* `useReportSurface` rather than relaying through a side channel.
|
||||
*
|
||||
* `surfaceId` is the activity message id (stable per call) so the canvas
|
||||
* dismiss can key on it without suppressing a later surface. This depends on
|
||||
* the activity message carrying an `id`; a later e2e task verifies the surface
|
||||
* mounts, and if `id` proves absent there it will be adjusted then.
|
||||
*/
|
||||
export function useOguiSurface(): {
|
||||
content: OpenGenerativeUIContent | null;
|
||||
surfaceId: string | null;
|
||||
} {
|
||||
const { agent } = useAgent();
|
||||
// No manual useMemo: downstream consumers guard on values, and the React
|
||||
// Compiler memoizes this derivation from agent.messages — a manual memo here
|
||||
// can't be preserved by the compiler (react-hooks/preserve-manual-memoization).
|
||||
const messages = agent?.messages as MaybeActivityMessage[] | undefined;
|
||||
if (messages) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (
|
||||
message?.role === "activity" &&
|
||||
message?.activityType === OGUI_ACTIVITY_TYPE
|
||||
) {
|
||||
return {
|
||||
content: (message.content as OpenGenerativeUIContent) ?? null,
|
||||
surfaceId: message.id ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { content: null, surfaceId: null };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import {
|
||||
A2UI_OPERATIONS_KEY,
|
||||
extractSurfaceId,
|
||||
type A2UIOp,
|
||||
} from "@/a2ui/build-report-ops";
|
||||
|
||||
/** Minimal shape of an A2UI activity message in the agent's message list. */
|
||||
type MaybeActivityMessage = {
|
||||
role?: string;
|
||||
activityType?: string;
|
||||
content?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest A2UI report surface in the agent's message stream.
|
||||
*
|
||||
* The A2UI middleware turns the render_report tool result into an
|
||||
* `a2ui-surface` activity message carrying `a2ui_operations`. We read that
|
||||
* directly from `agent.messages` (the pattern the framework's own renderer and
|
||||
* the reference apps use) rather than relaying through a side channel.
|
||||
*/
|
||||
export function useReportSurface(): {
|
||||
operations: A2UIOp[];
|
||||
surfaceId: string | null;
|
||||
} {
|
||||
const { agent } = useAgent();
|
||||
// No manual useMemo: downstream consumers guard on values (SurfaceMessageProcessor
|
||||
// hashes the ops; CanvasProvider compares the string surfaceId), and the React
|
||||
// Compiler memoizes this derivation from agent.messages — a manual memo here
|
||||
// can't be preserved by the compiler (react-hooks/preserve-manual-memoization).
|
||||
const messages = agent?.messages as MaybeActivityMessage[] | undefined;
|
||||
if (messages) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (
|
||||
message?.role === "activity" &&
|
||||
message?.activityType === "a2ui-surface"
|
||||
) {
|
||||
const operations =
|
||||
(message.content?.[A2UI_OPERATIONS_KEY] as A2UIOp[]) ?? [];
|
||||
return {
|
||||
operations,
|
||||
surfaceId: operations.length ? extractSurfaceId(operations) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { operations: [], surfaceId: null };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import type { Card as ICard, ExpensePolicy } from "@/app/api/v1/data";
|
||||
import { CardBrand } from "@/app/api/v1/data";
|
||||
import { VisaWordmark, MastercardMark } from "@/components/card-visual";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Small bordered brand chip (Visa wordmark / Mastercard mark) shown at the
|
||||
* left of each pickable row. Mirrors the chip used by the "New Card Request"
|
||||
* approval render so the two surfaces read as one design language. */
|
||||
function BrandChip({ type }: { type: CardBrand }) {
|
||||
return (
|
||||
<div className="flex h-9 w-12 flex-shrink-0 items-center justify-center rounded-md border border-hairline bg-surface">
|
||||
{type === CardBrand.Visa ? (
|
||||
<VisaWordmark className="h-4 w-auto text-[#1a1f71]" />
|
||||
) : (
|
||||
<MastercardMark className="h-6 w-auto" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-chat card picker. Renders the user's cards as a tappable list — each row
|
||||
* shows the brand mark, card type and masked last-4, plus the assigned policy
|
||||
* as a subtle subtitle. Clicking a row calls `onSelect(card)` once and then
|
||||
* locks the list into a selected/confirmed state (the other rows dim), echoing
|
||||
* the one-shot behaviour of <ApprovalButtons/>.
|
||||
*/
|
||||
export function CardPicker({
|
||||
cards,
|
||||
policies = [],
|
||||
onSelect,
|
||||
heading = "Select a card",
|
||||
}: {
|
||||
cards: ICard[];
|
||||
/** Optional — used only to show each card's assigned policy as a subtitle. */
|
||||
policies?: ExpensePolicy[];
|
||||
onSelect: (card: ICard) => void;
|
||||
/** Picker heading; the agent passes a contextual reason (e.g. the policy). */
|
||||
heading?: string;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
if (!cards.length) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
No cards available to choose from.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-sm font-semibold text-ink">{heading}</h3>
|
||||
<ul className="space-y-2">
|
||||
{cards.map((card) => {
|
||||
const policy = policies.find((p) => p.id === card.expensePolicyId);
|
||||
const isSelected = selectedId === card.id;
|
||||
const isDimmed = selectedId !== null && !isSelected;
|
||||
return (
|
||||
<li key={card.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedId !== null}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => {
|
||||
setSelectedId(card.id);
|
||||
onSelect(card);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl border bg-surface px-3 py-2.5 text-left transition-all",
|
||||
isSelected
|
||||
? "border-brand ring-2 ring-brand ring-offset-1 ring-offset-surface"
|
||||
: "border-hairline hover:border-brand/40 hover:bg-brand-soft",
|
||||
isDimmed && "opacity-50",
|
||||
selectedId === null && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<BrandChip type={card.type} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-ink">
|
||||
<span>{card.type}</span>
|
||||
<span className="font-mono tracking-wider text-ink-muted">
|
||||
•••• {card.last4}
|
||||
</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-ink-muted">
|
||||
{policy ? `${policy.type} policy` : "No policy assigned"}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="h-5 w-5 flex-shrink-0 text-brand" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{selectedId && (
|
||||
<p className="text-xs italic text-ink-muted">Card selected.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Card as ICard } from "@/app/api/v1/data";
|
||||
import { CardBrand } from "@/app/api/v1/data";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Inline VISA wordmark (white), for use on the dark gradient card face. */
|
||||
export function VisaWordmark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 780 500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Visa"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M293.2 348.7l33.4-195.8h53.4l-33.4 195.8zM540.7 157.2c-10.6-4-27.2-8.3-47.9-8.3-52.8 0-90 26.6-90.2 64.6-.3 28.1 26.5 43.8 46.8 53.2 20.8 9.6 27.8 15.7 27.7 24.3-.1 13.1-16.6 19.1-32 19.1-21.4 0-32.7-3-50.3-10.2l-6.9-3.1-7.5 43.8c12.5 5.5 35.6 10.2 59.6 10.5 56.2 0 92.6-26.3 93-66.8.2-22.3-14-39.2-44.8-53.2-18.6-9.1-30.1-15.1-30-24.3 0-8.1 9.7-16.8 30.6-16.8 17.4-.3 30.1 3.5 39.9 7.5l4.8 2.3 7.2-42.7zM676.3 152.9h-41.3c-12.8 0-22.4 3.5-28 16.3l-79.4 179.5h56.2s9.2-24.2 11.3-29.5c6.1 0 60.8.1 68.6.1 1.6 6.9 6.5 29.4 6.5 29.4h49.7l-43.6-195.8zm-65.8 126.3c4.4-11.3 21.4-54.8 21.4-54.8-.3.5 4.4-11.4 7.1-18.8l3.6 17s10.3 47 12.4 56.6h-44.5zM232.2 152.9L180 283.6l-5.6-27c-9.7-31.2-39.9-65-73.7-81.9l47.9 173.8h56.6l84.2-195.6h-57.2"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
opacity="0.85"
|
||||
d="M131.9 152.9H46.3l-.7 3.8c67.1 16.2 111.5 55.4 129.9 102.5L157.2 169c-3.2-12.5-12.7-15.7-25.3-16.1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overlapping-circles Mastercard mark. */
|
||||
export function MastercardMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 780 500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Mastercard"
|
||||
>
|
||||
<circle cx="312" cy="250" r="200" fill="#eb001b" />
|
||||
<circle cx="468" cy="250" r="200" fill="#f79e1b" />
|
||||
<path
|
||||
d="M390 100.2c-49.7 38.3-81.6 98.1-81.6 165.8s31.9 127.5 81.6 165.8c49.7-38.3 81.6-98.1 81.6-165.8S439.7 138.5 390 100.2z"
|
||||
fill="#ff5f00"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The vivid violet→indigo gradient credit card face. Purely presentational.
|
||||
* Shows a masked number, holder, valid-thru and brand mark. `subtle` renders a
|
||||
* dimmed/peeking variant for a card stacked behind the active one.
|
||||
*/
|
||||
export function GradientCreditCard({
|
||||
card,
|
||||
holder,
|
||||
subtle = false,
|
||||
className,
|
||||
}: {
|
||||
card: Pick<ICard, "last4" | "expiry" | "type">;
|
||||
holder: string;
|
||||
subtle?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex aspect-[1.586/1] min-h-[185px] w-full overflow-hidden rounded-[22px] p-5 text-white @container",
|
||||
subtle
|
||||
? "bg-gradient-to-br from-indigo-400/80 to-violet-500/80"
|
||||
: "brand-gradient shadow-[0_16px_38px_hsl(252_83%_55%/0.34)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Soft sheen + decorative orbs for depth. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-10 -top-16 h-44 w-44 rounded-full bg-white/15 blur-xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-20 -left-10 h-44 w-44 rounded-full bg-white/10 blur-xl"
|
||||
/>
|
||||
|
||||
<div className="relative flex h-full min-w-0 flex-1 flex-col justify-between gap-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* EMV chip */}
|
||||
<div className="h-7 w-10 rounded-md bg-gradient-to-br from-amber-200 to-amber-400/80 shadow-inner" />
|
||||
</div>
|
||||
{card.type === CardBrand.Visa ? (
|
||||
<VisaWordmark className="h-7 w-auto text-white" />
|
||||
) : (
|
||||
<MastercardMark className="h-9 w-auto" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Masked PAN. `clamp()` + nowrap keeps all four groups on a single
|
||||
line at every card width (the wide dashboard hero AND the narrow
|
||||
/-page cards) without ever wrapping or clipping the last group. */}
|
||||
<p
|
||||
className="whitespace-nowrap font-mono tracking-[0.16em] text-white/95"
|
||||
style={{ fontSize: "clamp(0.95rem, 4.8cqw, 1.125rem)" }}
|
||||
>
|
||||
•••• •••• •••• {card.last4}
|
||||
</p>
|
||||
<div className="flex items-end justify-between text-xs">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-white/60">
|
||||
Card holder
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-medium tracking-wide">
|
||||
{holder}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="uppercase tracking-wide text-white/60">
|
||||
Valid thru
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-medium tracking-wide">
|
||||
{card.expiry}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreditCard, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Card } from "@/app/api/v1/data";
|
||||
|
||||
export function ChangePinDialog({
|
||||
onDialogOpenChange,
|
||||
dialogOpen,
|
||||
loading,
|
||||
onSubmit,
|
||||
cards,
|
||||
}: {
|
||||
onDialogOpenChange: (open: boolean) => void;
|
||||
dialogOpen: boolean;
|
||||
loading: boolean;
|
||||
onSubmit: (args: { pin: string; cardId?: string }) => void;
|
||||
cards: Card[];
|
||||
}) {
|
||||
const [pin, setPin] = useState("");
|
||||
const [cardId, setCardId] = useState("");
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={onDialogOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change PIN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your new 4-digit PIN below. Make sure it's something you
|
||||
can remember but hard for others to guess.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="col-span-4 justify-between"
|
||||
>
|
||||
{cardId
|
||||
? (() => {
|
||||
const c = cards.find((card) => card.id === cardId);
|
||||
return c ? `${c.type} - ${c.last4}` : "Choose card";
|
||||
})()
|
||||
: "Choose card"}
|
||||
<CreditCard className="ml-2 h-4 w-4 text-ink-muted" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{cards.map((card) => (
|
||||
<DropdownMenuItem
|
||||
key={card.id}
|
||||
onClick={() => setCardId(card.id)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="brand-gradient mr-2 rounded-full p-1">
|
||||
<CreditCard className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{card.type} - {card.last4}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="pin" className="text-right">
|
||||
New PIN
|
||||
</Label>
|
||||
<Input
|
||||
id="pin"
|
||||
type="password"
|
||||
maxLength={4}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onSubmit({ pin, cardId })}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Confirm Change"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Shared controller for the docked chat panel's conversation inbox.
|
||||
*
|
||||
* The panel (a `CopilotSidebar`) and the inbox overlay live in two different
|
||||
* parts of the tree: the inbox + new-conversation buttons live INSIDE the
|
||||
* sidebar's header slot, while the inbox overlay is rendered as a sibling of
|
||||
* the sidebar (so it can cover the panel without being clipped by the chat
|
||||
* view). This context bridges them — and carries the thread actions from
|
||||
* `useThreadSelection` so the header and the inbox rows can switch / start
|
||||
* conversations and collapse the inbox in one place.
|
||||
*/
|
||||
export interface ChatInboxContextValue {
|
||||
isInboxOpen: boolean;
|
||||
openInbox: () => void;
|
||||
closeInbox: () => void;
|
||||
toggleInbox: () => void;
|
||||
/** The chat's active thread id (from useThreadSelection). */
|
||||
selectedThreadId: string;
|
||||
/** Load an existing conversation, then return to the chat view. */
|
||||
selectConversation: (id: string) => void;
|
||||
/** Start a fresh conversation, then return to the chat view. */
|
||||
startNewConversation: () => void;
|
||||
}
|
||||
|
||||
const ChatInboxContext = createContext<ChatInboxContextValue | null>(null);
|
||||
|
||||
export interface ChatInboxProviderProps {
|
||||
children: React.ReactNode;
|
||||
selectedThreadId: string;
|
||||
onSelectThread: (id: string) => void;
|
||||
onCreateThread: () => void;
|
||||
}
|
||||
|
||||
export function ChatInboxProvider({
|
||||
children,
|
||||
selectedThreadId,
|
||||
onSelectThread,
|
||||
onCreateThread,
|
||||
}: ChatInboxProviderProps) {
|
||||
const [isInboxOpen, setIsInboxOpen] = useState(false);
|
||||
|
||||
const value = useMemo<ChatInboxContextValue>(
|
||||
() => ({
|
||||
isInboxOpen,
|
||||
openInbox: () => setIsInboxOpen(true),
|
||||
closeInbox: () => setIsInboxOpen(false),
|
||||
toggleInbox: () => setIsInboxOpen((open) => !open),
|
||||
selectedThreadId,
|
||||
selectConversation: (id: string) => {
|
||||
onSelectThread(id);
|
||||
setIsInboxOpen(false);
|
||||
},
|
||||
startNewConversation: () => {
|
||||
onCreateThread();
|
||||
setIsInboxOpen(false);
|
||||
},
|
||||
}),
|
||||
[isInboxOpen, selectedThreadId, onSelectThread, onCreateThread],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatInboxContext.Provider value={value}>
|
||||
{children}
|
||||
</ChatInboxContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the panel inbox controller. Returns a safe no-op fallback when used
|
||||
* outside the provider so a stray render never throws.
|
||||
*/
|
||||
export function useChatInbox(): ChatInboxContextValue {
|
||||
const ctx = useContext(ChatInboxContext);
|
||||
if (ctx) return ctx;
|
||||
return {
|
||||
isInboxOpen: false,
|
||||
openInbox: () => {},
|
||||
closeInbox: () => {},
|
||||
toggleInbox: () => {},
|
||||
selectedThreadId: "",
|
||||
selectConversation: () => {},
|
||||
startNewConversation: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
export default ChatInboxContext;
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { Archive, ArrowLeft, Inbox, Plus, Trash2 } from "lucide-react";
|
||||
import { useThreads } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useChatInbox } from "./chat-inbox-context";
|
||||
|
||||
const UNTITLED_LABEL = "New conversation";
|
||||
|
||||
/**
|
||||
* Inbox-style conversation list that paints over the docked chat panel's chat
|
||||
* area. It is rendered as a sibling of the `CopilotSidebar` (not inside the
|
||||
* chat view) so it can cover the full panel without being clipped, and is
|
||||
* positioned to exactly overlap the docked panel on the right edge.
|
||||
*
|
||||
* Visibility is driven by two pieces of shared state:
|
||||
* - `isInboxOpen` from {@link useChatInbox} (toggled by the panel header), and
|
||||
* - `panelOpen` passed by the parent (the sidebar's modal-open state),
|
||||
* so the inbox only shows while the panel itself is open.
|
||||
*/
|
||||
export function ChatInbox({
|
||||
panelOpen,
|
||||
showArchived,
|
||||
onShowArchivedChange,
|
||||
width,
|
||||
}: {
|
||||
panelOpen: boolean;
|
||||
showArchived: boolean;
|
||||
onShowArchivedChange: (next: boolean) => void;
|
||||
width: number;
|
||||
}) {
|
||||
const {
|
||||
isInboxOpen,
|
||||
closeInbox,
|
||||
selectedThreadId,
|
||||
selectConversation,
|
||||
startNewConversation,
|
||||
} = useChatInbox();
|
||||
|
||||
const {
|
||||
threads,
|
||||
isLoading,
|
||||
archiveThread,
|
||||
deleteThread,
|
||||
hasMoreThreads,
|
||||
isFetchingMoreThreads,
|
||||
fetchMoreThreads,
|
||||
} = useThreads({
|
||||
agentId: "default",
|
||||
includeArchived: showArchived,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const visible = isInboxOpen && panelOpen;
|
||||
|
||||
const handleArchive = (id: string) => {
|
||||
if (id === selectedThreadId) startNewConversation();
|
||||
Promise.resolve(archiveThread(id)).catch((err: unknown) => {
|
||||
console.error("Unable to archive conversation", err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, title: string) => {
|
||||
if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) return;
|
||||
if (id === selectedThreadId) startNewConversation();
|
||||
Promise.resolve(deleteThread(id)).catch((err: unknown) => {
|
||||
console.error("Unable to delete conversation", err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="chat-inbox"
|
||||
aria-label="Conversations"
|
||||
aria-hidden={!visible}
|
||||
style={{ "--inbox-width": `${width}px` } as CSSProperties}
|
||||
className={cn(
|
||||
"fixed top-0 right-0 z-[1300] flex h-[100dvh] max-h-screen w-full flex-col",
|
||||
"w-full md:w-[var(--inbox-width)]",
|
||||
"border-l border-hairline bg-surface text-ink shadow-lift",
|
||||
"transition-[opacity,transform] duration-200 ease-out",
|
||||
visible
|
||||
? "translate-x-0 opacity-100"
|
||||
: "pointer-events-none translate-x-2 opacity-0",
|
||||
)}
|
||||
>
|
||||
{/* Inbox header — mirrors the panel header height so it sits flush. */}
|
||||
<header className="flex h-[68px] flex-shrink-0 items-center justify-between gap-2 border-b border-hairline px-4">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-brand-soft text-brand-indigo dark:text-brand-violet">
|
||||
<Inbox className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold leading-tight tracking-tight text-ink">
|
||||
Conversations
|
||||
</p>
|
||||
<p className="truncate text-xs leading-tight text-ink-muted">
|
||||
{threads.length}{" "}
|
||||
{threads.length === 1 ? "conversation" : "conversations"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Back to chat"
|
||||
title="Back to chat"
|
||||
data-testid="chat-inbox-back"
|
||||
onClick={closeInbox}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface dark:hover:text-brand-violet"
|
||||
>
|
||||
<ArrowLeft className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* New conversation + archived toggle. */}
|
||||
<div className="flex items-center gap-2 px-4 pb-2 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="inbox-new-conversation"
|
||||
onClick={startNewConversation}
|
||||
className="brand-gradient inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2.5 text-sm font-semibold text-surface shadow-[0_8px_20px_hsl(252_83%_60%/0.28)] transition-transform hover:-translate-y-0.5 hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New conversation
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 px-4 pb-2 text-xs font-medium text-ink-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => onShowArchivedChange(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-[hsl(var(--brand))]"
|
||||
/>
|
||||
Show archived
|
||||
</label>
|
||||
|
||||
{/* List. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-2.5 pb-4 pt-1">
|
||||
{isLoading && threads.length === 0 ? (
|
||||
<InboxEmpty
|
||||
title="Loading conversations…"
|
||||
message="Fetching your recent conversations."
|
||||
/>
|
||||
) : threads.length === 0 ? (
|
||||
<InboxEmpty
|
||||
title="No conversations yet"
|
||||
message="Start a new conversation to chat with the copilot."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{threads.map((thread) => {
|
||||
const title = thread.name ?? UNTITLED_LABEL;
|
||||
const selected = thread.id === selectedThreadId;
|
||||
return (
|
||||
<div
|
||||
key={thread.id}
|
||||
data-testid="inbox-thread-row"
|
||||
className="group relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={() => selectConversation(thread.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-2xl py-2.5 pl-3 pr-[5.5rem] text-left transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1 focus-visible:ring-offset-surface",
|
||||
selected
|
||||
? "bg-brand-soft shadow-[inset_0_0_0_1px_hsl(var(--brand)/0.25)]"
|
||||
: "hover:bg-brand-soft/60",
|
||||
thread.archived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-8 w-1 flex-none rounded-full",
|
||||
selected
|
||||
? "bg-gradient-to-b from-brand-violet to-brand-indigo"
|
||||
: "bg-hairline",
|
||||
)}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 truncate text-sm font-semibold tracking-tight",
|
||||
thread.name
|
||||
? selected
|
||||
? "text-brand-indigo dark:text-brand-violet"
|
||||
: "text-ink"
|
||||
: "font-medium text-ink-muted",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
{thread.archived && (
|
||||
<span className="flex-none rounded-full bg-surface-muted px-1.5 py-0.5 text-[0.6rem] font-semibold text-ink-muted">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-xs text-ink-muted">
|
||||
{formatRelativeTime(
|
||||
thread.lastRunAt ?? thread.updatedAt,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
||||
{!thread.archived && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Archive ${title}`}
|
||||
title="Archive"
|
||||
onClick={() => handleArchive(thread.id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:hover:text-brand-violet"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete ${title}`}
|
||||
title="Delete"
|
||||
onClick={() => handleDelete(thread.id, title)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-negative transition-colors hover:bg-negative-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasMoreThreads && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isFetchingMoreThreads}
|
||||
onClick={() => fetchMoreThreads?.()}
|
||||
className="mt-1 inline-flex w-full items-center justify-center rounded-xl border border-hairline bg-surface-muted px-3 py-2 text-sm font-semibold text-ink-muted transition-colors hover:border-brand/30 hover:bg-brand-soft hover:text-brand-indigo disabled:opacity-60 dark:hover:text-brand-violet"
|
||||
>
|
||||
{isFetchingMoreThreads ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxEmpty({ title, message }: { title: string; message: string }) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="flex max-w-[15rem] flex-col items-start gap-2 rounded-2xl border border-hairline bg-surface-muted/90 p-4">
|
||||
<p className="text-sm font-bold text-ink">{title}</p>
|
||||
<p className="text-xs leading-relaxed text-ink-muted">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative timestamp ("just now", "3h ago", "2d ago", or a date).
|
||||
*/
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "Updated recently";
|
||||
const diffMs = Date.now() - then;
|
||||
const sec = Math.round(diffMs / 1000);
|
||||
if (sec < 45) return "just now";
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
if (day < 7) return `${day}d ago`;
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(then);
|
||||
}
|
||||
|
||||
export default ChatInbox;
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { MessagesSquare, SquarePen, X } from "lucide-react";
|
||||
import { useCopilotChatConfiguration } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IDENTITY } from "@/lib/identity";
|
||||
import { useChatInbox } from "./chat-inbox-context";
|
||||
|
||||
/**
|
||||
* Custom header for the docked chat panel, supplied to `CopilotSidebar` via its
|
||||
* `header` slot. It renders INSIDE the sidebar's own
|
||||
* `CopilotChatConfigurationProvider`, so `useCopilotChatConfiguration()` is the
|
||||
* live handle for the panel — `setModalOpen(false)` collapses the panel and the
|
||||
* change propagates up to the wrapper-level provider the inbox overlay reads.
|
||||
*
|
||||
* Layout: a small violet→indigo brand chip + the assistant title on the left,
|
||||
* and three actions on the right — open the conversation inbox, start a new
|
||||
* conversation, and close the panel.
|
||||
*/
|
||||
export function ChatPanelHeader() {
|
||||
const configuration = useCopilotChatConfiguration();
|
||||
const { isInboxOpen, toggleInbox, startNewConversation } = useChatInbox();
|
||||
|
||||
const title = configuration?.labels.modalHeaderTitle ?? IDENTITY.assistant;
|
||||
|
||||
const closePanel = () => configuration?.setModalOpen?.(false);
|
||||
|
||||
return (
|
||||
<header
|
||||
data-testid="chat-panel-header"
|
||||
className="flex h-[68px] flex-shrink-0 items-center justify-between gap-2 border-b border-hairline bg-surface/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-surface/80"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="brand-gradient flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl text-surface shadow-[0_6px_16px_hsl(252_83%_60%/0.4)]">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4 13.5L9 7l4 4.5L20 4"
|
||||
stroke="white"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="20" cy="4" r="2" fill="white" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold leading-tight tracking-tight text-ink">
|
||||
{title}
|
||||
</p>
|
||||
<p className="truncate text-xs leading-tight text-ink-muted">
|
||||
{IDENTITY.brand}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<HeaderIconButton
|
||||
label={isInboxOpen ? "Back to chat" : "Conversations"}
|
||||
active={isInboxOpen}
|
||||
onClick={toggleInbox}
|
||||
testId="chat-inbox-toggle"
|
||||
>
|
||||
<MessagesSquare className="h-[18px] w-[18px]" />
|
||||
</HeaderIconButton>
|
||||
<HeaderIconButton
|
||||
label="New conversation"
|
||||
onClick={startNewConversation}
|
||||
testId="chat-header-new-conversation"
|
||||
>
|
||||
<SquarePen className="h-[18px] w-[18px]" />
|
||||
</HeaderIconButton>
|
||||
<HeaderIconButton
|
||||
label="Close chat"
|
||||
onClick={closePanel}
|
||||
testId="chat-header-close"
|
||||
>
|
||||
<X className="h-[18px] w-[18px]" />
|
||||
</HeaderIconButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderIconButton({
|
||||
label,
|
||||
onClick,
|
||||
active = false,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface",
|
||||
active
|
||||
? "bg-brand-soft text-brand-indigo dark:text-brand-violet"
|
||||
: "text-ink-muted hover:bg-brand-soft hover:text-brand-indigo dark:hover:text-brand-violet",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatPanelHeader;
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CopilotSidebar,
|
||||
useCopilotChatConfiguration,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import type { CopilotSidebarProps } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { IDENTITY } from "@/lib/identity";
|
||||
import { ChatPanelHeader } from "./chat-panel-header";
|
||||
import { ChatInbox } from "./chat-inbox";
|
||||
|
||||
/** Docked panel width on desktop (px). Mobile falls back to full width.
|
||||
* Sized so the always-on suggestion pills flow two-per-row instead of
|
||||
* stacking into a single tall column. */
|
||||
const PANEL_WIDTH = 560;
|
||||
|
||||
/**
|
||||
* The docked chat experience: a right-side `CopilotSidebar` that pushes page
|
||||
* content aside (CopilotSidebarView manages the body margin) plus an
|
||||
* inbox-style conversation list that paints over the chat area.
|
||||
*
|
||||
* Why `CopilotSidebar` directly (no license bypass): the OSS demo ships no
|
||||
* license token, so `CopilotKitProvider` wires `createLicenseContextValue(null)`
|
||||
* whose `checkFeature` returns `true` for every feature. `CopilotSidebar`'s
|
||||
* `checkFeature("sidebar")` therefore passes — no `InlineFeatureWarning` banner
|
||||
* and no console warning. Verified visually as well.
|
||||
*
|
||||
* `threadId` is threaded through to `CopilotSidebar` (which forwards it to the
|
||||
* underlying `CopilotChat`) so frontend-tool round-trips keep their thread
|
||||
* anchor. The wrapper-level `CopilotChatConfigurationProvider` already supplies
|
||||
* `hasExplicitThreadId`, which flows down to the chat.
|
||||
*/
|
||||
export function ChatPanel({ threadId }: { threadId: string }) {
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
// Read the panel's open state from the configuration chain. The wrapper's
|
||||
// provider stays in sync with the sidebar's internal modal state (open/close
|
||||
// propagates upward), so this reflects whether the panel is currently docked.
|
||||
const configuration = useCopilotChatConfiguration();
|
||||
const panelOpen = configuration?.isModalOpen ?? false;
|
||||
|
||||
// Start the docked panel CLOSED for a clean dashboard first impression.
|
||||
//
|
||||
// `CopilotSidebar` accepts `defaultOpen={false}`, but it cannot win here: the
|
||||
// v1 `CopilotKit` bridge (this app uses the v1 export) mounts its own
|
||||
// top-level `CopilotChatConfigurationProvider` with `isModalDefaultOpen`
|
||||
// hard-commented-out, so it defaults the modal OPEN (true) and that value
|
||||
// cascades DOWN through every nested chat provider via their parent→child
|
||||
// sync. There is no bridge prop to change that default, so we correct it once
|
||||
// on mount. `ChatPanel` reads the wrapper-level provider, which has no
|
||||
// explicit default and therefore delegates its setter to the bridge — so this
|
||||
// one call flips the root closed and the change cascades to the sidebar. A ref
|
||||
// guard makes it a one-time action; the floating toggle still opens the panel
|
||||
// freely afterward, and `useLayoutEffect` runs before paint so it never
|
||||
// flashes open.
|
||||
const setModalOpen = configuration?.setModalOpen;
|
||||
const didCloseRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (didCloseRef.current) return;
|
||||
didCloseRef.current = true;
|
||||
setModalOpen?.(false);
|
||||
}, [setModalOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CopilotSidebar
|
||||
agentId="default"
|
||||
threadId={threadId}
|
||||
position="right"
|
||||
width={PANEL_WIDTH}
|
||||
defaultOpen={false}
|
||||
// The `header` slot is typed as `SlotValue<typeof CopilotModalHeader>`,
|
||||
// which expects a component carrying CopilotModalHeader's namespace
|
||||
// statics (Title/CloseButton). A plain replacement component does not
|
||||
// structurally match that, so we cast — the same pattern CopilotKit's
|
||||
// own slot tests use for custom headers. `renderSlot` renders any
|
||||
// component reference at runtime.
|
||||
header={ChatPanelHeader as CopilotSidebarProps["header"]}
|
||||
labels={{
|
||||
modalHeaderTitle: IDENTITY.assistant,
|
||||
welcomeMessageText: IDENTITY.greeting,
|
||||
}}
|
||||
/>
|
||||
<ChatInbox
|
||||
panelOpen={panelOpen}
|
||||
showArchived={showArchived}
|
||||
onShowArchivedChange={setShowArchived}
|
||||
width={PANEL_WIDTH}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatPanel;
|
||||
@@ -0,0 +1,820 @@
|
||||
"use client";
|
||||
import {
|
||||
useAgentContext,
|
||||
useComponent,
|
||||
useHumanInTheLoop,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import useCreditCards from "@/app/actions";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import { useRecording } from "@/components/recording-context";
|
||||
import { ApprovalButtons } from "@/components/approval-buttons";
|
||||
import { RecordingSteps } from "@/components/recording-feed";
|
||||
import { PendingApprovalsChat } from "@/components/wow/pending-approvals-chat";
|
||||
import {
|
||||
SpendingTrendChart,
|
||||
BudgetUsageChart,
|
||||
SpendBreakdownChart,
|
||||
IncomeExpenseChart,
|
||||
} from "@/components/analytics-charts";
|
||||
import { ApprovalFlowDiagram } from "@/components/approval-flow-diagram";
|
||||
import { PERMISSIONS } from "@/app/api/v1/permissions";
|
||||
import { withOverLimit } from "@/lib/over-limit";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export enum Page {
|
||||
Cards = "cards",
|
||||
Team = "team",
|
||||
}
|
||||
|
||||
export enum CardsPageOperations {
|
||||
ChangePin = "change-pin",
|
||||
}
|
||||
|
||||
export enum TeamPageOperations {
|
||||
InviteMember = "invite-member",
|
||||
RemoveMember = "remove-member",
|
||||
EditMember = "edit-member",
|
||||
}
|
||||
|
||||
export const AVAILABLE_OPERATIONS_PER_PAGE = {
|
||||
[Page.Cards]: Object.values(CardsPageOperations),
|
||||
[Page.Team]: Object.values(TeamPageOperations),
|
||||
};
|
||||
|
||||
// Self-learning "teach a workflow" loop — the canonical procedure echoed to the
|
||||
// agent when the officer saves the demonstrated workflow. It names the exact
|
||||
// (justifying) code the officer used; finalizing such an exception is what lifts
|
||||
// the over-limit gate (proven in scripts/over-limit-gate-smoke.mjs). This text
|
||||
// lands in the thread, which is how the agent recalls the procedure later in the
|
||||
// SAME session. The agent only ever sees the code, never its human label.
|
||||
const canonicalProcedure = (code: string): string =>
|
||||
`Saved workflow for clearing an over-limit charge: (1) open a policy ` +
|
||||
`exception against the transaction with code ${code}, (2) finalize the ` +
|
||||
`exception, then (3) approve the transaction. Finalizing a ${code} exception ` +
|
||||
`lifts the policy-limit gate. Reuse this same procedure for any other ` +
|
||||
`over-limit charge — do not ask how to proceed.`;
|
||||
|
||||
// A component dedicated to adding readables/actions that are global to the app.
|
||||
//
|
||||
// The self-learning teach/recall tools (offerWorkflowRecording,
|
||||
// awaitDashboardDemonstration, saveLearnedWorkflow, openPolicyException,
|
||||
// finalizePolicyException, approveTransaction) live HERE, not on the Credit
|
||||
// Cards page, because the officer demonstrates on the /dashboard route: if these
|
||||
// tools were registered by a route component they would unmount on navigation,
|
||||
// the in-progress card would lose its render, and the followUp continuation
|
||||
// after "I'm done" would never fire. Registered globally they survive route
|
||||
// changes and render on whichever page the user is on.
|
||||
const CopilotContext = ({ children }: { children: React.ReactNode }) => {
|
||||
const { currentUser } = useAuthContext();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const {
|
||||
cards,
|
||||
policies,
|
||||
transactions,
|
||||
changeTransactionStatus,
|
||||
openPolicyException,
|
||||
finalizePolicyException,
|
||||
} = useCreditCards();
|
||||
const { beginRecording, endRecording, getDemonstratedCode } = useRecording();
|
||||
|
||||
// A readable of app wide authentication and authorization context.
|
||||
// The LLM will now know which user is it working against, when performing operations.
|
||||
// Given the respective authorization role, the LLM will allow/deny actions/information throughout the entire app.
|
||||
useAgentContext({
|
||||
description: "The current user logged into the system",
|
||||
value: JSON.stringify(currentUser),
|
||||
});
|
||||
|
||||
useAgentContext({
|
||||
description:
|
||||
"The available pages and operations, as well as the current page",
|
||||
value: {
|
||||
pages: Object.values(Page),
|
||||
operations: AVAILABLE_OPERATIONS_PER_PAGE,
|
||||
currentPage: pathname.split("/").pop() as Page,
|
||||
},
|
||||
});
|
||||
|
||||
// The app's cards, policies and transactions — the single agent-facing data
|
||||
// readable (registered globally so it is present on every route). When the
|
||||
// user names a charge by merchant/amount, the agent resolves it to the id
|
||||
// here. `overLimit: true` is the symptom only — it does NOT reveal the unlock
|
||||
// procedure (the agent still has to learn that).
|
||||
useAgentContext({
|
||||
description:
|
||||
"The available credit cards, expense policies and transactions. When the " +
|
||||
"user refers to a charge by merchant name and/or amount, resolve it to the " +
|
||||
"matching transaction id here and pass that id to the transaction tools; " +
|
||||
"never invent an id. `overLimit: true` on a transaction means it is over " +
|
||||
"its policy limit and cannot be approved normally — it has no standing " +
|
||||
"approval yet.",
|
||||
value: JSON.stringify({
|
||||
cards,
|
||||
policies,
|
||||
transactions: withOverLimit(transactions, policies),
|
||||
}),
|
||||
});
|
||||
|
||||
// Actions the current user is NOT permitted to perform (role-derived). Global
|
||||
// so the agent applies the same permission rules on every route.
|
||||
useAgentContext({
|
||||
description:
|
||||
"Actions the current user is NOT permitted to perform. An empty list " +
|
||||
"means the user is permitted to use every available action. Only " +
|
||||
"refuse an action if it appears in this list — never refuse for any " +
|
||||
"other reason. When refusing, say the user lacks permission; do not " +
|
||||
"tell them they are on the wrong page.",
|
||||
value: Object.keys(PERMISSIONS).filter(
|
||||
(key) =>
|
||||
!PERMISSIONS[key as keyof typeof PERMISSIONS].includes(
|
||||
currentUser.role,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
// This action is a generic "fits all" action
|
||||
// It's meant to allow the LLM to navigate to a page where an operation is available or probably available, and possibly activate the operation there.
|
||||
// It is tired to the readable above, and requires that operations are implemented in their respective pages.
|
||||
// The LLM here will redirect the user to a different page, and set an `operation` query param to notify the page of the requested action
|
||||
// For example, you can find `change-pin` in the cards page, which is activated when `operation=change-pin` query param is sent
|
||||
useHumanInTheLoop({
|
||||
name: "navigateToPageAndPerform",
|
||||
description: `
|
||||
Navigate to a different page to perform an operation.
|
||||
IMPORTANT: Only use this action when the user needs to go to a DIFFERENT page than the one they are currently on.
|
||||
Do NOT use this if the user is already on the correct page - instead, use the page-specific tools directly.
|
||||
For example, if the user is on the cards page and asks to add a card, do NOT use this action - use the addNewCard tool instead.
|
||||
Only use this when the user is on the wrong page entirely (e.g., on team page but asking about cards).
|
||||
`,
|
||||
parameters: z.object({
|
||||
page: z
|
||||
.enum(["/cards", "/team", "/"])
|
||||
.describe("The page in which to perform the operation"),
|
||||
operation: z
|
||||
.string()
|
||||
.describe(
|
||||
"The operation to perform. Use operation code from available operations per page. If the operation is unavailable, do not pass it",
|
||||
)
|
||||
.optional(),
|
||||
operationAvailable: z
|
||||
.boolean()
|
||||
.describe("Flag if the operation is available"),
|
||||
}),
|
||||
followUp: false,
|
||||
render: ({ args, respond }) => {
|
||||
const { page, operation, operationAvailable } = args;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<div className="text-sm">
|
||||
Navigate to <span className="font-semibold">{page}</span>?
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const operationParams = `?operation=${operation}`;
|
||||
// `/cards` mirrors the dashboard; the card tools/operations
|
||||
// (add card, change PIN) are registered on the home route, so
|
||||
// card requests must land on `/` or the operation dies on
|
||||
// arrival.
|
||||
const target = page === "/cards" ? "/" : page!.toLowerCase();
|
||||
// Client-side navigation: a full reload (window.location) tears
|
||||
// down the chat panel mid-run, so the conversation — and the
|
||||
// in-flight operation — is lost the moment we navigate.
|
||||
router.push(
|
||||
`${target}${operationAvailable ? operationParams : ""}`,
|
||||
);
|
||||
respond?.(page!);
|
||||
}}
|
||||
aria-label="Confirm Navigation"
|
||||
className="h-12 w-12 rounded-full bg-brand-soft text-brand-indigo hover:bg-brand-soft/70 dark:text-brand-violet"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => respond?.("cancelled")}
|
||||
aria-label="Cancel Navigation"
|
||||
className="h-12 w-12 rounded-full bg-surface-muted text-ink-muted hover:bg-surface-muted/70"
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Generative-UI: the pending-approval queue, rendered IN the chat. Mirrors the
|
||||
// dashboard's "Pending approval" tab behaviors (identical over-limit gating,
|
||||
// exception filing, and teach-mode recording payloads) so the officer can
|
||||
// triage and clear over-limit charges without leaving the conversation.
|
||||
// Rendered via PendingApprovalsChat — the dashboard's 4-column table is
|
||||
// ~550px wide and its Actions column lands past the ~375px chat card's edge
|
||||
// (buttons render but cannot be clicked), so the chat uses a stacked layout
|
||||
// with labeled actions instead. Display-only `useComponent` (not
|
||||
// useFrontendTool) so the card persists in the transcript after the call;
|
||||
// re-registers when the data changes, or the closure captures empty arrays.
|
||||
useComponent(
|
||||
{
|
||||
name: "showPendingApprovals",
|
||||
description:
|
||||
"Show the queue of transactions awaiting approval (including over-limit " +
|
||||
"charges) as an interactive list in the chat. Call this whenever the " +
|
||||
"user asks what is pending, what needs approval, or to review pending or " +
|
||||
"over-limit charges — do NOT list the transactions in plain text. After " +
|
||||
"the list renders, add one short sentence pointing at what needs " +
|
||||
"attention (e.g. how many are over their policy limit).",
|
||||
parameters: z.object({}),
|
||||
render: () => {
|
||||
const pending = transactions.filter((t) => t.status === "pending");
|
||||
if (!pending.length) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
No transactions are pending approval.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
// `pointer-events-auto`: this is a `useComponent` (display-only) render,
|
||||
// which CopilotKit paints with `pointer-events: none` on the assistant
|
||||
// message. But this table is interactive (Approve / Deny / File policy
|
||||
// exception), so opt its subtree back into pointer events or the row
|
||||
// actions (incl. the "More actions" menu) are unclickable in the chat.
|
||||
<div className="pointer-events-auto space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Pending approvals
|
||||
</h3>
|
||||
<PendingApprovalsChat
|
||||
transactions={pending}
|
||||
policies={policies}
|
||||
openPolicyException={openPolicyException}
|
||||
finalizePolicyException={finalizePolicyException}
|
||||
onApprove={async (id) =>
|
||||
(await changeTransactionStatus({ id, status: "approved" })).ok
|
||||
}
|
||||
onDeny={async (id) =>
|
||||
(await changeTransactionStatus({ id, status: "denied" })).ok
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
[transactions, policies],
|
||||
);
|
||||
|
||||
// ── Generative-UI charts & diagrams ─────────────────────────────────────────
|
||||
// Visualizations the agent can summon directly in the chat (display-only
|
||||
// `useComponent`, so they persist in the transcript like showTransactions).
|
||||
// All hand-rolled SVG/CSS in the brand style — no charting dependency. Each
|
||||
// re-registers when the data it reads changes.
|
||||
//
|
||||
// Every chart description carries the same "chart + answer" rule: the chart
|
||||
// replaces restating the raw numbers, NOT the answer itself. Without it the
|
||||
// model renders the right chart and never addresses the user's actual
|
||||
// question ("which policy is closest to its limit?" → chart, silence).
|
||||
const CHART_ANSWER_RULE =
|
||||
" After the chart renders, ALSO answer the user's specific question in " +
|
||||
"one or two sentences grounded in the data — the chart replaces listing " +
|
||||
"the raw numbers, not your answer. If the user asked no specific " +
|
||||
"question, one short takeaway sentence is enough.";
|
||||
|
||||
useComponent(
|
||||
{
|
||||
name: "showSpendingTrend",
|
||||
description:
|
||||
"Render a chart of spending over time in the chat. Call this for any " +
|
||||
"question about spend trends, history, or how spending has changed." +
|
||||
CHART_ANSWER_RULE,
|
||||
parameters: z.object({}),
|
||||
render: () => (
|
||||
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">Spending trend</h3>
|
||||
<SpendingTrendChart transactions={transactions} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[transactions],
|
||||
);
|
||||
|
||||
useComponent(
|
||||
{
|
||||
name: "showBudgetUsage",
|
||||
description:
|
||||
"Render a chart of budget usage per expense policy (spent vs limit) in " +
|
||||
"the chat. Call this for questions about budgets, limits, utilization, " +
|
||||
"or which teams are close to or over their limit." +
|
||||
CHART_ANSWER_RULE,
|
||||
parameters: z.object({}),
|
||||
render: () => (
|
||||
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Budget usage by policy
|
||||
</h3>
|
||||
<BudgetUsageChart policies={policies} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[policies],
|
||||
);
|
||||
|
||||
useComponent(
|
||||
{
|
||||
name: "showSpendBreakdown",
|
||||
description:
|
||||
"Render a donut chart breaking spend down by team/policy in the chat. " +
|
||||
"Call this for 'where is the money going', spend distribution, or " +
|
||||
"breakdown-by-team questions." +
|
||||
CHART_ANSWER_RULE,
|
||||
parameters: z.object({}),
|
||||
render: () => (
|
||||
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">Spend breakdown</h3>
|
||||
<SpendBreakdownChart policies={policies} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[policies],
|
||||
);
|
||||
|
||||
useComponent(
|
||||
{
|
||||
name: "showIncomeVsExpenses",
|
||||
description:
|
||||
"Render a chart comparing total income vs expenses (and the net) in " +
|
||||
"the chat. Call this for cash-flow, income-vs-spend, or net-position " +
|
||||
"questions." +
|
||||
CHART_ANSWER_RULE,
|
||||
parameters: z.object({}),
|
||||
render: () => (
|
||||
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">Income vs expenses</h3>
|
||||
<IncomeExpenseChart transactions={transactions} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[transactions],
|
||||
);
|
||||
|
||||
useComponent(
|
||||
{
|
||||
name: "showApprovalFlow",
|
||||
description:
|
||||
"Render a diagram of how an over-limit charge gets cleared (file " +
|
||||
"exception → finalize → approve). Call this when the user asks how " +
|
||||
"approvals or over-limit charges work, or to explain the process.",
|
||||
parameters: z.object({}),
|
||||
render: () => (
|
||||
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Clearing an over-limit charge
|
||||
</h3>
|
||||
<ApprovalFlowDiagram />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Recall tools (open → finalize → approve) ───────────────────────────────
|
||||
// Neutral descriptions: they must not say what each step accomplishes, name a
|
||||
// code, or describe a sequence — the agent learns the order from the saved
|
||||
// procedure (canonicalProcedure), never from the prompt.
|
||||
|
||||
// Open a draft policy exception against a transaction (human-in-the-loop).
|
||||
useHumanInTheLoop({
|
||||
// followUp:true so that during recall the agent CONTINUES after the
|
||||
// exception is opened — it must chain to finalizePolicyException (and then
|
||||
// approval) on its own. With followUp:false the run ended here and the
|
||||
// recall stalled after opening the exception.
|
||||
followUp: true,
|
||||
name: "openPolicyException",
|
||||
description:
|
||||
"Open a draft policy exception against a transaction. Requires human approval.",
|
||||
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
transactionId: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { transactionId, code } = args;
|
||||
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Open policy exception
|
||||
</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Transaction:</span>{" "}
|
||||
{transactionId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-ink-muted">Code:</span> {code}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!transactionId || !code) {
|
||||
respond?.("Missing transaction or exception code");
|
||||
return;
|
||||
}
|
||||
const { ok, data, error } = await openPolicyException({
|
||||
transactionId,
|
||||
code,
|
||||
});
|
||||
// Directive result so the agent reliably chains the NEXT step
|
||||
// during recall: finalize THIS exception (by id) before any
|
||||
// approval. gpt-5.4-mini otherwise tends to jump straight to
|
||||
// approving, which the gate then rejects.
|
||||
respond?.(
|
||||
ok
|
||||
? `Policy exception opened as a DRAFT. Its exceptionId is "${data?.id ?? ""}" — this is the id of the exception, NOT the transaction id. Next, call finalizePolicyException with exceptionId "${data?.id ?? ""}" exactly. The transaction stays blocked until this exception is finalized — do not try to approve before then.`
|
||||
: `Could not open exception: ${error}`,
|
||||
);
|
||||
}}
|
||||
onDeny={() => respond?.("Denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Finalize a policy exception (human-in-the-loop).
|
||||
useHumanInTheLoop({
|
||||
// followUp:true so the agent CONTINUES after finalizing to perform the
|
||||
// approval — completing the open -> finalize -> approve recall chain.
|
||||
followUp: true,
|
||||
name: "finalizePolicyException",
|
||||
description: "Finalize a policy exception. Requires human approval.",
|
||||
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
exceptionId: z.string(),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { exceptionId } = args;
|
||||
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Finalize policy exception
|
||||
</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Exception:</span> {exceptionId}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!exceptionId) {
|
||||
respond?.("Missing exception id");
|
||||
return;
|
||||
}
|
||||
const { ok, error } = await finalizePolicyException({
|
||||
exceptionId,
|
||||
});
|
||||
respond?.(
|
||||
ok
|
||||
? "Exception finalized — the policy-limit gate is now lifted. Approve the transaction to complete it."
|
||||
: `Could not finalize exception: ${error}`,
|
||||
);
|
||||
}}
|
||||
onDeny={() => respond?.("Denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Approve a transaction (human-in-the-loop). The final step of the recall
|
||||
// chain, once the over-limit gate has been lifted. Single-purpose and
|
||||
// neutral: it says nothing about exceptions or codes. Its description gates it
|
||||
// to the post-unlock state so the agent does NOT fire it as the first response
|
||||
// to an over-limit approval request — at Beat 1 it has no saved procedure and
|
||||
// must offer to record instead.
|
||||
useHumanInTheLoop({
|
||||
followUp: true,
|
||||
name: "approveTransaction",
|
||||
description:
|
||||
"Approve a single transaction. Only call this for a charge that can actually be approved now — either it is within its policy limit, or its over-limit gate has already been lifted by the earlier steps of your saved procedure. Never call this as the first response to an over-limit approval request.",
|
||||
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
transactionId: z.string(),
|
||||
}),
|
||||
render: ({ args, respond, status }) => {
|
||||
const { transactionId } = args;
|
||||
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Approve transaction
|
||||
</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-ink-muted">Transaction:</span>{" "}
|
||||
{transactionId}
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onApprove={async () => {
|
||||
if (!transactionId) {
|
||||
respond?.("Missing transaction id");
|
||||
return;
|
||||
}
|
||||
const { ok, error } = await changeTransactionStatus({
|
||||
id: transactionId,
|
||||
status: "approved",
|
||||
});
|
||||
respond?.(
|
||||
ok
|
||||
? `Transaction ${transactionId} approved.`
|
||||
: `Could not approve transaction ${transactionId}: ${error}`,
|
||||
);
|
||||
}}
|
||||
onDeny={() => respond?.("Denied by user")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Self-learning "teach a workflow" loop ──────────────────────────────────
|
||||
// The agent drives this as a narrated sequence once it's asked to approve an
|
||||
// over-limit charge it has no saved procedure for (the sequencing rules live
|
||||
// in api/copilotkit/[[...slug]]/route.ts): offer to record -> wait while the
|
||||
// officer demonstrates the fix ON THE DASHBOARD -> summarize and save. All are
|
||||
// followUp:true so the agent advances to the next beat after each card
|
||||
// resolves. Recording mode (the canvas vignette) opens on "Start recording"
|
||||
// and closes on "I'm done"/Save/Discard/Cancel; RecordingProvider ref-counts,
|
||||
// so the dashboard's own begin/end brackets nest harmlessly inside this window.
|
||||
|
||||
// BEAT 2 — offer to record, after an approval is declined with no procedure.
|
||||
useHumanInTheLoop({
|
||||
followUp: true,
|
||||
name: "offerWorkflowRecording",
|
||||
description:
|
||||
"Offer to record how the user handles a charge you have no saved procedure for. Call this immediately after you decline an over-limit approval you have no saved workflow for — do NOT just ask how to proceed.",
|
||||
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
transactionId: z
|
||||
.string()
|
||||
.describe("The transaction whose approval was just declined."),
|
||||
}),
|
||||
render: ({ args, respond, status, result }) => {
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Once the user has chosen, collapse to a static line so the
|
||||
// "Start recording" button doesn't linger (or get clicked twice) — the
|
||||
// live "Recording your workflow" card (awaitDashboardDemonstration) takes
|
||||
// over from here. Branch on the resolved `result` (fresh on complete) — NOT
|
||||
// isRecording, whose value in this render closure can be stale and wrongly
|
||||
// show "not recording" right after Start recording. onDeny resolves
|
||||
// "declined"; onApprove resolves the (non-"declined") directive string.
|
||||
if (status === "complete") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
{result === "declined"
|
||||
? "Okay — not recording."
|
||||
: "Recording started — go ahead and demonstrate the fix on the dashboard."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Record a workflow?
|
||||
</h3>
|
||||
<p className="text-sm text-ink-muted">
|
||||
No saved procedure for this charge yet. Want me to record how you
|
||||
handle it so I can do it myself next time?
|
||||
</p>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
approveLabel="Start recording"
|
||||
denyLabel="Not now"
|
||||
onApprove={() => {
|
||||
beginRecording();
|
||||
// Directive result. With a bare "started", gpt-5.4-mini tends to
|
||||
// just SAY awaitDashboardDemonstration's "go ahead and I'll watch"
|
||||
// line (from its description) instead of CALLING it — leaving this
|
||||
// card frozen with no live recording card. Tell it explicitly to
|
||||
// call the tool and not reply in prose (mirrors the save beat).
|
||||
respond?.(
|
||||
`Recording started. Now IMMEDIATELY call awaitDashboardDemonstration with transactionId "${args?.transactionId ?? ""}" — that tool renders the live "Recording your workflow" card and is how you watch. Do NOT reply in plain text; calling that tool is the only correct next step.`,
|
||||
);
|
||||
}}
|
||||
onDeny={() => respond?.("declined")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// BEAT 3 — the officer demonstrates on the real dashboard. This card just
|
||||
// waits: the actual file-exception + approve happens on /dashboard (Transactions
|
||||
// -> Pending approval), which records to the active thread. When the officer
|
||||
// returns and clicks "I'm done" we end recording and report the exception code
|
||||
// they used (captured via the recording context) so the agent can summarize
|
||||
// and save it. No deps array: getDemonstratedCode reads a ref, so the click
|
||||
// handler always sees the latest code even though this card rendered before
|
||||
// the officer filed anything on the dashboard.
|
||||
useHumanInTheLoop({
|
||||
followUp: true,
|
||||
name: "awaitDashboardDemonstration",
|
||||
description: `Wait while the user demonstrates how they clear this charge themselves. Call this after the user agrees to record (offerWorkflowRecording returned "started"). Do NOT give the user step-by-step directions or tell them where to click — you do not know the procedure, which is the whole point of watching. Say only something brief like "Go ahead and do it now and I'll watch and learn." When they finish you receive the exception code they used.`,
|
||||
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
transactionId: z
|
||||
.string()
|
||||
.describe("The transaction the user will demonstrate on."),
|
||||
}),
|
||||
render: ({ respond, status, result }) => {
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Once resolved, collapse so the "I'm done" button can't linger.
|
||||
if (status === "complete") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
{result === "cancelled"
|
||||
? "Recording cancelled."
|
||||
: "Recording finished — saving the workflow…"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5" aria-hidden>
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-negative opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-negative" />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Recording your workflow
|
||||
</h3>
|
||||
<span className="ml-auto text-[0.65rem] font-semibold uppercase tracking-wide text-negative">
|
||||
Rec
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-ink-muted">
|
||||
I don't know how to do this yet — go ahead and do it yourself
|
||||
now and I'll watch and learn. Click{" "}
|
||||
<span className="font-medium text-ink">I'm done</span> when
|
||||
you're finished.
|
||||
</p>
|
||||
</div>
|
||||
{/* Live feed of the actions being captured — same chat card, so it
|
||||
reads consistently with the other cards (not a floating overlay). */}
|
||||
<RecordingSteps />
|
||||
<ApprovalButtons
|
||||
approveLabel="I'm done"
|
||||
denyLabel="Cancel"
|
||||
onApprove={() => {
|
||||
endRecording();
|
||||
const code = getDemonstratedCode();
|
||||
// Directive result so the agent reliably renders the Save card as
|
||||
// its next step instead of just asking "should I save this?" in
|
||||
// prose (gpt-5.4-mini otherwise tends to summarize in text and
|
||||
// stall, leaving the user nothing to click). saveLearnedWorkflow
|
||||
// IS the way it asks.
|
||||
respond?.(
|
||||
code
|
||||
? `The user filed a policy exception with code ${code} and approved the charge on the dashboard. Now call saveLearnedWorkflow with this transaction id and code "${code}" exactly — that renders the card that asks them to save it. Do NOT ask whether to save in plain text; the card is how you ask.`
|
||||
: "The user finished on the dashboard, but no exception code was captured. Ask them which exception code they used, then call saveLearnedWorkflow with it.",
|
||||
);
|
||||
}}
|
||||
onDeny={() => {
|
||||
endRecording();
|
||||
respond?.("cancelled");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// BEAT 4 — summarize and save. Echoes the demonstrated procedure back into the
|
||||
// thread (the same-session recall mechanism) and closes recording mode.
|
||||
useHumanInTheLoop({
|
||||
followUp: true,
|
||||
name: "saveLearnedWorkflow",
|
||||
description:
|
||||
"Summarize the procedure the user just demonstrated and ask to save it. Call this after awaitDashboardDemonstration reports a filed exception, passing the exact code from that result.",
|
||||
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
|
||||
parameters: z.object({
|
||||
transactionId: z.string(),
|
||||
code: z
|
||||
.string()
|
||||
.describe(
|
||||
"The exception code the user demonstrated, taken from awaitDashboardDemonstration's result.",
|
||||
),
|
||||
}),
|
||||
render: ({ args, respond, status, result }) => {
|
||||
if (status === "inProgress") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Once resolved, collapse so the "Save workflow" button can't linger or be
|
||||
// re-clicked. `result` carries the value passed to respond() on complete.
|
||||
if (status === "complete") {
|
||||
const saved =
|
||||
typeof result === "string" && result.includes("status: saved");
|
||||
return (
|
||||
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
|
||||
{saved
|
||||
? "Workflow saved — I’ll reuse it for future over-limit charges."
|
||||
: "Discarded — not saved."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { code, transactionId } = args;
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-ink">
|
||||
Save this workflow?
|
||||
</h3>
|
||||
<p className="text-sm text-ink-muted">
|
||||
To clear an over-limit charge:
|
||||
</p>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm text-ink">
|
||||
<li>
|
||||
Open a policy exception under code{" "}
|
||||
<span className="font-mono text-xs">{code}</span>
|
||||
</li>
|
||||
<li>Finalize the exception</li>
|
||||
<li>Approve the transaction</li>
|
||||
</ol>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
approveLabel="Save workflow"
|
||||
denyLabel="Discard"
|
||||
onApprove={() => {
|
||||
endRecording();
|
||||
// Resolve a result the agent recognizes as "saved" and that drives it
|
||||
// to persist the procedure durably via save_memory (Option A — see the
|
||||
// TEACH & RECALL prompt). The demonstrated charge was cleared BY the
|
||||
// demonstration, so it is already approved; say so explicitly or the
|
||||
// agent tends to re-run the just-saved procedure on that same charge —
|
||||
// opening a redundant exception on an approved transaction.
|
||||
respond?.(
|
||||
`(status: saved) ${canonicalProcedure(code)} Now persist this durably: call save_memory with scope "project", kind "operational", and content describing this over-limit procedure using code "${code}". You learned this by watching the user clear transaction ${transactionId}, which is already approved now — do NOT apply the procedure to ${transactionId} again or re-approve it. The original request is complete; wait for the user's next instruction before acting.`,
|
||||
);
|
||||
}}
|
||||
onDeny={() => {
|
||||
endRecording();
|
||||
respond?.("discarded");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default CopilotContext;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Lock, Settings2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GradientCreditCard } from "@/components/card-visual";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { Card as ICard, ExpensePolicy } from "../app/api/v1/data";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
|
||||
export function CreditCardDetails({
|
||||
card,
|
||||
policy,
|
||||
holder = "Northwind Finance",
|
||||
onChangePinModalOpen,
|
||||
}: {
|
||||
card: ICard;
|
||||
policy?: ExpensePolicy;
|
||||
/** Cardholder name shown on the card face. */
|
||||
holder?: string;
|
||||
onChangePinModalOpen: () => void;
|
||||
}) {
|
||||
const usagePct =
|
||||
policy && policy.limit > 0
|
||||
? Math.min(100, (policy.spent / policy.limit) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-[26px] border border-hairline bg-surface p-4 shadow-soft transition-shadow hover:shadow-lift">
|
||||
<GradientCreditCard card={card} holder={holder.toUpperCase()} />
|
||||
|
||||
{policy ? (
|
||||
<div className="space-y-3 px-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-ink-muted">Credit limit</span>
|
||||
<span className="font-semibold text-ink">
|
||||
{formatCurrency(policy.limit)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={usagePct} />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-ink-muted">Available</span>
|
||||
<span className="font-semibold text-positive">
|
||||
{formatCurrency(policy.limit - policy.spent)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-1 text-sm text-ink-muted">
|
||||
No expense policy assigned
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
Manage Card
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56">
|
||||
<div className="grid gap-3">
|
||||
<h4 className="text-sm font-semibold leading-none text-ink">
|
||||
Card options
|
||||
</h4>
|
||||
<div className="h-px w-full bg-hairline" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={onChangePinModalOpen}
|
||||
>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Change PIN
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { GlassEngineProvider, useGlassEngine } from "./glass-engine-context";
|
||||
|
||||
const STORAGE_KEY = "northwind.glassEngine";
|
||||
|
||||
afterEach(() => window.localStorage.clear());
|
||||
|
||||
function makeWrapper(available: boolean) {
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<GlassEngineProvider available={available}>
|
||||
{children}
|
||||
</GlassEngineProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useGlassEngine", () => {
|
||||
it("defaults to disabled when nothing is stored", () => {
|
||||
const { result } = renderHook(() => useGlassEngine(), {
|
||||
wrapper: makeWrapper(true),
|
||||
});
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(result.current.active).toBe(false);
|
||||
});
|
||||
|
||||
it("toggle() flips enabled and persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useGlassEngine(), {
|
||||
wrapper: makeWrapper(true),
|
||||
});
|
||||
act(() => result.current.toggle());
|
||||
expect(result.current.enabled).toBe(true);
|
||||
expect(result.current.active).toBe(true); // available && enabled
|
||||
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
it("reads the persisted value on mount", () => {
|
||||
window.localStorage.setItem(STORAGE_KEY, "true");
|
||||
const { result } = renderHook(() => useGlassEngine(), {
|
||||
wrapper: makeWrapper(true),
|
||||
});
|
||||
expect(result.current.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("is never active when unavailable, even if localStorage says enabled", () => {
|
||||
// The public-host safety property: a forced localStorage value cannot
|
||||
// activate the pane when the deployment has not opted in.
|
||||
window.localStorage.setItem(STORAGE_KEY, "true");
|
||||
const { result } = renderHook(() => useGlassEngine(), {
|
||||
wrapper: makeWrapper(false),
|
||||
});
|
||||
expect(result.current.enabled).toBe(true);
|
||||
expect(result.current.available).toBe(false);
|
||||
expect(result.current.active).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const STORAGE_KEY = "northwind.glassEngine";
|
||||
|
||||
interface GlassEngineContextValue {
|
||||
/** Deployment-level gate (server-provided). When false, Glass Engine is absent. */
|
||||
available: boolean;
|
||||
/** Presenter's per-session on/off (localStorage). Meaningful only when available. */
|
||||
enabled: boolean;
|
||||
/** The render condition for the pane: available AND enabled. */
|
||||
active: boolean;
|
||||
setEnabled: (next: boolean) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const GlassEngineContext = createContext<GlassEngineContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function GlassEngineProvider({
|
||||
available,
|
||||
children,
|
||||
}: {
|
||||
available: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Default off; standard mode is the default experience. Read the persisted
|
||||
// value lazily so SSR renders `false` and the client hydrates to the stored
|
||||
// value on mount.
|
||||
const [enabled, setEnabledState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
// Client-only hydration from localStorage (an external system): SSR renders
|
||||
// the `false` default and the client syncs the persisted value post-mount. A
|
||||
// lazy initializer would read window during SSR and cause a hydration mismatch.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional localStorage hydration
|
||||
setEnabledState(window.localStorage.getItem(STORAGE_KEY) === "true");
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((next: boolean) => {
|
||||
setEnabledState(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, String(next));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => setEnabled(!enabled), [enabled, setEnabled]);
|
||||
|
||||
// `active` is the security-relevant render gate: it can never be true on a
|
||||
// deployment that did not opt in, regardless of what localStorage holds.
|
||||
const active = available && enabled;
|
||||
|
||||
return (
|
||||
<GlassEngineContext.Provider
|
||||
value={{ available, enabled, active, setEnabled, toggle }}
|
||||
>
|
||||
{children}
|
||||
</GlassEngineContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGlassEngine(): GlassEngineContextValue {
|
||||
const ctx = useContext(GlassEngineContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useGlassEngine must be used within a GlassEngineProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCopilotChatConfiguration } from "@copilotkit/react-core/v2";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useGlassEngine } from "@/components/glass-engine-context";
|
||||
import { useInspector } from "@/lib/inspector/store";
|
||||
import { useInspectorEvents } from "./use-inspector-events";
|
||||
import { TimelineTab } from "./timeline-tab";
|
||||
import { MemoryTab } from "./memory-tab";
|
||||
import { LearningTab } from "./learning-tab";
|
||||
|
||||
/** Matches CopilotSidebar's docked width (chat-panel.tsx PANEL_WIDTH = 440). */
|
||||
const CHAT_WIDTH = 440;
|
||||
|
||||
export function InspectorPane() {
|
||||
// Tap the live AG-UI stream regardless of which tab is open. Runs whenever the
|
||||
// pane is mounted (Task 12 mounts it only when the deployment opted in), so the
|
||||
// Timeline accumulates history even while the pane is collapsed.
|
||||
useInspectorEvents();
|
||||
const { active, setEnabled } = useGlassEngine();
|
||||
const { clear } = useInspector();
|
||||
const [tab, setTab] = useState("timeline");
|
||||
|
||||
// Slide in next to the chat: anchored to the chat's right-dock width when the
|
||||
// chat is open, flush to the edge when it's closed.
|
||||
const chatOpen = useCopilotChatConfiguration()?.isModalOpen ?? false;
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
// Desktop-only (mirrors CopilotSidebar's body-margin desktop gate). Fixed
|
||||
// so it's independent of the body margin CopilotSidebar manages.
|
||||
className="glass-surface fixed inset-y-0 z-30 hidden w-96 flex-col border-l border-hairline shadow-lift transition-[right] duration-300 md:flex"
|
||||
style={{ right: chatOpen ? CHAT_WIDTH : 0 }}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-hairline px-3 py-2">
|
||||
<h2 className="text-xs font-semibold text-ink">
|
||||
Glass Engine — live protocol inspector
|
||||
</h2>
|
||||
<button
|
||||
onClick={clear}
|
||||
className="ml-auto text-[10px] text-ink-muted hover:text-ink"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEnabled(false)}
|
||||
className="text-[10px] text-ink-muted hover:text-ink"
|
||||
>
|
||||
collapse →
|
||||
</button>
|
||||
</header>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList variant="underline" className="shrink-0 px-3 pt-2">
|
||||
<TabsTrigger value="timeline">Timeline</TabsTrigger>
|
||||
<TabsTrigger value="memory">Memory</TabsTrigger>
|
||||
<TabsTrigger value="learning">Learning</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="timeline">
|
||||
<TimelineTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="memory">
|
||||
<MemoryTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="learning">
|
||||
<LearningTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import { useInspector } from "@/lib/inspector/store";
|
||||
import type { PanelMemory } from "@/lib/intelligence/memory";
|
||||
|
||||
// Backstop only; the real trigger is a memory-kind event in the store.
|
||||
const BACKSTOP_POLL_MS = 15_000;
|
||||
|
||||
/** The over-limit unlock is saved as project-scope, operational (Intelligence main's memory-kind enum). */
|
||||
function isOverLimitProcedure(m: PanelMemory): boolean {
|
||||
return m.scope === "project" && m.kind === "operational";
|
||||
}
|
||||
|
||||
function parseMemories(value: unknown): PanelMemory[] {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Array.isArray((value as { memories?: unknown }).memories)
|
||||
) {
|
||||
return (value as { memories: PanelMemory[] }).memories;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function LearningTab() {
|
||||
const { currentUser } = useAuthContext();
|
||||
const role = currentUser?.role;
|
||||
const memberId = currentUser?.id;
|
||||
const { cards } = useInspector();
|
||||
|
||||
const [procedures, setProcedures] = useState<PanelMemory[]>([]);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchProcedures = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/memories", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(role ? { "x-northwind-role": role } : {}),
|
||||
...(memberId ? { "x-northwind-user-id": memberId } : {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 503) {
|
||||
if (isMountedRef.current) {
|
||||
setDisabled(true);
|
||||
setLoaded(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const json: unknown = await res.json();
|
||||
if (isMountedRef.current) {
|
||||
setProcedures(parseMemories(json).filter(isOverLimitProcedure));
|
||||
setDisabled(false);
|
||||
setLoaded(true);
|
||||
}
|
||||
} catch {
|
||||
if (isMountedRef.current) setLoaded(true);
|
||||
}
|
||||
}, [role, memberId]);
|
||||
|
||||
const memoryEvents = cards.filter((c) => c.kind === "memory");
|
||||
|
||||
// Fetch on mount + slow backstop poll. fetchProcedures is async — it setStates
|
||||
// only after `await fetch`, so there is no synchronous cascading render.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
|
||||
fetchProcedures().catch(() => {});
|
||||
const handle = window.setInterval(
|
||||
() => fetchProcedures().catch(() => {}),
|
||||
BACKSTOP_POLL_MS,
|
||||
);
|
||||
return () => window.clearInterval(handle);
|
||||
}, [fetchProcedures]);
|
||||
|
||||
// Event-driven: re-fetch the moment a memory tool-call streams in.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
|
||||
if (memoryEvents.length > 0) fetchProcedures().catch(() => {});
|
||||
}, [memoryEvents.length, fetchProcedures]);
|
||||
|
||||
const learned = procedures.length > 0;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="p-4 text-xs text-ink-muted">
|
||||
<p className="font-medium text-ink">Requires Intelligence mode</p>
|
||||
<p className="mt-1">
|
||||
Durable self-learning needs the Intelligence backend. Set the
|
||||
<code className="mx-1 rounded bg-surface-muted px-1">
|
||||
INTELLIGENCE_*
|
||||
</code>{" "}
|
||||
env vars to enable it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${learned ? "bg-positive" : "bg-ink-muted"}`}
|
||||
/>
|
||||
<h3 className="text-xs font-semibold text-ink">
|
||||
Over-limit procedure — {learned ? "learned" : "not yet learned"}
|
||||
</h3>
|
||||
</div>
|
||||
{!loaded ? (
|
||||
<p className="text-[11px] text-ink-muted">Loading…</p>
|
||||
) : learned ? (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{procedures.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="rounded border border-positive/40 bg-positive-soft p-2 text-[11px]"
|
||||
>
|
||||
<p className="text-ink">{p.content}</p>
|
||||
<p className="mt-1 text-[10px] text-ink-muted">
|
||||
learned from {p.sourceThreadIds.length}{" "}
|
||||
{p.sourceThreadIds.length === 1 ? "thread" : "threads"}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="rounded border border-hairline p-2 text-[11px] text-ink-muted">
|
||||
No saved over-limit procedure yet. Ask the copilot to approve an
|
||||
over-limit charge — it will stall, offer to record, and you teach it
|
||||
by demonstrating the policy exception. Once saved, it appears here
|
||||
and a fresh thread recalls it.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold text-ink">
|
||||
Recall activity (this session)
|
||||
</h3>
|
||||
{memoryEvents.length === 0 ? (
|
||||
<p className="text-[11px] text-ink-muted">
|
||||
No memory tool calls yet. Recall/save events appear here as the
|
||||
agent uses long-term memory.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-1">
|
||||
{memoryEvents.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="rounded border border-hairline px-2 py-1 text-[11px] text-ink"
|
||||
>
|
||||
<span className="mr-1 inline-block h-2 w-2 rounded-full bg-positive align-middle" />
|
||||
{c.title}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import { useInspector } from "@/lib/inspector/store";
|
||||
import type { PanelMemory } from "@/lib/intelligence/memory";
|
||||
|
||||
// Backstop only — the primary refresh trigger is a memory-kind event landing in
|
||||
// the store (see the memoryEventCount effect). Catches rare out-of-band changes.
|
||||
const BACKSTOP_POLL_MS = 15_000;
|
||||
|
||||
const KIND_COLORS: Record<string, string> = {
|
||||
topical: "bg-brand-soft text-brand-indigo",
|
||||
episodic: "bg-brand-soft text-brand-violet",
|
||||
operational: "bg-positive-soft text-positive",
|
||||
};
|
||||
const SCOPE_COLORS: Record<string, string> = {
|
||||
user: "bg-surface-muted text-ink-muted",
|
||||
project: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
};
|
||||
|
||||
function parseMemories(value: unknown): PanelMemory[] {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Array.isArray((value as { memories?: unknown }).memories)
|
||||
) {
|
||||
return (value as { memories: PanelMemory[] }).memories;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function MemoryCard({
|
||||
memory,
|
||||
relevance,
|
||||
}: {
|
||||
memory: PanelMemory;
|
||||
relevance?: number;
|
||||
}) {
|
||||
const kindColor =
|
||||
KIND_COLORS[memory.kind] ?? "bg-surface-muted text-ink-muted";
|
||||
const scopeColor =
|
||||
SCOPE_COLORS[memory.scope] ?? "bg-surface-muted text-ink-muted";
|
||||
const threadCount = memory.sourceThreadIds.length;
|
||||
return (
|
||||
<li className="flex flex-col gap-1 rounded border border-hairline p-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${kindColor}`}
|
||||
>
|
||||
{memory.kind}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${scopeColor}`}
|
||||
>
|
||||
{memory.scope}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-ink">{memory.content}</p>
|
||||
{relevance !== undefined && (
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-indigo"
|
||||
style={{ width: `${Math.max(6, Math.round(relevance * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-ink-muted">
|
||||
{threadCount} {threadCount === 1 ? "source thread" : "source threads"}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<div className="p-4 text-xs text-ink-muted">
|
||||
<p className="font-medium text-ink">Requires Intelligence mode</p>
|
||||
<p className="mt-1">
|
||||
Durable memory lives in the Intelligence backend. Set the
|
||||
<code className="mx-1 rounded bg-surface-muted px-1">
|
||||
INTELLIGENCE_*
|
||||
</code>
|
||||
env vars (see the README) to enable the memory store and recall.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryTab() {
|
||||
const { currentUser } = useAuthContext();
|
||||
const role = currentUser?.role;
|
||||
const memberId = currentUser?.id;
|
||||
|
||||
// The store's memory-kind cards are our change signal: memory only changes
|
||||
// when the agent saves/recalls, which is exactly when these events fire.
|
||||
const { cards } = useInspector();
|
||||
const memoryEventCount = useMemo(
|
||||
() => cards.filter((c) => c.kind === "memory").length,
|
||||
[cards],
|
||||
);
|
||||
|
||||
const [memories, setMemories] = useState<PanelMemory[]>([]);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
|
||||
const [recallQuery, setRecallQuery] = useState("");
|
||||
const [recallResults, setRecallResults] = useState<PanelMemory[] | null>(
|
||||
null,
|
||||
);
|
||||
const [recallError, setRecallError] = useState<string | null>(null);
|
||||
const [isRecalling, setIsRecalling] = useState(false);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const headers = useCallback(
|
||||
(extra?: Record<string, string>) => ({
|
||||
Accept: "application/json",
|
||||
...(role ? { "x-northwind-role": role } : {}),
|
||||
...(memberId ? { "x-northwind-user-id": memberId } : {}),
|
||||
...extra,
|
||||
}),
|
||||
[role, memberId],
|
||||
);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/memories", { headers: headers() });
|
||||
if (response.status === 503) {
|
||||
if (isMountedRef.current) {
|
||||
setDisabled(true);
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(`recall failed (${response.status})`);
|
||||
const json: unknown = await response.json();
|
||||
if (isMountedRef.current) {
|
||||
setMemories(parseMemories(json));
|
||||
setDisabled(false);
|
||||
setListError(null);
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setListError(err instanceof Error ? err.message : "unknown error");
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
}
|
||||
}, [headers]);
|
||||
|
||||
// Fetch on mount + a slow backstop poll for rare out-of-band changes.
|
||||
// fetchList is async — it setStates only after `await fetch`, so there is no
|
||||
// synchronous cascading render (what the rule guards against).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
|
||||
fetchList().catch(() => {});
|
||||
const handle = window.setInterval(
|
||||
() => fetchList().catch(() => {}),
|
||||
BACKSTOP_POLL_MS,
|
||||
);
|
||||
return () => window.clearInterval(handle);
|
||||
}, [fetchList]);
|
||||
|
||||
// Event-driven refresh: re-fetch the instant a memory tool-call streams in.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
|
||||
if (memoryEventCount > 0) fetchList().catch(() => {});
|
||||
}, [memoryEventCount, fetchList]);
|
||||
|
||||
const runRecall = useCallback(
|
||||
async (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) return;
|
||||
setIsRecalling(true);
|
||||
setRecallError(null);
|
||||
try {
|
||||
const response = await fetch("/api/memories/recall", {
|
||||
method: "POST",
|
||||
headers: headers({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ query: trimmed }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`recall failed (${response.status})`);
|
||||
const json: unknown = await response.json();
|
||||
if (isMountedRef.current) setRecallResults(parseMemories(json));
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setRecallError(err instanceof Error ? err.message : "unknown error");
|
||||
setRecallResults(null);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsRecalling(false);
|
||||
}
|
||||
},
|
||||
[headers],
|
||||
);
|
||||
|
||||
const maxScore =
|
||||
recallResults && recallResults.length > 0
|
||||
? Math.max(...recallResults.map((m) => m.score ?? 0))
|
||||
: 0;
|
||||
|
||||
if (disabled) return <DisabledState />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-ink">Memory store</h3>
|
||||
<p className="text-[10px] text-ink-muted">
|
||||
Long-term memory. Updates as the agent saves and recalls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
runRecall(recallQuery).catch(() => {});
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Recall by meaning…"
|
||||
value={recallQuery}
|
||||
onChange={(e) => setRecallQuery(e.target.value)}
|
||||
aria-label="Recall memories by meaning"
|
||||
className="flex-1 rounded border border-hairline bg-transparent px-2 py-1 text-xs text-ink placeholder:text-ink-muted focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isRecalling || recallQuery.trim().length === 0}
|
||||
className="rounded border border-hairline px-2 py-1 text-xs font-medium text-ink hover:bg-brand-soft disabled:opacity-40"
|
||||
>
|
||||
{isRecalling ? "…" : "Recall"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{recallResults !== null && (
|
||||
<section aria-label="Recall results">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-ink">
|
||||
Semantic recall ({recallResults.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRecallResults(null);
|
||||
setRecallError(null);
|
||||
}}
|
||||
className="text-[10px] text-ink-muted hover:text-ink"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{recallError ? (
|
||||
<p className="text-[11px] text-negative">
|
||||
Recall failed: {recallError}
|
||||
</p>
|
||||
) : recallResults.length === 0 ? (
|
||||
<p className="text-[11px] text-ink-muted">
|
||||
No memories matched that query.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{recallResults.map((m) => (
|
||||
<MemoryCard
|
||||
key={m.id}
|
||||
memory={m}
|
||||
relevance={
|
||||
maxScore > 0 ? (m.score ?? 0) / maxScore : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section aria-label="Recalled memories">
|
||||
{/* "Recalled", NOT "All": this is top-k semantic recall, not a complete
|
||||
enumeration. No absolute count — the UI must not claim completeness. */}
|
||||
<h3 className="mb-1.5 text-xs font-semibold text-ink">
|
||||
Recalled memories
|
||||
</h3>
|
||||
{listError ? (
|
||||
<p className="text-[11px] text-negative">
|
||||
Unable to load memories: {listError}
|
||||
</p>
|
||||
) : !hasLoadedOnce ? (
|
||||
<p className="text-[11px] text-ink-muted">Loading…</p>
|
||||
) : memories.length === 0 ? (
|
||||
<p className="text-[11px] text-ink-muted">
|
||||
Nothing recalled yet. Teach the agent a durable procedure and watch
|
||||
it appear here.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{memories.map((m) => (
|
||||
<MemoryCard key={m.id} memory={m} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useInspector } from "@/lib/inspector/store";
|
||||
import type { StoredCard } from "@/lib/inspector/store";
|
||||
|
||||
const KIND_DOT: Record<StoredCard["kind"], string> = {
|
||||
lifecycle: "bg-ink-muted",
|
||||
error: "bg-negative",
|
||||
"tool-call": "bg-brand-indigo",
|
||||
"tool-result": "bg-brand-violet/60",
|
||||
state: "bg-brand-violet",
|
||||
"hitl-gate": "bg-amber-500",
|
||||
custom: "bg-ink-muted",
|
||||
memory: "bg-positive",
|
||||
};
|
||||
|
||||
export function TimelineTab() {
|
||||
const { cards } = useInspector();
|
||||
// Auto-scroll to the newest card as events stream in, so the timeline reads
|
||||
// as a live feed during the demo.
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
|
||||
}, [cards.length]);
|
||||
|
||||
if (cards.length === 0) {
|
||||
return (
|
||||
<p className="p-4 text-sm text-ink-muted">
|
||||
Send a message — every AG-UI protocol event the run emits appears here,
|
||||
live.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-2 p-3">
|
||||
{cards.map((c) => (
|
||||
<li key={c.id} className="rounded-xl border border-hairline p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${KIND_DOT[c.kind]}`}
|
||||
/>
|
||||
<span className="text-xs font-medium text-ink">{c.title}</span>
|
||||
</div>
|
||||
{c.summary && (
|
||||
<p className="mt-1 text-[11px] text-ink-muted">{c.summary}</p>
|
||||
)}
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-[10px] text-ink-muted">
|
||||
raw event
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-auto rounded bg-surface-muted p-2 text-[10px] leading-relaxed text-ink">
|
||||
{JSON.stringify(c.raw, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
<div ref={endRef} aria-hidden />
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
// `@ag-ui/*` isn't a direct dep of this demo; react-core/v2 re-exports the
|
||||
// client types (`export * from "@ag-ui/client"`), so `BaseEvent` comes from there.
|
||||
import { useAgent, type BaseEvent } from "@copilotkit/react-core/v2";
|
||||
import { eventToCard } from "@/lib/inspector/event-cards";
|
||||
import { useInspector } from "@/lib/inspector/store";
|
||||
|
||||
/**
|
||||
* Bridges the raw AG-UI event stream into the inspector store. Mount once
|
||||
* inside both the CopilotKit provider and the InspectorStoreProvider.
|
||||
*
|
||||
* Ported from splat-demo (Intelligence #361); the governance_decision handling
|
||||
* was dropped — banking has no server-side component governance.
|
||||
*/
|
||||
export function useInspectorEvents(): void {
|
||||
const { agent } = useAgent();
|
||||
const { pushCard } = useInspector();
|
||||
|
||||
useEffect(() => {
|
||||
const sub = agent.subscribe({
|
||||
onEvent({ event }: { event: BaseEvent }) {
|
||||
const card = eventToCard(event);
|
||||
if (card) pushCard(card);
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [agent, pushCard]);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CreditCard,
|
||||
HelpCircle,
|
||||
LayoutDashboard,
|
||||
RotateCcw,
|
||||
Telescope,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { Member } from "@/app/api/v1/data";
|
||||
import { MemberRole } from "@/app/api/v1/data";
|
||||
import { useAuthContext } from "@/components/auth-context";
|
||||
import { useGlassEngine } from "@/components/glass-engine-context";
|
||||
import { useRecording } from "@/components/recording-context";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { IDENTITY } from "@/lib/identity";
|
||||
import { useCanvas } from "@/components/canvas/canvas-context";
|
||||
import { ReportCanvas } from "@/components/canvas/report-canvas";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
resetEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Compact violet→indigo logo mark used at the top of the floating rail. */
|
||||
function BrandMark() {
|
||||
return (
|
||||
<span className="brand-gradient flex h-11 w-11 items-center justify-center rounded-2xl text-surface shadow-[0_8px_20px_hsl(252_83%_60%/0.4)]">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4 13.5L9 7l4 4.5L20 4"
|
||||
stroke="white"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="20" cy="4" r="2" fill="white" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UserNavigation({
|
||||
availableUsers,
|
||||
currentUser,
|
||||
onChangeUser,
|
||||
}: {
|
||||
availableUsers: Member[];
|
||||
currentUser: Member;
|
||||
onChangeUser: (user: Member) => void;
|
||||
}) {
|
||||
const getInitials = (name: string) => {
|
||||
return (name || "X Y")
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-2xl p-0 hover:bg-brand-soft"
|
||||
aria-label="Account menu"
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback>{getInitials(currentUser.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end" side="right">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold leading-none text-ink">
|
||||
{currentUser.name}
|
||||
</h4>
|
||||
<p className="text-xs text-ink-muted">{currentUser.email}</p>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
Switch user
|
||||
</h4>
|
||||
{availableUsers.map((user) => (
|
||||
<Button
|
||||
key={user.id}
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 px-2"
|
||||
onClick={() => onChangeUser(user)}
|
||||
>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[0.6rem]">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-sm">
|
||||
{user.name} (
|
||||
{user.role === MemberRole.Admin
|
||||
? user.role
|
||||
: user.role == MemberRole.Assistant
|
||||
? user.team + " " + user.role
|
||||
: user.team}
|
||||
)
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutComponent({
|
||||
children,
|
||||
resetEnabled = false,
|
||||
}: LayoutProps) {
|
||||
const { users, currentUser, setCurrentUser } = useAuthContext();
|
||||
const {
|
||||
available: glassAvailable,
|
||||
active: glassActive,
|
||||
toggle: toggleGlass,
|
||||
} = useGlassEngine();
|
||||
const pathname = usePathname();
|
||||
useAgentContext({
|
||||
description: "The current page where the user is",
|
||||
value: pathname.split("/")[1] === "" ? "cards" : pathname.split("/")[1],
|
||||
});
|
||||
const { activeSurfaceId, clear } = useCanvas();
|
||||
|
||||
const handleReset = async () => {
|
||||
// Native confirm keeps the booth tool dependency-free and reliable; a stray
|
||||
// click can't nuke the demo mid-show.
|
||||
if (
|
||||
!window.confirm(
|
||||
"Reset demo state? This clears all learned memories and restores pending charges.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/v1/dev/reset", { method: "POST" });
|
||||
if (res.ok) {
|
||||
// Full reload -> pristine client slate (fresh transactions, cleared
|
||||
// canvas, new thread on next message).
|
||||
window.location.reload();
|
||||
} else {
|
||||
window.alert(`Reset failed (HTTP ${res.status}). See the server logs.`);
|
||||
}
|
||||
} catch (err) {
|
||||
window.alert(`Reset failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigating via the rail dismisses any stale surface.
|
||||
useEffect(() => {
|
||||
clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-screen overflow-hidden bg-canvas transition-[padding] duration-300",
|
||||
glassActive && "md:pr-96",
|
||||
)}
|
||||
>
|
||||
{/* Floating icon rail. */}
|
||||
<div className="flex flex-shrink-0 flex-col py-4 pl-4">
|
||||
<aside className="glass-surface flex h-full w-[72px] flex-col items-center rounded-[28px] border border-white/60 px-2 py-5 shadow-lift dark:border-hairline">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center"
|
||||
aria-label={IDENTITY.brand}
|
||||
>
|
||||
<BrandMark />
|
||||
</Link>
|
||||
<nav className="mt-8 flex flex-1 flex-col items-center gap-3">
|
||||
<NavItem
|
||||
href="/dashboard"
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
active={pathname.startsWith("/dashboard")}
|
||||
/>
|
||||
<NavItem
|
||||
href="/"
|
||||
icon={CreditCard}
|
||||
label="Credit Cards"
|
||||
active={pathname === "/" || pathname.startsWith("/cards")}
|
||||
/>
|
||||
{currentUser.role === MemberRole.Admin ? (
|
||||
<NavItem
|
||||
href="/team"
|
||||
icon={Users}
|
||||
label="Team Management"
|
||||
active={pathname.startsWith("/team")}
|
||||
/>
|
||||
) : null}
|
||||
</nav>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{resetEnabled && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
aria-label="Reset demo state"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Reset demo state</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{glassAvailable && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGlass}
|
||||
aria-pressed={glassActive}
|
||||
aria-label="Glass Engine"
|
||||
className={cn(
|
||||
"hidden h-10 w-10 items-center justify-center rounded-2xl transition-all md:flex",
|
||||
glassActive
|
||||
? "brand-gradient text-surface shadow-[0_8px_18px_hsl(252_83%_60%/0.4)]"
|
||||
: "text-ink-muted hover:bg-brand-soft hover:text-brand-indigo",
|
||||
)}
|
||||
>
|
||||
<Telescope className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Glass Engine (advanced)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<UserNavigation
|
||||
availableUsers={users}
|
||||
currentUser={currentUser}
|
||||
onChangeUser={setCurrentUser}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Help"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Help & support</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-20 items-center justify-between px-6 md:px-10">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-ink-muted">
|
||||
{IDENTITY.brand}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-ink">
|
||||
Hello, {currentUser.name.split(" ")[0]}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto px-2 pb-6 md:px-6">
|
||||
{activeSurfaceId ? (
|
||||
<div className="flex h-full flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-hairline bg-surface px-3 py-1.5 text-sm text-ink shadow-soft"
|
||||
>
|
||||
← Back to {pathname.split("/")[1] || "dashboard"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ReportCanvas />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ href, icon: Icon, label, active = false }: NavItemProps) {
|
||||
// Narrate nav clicks into the recorder HUD — a no-op unless a workflow is
|
||||
// being recorded, so it only fires while the officer is demonstrating.
|
||||
const { logStep } = useRecording();
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => logStep(`Opened ${label}`)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative flex h-11 w-11 items-center justify-center rounded-2xl transition-all duration-200",
|
||||
active
|
||||
? "brand-gradient text-surface shadow-[0_8px_18px_hsl(252_83%_60%/0.4)]"
|
||||
: "text-ink-muted hover:bg-brand-soft hover:text-brand-indigo",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { PolicyException } from "@/app/api/v1/data";
|
||||
import {
|
||||
POLICY_EXCEPTION_CODES,
|
||||
isJustifying,
|
||||
labelForExceptionCode,
|
||||
} from "@/app/api/v1/policy-exception-codes";
|
||||
import { useRecording } from "@/components/recording-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// This card exists to CLEAR an over-limit charge, and only a justifying code
|
||||
// actually lifts the policy-limit gate server-side (see store.hasApprovedException
|
||||
// + isJustifying). Offering a non-justifying code here is a foot-gun: it files,
|
||||
// flips the row to "Cleared", but the subsequent approve is rejected 422. So we
|
||||
// only present the codes that can genuinely authorize the override.
|
||||
const JUSTIFYING_CODES = POLICY_EXCEPTION_CODES.filter((c) =>
|
||||
isJustifying(c.code),
|
||||
);
|
||||
|
||||
const DEFAULT_CODE = JUSTIFYING_CODES[0].code;
|
||||
|
||||
type ExceptionResult = {
|
||||
ok: boolean;
|
||||
data?: PolicyException;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
transactionId: string;
|
||||
openPolicyException: (args: {
|
||||
transactionId: string;
|
||||
code: string;
|
||||
}) => Promise<ExceptionResult>;
|
||||
finalizePolicyException: (args: {
|
||||
exceptionId: string;
|
||||
}) => Promise<ExceptionResult>;
|
||||
onFiled?: (code: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline "file a policy exception" card — the in-chat twin of
|
||||
* `PolicyExceptionModal`. Rendered as a chat-card (no `Dialog` chrome) so the
|
||||
* officer's whole demonstration happens right in the conversation: see the
|
||||
* over-limit symptom, file the exception, watch it get recorded. That's what
|
||||
* makes the recorded demonstration feel like "the agent watched me do it here."
|
||||
*
|
||||
* Behaviour is identical to the modal:
|
||||
* - shows the human label per code (codes persisted; the agent only ever sees
|
||||
* the code, never the label — the learning invariant),
|
||||
* - opens the exception via REST then immediately finalizes it.
|
||||
*
|
||||
* The submission is bracketed by `beginRecording()` / `endRecording()` so the
|
||||
* canvas recording vignette pulses while the demonstration is captured.
|
||||
*/
|
||||
export function PolicyExceptionInline(props: Props) {
|
||||
const [code, setCode] = useState<string>(DEFAULT_CODE);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [doneId, setDoneId] = useState<string | null>(null);
|
||||
|
||||
const { beginRecording, endRecording, noteDemonstratedCode, logStep } =
|
||||
useRecording();
|
||||
|
||||
const submitDisabled = busy || code === "";
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
beginRecording();
|
||||
try {
|
||||
const opened = await props.openPolicyException({
|
||||
transactionId: props.transactionId,
|
||||
code,
|
||||
});
|
||||
if (!opened.ok || !opened.data) {
|
||||
setError(opened.error ?? "Failed to open policy exception");
|
||||
return;
|
||||
}
|
||||
const exceptionId = opened.data.id;
|
||||
|
||||
const finalized = await props.finalizePolicyException({ exceptionId });
|
||||
if (!finalized.ok) {
|
||||
setError(finalized.error ?? "Failed to finalize policy exception");
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface the demonstrated code to the teach-mode context so the chat's
|
||||
// awaitDashboardDemonstration card can report it to the agent — the
|
||||
// demonstration happens on the dashboard, outside the chat HITL flow.
|
||||
noteDemonstratedCode(code);
|
||||
// Narrate the filing into the recorder HUD.
|
||||
logStep("Filed the policy exception");
|
||||
setDoneId(exceptionId);
|
||||
props.onFiled?.(code);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
endRecording();
|
||||
}
|
||||
};
|
||||
|
||||
if (doneId !== null) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2.5 rounded-2xl bg-positive-soft px-3.5 py-3 text-sm text-ink ring-1 ring-inset ring-positive/30">
|
||||
<CheckCircle2 className="size-5 flex-shrink-0 text-positive" />
|
||||
<span>
|
||||
Exception <span className="font-mono font-medium">{doneId}</span>{" "}
|
||||
filed ({labelForExceptionCode(code)}). You can approve now.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-ink">
|
||||
File a policy exception
|
||||
</p>
|
||||
<p className="text-xs text-ink-muted">
|
||||
This transaction is over its policy limit. File an exception to
|
||||
proceed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="exception-code-inline" className="text-xs">
|
||||
Code
|
||||
</Label>
|
||||
<Select value={code} onValueChange={setCode}>
|
||||
<SelectTrigger id="exception-code-inline">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{JUSTIFYING_CODES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
<span className="font-mono text-xs text-ink-muted">
|
||||
{c.code}
|
||||
</span>
|
||||
<span className="ml-2">{c.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitDisabled}
|
||||
className="flex-1"
|
||||
>
|
||||
{busy ? "Filing…" : "File exception"}
|
||||
</Button>
|
||||
{props.onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={props.onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error !== null ? (
|
||||
<p className="rounded-xl bg-negative-soft px-3.5 py-2.5 text-sm text-negative ring-1 ring-inset ring-negative/30">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PolicyExceptionInline;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user