updates on the ui changes and changed into doormile
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "krow-demo",
|
||||
"name": "doormile-console",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# The Doormile Express admin API.
|
||||
#
|
||||
# Leave this unset in production — the console defaults to the real backend,
|
||||
# https://api.doormile.com/api/v1, and every /admin/* call goes through it.
|
||||
# Set it only to point a local build at a staging or mock backend; a build
|
||||
# started with an override logs a warning saying so.
|
||||
#
|
||||
# VITE_DOORMILE_URL=https://api.doormile.com/api/v1
|
||||
|
||||
# OSRM routing, used to price an order by real driving distance. Falls back to
|
||||
# the public demo server, which is rate-limited and not for production traffic.
|
||||
VITE_OSRM_URL=https://router.project-osrm.org
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
#env
|
||||
.env
|
||||
.env.*
|
||||
# The template is meant to be committed — it documents the variables, never their values.
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
/logs
|
||||
|
||||
285
README.md
285
README.md
@@ -1,19 +1,20 @@
|
||||
# KROW Demo
|
||||
# Doormile Express Console
|
||||
|
||||
AI-powered hiring for event and hospitality staffing — a standalone demo build of KROW Forge.
|
||||
|
||||
Runs with no backend, no API keys and no real login. `npm install && npm run dev` and the app is fully
|
||||
populated.
|
||||
The operator console for the Doormile Express dispatch platform — orders, deliveries, riders,
|
||||
dispatch optimisation and reporting, talking to the live `api.doormile.com` admin API.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173 — opens on /admin/login
|
||||
cp .env.example .env.local # point it at the API you want
|
||||
npm run dev # http://localhost:5173 — opens on /login
|
||||
```
|
||||
|
||||
Production users are warehouse and dispatch staff, not end customers.
|
||||
|
||||
## Stack
|
||||
|
||||
Vite 6 · React 19 · React Router 6 · React Query 5 · Tailwind + shadcn/ui · Framer Motion ·
|
||||
Recharts + MUI X Charts · react-hot-toast.
|
||||
Vite 6 · React 19 · React Router 6 · React Query 5 · axios · Tailwind + shadcn/ui ·
|
||||
Framer Motion · Leaflet · dayjs · SheetJS · react-hot-toast.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -23,129 +24,165 @@ src/
|
||||
App.jsx providers + the entire route table
|
||||
index.css design tokens (HSL CSS variables) + utility layers
|
||||
|
||||
api/ the backend seam — see below
|
||||
base44Client.js the contract the production app talks to
|
||||
store.js in-memory entity store, mirrored to localStorage
|
||||
aiEngine.js local, deterministic InvokeLLM
|
||||
seed.js demo dataset
|
||||
attendanceSeed.js shift/attendance dataset
|
||||
api/doormile/ the backend seam — see below
|
||||
client.js axios instance, bearer token, 401 handling
|
||||
endpoints.js one function per /admin/* route
|
||||
queries.js the page-facing layer: joins, status mapping, solver calls
|
||||
notify.js the data layer's toast seam
|
||||
index.js the single import surface
|
||||
|
||||
lib/ domain logic and data hooks
|
||||
krowHooks.js React Query hooks over the entity store
|
||||
krowAi.js AI workflows
|
||||
krowScore.js score engine
|
||||
AuthContext.jsx auth provider
|
||||
admin/ admin session, permissions, position insights
|
||||
skills/ the Owliver skill system (registry, resolver, flows)
|
||||
agents/ the agent system (registry, runtime, lifecycle)
|
||||
|
||||
skills/ *.md skill definitions — loaded by import.meta.glob
|
||||
agents/ *.md agent definitions — loaded by import.meta.glob
|
||||
doormileHooks.js React Query hooks over the API — every page reads through here
|
||||
doormileFormat.js currency/number/distance formatting, search, xlsx export
|
||||
doormileTimestamp.js the IST wall-clock parse every date must go through
|
||||
batchBucket.js the Morning/Afternoon/Evening wave windows
|
||||
orderStatusGroups.js which raw booking enums make up each Orders tab
|
||||
dispatchPreview.js the optimiser preview's tree/flat-list model
|
||||
profitability.js revenue, cost and margin for a delivery day
|
||||
distance.js OSRM driving distance, Haversine fallback
|
||||
bulkOrderColumns.js the bulk-upload sheet's column map
|
||||
preferences.js per-browser display settings
|
||||
AuthContext.jsx the Doormile session
|
||||
|
||||
components/
|
||||
ui/ 23 shadcn primitives
|
||||
ds/ 30 design-system components (the app's real vocabulary)
|
||||
admin/ admin page shell + role glyphs
|
||||
krow/ domain components (+ talent/, proving/)
|
||||
charts/ MUI X Charts wrappers and theme
|
||||
agents/ agent configure / canvas / test panel
|
||||
forge/ KROW Forge skill + challenge components
|
||||
skills/ skill authoring surfaces
|
||||
ai-assistant/ Owliver — panel, routing, capabilities, history
|
||||
ui/ shadcn primitives
|
||||
ds/ the design system — the vocabulary pages are written in
|
||||
doormile/ console-specific pieces (ListToolbar, DispatchMap)
|
||||
|
||||
layouts/ AdminLayout — header nav, ⌘K search, assistant panel
|
||||
pages/ route components (admin/ holds the live product)
|
||||
hooks/ shared hooks
|
||||
assets/ brand asset references
|
||||
lib/assistant/ Doormile AI — the deterministic operations copilot
|
||||
intents.js the intent catalog: match → run → answer
|
||||
scan.js booking scans with honest truncation reporting
|
||||
vocab.js dates, statuses, batches, typo tolerance
|
||||
flows.js the three conversational writes + the confirm gate
|
||||
pageContext.js route → context label + suggested questions
|
||||
|
||||
layouts/ AdminLayout — header nav, ⌘K search, exception notifications, AI trigger
|
||||
pages/ Login + doormile/ (the console)
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
The app opens on **`/admin/login`**. Everything else lives under `/admin/*` behind two guards:
|
||||
`ProtectedRoute` (auth) and `AdminRoute` (admin session), rendered inside `AdminLayout`.
|
||||
The app opens on **`/login`**. Everything else lives under `/doormile/*` behind `ProtectedRoute`,
|
||||
rendered inside `AdminLayout`.
|
||||
|
||||
| Path | Page |
|
||||
| --- | --- |
|
||||
| `/admin/login` | Admin sign-in — outside the protected boundary |
|
||||
| `/admin` | Control Center |
|
||||
| `/admin/positions`, `positions/new`, `positions/:id` | Positions, Create, Detail |
|
||||
| `/admin/candidates`, `candidates/:id` | Candidates, Candidate Intelligence profile |
|
||||
| `/admin/hired` · `/admin/talent-pool` | Hired History, Talent Pool |
|
||||
| `/admin/university`, `university/:id` | KROW Forge, challenge detail |
|
||||
| `/admin/analytics` · `/admin/activity` | Analytics, Activity |
|
||||
| `/admin/profile` · `/admin/settings` | Profile, Settings |
|
||||
| `/admin/workspace` | Workspace hub |
|
||||
| `/admin/workspace/agents`, `agents/new`, `agents/:id` | Agent list, agent editor |
|
||||
| `/admin/workspace/skills`, `skills/new`, `skills/:id` | UI skill list and editor |
|
||||
| `/admin/workspace/skills/owliver/new`, `owliver/:id` | Owliver skill editor |
|
||||
| `/admin/workspace/skill-development` | Skill Development |
|
||||
| `/login` | Sign-in — outside the protected boundary |
|
||||
| `/doormile/dispatch` | The dispatch board — live map, rider detail, wave economics |
|
||||
| `/doormile/dispatch/preview` | The optimiser's plan — reconcile, then commit |
|
||||
| `/doormile/orders`, `orders/create`, `orders/createorders`, `orders/preview` | Orders, create one, bulk upload, assignment preview |
|
||||
| `/doormile/deliveries` | Deliveries |
|
||||
| `/doormile/riders`, `riders/create`, `riders/edit` | Riders, create, edit |
|
||||
| `/doormile/tenants` · `clients/create` | Clients, create |
|
||||
| `/doormile/customers` · `customer/create` | Customers, create |
|
||||
| `/doormile/pricing` | Per-client rate cards + quote simulator |
|
||||
| `/doormile/hubs` · `vehicles` · `tripsheets` · `exceptions` · `competitive-intel` · `app-users` | Fleet Ops |
|
||||
| `/doormile/reports/orderssummary`, `ordersdetails`, `riderssummary`, `profitability` | Reports |
|
||||
| `/doormile/profile` | Account and password |
|
||||
| `*` | `lib/PageNotFound.jsx` |
|
||||
|
||||
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
|
||||
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
|
||||
equivalent, id preserved. Static route segments are declared before dynamic ones so
|
||||
`workspace/agents/new` cannot be read as an agent whose id is `"new"`.
|
||||
|
||||
> **Note.** `App.jsx` still imports a set of pre-redesign Employer/Talent pages
|
||||
> (`Overview`, `Apply`, `WorkerProfile`, `KrowIdentity`, `EmployeeDashboard`, `DesignSystem`, …)
|
||||
> and `layouts/Layout.jsx`. None of them are mounted on a route — they are unreachable at runtime
|
||||
> and retained pending a decision on whether to re-route or remove them.
|
||||
Static route segments are declared before dynamic ones, so `orders/create` cannot be read as an
|
||||
order whose id is `"create"`.
|
||||
|
||||
## The backend seam
|
||||
|
||||
The production app talks to a Base44 backend through `src/api/base44Client.js`. This demo keeps that
|
||||
module path, export name and full method contract, and swaps only the transport:
|
||||
Everything goes through `src/api/doormile/`, which talks to `api.doormile.com/api/v1/admin/*`.
|
||||
|
||||
| Contract | Demo implementation |
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
|
||||
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.js` — local, deterministic |
|
||||
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
|
||||
| `base44.auth.*` | seeded employer/admin user, always signed in |
|
||||
| `client.js` | One axios instance. Attaches `Bearer localStorage.doormileToken` to every request; a 401 clears the session and returns to `/login`. Rejects with the server's own body, keeping the status on `err.httpStatus`. |
|
||||
| `endpoints.js` | One named export per route. Thin: build the URL, call, unwrap the envelope. GET/list return `response.data.data`; mutations return the whole `{ success, data \| message }`. |
|
||||
| `queries.js` | The page-facing layer — the joins, the status mapping, the AI-solver calls, and safe empty defaults for the resources this API has no equivalent for. |
|
||||
|
||||
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
|
||||
`lib/krowScore.js`, every page and component — is unchanged from the reference implementation.
|
||||
Pages do not import these directly. They use the React Query hooks in `lib/doormileHooks.js`, so a
|
||||
resource has exactly one cache key across the app and a write on one page invalidates the list
|
||||
another page is showing.
|
||||
|
||||
## The AI engine
|
||||
### What this API cannot do
|
||||
|
||||
Every AI workflow funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
|
||||
`api/aiEngine.js` reimplements it locally: it recognises each workflow by the phrase its prompt opens
|
||||
with, reads the structured fields the prompt already carries (`Years Experience: 6`,
|
||||
`Required Certifications: …`, the talent-pool JSON block) and scores them deterministically.
|
||||
Several things the previous backend served have no equivalent here. The functions covering them
|
||||
return safe empty defaults rather than throwing, so a page renders an empty state instead of
|
||||
crashing:
|
||||
|
||||
Nine workflows are implemented: candidate screening, job-description generation, résumé building,
|
||||
interview questions, interview evaluation, talent matching, Owliver's conversation, Career DNA
|
||||
building and Proving Ground evaluation. Results respond to the data rather than being canned — the
|
||||
same input always returns the same output.
|
||||
- **Zones** are not a resource. `applocationid` exists only as a field on a hub, so every zone
|
||||
picker is derived from the distinct cities across `GET /admin/hubs`.
|
||||
- **Rider substitution** has no endpoint at all, so the console has no substitution surface.
|
||||
- **Invoices and expense approvals** have no endpoint.
|
||||
- **Payment-mode types, item subcategories, rider shifts and vehicle types** are not enumerable.
|
||||
|
||||
## Skills and agents
|
||||
The AI dispatch optimiser (`routes.workolik.com` / `routemate.workolik.com`) is a separate solver
|
||||
service with no equivalent in the admin API, and is called directly.
|
||||
|
||||
Two registries discover their definitions from markdown at build time:
|
||||
## Doormile AI
|
||||
|
||||
| Registry | Glob | Files |
|
||||
| --- | --- | --- |
|
||||
| `lib/skills/registry.js` | `import.meta.glob('/src/skills/**/*.md')` | 18 Owliver skills, 5 workforce training skills |
|
||||
| `lib/agents/registry.js` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
||||
An in-console copilot, opened from the header — not a route. It answers questions about live data
|
||||
by calling **the same endpoints the pages call**, so its numbers and the screen's cannot disagree.
|
||||
|
||||
**These globs are absolute paths.** Moving or renaming `src/skills/` or `src/agents/` makes the
|
||||
registry silently return nothing — no build error, no import failure, just an empty registry. Add
|
||||
definitions by dropping a new `.md` file into the right folder; nothing else needs to change.
|
||||
**It is deterministic, not an LLM.** This app has no backend of its own, so there is nowhere to hold
|
||||
a model key that would not ship to the browser. The trade was taken deliberately: a rich matcher
|
||||
over typed API calls, rather than a generated answer nobody can verify.
|
||||
|
||||
## Demo data
|
||||
- **Never fabricate a number.** A question no API function covers is left unanswered. A wrong number
|
||||
from this bot is worse than no answer.
|
||||
- **Every answer carries its sources** behind a disclosure — which endpoint ran, and what came back.
|
||||
That is the verifiability contract; do not remove it.
|
||||
- **A truncated scan is a floor, not a total.** `/admin/bookings` caps `pagesize` at 1000, so scans
|
||||
drain pages up to a budget and report `truncated`. Counts built on one say "at least", and the
|
||||
source entry reports an error rather than a green tick.
|
||||
- **Writes are gated.** The three creates — customer, order, bulk — are conversations, one question
|
||||
per turn. The bot gathers, shows exactly what will be sent, and the mutation fires only when the
|
||||
operator presses the button. `executeFlow` is the only mutating function and no `match` calls it.
|
||||
- **A flow reply never reaches the router.** The panel intercepts it first: the router matches text,
|
||||
and a bare answer like a phone number matches no intent.
|
||||
- **Ordering is load-bearing.** Domain intents (rider, tenant, fleet) sit ahead of the generic
|
||||
order/status ones, and the generic ones additionally guard against another domain's keywords. A
|
||||
bug shipped once where "how many riders are active" was swallowed by the order-status intent,
|
||||
because "active" is a valid order status.
|
||||
|
||||
Seeded in `src/api/seed.js` — positions, applicants, AI-scored candidates, interviews, hires,
|
||||
talent-pool profiles, Proving Ground courses and an activity log; shift data in
|
||||
`src/api/attendanceSeed.js`.
|
||||
## Dispatch is a monitoring board
|
||||
|
||||
Edits persist to `localStorage`. To restore the shipped dataset:
|
||||
It reads the deliveries feed, which by construction returns only bookings that **already carry a
|
||||
rider or a consignment** — so an unassigned order cannot appear on it. Assignment therefore lives on
|
||||
Orders, which reads raw bookings and can see the pending pile. Running the optimiser from Dispatch
|
||||
would re-send work that is already assigned; the source console never did either.
|
||||
|
||||
```js
|
||||
// in the browser console
|
||||
localStorage.removeItem('krow_demo_db'); location.reload();
|
||||
```
|
||||
Selecting a rider opens their day: live position with a reverse-geocoded area and ping history, the
|
||||
plan against their GPS trail, and what the day earned. Per-stop actual distance is apportioned from
|
||||
the trail by each stop's share of the plan — the feed carries no per-stop odometer, so it is an
|
||||
estimate and the panel says so.
|
||||
|
||||
Bumping `STORAGE_VERSION` in `src/api/store.js` (currently `8`) invalidates saved snapshots
|
||||
automatically, so changing the seed does not require a manual clear.
|
||||
## Things that look wrong but are not
|
||||
|
||||
- **A delivery can read "Active" while the booking says `Converted_To_Consignment`.** A booking's
|
||||
status freezes the moment it is picked up; the lifecycle continues on the consignment. The
|
||||
Deliveries table lets the consignment's status win and labels the row so you can tell where the
|
||||
value came from.
|
||||
- **The Picked tab is empty on hyperlocal traffic.** A matching 3-digit pickup/delivery pincode
|
||||
prefix goes straight to `Out_for_Delivery` at pickup, so those parcels never rest in "picked".
|
||||
- **Update Status refuses half its own options.** The write is consignment-scoped, and Pending,
|
||||
Accepted, Arrived, Picked and Skipped describe states a consignment cannot be in. Those are
|
||||
refused with the reason rather than guessed at.
|
||||
- **Assigned means different things on Orders and Deliveries.** Orders tracks the *operator's*
|
||||
workflow, so assigning a rider counts as Assigned. Deliveries tracks the *rider's* engagement, so
|
||||
it waits for the rider to accept. This is deliberate, and the two taxonomies live in
|
||||
`orderStatusGroups.js` and `queries.js` respectively.
|
||||
|
||||
## Batches, dates and the one rule about time
|
||||
|
||||
Waves bucket on a booking's **creation time** (`orderdate`), never on `assigntime` — which is mapped
|
||||
to the backend's last-modified column and is re-stamped by any status change — and never on
|
||||
`expecteddeliverytime`, which is the promised slot rather than the wave the order was placed in.
|
||||
|
||||
`GET /admin/bookings` takes no date parameter, so the range is applied client-side on that same
|
||||
field. **Filtering on one timestamp while bucketing on another cannot produce an honest count**, and
|
||||
both have been tried.
|
||||
|
||||
The windows in `lib/batchBucket.js` cover the whole clock, and that is load-bearing: earlier windows
|
||||
left 40% of the day in a gap where an order belonged to no wave and vanished from every filter.
|
||||
|
||||
Every timestamp goes through `parseDoormileTimestamp`. Doormile stores IST wall-clock in naive
|
||||
columns and some responses carry a false trailing `Z`; parsing that as UTC shifts a row by five and
|
||||
a half hours, and therefore into the wrong day and the wrong wave.
|
||||
|
||||
## Design system
|
||||
|
||||
@@ -153,56 +190,40 @@ Tokens live in `src/index.css` as HSL CSS variables consumed by `tailwind.config
|
||||
|
||||
| Token | Value |
|
||||
| --- | --- |
|
||||
| Primary — KROW Blue | `#0A39DF` |
|
||||
| Accent — KROW Yellow | `#F9E547` |
|
||||
| Pale Yellow | `#F8E08E` |
|
||||
| Primary — Doormile red | `#C8102E` |
|
||||
| Accent | `#F9E547` |
|
||||
| Dark Navy | `#333F48` |
|
||||
| Mint | `#D1E0D7` |
|
||||
| Radius | `0.75rem` |
|
||||
| Headings | Sora |
|
||||
| Body | Inter |
|
||||
|
||||
`src/components/ds/` is the vocabulary the app actually writes in — `Surface`, `KpiCard`,
|
||||
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.js`.
|
||||
`src/components/ui/` holds the shadcn primitives those are built on. Surfaces use `glass` and
|
||||
`glass-card`; the page background is the `gradient-mesh` utility.
|
||||
`src/components/ds/` is the vocabulary the app writes in — `Surface`, `KpiCard`, `DataTable`,
|
||||
`PageHeader`, `Modal`, `StatusBadge`, `toast` and the rest, exported through `ds/index.js`.
|
||||
`src/components/ui/` holds the shadcn primitives those are built on.
|
||||
|
||||
Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<HotToaster>` in
|
||||
`App.jsx`.
|
||||
`StatusBadge`'s `STATUS_MAP` is the only place a lifecycle status maps to a colour and a label.
|
||||
Statuses are looked up case-insensitively, and an unmapped value renders as a neutral badge showing
|
||||
the humanised raw string — so a missing entry is visible rather than silent. Add states there, never
|
||||
in a page-local table.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `VITE_DOORMILE_URL` | The admin API. Defaults to `https://api.doormile.com/api/v1`. |
|
||||
| `VITE_OSRM_URL` | Routing for order pricing. Defaults to the public OSRM demo server, which is rate-limited and not for production. |
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Dev server (honors `PORT`, default 5173) |
|
||||
| `npm run dev` | Dev server (honours `PORT`, default 5173) |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run preview` | Serve the build |
|
||||
| `npm run lint` | ESLint — currently clean |
|
||||
| `npm run lint:fix` | ESLint with `--fix` |
|
||||
| `npm test` | `scripts/skill-check.mjs` — 835 assertions over the skill and agent systems |
|
||||
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
|
||||
|
||||
### The test harness
|
||||
|
||||
`npm test` boots a real Vite dev server in middleware mode and `ssrLoadModule`s the actual
|
||||
application modules, so it exercises the real `@/` alias and `import.meta.glob` pipeline rather than
|
||||
mocks. It also diffs Owliver's behaviour across eleven page contexts against a committed snapshot,
|
||||
`scripts/__baseline__/owliver-baseline.json`; regenerate that deliberately with
|
||||
`node scripts/owliver-baseline.mjs --write`, never to turn a red check green.
|
||||
|
||||
Because it loads modules by absolute path, the harness pins the locations of `src/api/`, `src/lib/`,
|
||||
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.jsx`,
|
||||
`src/layouts/AdminLayout.jsx` and a handful of agent files. Move any of them and update
|
||||
`scripts/owliver-capture.mjs` and `scripts/skill-check.mjs` in the same change.
|
||||
|
||||
### Known-failing checks
|
||||
|
||||
Two checks fail on a clean checkout and are tracked as separate work, not regressions:
|
||||
|
||||
- `npm test` — 834/835 pass; `the seeded overtime climb is found` fails.
|
||||
- `npm run typecheck` — 59 errors, all pre-existing JSDoc/inference gaps in `lib/krowAi.js`,
|
||||
`lib/skills/*`, `lib/positionModel.js` and a few components.
|
||||
|
||||
## Deployment
|
||||
|
||||
`Dockerfile` builds with `node:22-alpine` (capped at 1 GB heap for small hosts) and serves the
|
||||
|
||||
31
fix_imports.cjs
Normal file
31
fix_imports.cjs
Normal file
@@ -0,0 +1,31 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dir = 'src/lib/assistant';
|
||||
fs.readdirSync(dir).filter(f => f.endsWith('.js')).forEach(f => {
|
||||
const p = path.join(dir, f);
|
||||
let content = fs.readFileSync(p, 'utf8');
|
||||
let changed = false;
|
||||
content = content.replace(/from\s+['"]utils\/([^'"]+)['"]/g, (m, p1) => {
|
||||
changed = true;
|
||||
return `from '@/lib/${p1}'`;
|
||||
});
|
||||
content = content.replace(/from\s+['"]pages\/([^'"]+)['"]/g, (m, p1) => {
|
||||
changed = true;
|
||||
return `from '@/${p1}'`;
|
||||
});
|
||||
content = content.replace(/from\s+['"]components\/nearle_components\/([^'"]+)['"]/g, (m, p1) => {
|
||||
changed = true;
|
||||
return `from '@/components/doormile/${p1}'`;
|
||||
});
|
||||
content = content.replace(/from\s+['"]components\/([^'"]+)['"]/g, (m, p1) => {
|
||||
changed = true;
|
||||
return `from '@/components/${p1}'`;
|
||||
});
|
||||
content = content.replace(/from\s+['"]hooks\/([^'"]+)['"]/g, (m, p1) => {
|
||||
changed = true;
|
||||
return `from '@/hooks/${p1}'`;
|
||||
});
|
||||
if (changed) {
|
||||
fs.writeFileSync(p, content);
|
||||
}
|
||||
});
|
||||
@@ -2,13 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/doormile-mark-cropped.png?v=2" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png?v=2" />
|
||||
<link rel="apple-touch-icon" href="/doormile-mark-cropped.png?v=2" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
<title>KROW Forge — AI-Powered Hiring</title>
|
||||
<meta name="description" content="KROW replaces quizzes with real AI voice interviews. Hire ready talent in minutes, not weeks." />
|
||||
<title>Doormile Express Console</title>
|
||||
<meta name="description" content="Operator console for Doormile Express — orders, deliveries, riders, dispatch and reporting." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
2312
package-lock.json
generated
2312
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "krow-demo",
|
||||
"name": "doormile-console",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -8,16 +8,12 @@
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node scripts/skill-check.mjs",
|
||||
"typecheck": "tsc -p ./jsconfig.json",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astryxdesign/core": "^0.3.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/material": "^9.3.1",
|
||||
"@mui/x-charts": "^9.11.1",
|
||||
"@astryxdesign/core": "^0.4.7",
|
||||
"@astryxdesign/theme-neutral": "^0.4.7",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
@@ -32,19 +28,26 @@
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"axios": "^1.19.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.23",
|
||||
"framer-motion": "^11.16.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.475.0",
|
||||
"papaparse": "^5.6.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.8",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
|
||||
31
patchImports.js
Normal file
31
patchImports.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dir = path.join(process.cwd(), 'src/components/assistant/DoormileAI');
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsx') && f !== 'shim.jsx');
|
||||
|
||||
for (const file of files) {
|
||||
const filepath = path.join(dir, file);
|
||||
let content = fs.readFileSync(filepath, 'utf8');
|
||||
|
||||
// Replace @astryxdesign/core imports with shim
|
||||
content = content.replace(/import\s+{\s*([^}]+)\s*}\s+from\s+'@astryxdesign\/core[^']*';/g, "import { $1 } from './shim';");
|
||||
|
||||
// Fix intents, flows, etc imports to point to src/lib/assistant/
|
||||
// The original were: import { something } from '../intents';
|
||||
// Now it should be: import { something } from '@/lib/assistant/intents';
|
||||
content = content.replace(/from\s+'\.\.\/(intents|actions|assignActions|bulkOrderActions|customerFlow|orderFlow|bulkFlow|assignFlow|repeatFlow|repeatRuns)'/g, "from '@/lib/assistant/$1'");
|
||||
|
||||
// Replace OpenToast with toast
|
||||
content = content.replace(/import\s+{\s*OpenToast\s*}\s*from\s*'components\/third-party\/OpenToast';/, "import { toast } from '@/components/ui/use-toast';\nconst OpenToast = ({message, tone}) => toast({description: message, variant: tone === 'error' ? 'destructive' : 'default'});");
|
||||
|
||||
// STATUS is from themes/dt/tokens
|
||||
content = content.replace(/import\s+{\s*STATUS\s*}\s*from\s*'themes\/dt\/tokens';/, "const STATUS = { error: 'error', success: 'success', warning: 'warning', info: 'info', muted: 'muted' };");
|
||||
|
||||
// A few icons from @ant-design/icons to lucide-react
|
||||
content = content.replace(/import\s+{\s*CloseOutlined,\s*MoreOutlined\s*}\s*from\s*'@ant-design\/icons';/, "import { X as CloseOutlined, MoreHorizontal as MoreOutlined } from 'lucide-react';");
|
||||
content = content.replace(/import\s+{\s*([A-Za-z]+Outlined)\s*}\s*from\s*'@ant-design\/icons';/g, "import { $1 } from 'lucide-react';"); // close enough for now
|
||||
|
||||
fs.writeFileSync(filepath, content, 'utf8');
|
||||
}
|
||||
console.log('Patch complete');
|
||||
BIN
public/doormile-logo.png
Normal file
BIN
public/doormile-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
public/doormile-mark-cropped.png
Normal file
BIN
public/doormile-mark-cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
public/doormile-mark.png
Normal file
BIN
public/doormile-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 69 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Writes the Owliver behaviour baseline.
|
||||
*
|
||||
* node scripts/owliver-baseline.mjs # report drift, write nothing
|
||||
* node scripts/owliver-baseline.mjs --write # (re)generate the snapshot
|
||||
*
|
||||
* The snapshot is the existing product's behaviour, recorded before the Agent
|
||||
* layer was built and asserted by `skill-check.mjs` on every run afterwards.
|
||||
*
|
||||
* Regenerating is a deliberate act with a reason attached. A baseline rewritten
|
||||
* to make a red check go green records the regression instead of catching it,
|
||||
* which is worse than having no baseline at all — it converts a caught bug into
|
||||
* a documented one.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { createServer } from 'vite';
|
||||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
const server = await createServer({
|
||||
root: ROOT,
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
logLevel: 'error',
|
||||
});
|
||||
|
||||
const captured = await captureBaseline(server);
|
||||
await server.close();
|
||||
|
||||
const serialized = `${JSON.stringify(captured, null, 2)}\n`;
|
||||
|
||||
if (process.argv.includes('--write')) {
|
||||
mkdirSync(dirname(BASELINE_PATH), { recursive: true });
|
||||
writeFileSync(BASELINE_PATH, serialized);
|
||||
const pages = Object.keys(captured.contexts).length;
|
||||
console.log(`Baseline written — ${pages} contexts, ${captured.skillIds.length} skills, ${Object.keys(captured.routes).length} routes.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(BASELINE_PATH)) {
|
||||
console.error('No baseline recorded. Run `node scripts/owliver-baseline.mjs --write`.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (readFileSync(BASELINE_PATH, 'utf8') === serialized) {
|
||||
console.log('Owliver behaviour matches the baseline.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error('Owliver behaviour has DRIFTED from the baseline. Run `npm test` for the per-page detail.');
|
||||
process.exit(1);
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* Today's Owliver behaviour, captured as data.
|
||||
*
|
||||
* The Agent layer is additive: opening a Krow page must produce exactly the
|
||||
* Owliver it produces now. That is a claim about eleven page contexts, their
|
||||
* skills, their suggestions, their prompts and their intent routing — far more
|
||||
* than anyone can hold in their head while editing, and all of it silent when
|
||||
* it breaks. So it is recorded here as a value, committed, and compared on
|
||||
* every run.
|
||||
*
|
||||
* One module, two callers: `owliver-baseline.mjs` writes the snapshot and
|
||||
* `skill-check.mjs` asserts against it. A second implementation of "what we
|
||||
* measure" would be a second definition of the contract.
|
||||
*
|
||||
* Everything below is pinned. `TODAY` is fixed rather than `new Date()`,
|
||||
* because `buildFacts` windows records by date and a baseline that drifts
|
||||
* daily is not a baseline. Lists are sorted; nothing depends on registry
|
||||
* ordering or on the clock.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** Where the committed snapshot lives. Named here rather than in the CLI so a
|
||||
* reader can import the path without also running a Vite server. */
|
||||
export const BASELINE_PATH = join(process.cwd(), 'scripts/__baseline__/owliver-baseline.json');
|
||||
|
||||
/** Fixed so the snapshot means the same thing tomorrow. */
|
||||
export const TODAY = new Date('2026-08-20T09:00:00.000Z');
|
||||
|
||||
/**
|
||||
* The contexts under contract.
|
||||
*
|
||||
* The eight Krow pages the brief protects, plus the three further Admin
|
||||
* contexts the placement table carries — Create Position, Candidates Analysis
|
||||
* and Profile. They are page-scoped by exactly the same mechanism, so leaving
|
||||
* them out would protect most of the boundary and quietly not the rest.
|
||||
*/
|
||||
export const CONTEXTS = [
|
||||
'admin.controlCenter',
|
||||
'admin.positions',
|
||||
'admin.createPosition',
|
||||
'admin.candidatesList',
|
||||
'admin.candidates',
|
||||
'admin.hiredHistory',
|
||||
'admin.talentPool',
|
||||
'admin.forge',
|
||||
'admin.analytics',
|
||||
'admin.activity',
|
||||
'admin.profile',
|
||||
];
|
||||
|
||||
/**
|
||||
* What gets asked, per context.
|
||||
*
|
||||
* Three kinds on purpose, because they exercise three different branches of
|
||||
* `resolveIntent` and a change to any one of them is a regression a reader
|
||||
* would notice: a question the page owns, a question another page owns (which
|
||||
* must route, not answer), and a question nothing owns (which must decline).
|
||||
*/
|
||||
export const QUESTIONS = [
|
||||
'What needs my attention?',
|
||||
'Summarize this page',
|
||||
'Show hiring activity',
|
||||
'Which positions need attention?',
|
||||
'Where are candidates dropping off?',
|
||||
'What are my permissions?',
|
||||
'What is the weather today?',
|
||||
];
|
||||
|
||||
const sorted = (list) => [...list].sort();
|
||||
|
||||
/** Block types only — structure is the contract, wording is not. */
|
||||
const docShape = (document) =>
|
||||
Array.isArray(document?.blocks) ? document.blocks.map((b) => b?.type ?? null) : null;
|
||||
|
||||
/**
|
||||
* Loads the real module graph and reads today's behaviour off it.
|
||||
*
|
||||
* `ssrLoadModule` rather than a mock, for the reason `skill-check.mjs` gives:
|
||||
* the pipeline is what is under test, and a mock of it would reproduce none of
|
||||
* the failures this exists to catch.
|
||||
*/
|
||||
export async function captureBaseline(server) {
|
||||
const registry = await server.ssrLoadModule('/src/lib/skills/registry.js');
|
||||
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
|
||||
const resolver = await server.ssrLoadModule('/src/lib/skills/owliverResolver.js');
|
||||
const dynamic = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.js');
|
||||
const routing = await server.ssrLoadModule('/src/components/ai-assistant/routing.js');
|
||||
const insights = await server.ssrLoadModule('/src/components/ai-assistant/insights.js');
|
||||
const seed = await server.ssrLoadModule('/src/api/seed.js');
|
||||
|
||||
/* The fact sheet the panel builds, from the shipped seed at a fixed instant. */
|
||||
const facts = insights.buildFacts({
|
||||
applications: seed.seedData.JobApplication,
|
||||
postings: seed.seedData.JobPosting,
|
||||
interviews: seed.seedData.AIInterview,
|
||||
staff: seed.seedData.Staff,
|
||||
profiles: seed.seedData.WorkerProfile,
|
||||
activity: seed.seedData.UserActivity,
|
||||
courses: seed.seedData.Course,
|
||||
profile: null,
|
||||
user: seed.DEMO_USER,
|
||||
today: TODAY,
|
||||
});
|
||||
|
||||
const contexts = {};
|
||||
for (const contextId of CONTEXTS) {
|
||||
const pageKey = registry.pageKeyForContext(contextId);
|
||||
|
||||
contexts[contextId] = {
|
||||
pageKey,
|
||||
route: pageKey ? registry.routeForPageKey(pageKey) : null,
|
||||
|
||||
/* The page boundary itself: which skills this page carries with no
|
||||
agent, no account customization and nothing disabled. */
|
||||
skills: sorted(registry.skillsForContext(contextId, [], []).map((s) => s.id)),
|
||||
|
||||
suggestions: sorted(
|
||||
resolver.owliverSuggestions(contextId, [], [], {}).map((s) => s.label)
|
||||
),
|
||||
|
||||
prompts: (dynamic.buildPrompts(contextId, facts, null) || []).map((p) => p.label),
|
||||
|
||||
intents: QUESTIONS.map((question) => {
|
||||
const matched = registry.matchSkill(question, contextId, [], []);
|
||||
const intent = routing.resolveIntent({ question, contextId });
|
||||
return {
|
||||
question,
|
||||
matchedSkill: matched?.id ?? null,
|
||||
kind: intent?.kind ?? null,
|
||||
skill: intent?.skill?.id ?? null,
|
||||
capability: intent?.capability ?? null,
|
||||
action: intent?.action?.name ?? null,
|
||||
destination: intent?.destination?.contextId ?? null,
|
||||
doc: docShape(intent?.doc),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/* Route → context, for every placement the product declares. An agent must
|
||||
never become an input to this. */
|
||||
const routes = {};
|
||||
for (const route of Object.keys(placement.PLACEMENT_ROUTES).sort()) {
|
||||
routes[route] = placement.resolveAssistantContext('admin', route)?.id ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
/* Bumped only when the shape of what we measure changes — never to make a
|
||||
failing comparison pass. */
|
||||
schema: 1,
|
||||
today: TODAY.toISOString(),
|
||||
skillIds: sorted(registry.SKILLS.map((s) => s.id)),
|
||||
diagnostics: registry.readSkillRegistry([]).diagnostics,
|
||||
routes,
|
||||
contexts,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
---
|
||||
id: board-invalid-context
|
||||
name: Board (invalid context)
|
||||
description: Negative test — a position-scoped source on a placement that supplies no position.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: position.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board (invalid context)
|
||||
|
||||
## Purpose
|
||||
|
||||
Deliberately wrong, and kept so the refusal stays tested. `position.activity`
|
||||
needs one position to read; `after-position-list-summary` renders once above the
|
||||
grid with no position in context. Saving this must be refused with a message that
|
||||
names both halves of the mismatch.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
id: board
|
||||
name: Board
|
||||
description: Helps Owliver understand, analyze, and act on the current task board.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board
|
||||
|
||||
## Purpose
|
||||
|
||||
Helps Owliver understand the current task board by summarizing relevant work activity, identifying items that need attention, and explaining the current status of tasks shown on the board.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Summarize relevant activity from the current board.
|
||||
- Identify tasks or work items that need attention.
|
||||
- Explain the current status of tasks and work items.
|
||||
- Highlight recent activity and meaningful changes.
|
||||
- Help Owliver analyze board activity using the available board data.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
id: board-position-card
|
||||
name: Board (per position)
|
||||
description: Control test — the same Board card, rendered inside every position card.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: grid-card
|
||||
title: Board
|
||||
source: position.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board (per position)
|
||||
|
||||
## Purpose
|
||||
|
||||
The position-aware control for the page-level Board card. `grid-card` resolves to
|
||||
`after-position-card`, which renders inside each position card and supplies that
|
||||
position as context — so `position.activity` reads and the card is drawn once per
|
||||
role rather than once for the page.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Summarize activity for the position the card belongs to.
|
||||
- Show that activity across today, the last 7 days and the previous month.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
id: board-ui-test
|
||||
name: Board
|
||||
description: Renders the Board card once above the position list on the Positions page.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board
|
||||
|
||||
## Purpose
|
||||
|
||||
The page half of the Board, and only that half. One card, above the position
|
||||
grid, reading activity across the workspace.
|
||||
|
||||
`after-position-list-summary` renders once for the page and supplies no
|
||||
position — `surfaces.js` records that placement as providing nothing — so the
|
||||
card reads `candidates.activity`, which declares `context: null` and counts
|
||||
applications across the workspace over the periods named here.
|
||||
`position.activity` is refused at this placement by `unresolvableSections`, and
|
||||
correctly: it needs one position, and this placement has none to give.
|
||||
|
||||
This definition declares no `owliver:` block, so it contributes no capability,
|
||||
no suggestion chip and no conversational answer. It is a page extension and
|
||||
nothing else.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Show workspace application activity above the position list.
|
||||
- Compare today, the last 7 days and the previous month in one card.
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
id: owliver-conversation-test
|
||||
name: Workspace Activity Assistant
|
||||
description: Answer questions about applications across the workspace and the roles still open.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
triggers:
|
||||
- workspace applications
|
||||
- applications across the workspace
|
||||
- roles still open
|
||||
- roles needing attention
|
||||
owliver:
|
||||
enabled: true
|
||||
# Every suggestion names the capability it asks for, so a chip and an answer
|
||||
# are one-to-one rather than both falling through to the first declared
|
||||
# capability.
|
||||
suggestions:
|
||||
- label: Summarize workspace applications
|
||||
capability: summary
|
||||
- label: List the roles still open
|
||||
capability: list
|
||||
capabilities:
|
||||
- summary
|
||||
- list
|
||||
responses:
|
||||
summary:
|
||||
title: Workspace Applications
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
list:
|
||||
title: Roles Still Open
|
||||
source: positions.demand
|
||||
limit: 5
|
||||
---
|
||||
|
||||
# Workspace Activity Assistant
|
||||
|
||||
## Purpose
|
||||
|
||||
The panel half, and only that half. No `ui:` block, so nothing is drawn on the
|
||||
Positions page and no card appears above or inside the grid.
|
||||
|
||||
Each response names its own `source:` directly, which is what a definition
|
||||
without a `ui:` section must do — `normalizeSkillOwliver` inherits a source from
|
||||
the first `ui:` section only when one exists, and reports
|
||||
`owliver.responses.<capability>: a response needs a source, or a ui: section to
|
||||
read from` when neither is present.
|
||||
|
||||
Both sources declare `context: null`, so neither answer asks which position is
|
||||
meant: `resolveEntity` returns immediately and no
|
||||
"This section needs a position to read" is possible. The shapes match what each
|
||||
source offers — `summary` is prose and is exempt from the shape check, and
|
||||
`list` is a shape `positions.demand` declares.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Summarize applications across the workspace over today, the last 7 days and
|
||||
the previous month.
|
||||
- List the open positions and how many applicants each has.
|
||||
236
src/App.jsx
236
src/App.jsx
@@ -1,67 +1,59 @@
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { Toaster as HotToaster } from 'react-hot-toast';
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { queryClientInstance } from '@/lib/query-client'
|
||||
import { Navigate, BrowserRouter as Router, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClientInstance } from '@/lib/query-client';
|
||||
import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import PageNotFound from './lib/PageNotFound';
|
||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import Layout from '@/layouts/Layout';
|
||||
import Overview from '@/pages/Overview';
|
||||
import Positions from '@/pages/Positions';
|
||||
import PositionDetail from '@/pages/PositionDetail';
|
||||
import Candidates from '@/pages/Candidates';
|
||||
import HiredHistory from '@/pages/HiredHistory';
|
||||
import TalentPool from '@/pages/TalentPool';
|
||||
import Apply from '@/pages/Apply';
|
||||
import UserTracking from '@/pages/UserTracking';
|
||||
import Analytics from '@/pages/Analytics';
|
||||
import CreatePosition from '@/pages/CreatePosition';
|
||||
import Profile from '@/pages/Profile';
|
||||
import University from '@/pages/University';
|
||||
import CourseDetail from '@/pages/CourseDetail';
|
||||
import WorkerProfile from '@/pages/WorkerProfile';
|
||||
import KrowIdentity from '@/pages/KrowIdentity';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
import EmployeeDashboard from '@/pages/EmployeeDashboard';
|
||||
import DesignSystem from '@/pages/DesignSystem';
|
||||
|
||||
/* Admin product — its own layout and route tree, so the Admin redesign can
|
||||
diverge without touching the Employer or Talent experiences. */
|
||||
import AdminLayout from '@/layouts/AdminLayout';
|
||||
import ControlCenter from '@/pages/admin/ControlCenter';
|
||||
import AdminPositions from '@/pages/admin/Positions';
|
||||
import AdminCandidates from '@/pages/admin/Candidates';
|
||||
import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
|
||||
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
|
||||
import AdminHiredHistory from '@/pages/admin/HiredHistory';
|
||||
import AdminTalentPool from '@/pages/admin/TalentPool';
|
||||
import AdminAnalytics from '@/pages/admin/Analytics';
|
||||
import AdminActivity from '@/pages/admin/Activity';
|
||||
import AdminProfile from '@/pages/admin/Profile';
|
||||
import AdminSettings from '@/pages/admin/Settings';
|
||||
import AdminWorkspace from '@/pages/admin/Workspace';
|
||||
import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills';
|
||||
import AdminWorkspaceAgents from '@/pages/admin/WorkspaceAgents';
|
||||
import AdminAgentDetail from '@/pages/admin/AgentDetail';
|
||||
import AdminSkillEditor from '@/pages/admin/SkillEditor';
|
||||
import AdminOwliverSkillEditor from '@/pages/admin/OwliverSkillEditor';
|
||||
import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment';
|
||||
import AdminLogin from '@/pages/admin/Login';
|
||||
import AdminRoute from '@/pages/admin/AdminRoute';
|
||||
import Login from '@/pages/Login';
|
||||
|
||||
/** `/university/:id` → the same challenge inside the Admin shell, id preserved. */
|
||||
const LegacyCourseRedirect = () => {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/admin/university/${id}`} replace />;
|
||||
};
|
||||
/* Every console page is code-split. The Dispatch board alone pulls the map,
|
||||
the solver payload builders and the comparison panels; loading that on the
|
||||
way to the Hubs table would make every first paint pay for it. */
|
||||
const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
|
||||
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx'));
|
||||
|
||||
/** `/positions/:id` → the same position inside the Admin shell, id preserved. */
|
||||
const LegacyPositionRedirect = () => {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/admin/positions/${id}`} replace />;
|
||||
};
|
||||
const Orders = lazy(() => import('@/pages/doormile/orders/Orders'));
|
||||
const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview'));
|
||||
const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder'));
|
||||
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
|
||||
|
||||
const Deliveries = lazy(() => import('@/pages/doormile/deliveries/Deliveries'));
|
||||
|
||||
const Tenants = lazy(() => import('@/pages/doormile/clients/Tenants'));
|
||||
const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient'));
|
||||
const CreateCustomer = lazy(() => import('@/pages/doormile/clients/CreateCustomer'));
|
||||
|
||||
const Pricing = lazy(() => import('@/pages/doormile/pricing/Pricing'));
|
||||
|
||||
const Riders = lazy(() => import('@/pages/doormile/riders/Riders'));
|
||||
const CreateRider = lazy(() => import('@/pages/doormile/riders/CreateRider'));
|
||||
const EditRider = lazy(() => import('@/pages/doormile/riders/EditRider'));
|
||||
|
||||
const Customers = lazy(() => import('@/pages/doormile/customers/Customers'));
|
||||
const Hubs = lazy(() => import('@/pages/doormile/hubs/Hubs'));
|
||||
const Vehicles = lazy(() => import('@/pages/doormile/vehicles/Vehicles'));
|
||||
const AppUsers = lazy(() => import('@/pages/doormile/appUsers/AppUsers'));
|
||||
const Tripsheets = lazy(() => import('@/pages/doormile/tripsheets/Tripsheets'));
|
||||
const Exceptions = lazy(() => import('@/pages/doormile/exceptions/Exceptions'));
|
||||
const CompetitiveIntel = lazy(() => import('@/pages/doormile/competitiveIntel/CompetitiveIntel'));
|
||||
|
||||
const OrdersSummary = lazy(() => import('@/pages/doormile/reports/OrdersSummary'));
|
||||
const OrdersDetails = lazy(() => import('@/pages/doormile/reports/OrdersDetails'));
|
||||
const RidersSummary = lazy(() => import('@/pages/doormile/reports/RidersSummary'));
|
||||
const Profitability = lazy(() => import('@/pages/doormile/reports/Profitability'));
|
||||
|
||||
const ViewProfile = lazy(() => import('@/pages/doormile/ViewProfile'));
|
||||
|
||||
const PageFallback = () => (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-slate-800" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { isLoadingAuth, isLoadingPublicSettings } = useAuth();
|
||||
@@ -75,83 +67,62 @@ const AuthenticatedApp = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Admin sign-in — outside the protected boundary. */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
{/* Sign-in — outside the protected boundary. */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
{/* The application opens on the Admin console, which is entered through
|
||||
its own sign-in. Employer keeps every one of its routes, including
|
||||
Overview at an explicit path — nothing of that product is removed,
|
||||
only the address the app happens to start at. */}
|
||||
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
{/* Dispatch is the console's home: it is the board operators sit on
|
||||
all day, and the one page that answers "what is happening right
|
||||
now" without a filter being set first. */}
|
||||
<Route path="/" element={<Navigate to="/doormile/dispatch" replace />} />
|
||||
|
||||
<Route element={<AdminRoute />}>
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<ControlCenter />} />
|
||||
<Route path="positions" element={<AdminPositions />} />
|
||||
<Route path="positions/new" element={<CreatePosition />} />
|
||||
<Route path="positions/:id" element={<PositionDetail />} />
|
||||
<Route path="candidates" element={<AdminCandidates />} />
|
||||
{/* The full Candidate Intelligence profile. A page rather than a
|
||||
drawer: the record carries around forty fields and fifteen
|
||||
screening dimensions, which a 480px column cannot show without
|
||||
hiding most of it. */}
|
||||
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
|
||||
<Route path="hired" element={<AdminHiredHistory />} />
|
||||
<Route path="talent-pool" element={<AdminTalentPool />} />
|
||||
<Route path="university" element={<University />} />
|
||||
<Route path="university/:id" element={<CourseDetail />} />
|
||||
<Route path="analytics" element={<AdminAnalytics />} />
|
||||
<Route path="activity" element={<AdminActivity />} />
|
||||
<Route path="profile" element={<AdminProfile />} />
|
||||
{/* Two centres, deliberately separate. Settings configures the
|
||||
account and the system; Workspace & Skills configures the
|
||||
workforce — Owliver's capabilities, training and progression —
|
||||
each with its own pages underneath rather than one page holding
|
||||
both subjects. */}
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="workspace" element={<AdminWorkspace />} />
|
||||
<Route path="workspace/agents" element={<AdminWorkspaceAgents />} />
|
||||
{/* Static before dynamic, so `agents/new` cannot be read as an
|
||||
agent whose id is "new". */}
|
||||
<Route path="workspace/agents/new" element={<AdminAgentDetail />} />
|
||||
<Route path="workspace/agents/:id" element={<AdminAgentDetail />} />
|
||||
<Route path="workspace/skills" element={<AdminWorkspaceSkills />} />
|
||||
{/* Two editors, because a UI skill and an Owliver skill configure
|
||||
different things. Static segments rank above the dynamic `:id`,
|
||||
so `skills/owliver/...` cannot be read as a skill called
|
||||
"owliver" — and the two original addresses are untouched. */}
|
||||
<Route path="workspace/skills/new" element={<AdminSkillEditor />} />
|
||||
<Route path="workspace/skills/owliver/new" element={<AdminOwliverSkillEditor />} />
|
||||
<Route path="workspace/skills/owliver/:id" element={<AdminOwliverSkillEditor />} />
|
||||
<Route path="workspace/skills/:id" element={<AdminSkillEditor />} />
|
||||
<Route path="workspace/skill-development" element={<AdminSkillDevelopment />} />
|
||||
<Route path="/doormile" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/doormile/dispatch" replace />} />
|
||||
|
||||
<Route path="dispatch" element={<Dispatch />} />
|
||||
<Route path="dispatch/preview" element={<DispatchPreview />} />
|
||||
|
||||
{/* Static before dynamic, so `orders/create` cannot be read as an
|
||||
order whose id is "create". */}
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/create" element={<CreateOrder />} />
|
||||
<Route path="orders/createorders" element={<MultipleOrders />} />
|
||||
<Route path="orders/preview" element={<OrdersPreview />} />
|
||||
|
||||
<Route path="deliveries" element={<Deliveries />} />
|
||||
|
||||
<Route path="tenants" element={<Tenants />} />
|
||||
<Route path="clients/create" element={<CreateClient />} />
|
||||
<Route path="customer/create" element={<CreateCustomer />} />
|
||||
|
||||
<Route path="pricing" element={<Pricing />} />
|
||||
|
||||
<Route path="riders" element={<Riders />} />
|
||||
<Route path="riders/create" element={<CreateRider />} />
|
||||
<Route path="riders/edit" element={<EditRider />} />
|
||||
|
||||
<Route path="customers" element={<Customers />} />
|
||||
<Route path="hubs" element={<Hubs />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="app-users" element={<AppUsers />} />
|
||||
<Route path="tripsheets" element={<Tripsheets />} />
|
||||
<Route path="exceptions" element={<Exceptions />} />
|
||||
<Route path="competitive-intel" element={<CompetitiveIntel />} />
|
||||
|
||||
<Route path="reports/orderssummary" element={<OrdersSummary />} />
|
||||
<Route path="reports/ordersdetails" element={<OrdersDetails />} />
|
||||
<Route path="reports/riderssummary" element={<RidersSummary />} />
|
||||
<Route path="reports/profitability" element={<Profitability />} />
|
||||
|
||||
<Route path="profile" element={<ViewProfile />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Redirect top-level legacy aliases to the unified global shell */}
|
||||
<Route path="/overview" element={<Navigate to="/admin" replace />} />
|
||||
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
|
||||
{/* `/positions` only matches the exact path, so the sub-routes need
|
||||
their own aliases or an inbound link lands on the 404 page. */}
|
||||
<Route path="/positions/new" element={<Navigate to="/admin/positions/new" replace />} />
|
||||
<Route path="/positions/:id" element={<LegacyPositionRedirect />} />
|
||||
<Route path="/candidates" element={<Navigate to="/admin/candidates" replace />} />
|
||||
<Route path="/hired" element={<Navigate to="/admin/hired" replace />} />
|
||||
<Route path="/talent-pool" element={<Navigate to="/admin/talent-pool" replace />} />
|
||||
<Route path="/university" element={<Navigate to="/admin/university" replace />} />
|
||||
{/* Same treatment as every other legacy alias: the challenge opens inside
|
||||
the application shell rather than on a bare page, so Forge → challenge
|
||||
keeps its navigation, header and page frame. Existing links to
|
||||
/university/:id are unchanged and still resolve here. */}
|
||||
<Route path="/university/:id" element={<LegacyCourseRedirect />} />
|
||||
<Route path="/analytics" element={<Navigate to="/admin/analytics" replace />} />
|
||||
<Route path="/activity" element={<Navigate to="/admin/activity" replace />} />
|
||||
<Route path="/tracking" element={<Navigate to="/admin/activity" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<PageNotFound />} />
|
||||
</Routes>
|
||||
<Route path="*" element={<PageNotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -165,13 +136,12 @@ function App() {
|
||||
</Router>
|
||||
<Toaster />
|
||||
{/* The design system's `toast` helper wraps react-hot-toast, which needs
|
||||
its own renderer mounted. Without this every toast in the app fired
|
||||
into nothing — confirmations, errors and the "not in the demo"
|
||||
notices all silently did nothing. */}
|
||||
its own renderer mounted. Without this every toast in the app fires
|
||||
into nothing — including the data layer's failure notices. */}
|
||||
<HotToaster position="bottom-right" />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
id: activity-agent
|
||||
name: Activity Agent
|
||||
description: The audit trail — what happened in this workspace, who did it, and what looks unusual.
|
||||
icon: activity
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Activity, for the event log, who did what, and anything that looks out of pattern.
|
||||
pages:
|
||||
- activity
|
||||
skills:
|
||||
- activity-analysis
|
||||
- anomaly-detection
|
||||
- operational-risk
|
||||
starters:
|
||||
- label: What happened recently?
|
||||
prompt: What has happened in the workspace recently?
|
||||
- label: Anything unusual?
|
||||
prompt: Is there any unusual activity?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# Activity Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about what has happened in this workspace: which events, by which
|
||||
account, and when.
|
||||
|
||||
Report something as unusual only when it genuinely departs from the pattern in
|
||||
the log. Flagging ordinary activity trains the reader to ignore the flag.
|
||||
|
||||
This agent carries no skills of its own; Activity answers from its own page
|
||||
reader.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report recent workspace events and who performed them.
|
||||
- Surface activity that departs from the usual pattern.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
id: analytics-agent
|
||||
name: Analytics Agent
|
||||
description: Hiring performance over time — trends, conversion, and how departments compare.
|
||||
icon: bar-chart
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Analytics, for trends over time, conversion rates and department comparisons.
|
||||
pages:
|
||||
- analytics
|
||||
skills:
|
||||
- analytics-insights
|
||||
- workforce-analytics
|
||||
- attendance-analysis
|
||||
- overtime-analysis
|
||||
- hiring-pulse-analysis
|
||||
starters:
|
||||
- label: What is the hiring trend?
|
||||
prompt: What is the hiring trend?
|
||||
- label: Where does the funnel lose people?
|
||||
prompt: Where does the funnel lose candidates?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# Analytics Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about performance over time: how hiring is trending, where the funnel
|
||||
converts and where it leaks, and how departments compare.
|
||||
|
||||
Explain the figures the Analytics page is already showing rather than producing
|
||||
different ones. When a movement is small enough to be noise, say so rather than
|
||||
narrating it as a trend.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Explain hiring trend and conversion.
|
||||
- Compare department performance, and identify where the funnel loses people.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
id: candidates-agent
|
||||
name: Candidates Agent
|
||||
description: The applicant pool — who is waiting on a decision, who is strongest, and where people are dropping off.
|
||||
icon: users
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Candidates, for screening, shortlisting and pipeline questions about applicants.
|
||||
pages:
|
||||
- candidates
|
||||
- candidates-analysis
|
||||
skills:
|
||||
- candidate-search
|
||||
- candidate-analysis
|
||||
starters:
|
||||
- label: Who needs a decision?
|
||||
prompt: Which candidates are waiting on a decision?
|
||||
- label: Who is strongest?
|
||||
prompt: Who are the strongest candidates right now?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# Candidates Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the people who have applied: who is waiting, who scores well, who
|
||||
has not been screened, and where the pipeline is losing candidates.
|
||||
|
||||
Quote a score only where one has been computed. An unscored candidate is
|
||||
unscored — say so rather than implying a low score.
|
||||
|
||||
Never advance, decline or hire a candidate without being asked to.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report who is waiting on a decision, and who is strongest.
|
||||
- Find candidates matching what a role asks for.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
id: control-center-agent
|
||||
name: Control Center Agent
|
||||
description: The operational picture — what needs attention across the workspace today.
|
||||
icon: layers
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on the Control Center, for workspace health, urgency and what to do next.
|
||||
pages:
|
||||
- control-center
|
||||
skills:
|
||||
- executive-summary
|
||||
- staffing-risk
|
||||
- operational-risk
|
||||
- anomaly-detection
|
||||
- attendance-analysis
|
||||
- overtime-analysis
|
||||
- hiring-pulse-analysis
|
||||
starters:
|
||||
- label: What needs my attention?
|
||||
prompt: What needs my attention right now?
|
||||
- label: How is the pipeline?
|
||||
prompt: How healthy is my hiring pipeline?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# Control Center Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the state of the workspace as a whole: what is urgent, where the
|
||||
funnel is losing people, and what the reader should do next.
|
||||
|
||||
Read the figures the Control Center already shows rather than recomputing them,
|
||||
so the answer and the dashboard beside it can never disagree.
|
||||
|
||||
This agent carries no skills of its own. That is deliberate — the Control
|
||||
Center answers from its own page reader, and inventing skills to fill the list
|
||||
would promise capabilities that do not exist.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Say what needs attention across the workspace.
|
||||
- Explain where the hiring funnel is losing candidates.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
id: hired-history-agent
|
||||
name: Hired History Agent
|
||||
description: Completed hires — who was hired, for which role, how quickly, and how well.
|
||||
icon: user-check
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Hired History, for hiring outcomes, time-to-hire and quality by department.
|
||||
pages:
|
||||
- hired-history
|
||||
skills:
|
||||
- hiring-history-analysis
|
||||
starters:
|
||||
- label: Who did we hire recently?
|
||||
prompt: Who did we hire recently?
|
||||
- label: How is hire quality?
|
||||
prompt: How is hire quality by department?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# Hired History Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about hires that have already happened: who, for which role, how long it
|
||||
took and how they scored.
|
||||
|
||||
This is the record after the decision, not the pipeline before it. A question
|
||||
about people still being considered belongs to Candidates.
|
||||
|
||||
This agent carries no skills of its own. Hired History answers from its own
|
||||
page reader, and a placeholder skill would promise a capability that does not
|
||||
exist.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report recent hires, and how quickly they were made.
|
||||
- Compare hiring outcomes across departments.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
id: krow-forge-agent
|
||||
name: KROW Forge Agent
|
||||
description: The training library — what exists, what is published, and how the workforce is progressing.
|
||||
icon: graduation-cap
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on KROW Forge, for training paths, challenges, verification and skill progression.
|
||||
pages:
|
||||
- krow-forge
|
||||
skills:
|
||||
- forge-skill-management
|
||||
- learning-analysis
|
||||
starters:
|
||||
- label: What is in the library?
|
||||
prompt: What training does the library hold?
|
||||
- label: Where are the gaps?
|
||||
prompt: Where are the gaps in workforce training?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# KROW Forge Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the training library and what the workforce has proved: which
|
||||
paths exist, which are published, what a challenge checks, and where coverage
|
||||
is thin.
|
||||
|
||||
A skill in Forge is something a person learns and is verified in. It is not an
|
||||
Owliver capability — never describe the two as the same thing.
|
||||
|
||||
Never publish or archive training without being asked to.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report what the training library holds and what is live.
|
||||
- Identify gaps between what roles need and what is taught.
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
id: krow-workforce-agent
|
||||
name: Krow Workforce Agent
|
||||
description: The general workforce agent. Reasons across every Krow domain, within whatever page you are on.
|
||||
icon: owliver
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use when a question spans more than one Krow domain, or when you are on a page whose own agent cannot help.
|
||||
pages:
|
||||
- control-center
|
||||
- positions
|
||||
- create-position
|
||||
- candidates
|
||||
- candidates-analysis
|
||||
- hired-history
|
||||
- talent-pool
|
||||
- krow-forge
|
||||
- analytics
|
||||
- activity
|
||||
- profile
|
||||
# The agent workspace. Carries no operational skill, so standing here the
|
||||
# root agent answers about agents and skills and nothing else — which is the
|
||||
# point: configuring the Analytics Agent must not put the reader on Analytics.
|
||||
- workspace-agent-configure
|
||||
# Settings and the rest of the workspace. Nobody wrote a specialist for a
|
||||
# configuration screen and nobody should: these pages hold no workforce
|
||||
# records, so what they need is a general agent, not a Settings Agent with
|
||||
# invented skills. Listing them here is the whole of the fallback — a page
|
||||
# named by this agent has an agent, and Owliver is alive on it.
|
||||
- settings
|
||||
- workspace
|
||||
- workspace-agents
|
||||
- workspace-skills
|
||||
- workspace-skill-configure
|
||||
- skill-development
|
||||
skills:
|
||||
- create-position
|
||||
- hiring-activity-assistant
|
||||
- candidate-search
|
||||
- analytics-insights
|
||||
- forge-skill-management
|
||||
- staffing-risk
|
||||
- attendance-analysis
|
||||
- overtime-analysis
|
||||
- candidate-analysis
|
||||
- talent-pool-analysis
|
||||
- workforce-analytics
|
||||
- anomaly-detection
|
||||
- activity-analysis
|
||||
- operational-risk
|
||||
- executive-summary
|
||||
- hiring-history-analysis
|
||||
- learning-analysis
|
||||
- hiring-pulse-analysis
|
||||
subagents:
|
||||
- control-center-agent
|
||||
- positions-agent
|
||||
- candidates-agent
|
||||
- hired-history-agent
|
||||
- talent-pool-agent
|
||||
- krow-forge-agent
|
||||
- analytics-agent
|
||||
- activity-agent
|
||||
knowledge:
|
||||
- id: page-boundary
|
||||
label: What this agent can see
|
||||
kind: note
|
||||
body: Owliver answers from the page you are on. Covering every page does not mean reading every page at once — the page you are standing on decides which records are in reach.
|
||||
starters:
|
||||
- label: What needs my attention?
|
||||
prompt: What needs my attention right now?
|
||||
- label: Summarize this page
|
||||
prompt: Summarize what this page is showing
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
people:
|
||||
- user: demo@krow.app
|
||||
role: manager
|
||||
---
|
||||
|
||||
# Krow Workforce Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer from the records this workspace holds, for the page the reader is on.
|
||||
|
||||
State a figure only where a skill has read it. When a reading needs a position
|
||||
or a candidate and none is open, ask which one rather than choosing one.
|
||||
|
||||
Covering every page is not permission to read every page at once. The page in
|
||||
front of the reader decides what is in reach; a question that belongs somewhere
|
||||
else should be answered by naming where it belongs, not by reaching for it.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Answer questions that span more than one Krow domain.
|
||||
- Stand in on pages whose own agent carries no skills.
|
||||
- Hand a question that clearly belongs to another page back to that page.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
id: positions-agent
|
||||
name: Positions Agent
|
||||
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
|
||||
icon: briefcase
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
|
||||
pages:
|
||||
- positions
|
||||
- create-position
|
||||
skills:
|
||||
- create-position
|
||||
- hiring-activity-assistant
|
||||
- staffing-risk
|
||||
starters:
|
||||
- label: Which positions need attention?
|
||||
prompt: Which positions need attention?
|
||||
- label: Show hiring activity
|
||||
prompt: Show hiring activity as a flow
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# Positions Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the roles this workspace has open: how they are filling, which are
|
||||
starved of applicants, and what a role still needs before it can be published.
|
||||
|
||||
When a question names a role, answer about that role. When it does not and one
|
||||
is open on the page, answer about that one. When neither is true, ask which.
|
||||
|
||||
Never create or publish a position without being asked to.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report how open roles are filling, and which are at risk.
|
||||
- Help specify a new role and its screening weights.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
id: talent-pool-agent
|
||||
name: Talent Pool Agent
|
||||
description: Available talent — who is in the pool, who is verified, and who is ready to place.
|
||||
icon: layers
|
||||
status: published
|
||||
version: 1
|
||||
reasoning: balanced
|
||||
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
|
||||
pages:
|
||||
- talent-pool
|
||||
skills:
|
||||
- talent-pool-analysis
|
||||
starters:
|
||||
- label: Who is available?
|
||||
prompt: Who is available in the talent pool?
|
||||
- label: How verified is the pool?
|
||||
prompt: How much of the talent pool is verified?
|
||||
permissions:
|
||||
owner: demo@krow.app
|
||||
access: all
|
||||
---
|
||||
|
||||
# Talent Pool Agent
|
||||
|
||||
## Instructions
|
||||
|
||||
Answer about the people this workspace already knows: who is in the pool, what
|
||||
they are verified in, and who could be placed now.
|
||||
|
||||
This is supply, not applicants. Someone in the pool has not applied to anything
|
||||
by being here — do not describe them as a candidate for a role.
|
||||
|
||||
This agent carries no skills of its own; Talent Pool answers from its own page
|
||||
reader.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Report who is available, and how ready they are.
|
||||
- Describe the pool's segments and verification coverage.
|
||||
@@ -1,809 +0,0 @@
|
||||
/**
|
||||
* Local AI engine for the KROW demo.
|
||||
*
|
||||
* The app funnels every AI workflow through a single call —
|
||||
* `integrations.Core.InvokeLLM({ prompt, response_json_schema, model })` — so
|
||||
* reimplementing that one function keeps `lib/krowAi.js` and
|
||||
* `lib/provingGround.js` byte-for-byte identical to the reference.
|
||||
*
|
||||
* Each workflow is recognized by a stable phrase its prompt opens with, and the
|
||||
* structured fields the prompt already carries ("Years Experience: 6",
|
||||
* "Required Certifications: …", the talent-pool JSON block) are read back out
|
||||
* and scored deterministically. Same input, same output — no network, no key,
|
||||
* and results that actually respond to the data instead of being canned.
|
||||
*/
|
||||
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
/** Simulated model latency so streaming/pending UI behaves as designed. */
|
||||
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/* ── Prompt field readers ──────────────────────────────────────────────── */
|
||||
|
||||
/** Reads a `Label: value` line out of a prompt. */
|
||||
function field(prompt, label) {
|
||||
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
function numField(prompt, label) {
|
||||
const raw = field(prompt, label);
|
||||
const match = raw.match(/-?\d+(\.\d+)?/);
|
||||
return match ? Number(match[0]) : 0;
|
||||
}
|
||||
|
||||
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
|
||||
function listField(prompt, label) {
|
||||
const raw = field(prompt, label);
|
||||
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
|
||||
function quotedBlock(prompt) {
|
||||
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
||||
|
||||
const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
||||
|
||||
/* ── 1. Candidate screening ────────────────────────────────────────────── */
|
||||
|
||||
function screenCandidate(prompt) {
|
||||
// Weights are stated inline: "experience 25%, english 20%, …"
|
||||
const weightOf = (name) => {
|
||||
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
||||
return match ? Number(match[1]) : 20;
|
||||
};
|
||||
const weights = {
|
||||
experience: weightOf('experience'),
|
||||
english: weightOf('english'),
|
||||
reliability: weightOf('reliability'),
|
||||
certifications: weightOf('certifications'),
|
||||
availability: weightOf('availability'),
|
||||
};
|
||||
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
const requiredEnglish = ENGLISH_RANK[field(prompt, 'English Required').toLowerCase()] || 1;
|
||||
const requiredCertNames = listField(prompt, 'Required Certifications');
|
||||
const requiredCerts = requiredCertNames.map((c) => c.toLowerCase());
|
||||
const customRequirements = field(prompt, 'Custom Requirements');
|
||||
|
||||
const name = field(prompt, 'Name');
|
||||
const years = numField(prompt, 'Years Experience');
|
||||
const englishLevel = ENGLISH_RANK[field(prompt, 'English Level').toLowerCase()] || 1;
|
||||
const certs = listField(prompt, 'Certifications');
|
||||
const skills = listField(prompt, 'Skills');
|
||||
const availability = listField(prompt, 'Availability');
|
||||
const summary = field(prompt, 'Professional Summary');
|
||||
const hasSummary = Boolean(summary) && !/^not provided$/i.test(summary);
|
||||
|
||||
// Experience: meeting the minimum is a pass, exceeding it earns headroom.
|
||||
const experience = minYears > 0
|
||||
? clamp(years >= minYears ? 78 + Math.min(21, (years - minYears) * 4) : (years / minYears) * 70)
|
||||
: clamp(45 + years * 8);
|
||||
|
||||
// English: at or above the requirement scores well, each level short costs.
|
||||
const english = clamp(englishLevel >= requiredEnglish
|
||||
? 84 + (englishLevel - requiredEnglish) * 5
|
||||
: 84 - (requiredEnglish - englishLevel) * 24);
|
||||
|
||||
// Certifications: fraction of required credentials actually held.
|
||||
const held = requiredCerts.filter((req) =>
|
||||
certs.some((c) => c.toLowerCase().includes(req) || req.includes(c.toLowerCase())));
|
||||
const certifications = requiredCerts.length
|
||||
? clamp((held.length / requiredCerts.length) * 92 + (certs.length > requiredCerts.length ? 8 : 0))
|
||||
: clamp(certs.length ? 88 : 55);
|
||||
|
||||
const availabilityScore = clamp(availability.length ? 52 + availability.length * 14 : 40);
|
||||
|
||||
// Reliability has no direct field — infer it from tenure, credentials and
|
||||
// whether the candidate bothered to describe their own work.
|
||||
const reliability = clamp(
|
||||
45 + Math.min(30, years * 4) + (certifications > 70 ? 14 : 0) + (hasSummary ? 8 : 0)
|
||||
);
|
||||
|
||||
const weightTotal = Object.values(weights).reduce((a, b) => a + b, 0) || 100;
|
||||
const overall = clamp(
|
||||
(experience * weights.experience +
|
||||
english * weights.english +
|
||||
reliability * weights.reliability +
|
||||
certifications * weights.certifications +
|
||||
availabilityScore * weights.availability) / weightTotal
|
||||
);
|
||||
|
||||
const skillDepth = clamp(skills.length ? 55 + skills.length * 9 : 30);
|
||||
const seniority = clamp(years * 8);
|
||||
|
||||
const label = overall >= 85 ? 'Excellent Match'
|
||||
: overall >= 70 ? 'Good Match'
|
||||
: overall >= 50 ? 'Possible Fit'
|
||||
: 'Not a Fit';
|
||||
const recommendation = overall >= 85 ? 'Shortlisted'
|
||||
: overall >= 65 ? 'Interview'
|
||||
: overall >= 50 ? 'Maybe'
|
||||
: 'Reject';
|
||||
|
||||
const strengths = [];
|
||||
if (years >= Math.max(1, minYears)) strengths.push(`${years} year${years === 1 ? '' : 's'} of relevant experience`);
|
||||
if (held.length) strengths.push(`${held.length === requiredCerts.length ? 'All' : 'Some'} required certifications on file`);
|
||||
if (englishLevel >= requiredEnglish) strengths.push(`${field(prompt, 'English Level') || 'Adequate'} English meets the requirement`);
|
||||
if (availability.length >= 2) strengths.push(`Broad availability (${availability.join(', ')})`);
|
||||
if (skills.length >= 3) strengths.push(`Relevant skills: ${skills.slice(0, 3).join(', ')}`);
|
||||
if (!strengths.length) strengths.push('Available and actively seeking work');
|
||||
|
||||
const gaps = [];
|
||||
if (minYears > 0 && years < minYears) gaps.push(`${minYears - years} year${minYears - years === 1 ? '' : 's'} short of the experience minimum`);
|
||||
// Report the credential using the posting's own capitalization.
|
||||
const missing = requiredCertNames.filter((c) => !held.includes(c.toLowerCase()));
|
||||
if (missing.length) gaps.push(`Missing certification${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
|
||||
if (englishLevel < requiredEnglish) gaps.push('English level below the posting requirement');
|
||||
if (availability.length <= 1) gaps.push('Narrow availability window');
|
||||
if (!skills.length) gaps.push('No skills listed on the application');
|
||||
if (!hasSummary) gaps.push('No professional summary provided');
|
||||
if (!gaps.length) gaps.push('No material gaps against this posting');
|
||||
|
||||
const verdictSentence = overall >= 85
|
||||
? `Strong fit for this posting.`
|
||||
: overall >= 70
|
||||
? `Meets the core requirements for this posting.`
|
||||
: overall >= 50
|
||||
? `Partial fit — worth a conversation, with caveats.`
|
||||
: `Below the requirement bar for this posting.`;
|
||||
|
||||
return {
|
||||
overall_score: overall,
|
||||
match_label: label,
|
||||
summary: `${verdictSentence} ${name || 'The candidate'} brings ${years} year${years === 1 ? '' : 's'} of experience with ${certs.length ? certs.join(' and ') : 'no listed certifications'}. ${gaps[0]}.`,
|
||||
strengths: strengths.slice(0, 4),
|
||||
gaps: gaps.slice(0, 3),
|
||||
recommendation,
|
||||
score_breakdown: {
|
||||
experience,
|
||||
english,
|
||||
reliability,
|
||||
certifications,
|
||||
availability: availabilityScore,
|
||||
personality: clamp(58 + skillDepth * 0.25 + (hasSummary ? 10 : 0)),
|
||||
culture_fit: clamp(overall * 0.9 + 6),
|
||||
communication_style: clamp(english * 0.85 + (hasSummary ? 12 : 0)),
|
||||
attendance_expectations: clamp(reliability * 0.95 + availabilityScore * 0.1),
|
||||
physical_requirements: clamp(70 + Math.min(20, years * 3)),
|
||||
leadership_expectations: clamp(seniority * 0.8 + (/lead|captain|chef|supervisor|manager/i.test(summary) ? 22 : 0)),
|
||||
job_related_answers: clamp(overall * 0.92 + skillDepth * 0.08),
|
||||
verified_skills: skillDepth,
|
||||
scenario_judgment: clamp(overall * 0.88 + (hasSummary ? 8 : 0)),
|
||||
employer_requirements: clamp(customRequirements && !/^none$/i.test(customRequirements)
|
||||
? overall * 0.85
|
||||
: overall * 0.95),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 2. Job description generator ──────────────────────────────────────── */
|
||||
|
||||
const RESPONSIBILITY_LIBRARY = {
|
||||
Bartender: [
|
||||
'Set up and break down a full service bar',
|
||||
'Pour to spec at event pace without sacrificing presentation',
|
||||
'Verify guest age and refuse service responsibly',
|
||||
'Track consumption and flag low stock before service',
|
||||
'Keep the bar clean and compliant throughout the shift',
|
||||
'Reconcile counts and close out the bar after service',
|
||||
],
|
||||
Server: [
|
||||
'Execute synchronized coursed service for seated events',
|
||||
'Polish and set glassware, china, and flatware',
|
||||
'Describe menus and handle allergen questions accurately',
|
||||
'Clear and reset tables discreetly between courses',
|
||||
'Support captains through arrivals, toasts, and departures',
|
||||
'Complete side work and floor close-out',
|
||||
],
|
||||
Security: [
|
||||
'Staff entry points and verify credentials',
|
||||
'Conduct bag checks per venue policy',
|
||||
'De-escalate guest conflicts calmly and early',
|
||||
'Document incidents accurately and promptly',
|
||||
'Coordinate with venue staff and local authorities',
|
||||
'Patrol assigned zones throughout the event',
|
||||
],
|
||||
Chef: [
|
||||
'Design and cost menus to target margins',
|
||||
'Lead and schedule the kitchen brigade',
|
||||
'Own food safety compliance across every kitchen',
|
||||
'Manage vendor relationships and food cost',
|
||||
'Execute tastings and client consultations',
|
||||
'Hold plate standards at volume',
|
||||
],
|
||||
Picker: [
|
||||
'Pick and stage orders against pick lists',
|
||||
'Scan and verify SKUs for accuracy',
|
||||
'Stage outbound pallets for dispatch',
|
||||
'Report damaged inventory immediately',
|
||||
'Keep aisles and staging lanes clear',
|
||||
'Meet daily accuracy and throughput targets',
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_RESPONSIBILITIES = [
|
||||
'Deliver consistent, guest-ready service every shift',
|
||||
'Set up and break down your station',
|
||||
'Communicate clearly with leads and teammates',
|
||||
'Follow safety and compliance standards',
|
||||
'Adapt to changing event timing without losing composure',
|
||||
'Complete close-out duties before leaving',
|
||||
];
|
||||
|
||||
function generateJobDescription(prompt) {
|
||||
const title = field(prompt, 'Role Title');
|
||||
const category = field(prompt, 'Role Category');
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
const english = field(prompt, 'English Level Required');
|
||||
const certs = listField(prompt, 'Required Certifications');
|
||||
const payMin = numField(prompt, 'Pay Range');
|
||||
const payMatch = prompt.match(/Pay Range: \$(\d+(?:\.\d+)?)–\$(\d+(?:\.\d+)?)/);
|
||||
const payMax = payMatch ? Number(payMatch[2]) : payMin;
|
||||
const location = field(prompt, 'Location');
|
||||
const custom = field(prompt, 'Custom Requirements');
|
||||
const hasCustom = custom && !/^none$/i.test(custom);
|
||||
const hasLocation = location && !/^not specified$/i.test(location);
|
||||
const role = title || category || 'team member';
|
||||
|
||||
const payLine = payMax > 0 ? `$${payMin}–$${payMax}/hr` : 'a competitive hourly rate';
|
||||
const whereLine = hasLocation ? ` in ${location}` : '';
|
||||
|
||||
const description = [
|
||||
`We are hiring ${/^[aeiou]/i.test(role) ? 'an' : 'a'} ${role}${whereLine} for premium events where the details are the product. This is hands-on work with a team that plans carefully, briefs properly, and backs you up when the room gets busy.`,
|
||||
`You will own your station from setup through close. ${minYears > 0 ? `We are looking for at least ${minYears} year${minYears === 1 ? '' : 's'} in a comparable role` : 'We will train the right person from the ground up'}, ${english ? `${english} English` : 'clear communication'}, and the judgment to make a call without waiting to be told. ${certs.length ? `${certs.join(' and ')} must be current.` : 'No specific certifications required.'}`,
|
||||
`We pay ${payLine}, publish schedules ahead of time, and promote from within — most of our leads started on the floor. ${hasCustom ? `One thing worth flagging: ${custom}` : 'If you take pride in work guests notice, you will fit here.'}`,
|
||||
].join('\n\n');
|
||||
|
||||
const responsibilities = (RESPONSIBILITY_LIBRARY[category] || DEFAULT_RESPONSIBILITIES).slice(0, 6);
|
||||
|
||||
const qualifications = [
|
||||
minYears > 0
|
||||
? `${minYears}+ year${minYears === 1 ? '' : 's'} of ${category ? `${category.toLowerCase()} ` : ''}experience`
|
||||
: 'Willingness to learn and take direction',
|
||||
english ? `${english.charAt(0).toUpperCase()}${english.slice(1)} English` : 'Clear communication',
|
||||
...certs.map((c) => `${c} — current and verifiable`),
|
||||
'Reliable transportation and consistent attendance',
|
||||
'Comfortable on your feet for a full shift',
|
||||
].filter(Boolean).slice(0, 6);
|
||||
|
||||
const niceToHaves = [
|
||||
`Prior ${hasLocation ? `${location} ` : ''}event experience`,
|
||||
'Bilingual',
|
||||
'Additional certifications beyond the requirement',
|
||||
'Experience training newer staff',
|
||||
].slice(0, 4);
|
||||
|
||||
return { description, responsibilities, qualifications, nice_to_haves: niceToHaves };
|
||||
}
|
||||
|
||||
/* ── 3. Resume builder ─────────────────────────────────────────────────── */
|
||||
|
||||
const SKILL_KEYWORDS = [
|
||||
['bartend', 'Bartending'], ['cocktail', 'Cocktail preparation'], ['serve', 'Table service'],
|
||||
['server', 'Table service'], ['banquet', 'Banquet service'], ['cook', 'Line cooking'],
|
||||
['chef', 'Kitchen leadership'], ['kitchen', 'Kitchen operations'], ['security', 'Event security'],
|
||||
['guard', 'Access control'], ['warehouse', 'Warehouse operations'], ['forklift', 'Forklift operation'],
|
||||
['pick', 'Order picking'], ['host', 'Guest relations'], ['cashier', 'Cash handling'],
|
||||
['clean', 'Sanitation'], ['train', 'Training others'], ['lead', 'Team leadership'],
|
||||
['supervis', 'Supervision'], ['manage', 'People management'], ['wine', 'Wine service'],
|
||||
['catering', 'Catering operations'], ['inventory', 'Inventory control'],
|
||||
];
|
||||
|
||||
const CERT_KEYWORDS = [
|
||||
['servsafe', 'ServSafe'], ['tips', 'TIPS Certified'], ['rbs', 'RBS Alcohol Server'],
|
||||
['guard card', 'Guard Card'], ['cpr', 'CPR / First Aid'], ['first aid', 'CPR / First Aid'],
|
||||
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
||||
];
|
||||
|
||||
function buildResume(prompt) {
|
||||
const text = quotedBlock(prompt);
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
const yearsMatch = lower.match(/(\d+)\s*(?:\+)?\s*(?:years?|yrs?)/);
|
||||
const years = yearsMatch ? Number(yearsMatch[1]) : 0;
|
||||
|
||||
const skills = [...new Set(
|
||||
SKILL_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)].slice(0, 8);
|
||||
|
||||
const certifications = [...new Set(
|
||||
CERT_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)];
|
||||
|
||||
// A capitalized two-word opener is almost always the person naming themselves.
|
||||
const nameMatch = text.match(/\b([A-Z][a-z]+ [A-Z][a-z]+)\b/);
|
||||
const inferredName = nameMatch ? nameMatch[1] : '';
|
||||
|
||||
const primary = skills[0] || 'hospitality';
|
||||
const professionalSummary = text
|
||||
? `${years > 0 ? `${years} year${years === 1 ? '' : 's'} of` : 'Hands-on'} experience in ${primary.toLowerCase()}${skills[1] ? ` and ${skills[1].toLowerCase()}` : ''}. ${certifications.length ? `Holds ${certifications.join(', ')}.` : 'Ready to certify as required.'} Dependable, quick to learn, and comfortable in a fast-moving service environment.`
|
||||
: '';
|
||||
|
||||
const coverLetter = text
|
||||
? `I am applying because this role matches what I already do well. ${years > 0 ? `Over ${years} year${years === 1 ? '' : 's'} I have` : 'I have'} worked in ${primary.toLowerCase()}${skills[1] ? `, with hands-on ${skills[1].toLowerCase()}` : ''}, and I show up ready for the shift I am given.\n\n${certifications.length ? `My ${certifications.join(' and ')} ${certifications.length > 1 ? 'are' : 'is'} current. ` : ''}I would welcome the chance to prove this on a trial shift rather than on paper.`
|
||||
: '';
|
||||
|
||||
return {
|
||||
inferred_name: inferredName,
|
||||
years_experience: years,
|
||||
skills,
|
||||
certifications,
|
||||
professional_summary: professionalSummary,
|
||||
cover_letter: coverLetter,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 4. Interview questions ────────────────────────────────────────────── */
|
||||
|
||||
const INTERVIEW_QUESTIONS = {
|
||||
en: [
|
||||
(role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
||||
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
|
||||
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
|
||||
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
|
||||
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
||||
],
|
||||
es: [
|
||||
(role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
||||
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
|
||||
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
|
||||
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
|
||||
() => `La última — ¿cómo es tu disponibilidad real, y qué te gustaría estar haciendo en un año?`,
|
||||
],
|
||||
};
|
||||
|
||||
function interviewQuestion(prompt) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
||||
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
const roleMatch = prompt.match(/AI interviewer for a (.+?) position/);
|
||||
const role = roleMatch ? roleMatch[1] : 'this';
|
||||
return INTERVIEW_QUESTIONS[lang][index](role);
|
||||
}
|
||||
|
||||
const OWLIVER_QUESTIONS = {
|
||||
en: [
|
||||
(name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
||||
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
|
||||
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
|
||||
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
|
||||
() => `What do you want to be known for in five years?`,
|
||||
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
||||
],
|
||||
es: [
|
||||
(name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
||||
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
|
||||
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
|
||||
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
|
||||
() => `¿Por qué te gustaría ser reconocido en cinco años?`,
|
||||
() => `Eso es todo lo que necesitaba. ¿Algo que quieras agregar antes de que lo prepare?`,
|
||||
],
|
||||
};
|
||||
|
||||
function owliverQuestion(prompt) {
|
||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||
const numberMatch = prompt.match(/you are on question (\d+)/);
|
||||
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||
const nameMatch = prompt.match(/voice chat with (.+?)\./);
|
||||
const name = nameMatch ? nameMatch[1].split(' ')[0] : 'there';
|
||||
return OWLIVER_QUESTIONS[lang][index](name);
|
||||
}
|
||||
|
||||
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
||||
|
||||
/** Scores a transcript on how substantive the candidate's own answers are. */
|
||||
function readTranscript(prompt) {
|
||||
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
|
||||
const answers = transcript
|
||||
.split('\n')
|
||||
.filter((line) => /^Candidate:|^Worker:/.test(line.trim()))
|
||||
.map((line) => line.replace(/^\w+:\s*/, '').trim())
|
||||
.filter(Boolean);
|
||||
const words = answers.reduce((sum, a) => sum + a.split(/\s+/).length, 0);
|
||||
const avgWords = answers.length ? words / answers.length : 0;
|
||||
// Concrete answers name specifics; vague ones stay abstract.
|
||||
const specifics = (transcript.match(/\b\d+\b/g) || []).length;
|
||||
return { answers, words, avgWords, specifics };
|
||||
}
|
||||
|
||||
function evaluateInterview(prompt) {
|
||||
const { answers, avgWords, specifics } = readTranscript(prompt);
|
||||
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
||||
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
||||
const spanish = isSpanish(prompt);
|
||||
const role = field(prompt, 'Job Title') || 'the role';
|
||||
const candidate = field(prompt, 'Candidate') || 'The candidate';
|
||||
|
||||
// Depth of answers is the dominant signal; specifics lift it, silence sinks it.
|
||||
const depth = clamp(avgWords * 2.1);
|
||||
const engagement = clamp(answers.length * 16);
|
||||
const concreteness = clamp(46 + specifics * 9);
|
||||
const base = clamp(depth * 0.45 + engagement * 0.25 + concreteness * 0.3);
|
||||
|
||||
const integrity = clamp(100 - fast * 12);
|
||||
const flags = [];
|
||||
if (fast >= 2) flags.push(`${fast} responses returned in under 8 seconds`);
|
||||
if (avgWords > 90) flags.push('Answer length and register suggest possible assistance');
|
||||
if (answers.length < 3) flags.push('Interview ended before enough signal was gathered');
|
||||
|
||||
const overall = clamp(base - (fast >= 3 ? 8 : 0));
|
||||
const verdict = overall >= 78 ? 'hire' : overall >= 55 ? 'maybe' : 'no';
|
||||
|
||||
const strengths = spanish
|
||||
? ['Respuestas claras y concretas', 'Se mantuvo tranquilo bajo presión']
|
||||
: ['Clear, concrete answers', 'Stayed composed describing pressure'];
|
||||
const concerns = spanish
|
||||
? ['Profundidad limitada en algunas respuestas']
|
||||
: ['Limited depth on some answers'];
|
||||
if (fast >= 2) concerns.push(spanish ? 'Tiempos de respuesta inusualmente rápidos' : 'Unusually fast response times');
|
||||
|
||||
return {
|
||||
overall_interview_score: overall,
|
||||
verdict,
|
||||
hire_recommendation: spanish
|
||||
? verdict === 'hire'
|
||||
? `Recomiendo avanzar con ${candidate} para ${role}.`
|
||||
: verdict === 'maybe'
|
||||
? `Vale un turno de prueba antes de comprometerse.`
|
||||
: `No recomiendo avanzar para este puesto.`
|
||||
: verdict === 'hire'
|
||||
? `Move forward with ${candidate} for ${role}.`
|
||||
: verdict === 'maybe'
|
||||
? `Worth a trial shift before committing to the season.`
|
||||
: `Not a fit for this posting as interviewed.`,
|
||||
integrity_score: integrity,
|
||||
ai_flags: flags,
|
||||
category_scores: {
|
||||
communication: clamp(depth * 0.6 + concreteness * 0.4),
|
||||
confidence: clamp(base * 0.95),
|
||||
experience_relevance: clamp(concreteness * 0.8 + depth * 0.2),
|
||||
culture_fit: clamp(base * 0.92 + 4),
|
||||
problem_solving: clamp(concreteness * 0.7 + depth * 0.3),
|
||||
personality: clamp(base * 0.98),
|
||||
communication_style: clamp(depth * 0.55 + 30),
|
||||
attendance_expectations: clamp(base * 0.9 + 8),
|
||||
reliability: clamp(base * 0.93 + 6),
|
||||
physical_requirements: clamp(72 + specifics * 2),
|
||||
leadership_expectations: clamp(base * 0.78),
|
||||
scenario_judgment: clamp(concreteness * 0.75 + depth * 0.25),
|
||||
job_related_answers: clamp(base * 0.96),
|
||||
verified_skills: clamp(base * 0.88),
|
||||
},
|
||||
strengths,
|
||||
concerns,
|
||||
best_fit_roles: [role],
|
||||
summary: spanish
|
||||
? `${candidate} dio ${answers.length} respuestas con detalle concreto. El criterio es sólido; la profundidad técnica aún está en desarrollo.`
|
||||
: `${candidate} gave ${answers.length} answers grounded in specifics rather than generalities. Judgment reads sound; technical depth is still developing.`,
|
||||
reasoning: spanish
|
||||
? `Puntuación basada en la profundidad de las respuestas (promedio ${Math.round(avgWords)} palabras) y ${specifics} detalles concretos. Integridad ${integrity} con ${fast} respuesta(s) rápida(s).`
|
||||
: `Scored on answer depth (${Math.round(avgWords)} words average) and ${specifics} concrete details cited. Integrity ${integrity} with ${fast} fast response(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 6. Talent matching ────────────────────────────────────────────────── */
|
||||
|
||||
function matchTalent(prompt) {
|
||||
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
|
||||
let pool = [];
|
||||
try {
|
||||
pool = block ? JSON.parse(block[1]) : [];
|
||||
} catch {
|
||||
pool = [];
|
||||
}
|
||||
|
||||
const title = (field(prompt, 'Title') || '').toLowerCase();
|
||||
const category = (field(prompt, 'Category') || '').toLowerCase();
|
||||
const minYears = numField(prompt, 'Min Experience');
|
||||
const requiredCerts = listField(prompt, 'Required Certifications').map((c) => c.toLowerCase());
|
||||
|
||||
const scored = pool.map((p) => {
|
||||
const wants = `${p.desired_position || ''} ${p.current_position || ''}`.toLowerCase();
|
||||
const roleFit = (title && wants.includes(title)) || (category && wants.includes(category)) ? 30
|
||||
: (title && title.split(' ').some((w) => w.length > 3 && wants.includes(w))) ? 18
|
||||
: 0;
|
||||
|
||||
const yearsFit = minYears > 0
|
||||
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
||||
: Math.min(20, (p.experience_years || 0) * 3);
|
||||
|
||||
const certs = (p.certifications || []).map((c) => c.toLowerCase());
|
||||
const certFit = requiredCerts.length
|
||||
? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
||||
: (certs.length ? 12 : 5);
|
||||
|
||||
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
||||
const verifiedFit = Math.min(10, (p.capabilities || []).length * 5);
|
||||
const reliabilityFit = ((p.attendance_score || 0) / 100) * 5;
|
||||
|
||||
const match = clamp(roleFit + yearsFit + certFit + scoreFit + verifiedFit + reliabilityFit);
|
||||
|
||||
const reasons = [];
|
||||
if (roleFit >= 18) reasons.push(`Targeting ${p.desired_position || p.current_position} — aligned with this posting`);
|
||||
if ((p.experience_years || 0) >= minYears && minYears > 0) reasons.push(`${p.experience_years} years experience meets the ${minYears}-year minimum`);
|
||||
if ((p.capabilities || []).length) reasons.push(`Verified: ${p.capabilities.join(', ')}`);
|
||||
if ((p.krow_score || 0) >= 75) reasons.push(`Career score ${p.krow_score} with ${p.shifts_completed || 0} shifts completed`);
|
||||
if (!reasons.length) reasons.push(`New to KROW — ${p.experience_years || 0} years experience, no verified evidence yet`);
|
||||
|
||||
return {
|
||||
profile_id: p.id,
|
||||
match_score: match,
|
||||
match_label: match >= 80 ? 'Strong Match' : match >= 65 ? 'Good Match' : match >= 50 ? 'Possible Fit' : 'Weak Fit',
|
||||
reasons: reasons.slice(0, 3),
|
||||
recommendation: match >= 80 ? 'Hire' : match >= 65 ? 'Interview' : match >= 50 ? 'Maybe' : 'Pass',
|
||||
};
|
||||
}).sort((a, b) => b.match_score - a.match_score);
|
||||
|
||||
const qualified = scored.filter((m) => m.match_score >= 50);
|
||||
return { matches: qualified.length ? qualified : scored.slice(0, 5) };
|
||||
}
|
||||
|
||||
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
||||
|
||||
/** Job titles a worker might name for themselves, most specific first. */
|
||||
const ROLE_TITLES = [
|
||||
[/banquet captain|captain/, 'Banquet Captain'],
|
||||
[/executive chef/, 'Executive Chef'],
|
||||
[/sous chef/, 'Sous Chef'],
|
||||
[/line cook/, 'Line Cook'],
|
||||
[/\bchef\b/, 'Chef'],
|
||||
[/bartender|bartend/, 'Bartender'],
|
||||
[/barback/, 'Barback'],
|
||||
[/event server|banquet server|\bserver\b/, 'Event Server'],
|
||||
[/security officer|guard/, 'Security Officer'],
|
||||
[/\bhost(ess)?\b/, 'Host'],
|
||||
[/picker|warehouse/, 'Picker'],
|
||||
[/supervisor/, 'Supervisor'],
|
||||
[/manager/, 'Manager'],
|
||||
];
|
||||
|
||||
const AVAILABILITY_TOKENS = [
|
||||
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
|
||||
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
||||
[/evening|night|noche|tarde/i, 'Evenings'],
|
||||
[/morning|mañana|early/i, 'Mornings'],
|
||||
[/overnight|graveyard/i, 'Overnight'],
|
||||
[/on.?call|flexible/i, 'On-Call'],
|
||||
];
|
||||
|
||||
function buildCareerDna(prompt) {
|
||||
const transcript = quotedBlock(prompt);
|
||||
const lower = transcript.toLowerCase();
|
||||
const { answers, avgWords, specifics } = (() => {
|
||||
const lines = transcript.split('\n').filter((l) => /^Worker:/.test(l.trim()));
|
||||
const texts = lines.map((l) => l.replace(/^Worker:\s*/, '').trim()).filter(Boolean);
|
||||
const words = texts.reduce((s, t) => s + t.split(/\s+/).length, 0);
|
||||
return {
|
||||
answers: texts,
|
||||
avgWords: texts.length ? words / texts.length : 0,
|
||||
specifics: (transcript.match(/\b\d+\b/g) || []).length,
|
||||
};
|
||||
})();
|
||||
|
||||
const nameMatch = prompt.match(/worker named (.+?),/) || prompt.match(/worker named (.+?)\b/);
|
||||
const workerName = nameMatch ? nameMatch[1].trim() : 'This worker';
|
||||
const spanish = isSpanish(prompt);
|
||||
|
||||
// People mention several spans ("4 years as captain, 7 years total") — the
|
||||
// largest is the career total the profile wants.
|
||||
const years = (lower.match(/(\d+)\s*(?:\+)?\s*(?:years?|yrs?|años)/g) || [])
|
||||
.map((m) => Number(m.match(/\d+/)[0]))
|
||||
.reduce((max, n) => Math.max(max, n), 0);
|
||||
|
||||
// A stated job title, not a skill label — an empty string is more honest than
|
||||
// labelling someone "Table service".
|
||||
const role = ROLE_TITLES.find(([re]) => re.test(lower));
|
||||
const roleTitle = role ? role[1] : '';
|
||||
|
||||
const skills = [...new Set(
|
||||
SKILL_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)].slice(0, 8);
|
||||
const certifications = [...new Set(
|
||||
CERT_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
|
||||
)];
|
||||
const availability = [...new Set(
|
||||
AVAILABILITY_TOKENS.filter(([re]) => re.test(transcript)).map(([, token]) => token)
|
||||
)];
|
||||
|
||||
const languages = ['English'];
|
||||
if (/spanish|español/i.test(transcript) || spanish) languages.push('Spanish');
|
||||
|
||||
// Depth of the conversation is the only honest basis for these scores, so
|
||||
// they stay conservative — the reference prompt is explicit about that.
|
||||
const depth = clamp(avgWords * 1.9);
|
||||
const engagement = clamp(answers.length * 15);
|
||||
const base = clamp(depth * 0.5 + engagement * 0.25 + clamp(40 + specifics * 8) * 0.25);
|
||||
const leadership = clamp(base * 0.7 + (/lead|supervis|manage|train|captain|chef/i.test(lower) ? 20 : 0));
|
||||
|
||||
const transportation = /\b(car|drive|vehicle|truck|coche|carro)\b/i.test(transcript)
|
||||
? 'Own vehicle'
|
||||
: /\b(bus|transit|train|bart|metro)\b/i.test(transcript)
|
||||
? 'Public transit'
|
||||
: '';
|
||||
|
||||
const primary = skills[0] || (spanish ? 'hospitalidad' : 'hospitality');
|
||||
|
||||
return {
|
||||
career_goals: answers.length
|
||||
? spanish
|
||||
? `Crecer en ${primary.toLowerCase()} y asumir más responsabilidad en el equipo.`
|
||||
: `Grow within ${primary.toLowerCase()} and take on more responsibility on the floor.`
|
||||
: '',
|
||||
desired_position: roleTitle,
|
||||
current_position: years > 0 ? roleTitle : '',
|
||||
experience_years: years,
|
||||
experience: years > 0 && roleTitle
|
||||
? [{ company: spanish ? 'Empleador anterior' : 'Previous employer', role: roleTitle, years }]
|
||||
: [],
|
||||
skills,
|
||||
languages,
|
||||
availability,
|
||||
transportation,
|
||||
certifications,
|
||||
personality: answers.length
|
||||
? spanish
|
||||
? 'Directo y tranquilo. Explica su razonamiento sin adornos y asume lo que le corresponde.'
|
||||
: 'Direct and level. Explains their reasoning without embellishment and owns their part.'
|
||||
: '',
|
||||
strengths: answers.length
|
||||
? spanish
|
||||
? ['Mantiene la calma bajo presión', 'Comunica con claridad', 'Toma iniciativa']
|
||||
: ['Keeps composure under pressure', 'Communicates clearly', 'Takes initiative']
|
||||
: [],
|
||||
weaknesses: answers.length
|
||||
? spanish ? ['Aún desarrollando experiencia de liderazgo'] : ['Still building formal leadership experience']
|
||||
: [],
|
||||
industries: skills.length ? ['Hospitality', 'Events'] : [],
|
||||
salary_expectations: '',
|
||||
leadership_potential: leadership,
|
||||
communication_style: answers.length
|
||||
? spanish
|
||||
? 'Conciso y concreto; da ejemplos en lugar de generalidades.'
|
||||
: 'Concise and concrete; reaches for examples rather than generalities.'
|
||||
: '',
|
||||
summary: answers.length
|
||||
? spanish
|
||||
? `${workerName} habló con detalle sobre su trabajo real. Muestra criterio práctico y ganas de crecer.`
|
||||
: `${workerName} talked about real work in real detail. Practical judgment, and clearly interested in getting better at it.`
|
||||
: '',
|
||||
career_dna: {
|
||||
identity: answers.length
|
||||
? spanish
|
||||
? `Alguien que resuelve en el momento y aprende sobre la marcha.`
|
||||
: `Someone who solves problems in the moment and learns on the way through.`
|
||||
: '',
|
||||
learning_speed: clamp(base * 0.9),
|
||||
// Reliability is unverified until real shifts land, so it is held down.
|
||||
reliability: clamp(base * 0.6 + 10),
|
||||
communication_score: clamp(depth * 0.6 + 28),
|
||||
leadership_index: leadership,
|
||||
adaptability_score: clamp(base * 0.95),
|
||||
work_style: /\bteam|equipo\b/i.test(lower) ? 'Team player' : /\balone|independ\b/i.test(lower) ? 'Independent' : 'Operator',
|
||||
culture_match: spanish
|
||||
? ['Equipos con expectativas claras', 'Operaciones de ritmo rápido']
|
||||
: ['Teams with clear expectations', 'Fast-paced service operations'],
|
||||
// Baseline trust from one conversation — deliberately modest.
|
||||
reputation_score: clamp(30 + base * 0.35),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
||||
|
||||
function evaluateChallenge(prompt) {
|
||||
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
||||
const criteria = criteriaMatch
|
||||
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
|
||||
: ['overall_performance'];
|
||||
|
||||
const isPhotoIdentify = /marked these hazards/.test(prompt);
|
||||
const isMedia = /uploaded a (photo|video)/.test(prompt);
|
||||
|
||||
let base;
|
||||
let strengths;
|
||||
let concerns;
|
||||
|
||||
if (isPhotoIdentify) {
|
||||
const marks = (prompt.match(/^- "/gm) || []).length;
|
||||
// Real hazard scenes have a handful of genuine risks; marking everything is
|
||||
// as wrong as marking nothing.
|
||||
base = clamp(marks === 0 ? 18 : 46 + Math.min(38, marks * 13) - Math.max(0, marks - 4) * 9);
|
||||
strengths = marks ? [`Identified ${marks} hazard${marks === 1 ? '' : 's'}`] : [];
|
||||
concerns = marks === 0
|
||||
? ['No hazards marked']
|
||||
: marks > 4
|
||||
? ['Several marks are likely false positives']
|
||||
: ['Some lower-visibility risks were missed'];
|
||||
} else if (isMedia) {
|
||||
base = 74;
|
||||
strengths = ['Submitted a clear demonstration', 'Followed the challenge brief'];
|
||||
concerns = ['Some steps happen off-camera and cannot be verified'];
|
||||
} else {
|
||||
const { answers, avgWords, specifics } = readTranscript(
|
||||
`TRANSCRIPT:\n${quotedBlock(prompt).replace(/^Worker:/gm, 'Candidate:')}`
|
||||
);
|
||||
const spoken = answers.length ? answers : quotedBlock(prompt) ? ['x'] : [];
|
||||
base = spoken.length
|
||||
? clamp(avgWords * 2.4 + specifics * 6 + 20)
|
||||
: 0;
|
||||
strengths = base >= 70
|
||||
? ['Went straight to a concrete remedy', 'Kept the guest experience intact']
|
||||
: base > 0 ? ['Engaged with the scenario'] : [];
|
||||
concerns = base >= 70
|
||||
? ['Could state the escalation path explicitly']
|
||||
: ['Response lacks the specifics an employer would need to trust it'];
|
||||
}
|
||||
|
||||
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
|
||||
|
||||
// Spread the overall score across the rubric so criteria are not identical.
|
||||
const rubric = {};
|
||||
criteria.forEach((criterion, i) => {
|
||||
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
||||
});
|
||||
|
||||
return {
|
||||
verdict,
|
||||
score: base,
|
||||
rubric,
|
||||
feedback: verdict === 'verified'
|
||||
? `This holds up. You handled the core of the scenario the way the job actually needs it handled. Tighten the closing steps and this becomes strong evidence.`
|
||||
: verdict === 'needs_work'
|
||||
? `You are close. The instinct is right, but an employer reading this could not yet tell you would do it consistently. Be more specific about what you do, in what order.`
|
||||
: `This does not yet demonstrate the skill. Work through the scenario again and walk through your actual steps out loud, in order, with specifics.`,
|
||||
strengths,
|
||||
concerns,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Router ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Recognizes each workflow by the phrase its prompt opens with. Order matters
|
||||
* only in that every branch is mutually exclusive by design.
|
||||
*/
|
||||
const ROUTES = [
|
||||
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
|
||||
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
|
||||
[/Extract and infer a structured resume/m, buildResume, 1200],
|
||||
[/^You are "KROW", a friendly but sharp AI interviewer/m, interviewQuestion, 700],
|
||||
[/^You are KROW's proactive talent-matching engine/m, matchTalent, 1300],
|
||||
[/^You are KROW's AI interview evaluator/m, evaluateInterview, 1500],
|
||||
[/^You are "Owliver"/m, owliverQuestion, 800],
|
||||
[/^You are Owliver's Career DNA engine/m, buildCareerDna, 1600],
|
||||
[/^You are KROW's Proving Ground evaluator/m, evaluateChallenge, 1400],
|
||||
[/^You are "KROW", running a short proving-ground challenge/m,
|
||||
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
|
||||
];
|
||||
|
||||
/** Drop-in replacement for `integrations.Core.InvokeLLM`. */
|
||||
export async function invokeLLM({ prompt = '', response_json_schema: schema } = {}) {
|
||||
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
|
||||
|
||||
if (!route) {
|
||||
// An unrecognized prompt should surface loudly in the demo rather than
|
||||
// silently returning a shape the caller cannot use.
|
||||
console.warn('[krow-demo] Unrecognized AI prompt — returning an empty result.', prompt.slice(0, 120));
|
||||
await think(400);
|
||||
return schema ? {} : '';
|
||||
}
|
||||
|
||||
const [, handler, latency] = route;
|
||||
await think(latency);
|
||||
return handler(prompt);
|
||||
}
|
||||
|
||||
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
||||
export async function uploadFile({ file } = {}) {
|
||||
await think(500);
|
||||
if (!file) return { file_url: '' };
|
||||
// A blob URL keeps uploaded media viewable for the rest of the session
|
||||
// without any storage backend.
|
||||
return { file_url: URL.createObjectURL(file), file_name: file.name, file_size: file.size };
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* Shift records — the workforce actually turning up, or not.
|
||||
*
|
||||
* This is the one collection in the demo whose dates are **anchored to now**
|
||||
* rather than written as calendar dates. Everything else in `seed.js` is a
|
||||
* fixed narrative: 22 people applied on particular days and three were hired,
|
||||
* and those dates are the story. Attendance is not a story, it is a rolling
|
||||
* operational record — "how was attendance last week" has to mean *last week*,
|
||||
* every week, or the feature reads as permanently empty and looks broken.
|
||||
*
|
||||
* The snapshot persists on first load (see `store.js`), so the figures are
|
||||
* stable for a browser once written; only a fresh workspace re-anchors them.
|
||||
* Every foreign key points at a fixed seeded record, so correlation with
|
||||
* positions and hires stays exact however the dates land.
|
||||
*
|
||||
* Nothing here is random. The distribution is written out below and generated
|
||||
* deterministically, so the same workspace always produces the same figures and
|
||||
* a test can assert against them. What it is *shaped* to contain:
|
||||
*
|
||||
* - **Marco** — the control. Reliable, occasional event-night overtime.
|
||||
* - **Marcus** — attendance degrading in the last fortnight: two absences, a
|
||||
* no-show and repeated lateness, against a clean record before that. This
|
||||
* is the attendance anomaly, and it is recent enough to be actionable.
|
||||
* - **Antoine** — present throughout, but overtime climbing steadily week on
|
||||
* week. This is the overtime anomaly, and it is a trend rather than a
|
||||
* spike, which is the kind a person reading a table would miss.
|
||||
*
|
||||
* The roster is three people because three people have been hired — `Staff` is
|
||||
* the workforce, and inventing a fourth to make the charts look busier would be
|
||||
* inventing an employee.
|
||||
*/
|
||||
|
||||
/** Eight weeks: long enough for a week-on-week trend and a month comparison. */
|
||||
const WINDOW_DAYS = 56;
|
||||
|
||||
/**
|
||||
* A local instant `n` days back, at a given hour.
|
||||
*
|
||||
* Local rather than UTC because a shift belongs to the day it was worked in the
|
||||
* place it was worked, and `periodRange` windows on local day boundaries too.
|
||||
*/
|
||||
function daysAgo(n, hour = 9, minute = 0) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
d.setHours(hour, minute, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The roster, joined to the records they were hired against.
|
||||
*
|
||||
* `job_posting_id` and `role_category` are the posting's own, so "department"
|
||||
* means here exactly what it means on Hired History and Analytics — see the
|
||||
* join in `lib/hiringRecords.js`.
|
||||
*/
|
||||
const ROSTER = [
|
||||
{
|
||||
staff_id: 'staff_marco',
|
||||
worker_name: 'Marco Rivera',
|
||||
worker_email: 'marco.rivera@email.com',
|
||||
job_posting_id: 'job_bartender_corp',
|
||||
role: 'Experienced Bartender – Corporate Events',
|
||||
role_category: 'Bartender',
|
||||
/* Wed–Sat: corporate events run late in the week. */
|
||||
weekdays: [3, 4, 5, 6],
|
||||
startHour: 16,
|
||||
scheduledHours: 8,
|
||||
},
|
||||
{
|
||||
staff_id: 'staff_marcus',
|
||||
worker_name: 'Marcus Williams',
|
||||
worker_email: 'marcus.w@email.com',
|
||||
job_posting_id: 'job_security',
|
||||
role: 'Event Security Officer',
|
||||
role_category: 'Security',
|
||||
/* Mon–Fri: a fixed security rota, which is what makes the recent
|
||||
absences stand out rather than read as an irregular schedule. */
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
startHour: 14,
|
||||
scheduledHours: 8,
|
||||
},
|
||||
{
|
||||
staff_id: 'staff_antoine',
|
||||
worker_name: 'Chef Antoine Dubois',
|
||||
worker_email: 'antoine.dubois@email.com',
|
||||
job_posting_id: 'job_chef',
|
||||
role: 'Executive Chef – Catering',
|
||||
role_category: 'Chef',
|
||||
/* Tue–Sat: kitchen service. */
|
||||
weekdays: [2, 3, 4, 5, 6],
|
||||
startHour: 12,
|
||||
scheduledHours: 9,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* How each worker's series behaves, by position in it.
|
||||
*
|
||||
* `i` counts back from the most recent shift, so "the last fortnight" is a
|
||||
* range of small indices and stays that way as the window rolls forward.
|
||||
* Returning a plain record keeps every rule visible in one place instead of
|
||||
* spread across the generator.
|
||||
*/
|
||||
const BEHAVIOUR = {
|
||||
/* Reliable. One late arrival every couple of months, and overtime only on
|
||||
the nights events actually overrun. */
|
||||
staff_marco: (i, weekday) => ({
|
||||
status: i === 14 ? 'late' : 'present',
|
||||
minutesLate: i === 14 ? 9 : 0,
|
||||
/* Friday and Saturday events overrun; midweek ones do not. */
|
||||
overtime: weekday === 5 || weekday === 6 ? 1 : 0,
|
||||
notes: '',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Clean for six weeks, then coming apart.
|
||||
*
|
||||
* Indices 0–9 are roughly the last fortnight. Two absences, one no-show and
|
||||
* three late arrivals inside that window, against a single late arrival in
|
||||
* the six weeks before it — a change big enough to be worth surfacing and
|
||||
* specific enough to act on.
|
||||
*/
|
||||
staff_marcus: (i) => {
|
||||
if (i === 2 || i === 7) {
|
||||
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
|
||||
}
|
||||
if (i === 4) {
|
||||
return { status: 'no_show', minutesLate: 0, overtime: 0, notes: 'No contact' };
|
||||
}
|
||||
if (i === 1) return { status: 'late', minutesLate: 24, overtime: 0, notes: '' };
|
||||
if (i === 5) return { status: 'late', minutesLate: 16, overtime: 0, notes: '' };
|
||||
if (i === 9) return { status: 'late', minutesLate: 12, overtime: 0, notes: '' };
|
||||
if (i === 26) return { status: 'late', minutesLate: 7, overtime: 0, notes: '' };
|
||||
return { status: 'present', minutesLate: 0, overtime: 0, notes: '' };
|
||||
},
|
||||
|
||||
/**
|
||||
* Always there, increasingly late leaving.
|
||||
*
|
||||
* Overtime rises about half an hour a week as the kitchen carries more
|
||||
* covers, on the three busiest shifts of each week. A steady climb rather
|
||||
* than a spike, which is exactly the shape that hides in a table of totals.
|
||||
*/
|
||||
staff_antoine: (i, weekday) => {
|
||||
const weekIndex = Math.floor(i / 5);
|
||||
const busy = weekday === 4 || weekday === 5 || weekday === 6;
|
||||
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
|
||||
return { status: 'present', minutesLate: 0, overtime, notes: '' };
|
||||
},
|
||||
};
|
||||
|
||||
/** Every shift date for one worker, most recent first. */
|
||||
function shiftOffsets(weekdays) {
|
||||
const offsets = [];
|
||||
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
||||
const day = daysAgo(offset).getDay();
|
||||
if (weekdays.includes(day)) offsets.push(offset);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
function buildShifts() {
|
||||
const records = [];
|
||||
|
||||
for (const worker of ROSTER) {
|
||||
const offsets = shiftOffsets(worker.weekdays);
|
||||
|
||||
offsets.forEach((offset, i) => {
|
||||
const scheduledStart = daysAgo(offset, worker.startHour);
|
||||
const weekday = scheduledStart.getDay();
|
||||
const scheduledEnd = new Date(scheduledStart.getTime() + worker.scheduledHours * HOUR);
|
||||
|
||||
const { status, minutesLate, overtime, notes } = BEHAVIOUR[worker.staff_id](i, weekday);
|
||||
const worked = status !== 'absent' && status !== 'no_show';
|
||||
|
||||
const actualStart = worked
|
||||
? new Date(scheduledStart.getTime() + minutesLate * 60 * 1000)
|
||||
: null;
|
||||
const actualEnd = worked
|
||||
? new Date(scheduledEnd.getTime() + overtime * HOUR)
|
||||
: null;
|
||||
|
||||
records.push({
|
||||
id: `shift_${worker.staff_id.replace('staff_', '')}_${pad(offsets.length - i)}`,
|
||||
staff_id: worker.staff_id,
|
||||
worker_name: worker.worker_name,
|
||||
worker_email: worker.worker_email,
|
||||
job_posting_id: worker.job_posting_id,
|
||||
role: worker.role,
|
||||
role_category: worker.role_category,
|
||||
|
||||
shift_date: localDate(scheduledStart),
|
||||
scheduled_start: scheduledStart.toISOString(),
|
||||
scheduled_end: scheduledEnd.toISOString(),
|
||||
scheduled_hours: worker.scheduledHours,
|
||||
|
||||
actual_start: actualStart ? actualStart.toISOString() : null,
|
||||
actual_end: actualEnd ? actualEnd.toISOString() : null,
|
||||
/* Arriving late shortens the shift; staying on lengthens it. A missed
|
||||
shift is zero hours worked, not a short one. */
|
||||
actual_hours: worked
|
||||
? round2(worker.scheduledHours - minutesLate / 60 + overtime)
|
||||
: 0,
|
||||
overtime_hours: worked ? round1(overtime) : 0,
|
||||
minutes_late: worked ? minutesLate : 0,
|
||||
status,
|
||||
notes,
|
||||
|
||||
/* Load-bearing: `inPeriod` in `lib/skills/dataResolver.js` windows every
|
||||
collection on `created_date`, so a shift's created date *is* the
|
||||
instant it was worked. Without that, every period reading of this
|
||||
collection would be empty and nothing would say why. */
|
||||
created_date: scheduledStart.toISOString(),
|
||||
updated_date: (actualEnd || scheduledEnd).toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* Most recent first, matching the `-created_date` order every other
|
||||
collection is listed in. */
|
||||
return records.sort(
|
||||
(a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export const SHIFT_RECORDS = buildShifts();
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Application data client.
|
||||
*
|
||||
* The reference app talks to a Base44 backend through this module. The demo
|
||||
* keeps the module path, the export name, and the full method contract, and
|
||||
* swaps the transport for the local store in `store.js` and the local AI engine
|
||||
* in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or
|
||||
* cares, which is exactly the point: the seam stays where it was.
|
||||
*/
|
||||
|
||||
import { createEntity, initStore, resetStore } from './store';
|
||||
import { invokeLLM, uploadFile } from './aiEngine';
|
||||
import { DEMO_USER, seedData } from './seed';
|
||||
|
||||
initStore(seedData);
|
||||
|
||||
const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
||||
'UserActivity', 'Evidence', 'User',
|
||||
/* Who is on which position, and for how long. The record that turns "hired"
|
||||
into workforce allocation: without it a position knows its demand and its
|
||||
applicants but not who is actually covering it. */
|
||||
'Assignment',
|
||||
/* Shifts worked, missed and overrun. The operational record behind
|
||||
attendance and overtime analysis — see `api/attendanceSeed.js`. */
|
||||
'ShiftRecord',
|
||||
];
|
||||
|
||||
const entities = Object.fromEntries(
|
||||
ENTITY_NAMES.map((name) => [name, createEntity(name)])
|
||||
);
|
||||
|
||||
/* ── Auth ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
const SESSION_KEY = 'krow_demo_user';
|
||||
|
||||
function loadUser() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
|
||||
} catch {
|
||||
return { ...DEMO_USER };
|
||||
}
|
||||
}
|
||||
|
||||
let currentUser = loadUser();
|
||||
|
||||
/**
|
||||
* Writes the session user, and says whether it actually landed.
|
||||
*
|
||||
* The old version was `try { setItem } catch {}` — a swallowed
|
||||
* `QuotaExceededError` or a private-browsing refusal, and the caller was handed
|
||||
* a user object indistinguishable from a successful write. For preferences that
|
||||
* is invisible; for `customSkills`, which is where every account-authored skill
|
||||
* definition lives, it is the whole "I saved it and it was gone" report: the
|
||||
* toast said added, the list showed it, the reload did not.
|
||||
*
|
||||
* The read-back matters as much as the catch. A write can be accepted and then
|
||||
* evicted, and a serialisation can land truncated; comparing what came back
|
||||
* with what went in is the only way to know the record is really there.
|
||||
*/
|
||||
function persistUser() {
|
||||
const payload = JSON.stringify(currentUser);
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, payload);
|
||||
} catch (error) {
|
||||
return { persisted: false, error };
|
||||
}
|
||||
try {
|
||||
if (localStorage.getItem(SESSION_KEY) !== payload) {
|
||||
return { persisted: false, error: new Error('The session record did not survive the write.') };
|
||||
}
|
||||
} catch (error) {
|
||||
return { persisted: false, error };
|
||||
}
|
||||
return { persisted: true, error: null };
|
||||
}
|
||||
|
||||
const auth = {
|
||||
/** The demo is always signed in as the seeded employer/admin user. */
|
||||
async me() {
|
||||
return { ...currentUser };
|
||||
},
|
||||
|
||||
async updateMe(patch) {
|
||||
currentUser = { ...currentUser, ...patch };
|
||||
persistUser();
|
||||
return { ...currentUser };
|
||||
},
|
||||
|
||||
/**
|
||||
* Preferences, read synchronously.
|
||||
*
|
||||
* `me()` is async because the real client fetches, but the panel provider has
|
||||
* to decide whether Owliver starts open during its first render — one tick
|
||||
* later is a visible flash of the wrong workspace. The session user is already
|
||||
* hydrated from localStorage at module load, so this is a plain read of the
|
||||
* same record `me()` returns, not a second copy of the state.
|
||||
*/
|
||||
preferences() {
|
||||
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
|
||||
},
|
||||
|
||||
/**
|
||||
* Merges into the stored preferences and persists with the rest of the user.
|
||||
*
|
||||
* Returns the write's outcome alongside the record, rather than the record
|
||||
* alone. Preferences are where account-authored skills live, so "did this
|
||||
* survive the reload" is a question the caller has to be able to answer —
|
||||
* see `persistUser`.
|
||||
*/
|
||||
async updatePreferences(patch) {
|
||||
currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } };
|
||||
const write = persistUser();
|
||||
return { user: { ...currentUser }, ...write };
|
||||
},
|
||||
|
||||
isAuthenticated() {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* There is no identity provider to sign out of, so this clears the local
|
||||
* session and returns to the requested page.
|
||||
*/
|
||||
logout(redirectTo = '/') {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
currentUser = { ...DEMO_USER };
|
||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/';
|
||||
},
|
||||
|
||||
redirectToLogin() {
|
||||
window.location.href = '/';
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
||||
|
||||
const integrations = {
|
||||
Core: {
|
||||
InvokeLLM: invokeLLM,
|
||||
UploadFile: uploadFile,
|
||||
},
|
||||
};
|
||||
|
||||
const analytics = {
|
||||
track({ eventName, properties } = {}) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[krow-demo] analytics', eventName, properties || {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const base44 = { entities, auth, integrations, analytics };
|
||||
|
||||
/** Restores the shipped demo data, discarding local edits. */
|
||||
export function resetDemoData() {
|
||||
resetStore(seedData);
|
||||
window.location.reload();
|
||||
}
|
||||
93
src/api/doormile/client.js
Normal file
93
src/api/doormile/client.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* The Doormile Express admin API client.
|
||||
*
|
||||
* One axios instance for `api.doormile.com/api/v1/admin/*`. Every request needs
|
||||
* a JWT Bearer token, so the token is attached here rather than at each call
|
||||
* site, and an expired one logs the console out the same way a manual sign-out
|
||||
* does — a half-authenticated shell that renders but 401s on every fetch is
|
||||
* worse than being sent back to the login page.
|
||||
*
|
||||
* Ported from the Express Console's `utils/doormileAxios.js`. The only change
|
||||
* is the environment seam: Vite exposes `import.meta.env`, not `process.env`.
|
||||
*/
|
||||
|
||||
export const DOORMILE_TOKEN_KEY = 'doormileToken';
|
||||
export const DOORMILE_USER_KEY = 'doormileUser';
|
||||
|
||||
/** The backend, for every `/admin/*` call the console makes. */
|
||||
export const DOORMILE_API_URL = 'https://api.doormile.com/api/v1';
|
||||
|
||||
/**
|
||||
* `VITE_DOORMILE_URL` exists to point a local build at a staging or mock
|
||||
* backend. It is never set in production: `.dockerignore` keeps every `.env`
|
||||
* file out of the image, so a container build always resolves to the real API.
|
||||
*
|
||||
* An override is announced loudly in development, because a console silently
|
||||
* talking to the wrong backend looks exactly like a console talking to the
|
||||
* right one with no data in it.
|
||||
*/
|
||||
export const DOORMILE_BASE_URL = import.meta.env.VITE_DOORMILE_URL || DOORMILE_API_URL;
|
||||
|
||||
if (import.meta.env.DEV && DOORMILE_BASE_URL !== DOORMILE_API_URL) {
|
||||
console.warn(
|
||||
`[Doormile] API overridden to ${DOORMILE_BASE_URL} — the real backend is ${DOORMILE_API_URL}. ` +
|
||||
'Unset VITE_DOORMILE_URL to use it.'
|
||||
);
|
||||
}
|
||||
|
||||
const doormileAxios = axios.create({ baseURL: DOORMILE_BASE_URL });
|
||||
|
||||
doormileAxios.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem(DOORMILE_TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
doormileAxios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && !window.location.href.includes('/login')) {
|
||||
localStorage.removeItem(DOORMILE_TOKEN_KEY);
|
||||
localStorage.removeItem(DOORMILE_USER_KEY);
|
||||
window.location.replace('/login');
|
||||
}
|
||||
|
||||
/* Reject with the server's own body — callers read `err.message` — but keep
|
||||
the HTTP status reachable as `err.httpStatus`. Without this the status is
|
||||
lost entirely and a 404, a 500 and a validation failure all collapse into
|
||||
the same generic failure, which is exactly the distinction the write
|
||||
paths need. Non-enumerable so nothing that spreads or stringifies the
|
||||
body starts carrying it around. */
|
||||
const body = error.response?.data;
|
||||
if (body === undefined || body === null) return Promise.reject(error);
|
||||
const payload = typeof body === 'object' ? body : { message: String(body) };
|
||||
return Promise.reject(
|
||||
Object.defineProperty(payload, 'httpStatus', {
|
||||
value: error.response?.status,
|
||||
enumerable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/** Reads the signed-in admin back out of storage, or null when signed out. */
|
||||
export const readStoredUser = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(DOORMILE_USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const readStoredToken = () => localStorage.getItem(DOORMILE_TOKEN_KEY);
|
||||
|
||||
/** The message a failed call should show — server text first, then the throw. */
|
||||
export const errorMessage = (err, fallback = 'Something went wrong') =>
|
||||
err?.message || err?.response?.data?.message || fallback;
|
||||
|
||||
export default doormileAxios;
|
||||
655
src/api/doormile/endpoints.js
Normal file
655
src/api/doormile/endpoints.js
Normal file
@@ -0,0 +1,655 @@
|
||||
import doormileAxios, { DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY } from './client';
|
||||
|
||||
// The Doormile Express admin API — one named async export per
|
||||
// `api.doormile.com/api/v1/admin/*` endpoint.
|
||||
//
|
||||
// Ported verbatim from the Express Console's `pages/api/doormileApi.js`, since
|
||||
// this is the contract the backend actually speaks; only the client import
|
||||
// changed. Every function unwraps the envelope for its caller: list/GET
|
||||
// functions return `response.data.data` directly, mutations return the full
|
||||
// `response.data` (`{ success, data | message }`) so callers can check
|
||||
// `res.success`.
|
||||
//
|
||||
// Pages consume these through the React Query hooks in `@/lib/doormileHooks`
|
||||
// rather than calling them directly, so cache keys stay coherent across the app.
|
||||
|
||||
const buildQuery = (params = {}) => {
|
||||
const usp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') usp.append(key, value);
|
||||
});
|
||||
const qs = usp.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
};
|
||||
|
||||
// ==============================|| Auth ||============================== //
|
||||
|
||||
export const loginAdmin = async (email, password) => {
|
||||
const response = await doormileAxios.post('/admin/login', { email, password });
|
||||
if (response.data.success) {
|
||||
localStorage.setItem(DOORMILE_TOKEN_KEY, response.data.token);
|
||||
localStorage.setItem(DOORMILE_USER_KEY, JSON.stringify(response.data.user));
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const logoutAdmin = () => {
|
||||
localStorage.removeItem(DOORMILE_TOKEN_KEY);
|
||||
localStorage.removeItem(DOORMILE_USER_KEY);
|
||||
};
|
||||
|
||||
// Drains a paginated list endpoint instead of taking whatever the server's
|
||||
// default page size happens to be.
|
||||
//
|
||||
// GET /admin/customers defaults to `pagesize: 20` — confirmed live against an
|
||||
// account with 37 customers, which came back as
|
||||
// { pageno: 1, pagesize: 20, pages: 2, total: 37 }. Every caller of
|
||||
// getAdminCustomers was therefore seeing 20 of 37 and had no way to know:
|
||||
// the Customers page reported "Total Customers 20", and the customer lookups
|
||||
// on the orders pages silently missed anyone on page 2.
|
||||
//
|
||||
// (express-console-api.md claims the default is 500. It is not, at least not
|
||||
// for this endpoint. Trust the envelope, not the doc.)
|
||||
const PAGE_SIZE = 100; // the doc's own example value — safer than assuming the 1000 cap holds
|
||||
const MAX_PAGES = 12;
|
||||
|
||||
const fetchAllPages = async (path) => {
|
||||
const first = await doormileAxios.get(`${path}${buildQuery({ pageno: 1, pagesize: PAGE_SIZE })}`);
|
||||
const body = first.data || {};
|
||||
const rows = body.data || [];
|
||||
const total = Number(body.total);
|
||||
if (!Number.isFinite(total) || rows.length >= total) return rows;
|
||||
|
||||
const pageCount = Math.min(Math.ceil(total / PAGE_SIZE), MAX_PAGES);
|
||||
for (let page = 2; page <= pageCount; page += 1) {
|
||||
|
||||
const next = await doormileAxios.get(`${path}${buildQuery({ pageno: page, pagesize: PAGE_SIZE })}`);
|
||||
const more = next.data?.data || [];
|
||||
if (!more.length) break;
|
||||
rows.push(...more);
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
// ==============================|| Dashboard, profile, reports ||============================== //
|
||||
|
||||
export const getDashboard = async () => {
|
||||
const response = await doormileAxios.get('/admin/dashboard');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// jupiter used fromdate/todate + applocationid (city); Doormile uses from/to
|
||||
// and no city filter at all (filters by hubid instead — see
|
||||
// jupiter2doormile.md §2.3/§6). Response carries by_location/by_hub/by_tenant/
|
||||
// by_rider blocks per that doc, not documented in express-console-api.md.
|
||||
export const getReports = async (from, to, tenantid, locationid, hubid) => {
|
||||
const response = await doormileAxios.get(`/admin/reports${buildQuery({ from, to, tenantid, locationid, hubid })}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// Confirmed against jupiter2doormile.md (Status: Done) as the replacement for
|
||||
// jupiter's getlocationsummary — per-tenant-site report breakdown. Not in
|
||||
// express-console-api.md.
|
||||
export const getLocationsSummary = async (tenantid, locationid, from, to) => {
|
||||
const response = await doormileAxios.get(`/admin/locations/summary${buildQuery({ tenantid, locationid, from, to })}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const getProfile = async () => {
|
||||
const response = await doormileAxios.get('/admin/profile');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateProfilePassword = async (current_password, new_password) => {
|
||||
const response = await doormileAxios.put('/admin/profile/password', { current_password, new_password });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| App users (staff logins) ||============================== //
|
||||
|
||||
export const getAppUsers = async () => {
|
||||
const response = await doormileAxios.get('/admin/users');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createAppUser = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/users', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateAppUser = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/users/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteAppUser = async (id) => {
|
||||
const response = await doormileAxios.delete(`/admin/users/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Partners (fleet / rider suppliers) ||============================== //
|
||||
|
||||
export const getPartners = async () => {
|
||||
const response = await doormileAxios.get('/admin/partners');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createPartner = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/partners', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPartner = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/partners/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updatePartner = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/partners/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deletePartner = async (id) => {
|
||||
const response = await doormileAxios.delete(`/admin/partners/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Tenants (client companies) ||============================== //
|
||||
|
||||
export const getAdminTenants = async () => {
|
||||
const response = await doormileAxios.get('/admin/tenants');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createAdminTenant = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/tenants', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getAdminTenant = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/tenants/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateAdminTenant = async (id, data) => {
|
||||
// requiredeliveryotp is a pointer server-side — omit the key entirely to leave the setting alone.
|
||||
const response = await doormileAxios.put(`/admin/tenants/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteAdminTenant = async (id) => {
|
||||
const response = await doormileAxios.delete(`/admin/tenants/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getTenantLocations = async (tenantId) => {
|
||||
const response = await doormileAxios.get(`/admin/tenants/${tenantId}/locations`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createTenantLocation = async (tenantId, data) => {
|
||||
const response = await doormileAxios.post(`/admin/tenants/${tenantId}/locations`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateTenantLocation = async (locationId, data) => {
|
||||
// Not nested under the tenant — PUT /admin/tenantlocations/:id.
|
||||
const response = await doormileAxios.put(`/admin/tenantlocations/${locationId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Tenant customers (a client's own end customers) ||============================== //
|
||||
|
||||
export const getTenantCustomers = async () => fetchAllPages('/admin/tenantcustomers');
|
||||
|
||||
export const createTenantCustomer = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/tenantcustomers', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getTenantCustomer = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/tenantcustomers/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateTenantCustomer = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/tenantcustomers/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteTenantCustomer = async (id) => {
|
||||
const response = await doormileAxios.delete(`/admin/tenantcustomers/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| B2C app customers ||============================== //
|
||||
|
||||
export const getAdminCustomers = async () => fetchAllPages('/admin/customers');
|
||||
|
||||
// POST /admin/customers.
|
||||
//
|
||||
// express-console-api.md lists this resource as GET + PATCH only, and states
|
||||
// that a customer there comes into existence through a booking. Added on
|
||||
// explicit instruction to create into the store the Customers page actually
|
||||
// reads. If the route genuinely does not exist the call returns 404/405, and
|
||||
// the assistant surfaces that verbatim with the endpoint name rather than
|
||||
// failing silently — so an unsupported route is immediately visible.
|
||||
//
|
||||
// Body mirrors this resource's own GET shape.
|
||||
export const createAdminCustomer = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/customers', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateAdminCustomer = async (id, data) => {
|
||||
const response = await doormileAxios.patch(`/admin/customers/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Hubs ||============================== //
|
||||
|
||||
export const getHubs = async () => {
|
||||
const response = await doormileAxios.get('/admin/hubs');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createHub = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/hubs', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getHub = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/hubs/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateHub = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/hubs/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteHub = async (id) => {
|
||||
// Soft delete server-side.
|
||||
const response = await doormileAxios.delete(`/admin/hubs/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Vehicles ||============================== //
|
||||
|
||||
export const getVehicles = async () => {
|
||||
const response = await doormileAxios.get('/admin/vehicles');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createVehicle = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/vehicles', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getVehicle = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/vehicles/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateVehicle = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/vehicles/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteVehicle = async (id) => {
|
||||
// Soft delete server-side.
|
||||
const response = await doormileAxios.delete(`/admin/vehicles/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Milers (riders) ||============================== //
|
||||
|
||||
export const getMilers = async () => {
|
||||
// Tenant-scoped server-side from the token — no tenantid param needed/accepted.
|
||||
const response = await doormileAxios.get('/admin/milers');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createMiler = async (data) => {
|
||||
// configid defaults to 1001 server-side (the miler-app login partition) — do not override it.
|
||||
const response = await doormileAxios.post('/admin/milers', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getMiler = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/milers/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateMiler = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/milers/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const blockMiler = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/milers/${id}/block`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const assignMilerVehicle = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/milers/${id}/assign-vehicle`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const notifyMiler = async (milerProfileId, title, message) => {
|
||||
const response = await doormileAxios.post(`/admin/milers/${milerProfileId}/notify`, { title, message });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Confirmed against jupiter2doormile.md (Status: Done — tested against a real
|
||||
// request), not in express-console-api.md. jupiter used fromdate/todate;
|
||||
// Doormile uses from/to (both YYYY-MM-DD). Aggregate rider KPI counts +
|
||||
// current position (currentlatitude/currentlongitude/lastpingat per the doc —
|
||||
// unconfirmed beyond those three field names).
|
||||
export const getMilerSummary = async (applocationid, from, to, tenantid, hubid) => {
|
||||
const response = await doormileAxios.get(`/admin/milers/summary${buildQuery({ applocationid, from, to, tenantid, hubid })}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// Replaces jupiter's `riderlogs` table — the rider's GPS/status ping history.
|
||||
export const getMilerLogs = async (id, from, to, limit) => {
|
||||
const response = await doormileAxios.get(`/admin/milers/${id}/logs${buildQuery({ from, to, limit })}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// No jupiter equivalent — new in Doormile.
|
||||
export const getMilerActivity = async (id, from, to) => {
|
||||
const response = await doormileAxios.get(`/admin/milers/${id}/activity${buildQuery({ from, to })}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// ==============================|| Bookings ||============================== //
|
||||
|
||||
export const getBookings = async (pageno, pagesize) => {
|
||||
const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// Same request as getBookings, but keeps the envelope's `total`/`page` instead
|
||||
// of throwing them away.
|
||||
//
|
||||
// getBookings returns `response.data.data` — just the rows — which means no
|
||||
// caller can tell a full result from a truncated one. `pagesize` is capped at
|
||||
// 1000 server-side (express-console-api.md, "Conventions": default 500, cap
|
||||
// 1000), so any account with more than 1000 bookings silently gets a partial
|
||||
// list from a single call. That's tolerable for a paginated table (it fetches
|
||||
// the next page as you scroll) but NOT for anything that counts or sums, which
|
||||
// would report a confidently wrong number.
|
||||
//
|
||||
// Kept as a separate export rather than changing getBookings' return shape:
|
||||
// four callers (fetchDeliveries, fetchCountAPI, orders.js, the assistant's own
|
||||
// per-order lookup) already destructure it as a bare array.
|
||||
export const getBookingsPage = async (pageno, pagesize) => {
|
||||
const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`);
|
||||
const body = response.data || {};
|
||||
return {
|
||||
rows: body.data || [],
|
||||
// `total` is documented as present on list responses; fall back to the row
|
||||
// count so a backend that omits it degrades to "this is everything" rather
|
||||
// than to NaN-driven pagination.
|
||||
total: Number.isFinite(Number(body.total)) ? Number(body.total) : (body.data || []).length,
|
||||
page: Number(body.page) || pageno
|
||||
};
|
||||
};
|
||||
|
||||
export const createExpressBooking = async (data) => {
|
||||
// tenantid is forced to the caller's own tenant server-side on client logins.
|
||||
// CityGate applies: pickup pincode prefix must be an open city (641/600/560/500/629).
|
||||
const response = await doormileAxios.post('/admin/expressbooking', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createExpressBookingBulk = async (bookings) => {
|
||||
// Max 200 per call; response carries per-row results.
|
||||
const response = await doormileAxios.post('/admin/expressbooking/bulk', { bookings });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getBooking = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/bookings/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const assignMilerToBooking = async (id, data) => {
|
||||
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-miler`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Doormile-native bulk assign: picks real milers via Redis GEO + AI scoring, no
|
||||
// external solver involved. Commits server-side in this one call — unlike the
|
||||
// workolik solver flow, there's no separate preview/reconcile/commit step.
|
||||
// Sequencing (POST /optimization/doormile/sequence) is not deployed yet, so
|
||||
// riders with more than one stop come back unsequenced (step: 0) — see
|
||||
// doormile-flow.md's State table.
|
||||
export const batchAssignBookings = async (bookingIds, maxPerRider = 5) => {
|
||||
const response = await doormileAxios.post('/hub/bookings/batch-assign', {
|
||||
bookingids: bookingIds,
|
||||
max_per_rider: maxPerRider
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const assignVehicleToBooking = async (id, data) => {
|
||||
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateBookingStatus = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/bookings/${id}/status`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const cancelBooking = async (id, data) => {
|
||||
const response = await doormileAxios.post(`/admin/bookings/${id}/cancel`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const bulkCancelBookings = async (bookingIds) => {
|
||||
const response = await doormileAxios.post('/admin/bookings/bulk-cancel', { bookingIds });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Confirmed against jupiter2doormile.md (Status: Done), not in
|
||||
// express-console-api.md.
|
||||
export const getBookingTrack = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/bookings/${id}/track`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// ==============================|| Consignments ||============================== //
|
||||
|
||||
export const getConsignments = async () => {
|
||||
const response = await doormileAxios.get('/admin/consignments');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
// Confirmed against jupiter2doormile.md (Status: Done) — the actual GPS/status
|
||||
// trail for a consignment, not in express-console-api.md.
|
||||
export const getConsignmentLogs = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/consignments/${id}/logs`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const getConsignment = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/consignments/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const trackConsignment = async (trackingno) => {
|
||||
const response = await doormileAxios.get(`/admin/consignments/track/${trackingno}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateConsignmentStatus = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/consignments/${id}/status`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Tripsheets (hub-to-hub transport) ||============================== //
|
||||
|
||||
export const getTripsheets = async () => {
|
||||
const response = await doormileAxios.get('/admin/tripsheets');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createTripsheet = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/tripsheets', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getTripsheet = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/tripsheets/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const addTripsheetItem = async (id, consignmentid) => {
|
||||
const response = await doormileAxios.post(`/admin/tripsheets/${id}/items`, { consignmentid });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const removeTripsheetItem = async (id, itemId) => {
|
||||
const response = await doormileAxios.delete(`/admin/tripsheets/${id}/items/${itemId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const dispatchTripsheet = async (id) => {
|
||||
const response = await doormileAxios.put(`/admin/tripsheets/${id}/dispatch`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const arriveTripsheet = async (id) => {
|
||||
const response = await doormileAxios.put(`/admin/tripsheets/${id}/arrive`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Pricing ||============================== //
|
||||
|
||||
export const getAdminPricing = async () => {
|
||||
const response = await doormileAxios.get('/admin/pricing');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createAdminPricing = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/pricing', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateAdminPricing = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/pricing/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteAdminPricing = async (id) => {
|
||||
const response = await doormileAxios.delete(`/admin/pricing/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const simulatePricing = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/pricing/simulate', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const quotePricing = async (data) => {
|
||||
// Same handler as simulate.
|
||||
const response = await doormileAxios.post('/admin/pricing/quote', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getDoormilePricing = async () => {
|
||||
const response = await doormileAxios.get('/admin/doormile-pricing');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createDoormilePricing = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/doormile-pricing', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateDoormilePricing = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/doormile-pricing/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteDoormilePricing = async (id) => {
|
||||
// Soft delete server-side.
|
||||
const response = await doormileAxios.delete(`/admin/doormile-pricing/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Exceptions ||============================== //
|
||||
|
||||
export const getExceptions = async () => {
|
||||
const response = await doormileAxios.get('/admin/exceptions');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const createException = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/exceptions', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getException = async (id) => {
|
||||
const response = await doormileAxios.get(`/admin/exceptions/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateExceptionStatus = async (id, status, resolution) => {
|
||||
// status: 'Resolved' | 'Closed'.
|
||||
const response = await doormileAxios.put(`/admin/exceptions/${id}/status`, { status, resolution });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| Competitive intel ||============================== //
|
||||
|
||||
// Confirmed live: this resource paginates on page/limit, not the
|
||||
// pageno/pagesize convention used everywhere else in the API — and defaults
|
||||
// to only 50 rows (there are 339+ competitor branches on a real tenant), so
|
||||
// callers must page through it explicitly rather than trusting one fetch to
|
||||
// return everything. Returns the full envelope (data/limit/page/total) so
|
||||
// the caller can build pagination controls.
|
||||
export const getCompetitorBranches = async (page, limit) => {
|
||||
const response = await doormileAxios.get(`/admin/competitor-branches${buildQuery({ page, limit })}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createCompetitorBranch = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/competitor-branches', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateCompetitorBranch = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/competitor-branches/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteCompetitorBranch = async (id) => {
|
||||
const response = await doormileAxios.delete(`/admin/competitor-branches/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Same page/limit pagination convention as competitor-branches (confirmed
|
||||
// live) — 368+ rows on a real tenant, default page returns only 100.
|
||||
export const getCarrierPricing = async (page, limit) => {
|
||||
const response = await doormileAxios.get(`/admin/carrier-pricing${buildQuery({ page, limit })}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createCarrierPricing = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/carrier-pricing', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateCarrierPricing = async (id, data) => {
|
||||
const response = await doormileAxios.put(`/admin/carrier-pricing/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteCarrierPricing = async (id) => {
|
||||
const response = await doormileAxios.delete(`/admin/carrier-pricing/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
29
src/api/doormile/index.js
Normal file
29
src/api/doormile/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The Doormile Express data layer — the single import surface for pages.
|
||||
*
|
||||
* import { getHubs, fetchDeliveries } from '@/api/doormile';
|
||||
*
|
||||
* Three files sit underneath:
|
||||
*
|
||||
* - `client.js` — the axios instance, the bearer token, the 401 handling.
|
||||
* - `endpoints.js` — one function per `api.doormile.com/api/v1/admin/*` route.
|
||||
* - `queries.js` — the page-facing layer: joins, status mapping, the solver
|
||||
* calls, and the safe empty defaults for the resources the
|
||||
* admin API has no equivalent for.
|
||||
*
|
||||
* Pages should generally reach for the React Query hooks in
|
||||
* `@/lib/doormileHooks` instead, so cache keys stay coherent across the app.
|
||||
*/
|
||||
|
||||
export { default as doormileAxios } from './client';
|
||||
export {
|
||||
DOORMILE_BASE_URL,
|
||||
DOORMILE_TOKEN_KEY,
|
||||
DOORMILE_USER_KEY,
|
||||
errorMessage,
|
||||
readStoredToken,
|
||||
readStoredUser,
|
||||
} from './client';
|
||||
export { OpenToast, messageOf } from './notify';
|
||||
export * from './endpoints';
|
||||
export * from './queries';
|
||||
22
src/api/doormile/notify.js
Normal file
22
src/api/doormile/notify.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { toast } from '@/components/ds/toast';
|
||||
|
||||
/**
|
||||
* The data layer's toast seam.
|
||||
*
|
||||
* The Express Console signals API failures with `OpenToast(message, variant,
|
||||
* duration)`. Keeping that call signature means the ported query functions read
|
||||
* exactly like their originals — the variants just resolve to the KROW design
|
||||
* system's toast instead of notistack, so a failed fetch looks like every other
|
||||
* notification in this app rather than importing a second toast system.
|
||||
*/
|
||||
export const OpenToast = (message, variant = 'error', duration) => {
|
||||
const text = String(message || 'Something went wrong');
|
||||
const options = duration ? { duration } : undefined;
|
||||
if (variant === 'success') return toast.success(text, options);
|
||||
if (variant === 'warning' || variant === 'info') return toast.info(text, options);
|
||||
return toast.error(text, options);
|
||||
};
|
||||
|
||||
/** The message a rejected Doormile call should show. */
|
||||
export const messageOf = (err, fallback = 'Something went wrong') =>
|
||||
err?.response?.data?.message || err?.message || fallback;
|
||||
1107
src/api/doormile/queries.js
Normal file
1107
src/api/doormile/queries.js
Normal file
File diff suppressed because it is too large
Load Diff
1928
src/api/seed.js
1928
src/api/seed.js
File diff suppressed because it is too large
Load Diff
173
src/api/store.js
173
src/api/store.js
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* In-memory entity store backing the KROW demo.
|
||||
*
|
||||
* Mirrors the Base44 entity API the app was built against
|
||||
* (`list` / `filter` / `get` / `create` / `update` / `delete`) so every hook,
|
||||
* page and component consumes data exactly as it did against the real backend.
|
||||
* Records live in memory and are mirrored to localStorage, so demo edits —
|
||||
* screening a candidate, hiring, creating a position — survive a reload.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'krow_demo_db';
|
||||
const STORAGE_VERSION = 8;
|
||||
|
||||
/** Simulated network latency, in ms, so loading states are real. */
|
||||
const LATENCY = { read: 140, write: 220 };
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Structured clone with a JSON fallback for older Safari. */
|
||||
const clone = (value) =>
|
||||
typeof structuredClone === 'function'
|
||||
? structuredClone(value)
|
||||
: JSON.parse(JSON.stringify(value));
|
||||
|
||||
let _counter = 0;
|
||||
export function makeId(prefix = 'rec') {
|
||||
_counter += 1;
|
||||
return `${prefix}_${Date.now().toString(36)}${_counter.toString(36).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Persistence ───────────────────────────────────────────────────────── */
|
||||
|
||||
let db = {};
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, db }));
|
||||
} catch {
|
||||
// Private browsing / quota — the demo still works from memory.
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
// A seed-data change bumps STORAGE_VERSION, which invalidates stale copies.
|
||||
return parsed?.version === STORAGE_VERSION ? parsed.db : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the store: persisted snapshot if one matches the current seed version,
|
||||
* otherwise the seed itself.
|
||||
*/
|
||||
export function initStore(seed) {
|
||||
db = readPersisted() || clone(seed);
|
||||
// A seed that gained an entity after the snapshot was written still resolves.
|
||||
for (const [name, records] of Object.entries(seed)) {
|
||||
if (!db[name]) db[name] = clone(records);
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
/** Drops all local edits and restores the shipped demo data. */
|
||||
export function resetStore(seed) {
|
||||
db = clone(seed);
|
||||
persist();
|
||||
}
|
||||
|
||||
/* ── Query helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Applies a Base44-style sort string: `'-ai_score'` descending,
|
||||
* `'created_date'` ascending. Unknown fields leave order untouched.
|
||||
*/
|
||||
function applySort(records, sort) {
|
||||
if (!sort) return records;
|
||||
const desc = sort.startsWith('-');
|
||||
const field = desc ? sort.slice(1) : sort;
|
||||
return [...records].sort((a, b) => {
|
||||
const av = a?.[field];
|
||||
const bv = b?.[field];
|
||||
if (av === bv) return 0;
|
||||
if (av === undefined || av === null) return 1;
|
||||
if (bv === undefined || bv === null) return -1;
|
||||
const result = typeof av === 'number' && typeof bv === 'number'
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return desc ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Shallow equality match across every key of `query`, array fields included. */
|
||||
function matches(record, query) {
|
||||
return Object.entries(query).every(([key, want]) => {
|
||||
const got = record?.[key];
|
||||
if (Array.isArray(want)) return want.includes(got);
|
||||
return got === want;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Entity API ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds the client surface for one entity. Every method is async and returns
|
||||
* cloned records, so callers can never mutate the store by reference.
|
||||
*/
|
||||
export function createEntity(name) {
|
||||
const table = () => (db[name] ||= []);
|
||||
|
||||
return {
|
||||
entityName: name,
|
||||
|
||||
async list(sort = '-created_date', limit = 100) {
|
||||
await delay(LATENCY.read);
|
||||
return clone(applySort(table(), sort).slice(0, limit));
|
||||
},
|
||||
|
||||
async filter(query = {}, sort = '-created_date', limit = 100) {
|
||||
await delay(LATENCY.read);
|
||||
const found = table().filter((record) => matches(record, query));
|
||||
return clone(applySort(found, sort).slice(0, limit));
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
await delay(LATENCY.read);
|
||||
const found = table().find((record) => record.id === id);
|
||||
if (!found) throw new Error(`${name} ${id} not found`);
|
||||
return clone(found);
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
await delay(LATENCY.write);
|
||||
const now = new Date().toISOString();
|
||||
const record = {
|
||||
id: makeId(name.toLowerCase()),
|
||||
created_date: now,
|
||||
updated_date: now,
|
||||
...data,
|
||||
};
|
||||
table().unshift(record);
|
||||
persist();
|
||||
return clone(record);
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
await delay(LATENCY.write);
|
||||
const index = table().findIndex((record) => record.id === id);
|
||||
if (index === -1) throw new Error(`${name} ${id} not found`);
|
||||
const next = { ...table()[index], ...data, updated_date: new Date().toISOString() };
|
||||
table()[index] = next;
|
||||
persist();
|
||||
return clone(next);
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await delay(LATENCY.write);
|
||||
db[name] = table().filter((record) => record.id !== id);
|
||||
persist();
|
||||
return { id };
|
||||
},
|
||||
|
||||
async bulkCreate(records = []) {
|
||||
const created = [];
|
||||
for (const record of records) created.push(await this.create(record));
|
||||
return created;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* Krow brand assets, served from `public/` so the dashboard has no remote image
|
||||
* dependency. Referenced through this module rather than inline strings so a
|
||||
* swap is a one-line change.
|
||||
* Doormile brand assets, served from `public/` so the console has no remote
|
||||
* image dependency. Referenced through this module rather than as inline
|
||||
* strings, so swapping an asset is a one-line change.
|
||||
*
|
||||
* Owliver is a separate product and owns its own assets — nothing here.
|
||||
* `doormile-logo.png` is the white wordmark — it is invisible on the console's
|
||||
* light chrome, so the circular red mark is what the header and the sign-in
|
||||
* page use. Reach for the wordmark only on a dark ground.
|
||||
*/
|
||||
|
||||
export const KROW_LOGO_URL = '/krow-logo.webp';
|
||||
/** The circular red D — the brand mark, legible on light surfaces. */
|
||||
export const DOORMILE_MARK_URL = '/doormile-mark.png';
|
||||
|
||||
/** The white wordmark. Dark backgrounds only. */
|
||||
export const DOORMILE_WORDMARK_URL = '/doormile-logo.png';
|
||||
|
||||
|
||||
BIN
src/assets/images/doormile-mark.png
Normal file
BIN
src/assets/images/doormile-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
29
src/components/CircularLoader.jsx
Normal file
29
src/components/CircularLoader.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
|
||||
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
// ==============================|| FULL-SCREEN BRANDED LOADER ||============================== //
|
||||
// Centered branded ring on a translucent scrim. The ring itself is
|
||||
// LoaderWithImage — this component only supplies the fixed-position overlay,
|
||||
// so the two loaders can't drift apart visually.
|
||||
|
||||
const CircularLoader = () => (
|
||||
<Center
|
||||
axis="both"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 2001,
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
padding: 20,
|
||||
borderRadius: DT.radiusInner + 'px'
|
||||
}}
|
||||
>
|
||||
<LoaderWithImage size={80} imgSize={40} alt="Doormile" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
export default CircularLoader;
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
||||
|
||||
const DefaultFallback = () => (
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
@@ -9,28 +7,21 @@ const DefaultFallback = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
/**
|
||||
* The console's only gate: a Doormile token, or the login page.
|
||||
*
|
||||
* The attempted path travels along in location state, so signing in returns you
|
||||
* to where you were going rather than dropping you on Dispatch — which is what
|
||||
* a bookmarked deep link needs.
|
||||
*/
|
||||
export default function ProtectedRoute({ fallback = <DefaultFallback /> }) {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoadingAuth, authChecked } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked && !isLoadingAuth) {
|
||||
checkUserAuth();
|
||||
}
|
||||
}, [authChecked, isLoadingAuth, checkUserAuth]);
|
||||
|
||||
if (isLoadingAuth || !authChecked) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
if (authError.type === 'user_not_registered') {
|
||||
return <UserNotRegisteredError />;
|
||||
}
|
||||
return unauthenticatedElement;
|
||||
}
|
||||
if (isLoadingAuth || !authChecked) return fallback;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return unauthenticatedElement;
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const UserNotRegisteredError = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
|
||||
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-6 rounded-full bg-orange-100">
|
||||
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-4">Access Restricted</h1>
|
||||
<p className="text-slate-600 mb-8">
|
||||
You are not registered to use this application. Please contact the app administrator to request access.
|
||||
</p>
|
||||
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
|
||||
<p>If you believe this is an error, you can:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Verify you are logged in with the correct account</li>
|
||||
<li>Contact the app administrator for access</li>
|
||||
<li>Try logging out and back in again</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserNotRegisteredError;
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Shared page furniture for the Admin product.
|
||||
*
|
||||
* Admin pages are dense and numerous, so the header, the section labels and the
|
||||
* vertical rhythm are defined once here. That is what makes seven independently
|
||||
* built pages read as one product rather than seven takes on a dashboard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* AdminPage — the standard page frame: title, subtitle, right-aligned controls,
|
||||
* then stacked sections.
|
||||
*
|
||||
* Controls live in the page rather than in the global chrome, which is why the
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
|
||||
{/* A count of whatever the page is a list of, stated beside its
|
||||
name rather than only inside the list. */}
|
||||
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||
Live System
|
||||
</span>
|
||||
</div>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
|
||||
</header>
|
||||
|
||||
{tabs}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SectionTitle — the label above a region.
|
||||
*
|
||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||
* four or five sections, loud labels compete with the data they introduce.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-medium text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{action.label}
|
||||
<ChevronRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar — the controls row above a table or list.
|
||||
*
|
||||
* Search takes the remaining space, filters keep their natural width, and the
|
||||
* primary action is pushed to the end, so every management page has the same
|
||||
* control geometry.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
{search && <div className="min-w-0 lg:max-w-xs lg:flex-1">{search}</div>}
|
||||
{filters && <div className="flex flex-wrap items-center gap-2">{filters}</div>}
|
||||
{actions && <div className="flex items-center gap-2 lg:ml-auto">{actions}</div>}
|
||||
</div>
|
||||
{meta && <p className="text-caption text-ink-4">{meta}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* RoleGlyph — the visual identity on a position card.
|
||||
*
|
||||
* Positions in this dataset have no photograph, and there is nothing to
|
||||
* photograph anyway: a role is not a person. The two obvious fallbacks are both
|
||||
* worse than this one — two-letter initials ("BA", "SE") read as a placeholder
|
||||
* someone forgot to replace, and stock photography attaches an invented face to a
|
||||
* job requisition, which is misleading on a page about hiring real people.
|
||||
*
|
||||
* So the glyph is the role's own trade: a chef's hat, a wine glass, a hard hat.
|
||||
* It is drawn from the existing icon language and the existing brand tint, it is
|
||||
* stable for a given category, and it tells you what kind of work the position is
|
||||
* from across the room — which is the actual job of an image in a card grid.
|
||||
*/
|
||||
|
||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
[/chef|cook|kitchen/i, ChefHat],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
||||
[/security|guard|officer/i, ShieldCheck],
|
||||
[/pick|warehouse|stock|inventory/i, PackageSearch],
|
||||
[/driver|delivery|logistic/i, Truck],
|
||||
[/construct|labour|labor|site/i, HardHat],
|
||||
];
|
||||
|
||||
export function iconForRole(category, title = '') {
|
||||
const subject = `${category || ''} ${title || ''}`;
|
||||
const match = GLYPHS.find(([pattern]) => pattern.test(subject));
|
||||
return match ? match[1] : Briefcase;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: { box: 'h-9 w-9 rounded-lg', icon: 'h-4 w-4' },
|
||||
md: { box: 'h-11 w-11 rounded-xl', icon: 'h-5 w-5' },
|
||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
const Icon = iconForRole(category, title);
|
||||
const s = SIZES[size] || SIZES.md;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative grid shrink-0 place-items-center bg-krow-blue-tint text-krow-blue ring-1 ring-krow-blue/10',
|
||||
s.box,
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className={s.icon} />
|
||||
|
||||
{/* AI-built positions carry the same spark used everywhere else for
|
||||
generated content, so provenance is legible without a second badge
|
||||
competing for room in the card header. */}
|
||||
{aiGenerated && (
|
||||
<span className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-yellow text-ink-1 ring-2 ring-white">
|
||||
<Sparkles className="h-2 w-2" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleGlyph;
|
||||
@@ -1,180 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, Modal, SearchInput } from '@/components/ds';
|
||||
import { allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { surfaceFor } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* Attaching skills to an agent.
|
||||
*
|
||||
* Reads **the one skill registry** — the same `allSkills` the Skills page and
|
||||
* Owliver itself read. There is deliberately no separate list for agents: a
|
||||
* second one would drift, and an agent would end up offering a skill the
|
||||
* runtime does not have.
|
||||
*
|
||||
* Categories are derived rather than written down. A skill's own `category`
|
||||
* field when it declares one, and the pages it attaches to otherwise, so a
|
||||
* filter can never offer a grouping that matches nothing — and a skill added
|
||||
* tomorrow appears under its own category without this file being edited.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
/** The groupings the registry actually contains, in a stable order. */
|
||||
function categoriesFor(skills) {
|
||||
const named = new Set();
|
||||
for (const skill of skills) {
|
||||
if (skill.category) named.add(skill.category);
|
||||
}
|
||||
return [ALL, ...[...named].sort()];
|
||||
}
|
||||
|
||||
/** What this skill contributes, in the terms the reader is choosing between. */
|
||||
function skillSummary(skill) {
|
||||
const pages = skill.pages.map((p) => surfaceFor(p)?.label || p);
|
||||
const capabilities = skill.owliver?.capabilities || [];
|
||||
return [
|
||||
pages.length ? pages.join(', ') : 'No pages',
|
||||
capabilities.length
|
||||
? `${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'}`
|
||||
: (skill.actions || []).length ? `${skill.actions.length} action${skill.actions.length === 1 ? '' : 's'}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AddSkillsModal({ open, onOpenChange, attached = [], customSkills = [], onAdd }) {
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [category, setCategory] = React.useState(ALL);
|
||||
const [picked, setPicked] = React.useState([]);
|
||||
|
||||
/* A fresh sheet each time, so a previous selection is not still ticked. */
|
||||
React.useEffect(() => {
|
||||
if (open) { setQuery(''); setCategory(ALL); setPicked([]); }
|
||||
}, [open]);
|
||||
|
||||
/* Owliver skills only. A workforce training path is something a person
|
||||
learns, not something an agent can be asked to do, and offering one here
|
||||
would promise behaviour that does not exist. */
|
||||
const available = React.useMemo(
|
||||
() => skillsWithFacet(allSkills(customSkills), 'owliver')
|
||||
.filter((s) => s.status === 'active' && !attached.includes(s.id)),
|
||||
[customSkills, attached]
|
||||
);
|
||||
|
||||
const categories = React.useMemo(() => categoriesFor(available), [available]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return available
|
||||
.filter((s) => category === ALL || s.category === category)
|
||||
.filter((s) => !q
|
||||
|| s.name.toLowerCase().includes(q)
|
||||
|| s.description.toLowerCase().includes(q)
|
||||
|| s.id.includes(q));
|
||||
}, [available, category, query]);
|
||||
|
||||
const toggle = (id) =>
|
||||
setPicked((current) => (current.includes(id)
|
||||
? current.filter((x) => x !== id)
|
||||
: [...current, id]));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Add skills"
|
||||
description="Select skills to extend what this agent can do. A skill still only answers on the pages it names."
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<p className="mr-auto text-body-sm text-ink-3">
|
||||
{picked.length
|
||||
? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected`
|
||||
: 'Nothing selected yet'}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button disabled={!picked.length} onClick={() => { onAdd(picked); onOpenChange(false); }}>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Search skills..." debounce={0} />
|
||||
|
||||
{categories.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{categories.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setCategory(id)}
|
||||
className={cn(
|
||||
`rounded-full border px-2.5 py-1 text-caption font-medium capitalize transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
category === id
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
{id === ALL ? 'All skills' : id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[22rem] space-y-1.5 overflow-y-auto pr-0.5">
|
||||
{visible.map((skill) => {
|
||||
const chosen = picked.includes(skill.id);
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => toggle(skill.id)}
|
||||
aria-pressed={chosen}
|
||||
className={cn(
|
||||
`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
chosen
|
||||
? 'border-krow-blue/50 bg-krow-blue-tint/50'
|
||||
: 'border-border bg-surface hover:border-krow-blue/30 hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
||||
chosen ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{chosen && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{skill.name}</span>
|
||||
{skill.description && (
|
||||
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
|
||||
{skill.description}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 block truncate text-[10px] text-ink-4">{skillSummary(skill)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{!visible.length && (
|
||||
<p className="px-1 py-6 text-center text-body-sm text-ink-3">
|
||||
{available.length
|
||||
? 'No skills match that.'
|
||||
: 'Every available skill is already attached to this agent.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSkillsModal;
|
||||
@@ -1,516 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from './icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
/**
|
||||
* The Agent Configure workspace, as parts.
|
||||
*
|
||||
* The screen this builds is an *authoring* surface, and it is the only one in
|
||||
* the Admin console that is. The eight operational pages report; this one is
|
||||
* written in. That is the whole reason it is allowed to look different from
|
||||
* them, and the difference is composition rather than colour: every token here
|
||||
* — `border-border`, `surface-subtle`, `krow-blue-tint`, the ink scale, the
|
||||
* duration and easing variables — is the same Control Tower vocabulary the rest
|
||||
* of the console uses.
|
||||
*
|
||||
* Three ideas carry the layout:
|
||||
*
|
||||
* 1. **One document, not a stack of cards.** Four equal cards make four
|
||||
* unrelated settings groups. One surface divided by hairlines makes a thing
|
||||
* being edited, which is what an agent definition is.
|
||||
*
|
||||
* 2. **A spine on the left.** The rail lists what an agent is made of —
|
||||
* Identity, Instructions, Capabilities, Behavior, and what is inside each —
|
||||
* with its current value beside it. It is the tree reading of the
|
||||
* configuration, and it is what lets someone understand the page before
|
||||
* reading a single field. It is navigation, never a second copy of state.
|
||||
*
|
||||
* 3. **Values on the surface.** A row states what it currently holds while
|
||||
* closed. Nothing has to be opened to be read.
|
||||
*
|
||||
* Motion is CSS only. `prefers-reduced-motion` is handled once, globally, in
|
||||
* `index.css`, so a component that animates with transitions inherits that and
|
||||
* a component that animates in JavaScript would not.
|
||||
*/
|
||||
|
||||
/* ── The surface ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The workspace: rail beside document, both inside one border.
|
||||
*
|
||||
* The rail only becomes a column when there is genuinely room for one. Owliver
|
||||
* holds a ~380px track on the right of every Admin page, so below `xl` the
|
||||
* remaining width belongs to the editor and the rail becomes a strip above it.
|
||||
*/
|
||||
export function Workspace({ rail, children, className }) {
|
||||
return (
|
||||
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
|
||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
|
||||
{rail}
|
||||
</div>
|
||||
<div className="min-w-0 w-full divide-y divide-border/60">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Motion ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Open and close, in height.
|
||||
*
|
||||
* `grid-template-rows: 0fr → 1fr` is the one way to transition to an unknown
|
||||
* height without measuring it in JavaScript. Content stays mounted, which is
|
||||
* what keeps a section addressable by the rail while it is closed.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Collapse({ open, children, className }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-slow ease-out',
|
||||
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* `inert` rather than `aria-hidden`: the content stays mounted so a
|
||||
closed section is still addressable, and a mounted control that cannot
|
||||
be seen must not be reachable by Tab either. `aria-hidden` alone would
|
||||
hide it from a screen reader while leaving it in the tab order, which
|
||||
is the worse of both. */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-opacity duration-base ease-out',
|
||||
open ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
inert={open ? undefined : true}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The rail ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The spine.
|
||||
*
|
||||
* Provides clear section navigation and leaf anchor shortcuts across the
|
||||
* configuration workspace.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Rail({ sections, activeId, onSelect }) {
|
||||
const activeSection = sections.find((s) => s.id === activeId);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Agent configuration"
|
||||
className="flex flex-wrap items-center justify-between gap-2.5"
|
||||
>
|
||||
{/* Section anchor pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
{sections.map((section) => {
|
||||
const active = section.id === activeId;
|
||||
const Icon = section.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(section.id)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={cn(
|
||||
`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-body-sm font-medium
|
||||
transition-all duration-base focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40`,
|
||||
active
|
||||
? 'bg-krow-blue text-white shadow-xs'
|
||||
: 'border border-border/80 bg-surface text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('h-3.5 w-3.5 shrink-0 transition-colors', active ? 'text-white' : 'text-ink-4')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{section.label}</span>
|
||||
{section.meta && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-caption tabular-nums',
|
||||
active ? 'bg-white/20 text-white' : 'bg-surface-sunken text-ink-4'
|
||||
)}
|
||||
>
|
||||
{section.meta}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sub-item quick jump leaves */}
|
||||
{activeSection?.items?.length > 0 && (
|
||||
<div className="hidden items-center gap-1.5 md:flex">
|
||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">Jump to:</span>
|
||||
{activeSection.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(activeSection.id, item.id)}
|
||||
className={`group inline-flex items-center gap-1 rounded-md border border-border/60 bg-surface
|
||||
px-2 py-1 text-caption font-medium text-ink-3 transition-colors duration-base
|
||||
hover:border-krow-blue/40 hover:bg-surface-sunken hover:text-ink-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
{item.meta !== undefined && item.meta !== null && item.meta !== '' && (
|
||||
<span className="tabular-nums text-ink-4">({item.meta})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The document ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One section of the document.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function DocSection({
|
||||
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
||||
children,
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-labelledby={`${id}-title`}
|
||||
className={cn('scroll-mt-24 w-full', !last && 'border-b border-border/60')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-body`}
|
||||
onClick={onToggle}
|
||||
className={`group flex w-full items-start gap-3 px-5 py-3.5 text-left transition-colors
|
||||
duration-base hover:bg-surface-subtle/50 focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/40
|
||||
sm:px-6`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg transition-colors duration-base',
|
||||
emphasis
|
||||
? 'bg-krow-blue text-white'
|
||||
: open
|
||||
? 'bg-krow-blue-tint text-krow-blue'
|
||||
: 'bg-surface-sunken text-ink-4 group-hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span
|
||||
id={`${id}-title`}
|
||||
className={cn(
|
||||
'font-heading font-semibold tracking-tight text-ink-1',
|
||||
emphasis ? 'text-body font-bold' : 'text-body font-semibold'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{meta !== undefined && meta !== null && meta !== '' && (
|
||||
<span className="text-caption tabular-nums text-ink-4 font-normal">({meta})</span>
|
||||
)}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="mt-0.5 block text-caption leading-relaxed text-ink-3">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'mt-1 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Collapse open={open}>
|
||||
<div id={`${id}-body`} className="px-5 pb-5 pt-1 sm:px-6 w-full">
|
||||
{children}
|
||||
</div>
|
||||
</Collapse>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled field in the document body. */
|
||||
/** @param {any} props */
|
||||
export function DocField({ id, label, hint, children, className }) {
|
||||
const reactId = React.useId();
|
||||
const controlId = id ? `${id}-control` : reactId;
|
||||
|
||||
return (
|
||||
<div id={id} className={cn('scroll-mt-28 space-y-1', className)}>
|
||||
<label
|
||||
htmlFor={controlId}
|
||||
className="block text-body-sm font-medium text-ink-2"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{React.isValidElement(children)
|
||||
? React.cloneElement(children, { id: children.props.id || controlId })
|
||||
: children}
|
||||
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The head of a group inside a section — Pages, Skills, Knowledge.
|
||||
*
|
||||
* One notch quieter than a section title and one louder than a row, which is
|
||||
* exactly the level of the thing it names.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={cn('flex scroll-mt-28 items-center gap-2.5 pt-3 pb-1.5', className)}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
<h4 className="text-body-sm font-semibold text-ink-1">{title}</h4>
|
||||
{count !== undefined && count !== null && (
|
||||
<span className="rounded-full bg-surface-sunken px-1.5 py-px text-caption font-medium tabular-nums text-ink-3">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{action && <div className="ml-auto">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A thing attached to the agent — a skill, a subagent — with a way to detach it.
|
||||
*
|
||||
* A row, not a card. Five attached skills as five cards is five borders
|
||||
* competing with the section that holds them; as rows they read as a list,
|
||||
* which is what they are.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
|
||||
return (
|
||||
<li
|
||||
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
|
||||
transition-colors duration-base hover:bg-surface-subtle`}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{title}</span>
|
||||
{detail && (
|
||||
<span className="mt-0.5 block text-caption leading-snug text-ink-3">{detail}</span>
|
||||
)}
|
||||
{warning && <span className="mt-0.5 block text-caption text-warning">{warning}</span>}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={removeLabel}
|
||||
className={`shrink-0 rounded-md p-1 text-ink-4 opacity-0 transition-all duration-base
|
||||
hover:bg-destructive-muted hover:text-destructive focus-visible:opacity-100
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40
|
||||
group-hover/item:opacity-100`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A behaviour setting: what it is, what it is set to, and the way to change it.
|
||||
*
|
||||
* Two shapes, one row. A setting whose control fits on the row carries it
|
||||
* there — a switch, a segmented choice — and is not a button, because wrapping
|
||||
* a switch in a button makes one press mean two things. A setting that needs
|
||||
* space gets a chevron and opens underneath.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SettingRow({
|
||||
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
||||
}) {
|
||||
const expandable = Boolean(children);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{Icon && <Icon className="mt-0.5 h-4 w-4 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
<span className="min-w-[11rem] flex-1 basis-[14rem]">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{title}</span>
|
||||
{value !== undefined && value !== null && value !== '' && (
|
||||
<span className="text-caption tabular-nums text-ink-3">{value}</span>
|
||||
)}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="mt-0.5 block text-caption leading-snug text-ink-4">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div id={id} className={cn('scroll-mt-28', !last && 'border-b border-border/60')}>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
className={`-mx-2 flex w-[calc(100%+1rem)] items-start gap-3 rounded-lg px-2 py-2.5 text-left
|
||||
transition-colors duration-base hover:bg-surface-subtle
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
{body}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 py-2.5">
|
||||
{body}
|
||||
{control && <div className="ml-auto shrink-0">{control}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandable && (
|
||||
<Collapse open={open}>
|
||||
<div className="pb-3.5 pt-0.5">{children}</div>
|
||||
</Collapse>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page an agent covers, or one it could. */
|
||||
/** @param {any} props */
|
||||
export function ScopeChip({ active, children, ...props }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption font-medium
|
||||
transition-all duration-base ease-out focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/40`,
|
||||
active
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-1'
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full transition-colors duration-base',
|
||||
active ? 'bg-krow-blue' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Identity ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The icons an agent may wear, as a picker. */
|
||||
/** @param {any} props */
|
||||
export function IconPicker({ icons, value, onChange }) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{icons.map((icon) => {
|
||||
const Icon = agentIconFor(icon);
|
||||
const active = value === icon;
|
||||
const name = icon.replace(/-/g, ' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
onClick={() => onChange(icon)}
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
`flex h-9 items-center justify-center rounded-lg border transition-all duration-base
|
||||
ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
|
||||
active
|
||||
? 'scale-105 border-krow-blue bg-krow-blue-tint text-krow-blue shadow-xs'
|
||||
: 'border-border bg-surface text-ink-4 hover:-translate-y-px hover:border-krow-blue/40 hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
{Icon
|
||||
? <Icon className="h-4 w-4" />
|
||||
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this agent will appear where people actually meet it.
|
||||
*
|
||||
* Identity is the one section whose fields have a visible consequence
|
||||
* elsewhere, so the consequence is shown rather than described. It renders the
|
||||
* same three things the switcher renders — icon, name, description — and
|
||||
* nothing it does not.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AgentPreview({ name, description, icon }) {
|
||||
const Icon = agentIconFor(icon);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle p-3">
|
||||
<p className="text-overline uppercase text-ink-4">In Owliver</p>
|
||||
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-subtle"
|
||||
>
|
||||
{Icon
|
||||
? <Icon className="h-3.5 w-3.5 text-krow-blue" />
|
||||
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-semibold text-ink-1">
|
||||
{name || 'New agent'}
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 block text-caption leading-snug text-ink-3">
|
||||
{description || 'No description yet.'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,681 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
|
||||
SlidersHorizontal, Trash2, X, Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Switch, Textarea,
|
||||
} from '@/components/ds';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { surfaceFor, SUPPORTED_SKILL_PAGES } from '@/lib/skills/surfaces';
|
||||
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||
import {
|
||||
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
||||
SettingRow, Workspace,
|
||||
} from './AgentCanvas';
|
||||
import { AddSkillsModal } from './AddSkillsModal';
|
||||
|
||||
/**
|
||||
* The agent configuration workspace.
|
||||
*
|
||||
* Four sections of one document — Identity, Instructions, Capabilities,
|
||||
* Behavior — with a rail beside them that lists the same four and what is
|
||||
* inside each. The rail is the page's table of contents and its scroll
|
||||
* position; it is never a second source of state, which is why every value it
|
||||
* shows is read from `fields` at render rather than tracked.
|
||||
*
|
||||
* Every control writes a *field*. The caller composes frontmatter from those
|
||||
* fields at save time, which is what keeps Markdown the storage format while a
|
||||
* recruiter never meets it.
|
||||
*/
|
||||
|
||||
const SECTION_IDS = ['identity', 'instructions', 'capabilities', 'behavior'];
|
||||
|
||||
/**
|
||||
* Which section the reader is in.
|
||||
*
|
||||
* Observed rather than set on click, so scrolling the document moves the rail
|
||||
* too — a table of contents that only updates when you use it is a menu, not a
|
||||
* position.
|
||||
*
|
||||
* Read from the sections' own top edges against a marker just below the sticky
|
||||
* header, rather than from intersection ratios. A ratio-based spy cannot
|
||||
* resolve the case this document actually has: Capabilities is long enough that
|
||||
* it is still crossing the reading band when Behavior arrives, so "the first
|
||||
* one intersecting" stays on Capabilities for the rest of the page. The last
|
||||
* section gets the same exemption for the same reason in reverse — the document
|
||||
* runs out of scroll before its top reaches the marker, so at the end of the
|
||||
* page it is simply what is being read.
|
||||
*/
|
||||
function useActiveSection(ids) {
|
||||
const [active, setActive] = React.useState(ids[0]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
let frame = 0;
|
||||
|
||||
const measure = () => {
|
||||
frame = 0;
|
||||
const marker = 140;
|
||||
|
||||
let current = ids[0];
|
||||
for (const id of ids) {
|
||||
const top = document.getElementById(id)?.getBoundingClientRect().top;
|
||||
if (top !== undefined && top <= marker) current = id;
|
||||
}
|
||||
|
||||
/* Only when the document genuinely scrolls. With every section closed it
|
||||
fits on one screen, and "the end of the page" is then also its start —
|
||||
which would light the last section while the reader is looking at the
|
||||
first. */
|
||||
const doc = document.documentElement;
|
||||
const scrolls = doc.scrollHeight > window.innerHeight + 8;
|
||||
const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
|
||||
if (scrolls && atBottom) current = ids[ids.length - 1];
|
||||
|
||||
setActive(current);
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (!frame) frame = requestAnimationFrame(measure);
|
||||
};
|
||||
|
||||
measure();
|
||||
window.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
window.removeEventListener('scroll', schedule);
|
||||
window.removeEventListener('resize', schedule);
|
||||
};
|
||||
}, [ids]);
|
||||
|
||||
return [active, setActive];
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentConfigure({ fields, agents, customSkills = [], onChange }) {
|
||||
const [addingSkills, setAddingSkills] = React.useState(false);
|
||||
|
||||
/* Sections are open by default: this is a document, and one that greets its
|
||||
author with four closed headers hides the thing they came to write. Closing
|
||||
is for focus, not for the initial reading. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => Object.fromEntries(SECTION_IDS.map((id) => [id, true]))
|
||||
);
|
||||
const [setting, setSetting] = React.useState(/** @type {string | null} */ (null));
|
||||
|
||||
const [active, setActive] = useActiveSection(SECTION_IDS);
|
||||
|
||||
const set = (patch) => onChange({ ...fields, ...patch });
|
||||
|
||||
const instructionWords = React.useMemo(
|
||||
() => (fields.instructions.trim() ? fields.instructions.trim().split(/\s+/).length : 0),
|
||||
[fields.instructions]
|
||||
);
|
||||
|
||||
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
const skillById = React.useMemo(() => new Map(registry.map((s) => [s.id, s])), [registry]);
|
||||
|
||||
const availableSubagents = React.useMemo(
|
||||
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
|
||||
[agents, fields.id]
|
||||
);
|
||||
|
||||
const reasoning = REASONING_MODES.find((m) => m.id === fields.reasoning);
|
||||
const pageLabel = (p) => surfaceFor(p)?.label || p;
|
||||
|
||||
const coveredLabels = fields.pages.map(pageLabel);
|
||||
const starterCount = fields.starters.filter((s) => s.label.trim()).length;
|
||||
|
||||
/**
|
||||
* Going somewhere.
|
||||
*
|
||||
* A closed section is still addressable — the rail lists what is inside it,
|
||||
* and following one of those leaves has to arrive at the field rather than at
|
||||
* a header that happens to be shut. Opening first, scrolling on the next
|
||||
* frame, so the target has a height by the time it is scrolled to.
|
||||
*/
|
||||
const goTo = React.useCallback((sectionId, itemId) => {
|
||||
setOpen((current) => (current[sectionId] ? current : { ...current, [sectionId]: true }));
|
||||
if (itemId && itemId.startsWith('behavior-')) setSetting(itemId);
|
||||
setActive(sectionId);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.getElementById(itemId || sectionId)
|
||||
|| document.getElementById(sectionId);
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
});
|
||||
}, [setActive]);
|
||||
|
||||
const toggleSection = (id) => setOpen((current) => ({ ...current, [id]: !current[id] }));
|
||||
const toggleSetting = (id) => setSetting((current) => (current === id ? null : id));
|
||||
|
||||
/* The rail's model. Derived from the same fields the document renders, so a
|
||||
count on the rail cannot disagree with the list under it. */
|
||||
const sections = [
|
||||
{
|
||||
id: 'identity',
|
||||
icon: IdCard,
|
||||
label: 'Identity',
|
||||
meta: '',
|
||||
items: [
|
||||
{ id: 'identity-name', label: 'Name' },
|
||||
{ id: 'identity-description', label: 'Description' },
|
||||
{ id: 'identity-icon', label: 'Icon' },
|
||||
{ id: 'identity-trigger', label: 'When to use' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'instructions',
|
||||
icon: FileText,
|
||||
label: 'Instructions',
|
||||
meta: instructionWords ? `${instructionWords}w` : '',
|
||||
items: [{ id: 'instructions-editor', label: 'Editor' }],
|
||||
},
|
||||
{
|
||||
id: 'capabilities',
|
||||
icon: Boxes,
|
||||
label: 'Capabilities',
|
||||
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
|
||||
items: [
|
||||
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
|
||||
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
|
||||
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'behavior',
|
||||
icon: SlidersHorizontal,
|
||||
label: 'Behavior',
|
||||
meta: reasoning?.label || '',
|
||||
items: [
|
||||
{ id: 'behavior-reasoning', label: 'Reasoning', meta: reasoning?.label },
|
||||
{ id: 'behavior-starters', label: 'Starters', meta: starterCount },
|
||||
{ id: 'behavior-web', label: 'Web search', meta: fields.webSearch ? 'On' : 'Off' },
|
||||
{ id: 'behavior-subagents', label: 'Subagents', meta: fields.subagents.length },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Workspace rail={<Rail sections={sections} activeId={active} onSelect={goTo} />}>
|
||||
{/* ── Identity ───────────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="identity"
|
||||
icon={IdCard}
|
||||
title="Identity"
|
||||
description="How this agent is presented to people using Owliver."
|
||||
open={open.identity}
|
||||
onToggle={() => toggleSection('identity')}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
||||
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
|
||||
<DocField id="identity-name" label="Name">
|
||||
<Input
|
||||
value={fields.name}
|
||||
placeholder="e.g. Positions Agent"
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</DocField>
|
||||
|
||||
<DocField
|
||||
id="identity-description"
|
||||
label="Description"
|
||||
hint="One line, shown under the name when choosing an agent."
|
||||
>
|
||||
<Input
|
||||
value={fields.description}
|
||||
placeholder="e.g. Open roles — what they need and which are at risk."
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</DocField>
|
||||
|
||||
<DocField
|
||||
id="identity-trigger"
|
||||
label="When to use it"
|
||||
hint="Helps a reader pick the right agent. Never matched against questions."
|
||||
>
|
||||
<Textarea
|
||||
value={fields.trigger}
|
||||
rows={3}
|
||||
placeholder="e.g. Use on Positions, for open roles and applicant flow."
|
||||
onChange={(e) => set({ trigger: e.target.value })}
|
||||
className="w-full resize-y"
|
||||
/>
|
||||
</DocField>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5 xl:col-span-5 space-y-3">
|
||||
<AgentPreview
|
||||
name={fields.name}
|
||||
description={fields.description}
|
||||
icon={fields.icon}
|
||||
/>
|
||||
|
||||
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
|
||||
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
|
||||
<IconPicker
|
||||
icons={AGENT_ICONS}
|
||||
value={fields.icon}
|
||||
onChange={(icon) => set({ icon })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Instructions ───────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="instructions"
|
||||
icon={FileText}
|
||||
title="Instructions"
|
||||
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
|
||||
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
|
||||
open={open.instructions}
|
||||
onToggle={() => toggleSection('instructions')}
|
||||
emphasis
|
||||
>
|
||||
<div
|
||||
id="instructions-editor"
|
||||
className={`w-full scroll-mt-28 overflow-hidden rounded-xl border border-border
|
||||
bg-surface transition-all duration-base ease-out
|
||||
hover:border-border focus-within:border-krow-blue/50
|
||||
focus-within:ring-2 focus-within:ring-krow-blue/15`}
|
||||
>
|
||||
<Textarea
|
||||
value={fields.instructions}
|
||||
rows={8}
|
||||
placeholder={'Answer from the records this page holds.\n\nState a figure only where a skill has read it. When a reading needs a position and none is open, ask which rather than choosing one.'}
|
||||
onChange={(e) => set({ instructions: e.target.value })}
|
||||
className={`min-h-[11rem] resize-y rounded-none border-0 bg-transparent px-4 py-3
|
||||
text-body-md leading-relaxed focus-visible:ring-0`}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface-subtle px-4 py-2">
|
||||
<span className="text-caption tabular-nums text-ink-4">
|
||||
{instructionWords} word{instructionWords === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="text-caption text-ink-4">Saved with the agent definition</span>
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Capabilities ───────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="capabilities"
|
||||
icon={Boxes}
|
||||
title="Capabilities"
|
||||
description="What this agent can reach and use when it answers."
|
||||
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.skills.length} skill${fields.skills.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
||||
open={open.capabilities}
|
||||
onToggle={() => toggleSection('capabilities')}
|
||||
>
|
||||
<div className="divide-y divide-border/60">
|
||||
{/* Pages ── the boundary. */}
|
||||
<div className="pb-4">
|
||||
<GroupHead
|
||||
id="capabilities-pages"
|
||||
icon={LayoutGrid}
|
||||
title="Pages it covers"
|
||||
count={fields.pages.length}
|
||||
/>
|
||||
<p className="pb-2 text-caption leading-relaxed text-ink-3">
|
||||
{coveredLabels.length
|
||||
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
|
||||
: 'No page selected — this agent has nowhere to answer yet.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUPPORTED_SKILL_PAGES.map((page) => (
|
||||
<ScopeChip
|
||||
key={page}
|
||||
active={fields.pages.includes(page)}
|
||||
onClick={() => set({
|
||||
pages: fields.pages.includes(page)
|
||||
? fields.pages.filter((p) => p !== page)
|
||||
: [...fields.pages, page],
|
||||
})}
|
||||
>
|
||||
{pageLabel(page)}
|
||||
</ScopeChip>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-ink-4">
|
||||
A page decides which skills exist there. An agent chooses among them — it can
|
||||
narrow that list, never widen it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills ── what it can do. */}
|
||||
<div className="py-4">
|
||||
<GroupHead
|
||||
id="capabilities-skills"
|
||||
icon={Zap}
|
||||
title="Skills"
|
||||
count={fields.skills.length}
|
||||
action={(
|
||||
<Button variant="outline" size="sm" onClick={() => setAddingSkills(true)}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add skills
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{fields.skills.length > 0 ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.skills.map((id) => {
|
||||
const skill = skillById.get(id);
|
||||
return (
|
||||
<ItemRow
|
||||
key={id}
|
||||
icon={Zap}
|
||||
title={skill?.name || id}
|
||||
detail={skill?.description}
|
||||
warning={skill ? null : 'No skill with this id is registered.'}
|
||||
removeLabel={`Remove ${skill?.name || id}`}
|
||||
onRemove={() => set({ skills: fields.skills.filter((x) => x !== id) })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No skills attached. This agent can still answer from the page's own reader.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-caption text-ink-4">
|
||||
Authored in{' '}
|
||||
<Link
|
||||
to="/admin/workspace/skills"
|
||||
className="font-medium text-krow-blue underline-offset-2 hover:underline"
|
||||
>
|
||||
Workspace → Skills
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Knowledge ── what it has been told. */}
|
||||
<div className="pt-4">
|
||||
<GroupHead
|
||||
id="capabilities-knowledge"
|
||||
icon={BookOpen}
|
||||
title="Knowledge"
|
||||
count={fields.knowledge.length}
|
||||
action={(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => set({
|
||||
knowledge: [
|
||||
...fields.knowledge,
|
||||
{ id: '', label: '', kind: 'note', body: '', url: '' },
|
||||
],
|
||||
})}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add knowledge
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{fields.knowledge.length ? (
|
||||
<div className="space-y-2.5">
|
||||
{fields.knowledge.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id || i}
|
||||
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
|
||||
>
|
||||
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<Field label="Title">
|
||||
<Input
|
||||
value={entry.label}
|
||||
placeholder="e.g. Overtime policy"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, label: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kind">
|
||||
<Select
|
||||
value={entry.kind}
|
||||
onValueChange={(kind) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, kind };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWLEDGE_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k} className="capitalize">
|
||||
{k.replace('-', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{entry.kind === 'link' ? (
|
||||
<Field label="Link">
|
||||
<Input
|
||||
value={entry.url}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, url: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field
|
||||
label="What it says"
|
||||
hint="Quoted back with its source named. Nothing is invented from it."
|
||||
>
|
||||
<Textarea
|
||||
value={entry.body}
|
||||
rows={3}
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, body: e.target.value };
|
||||
set({ knowledge: next });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => set({
|
||||
knowledge: fields.knowledge.filter((_, x) => x !== i),
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No knowledge attached. Nothing is invented — this agent answers from skills only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
|
||||
{/* ── Behavior ───────────────────────────────────────────────────── */}
|
||||
<DocSection
|
||||
id="behavior"
|
||||
icon={SlidersHorizontal}
|
||||
title="Behavior"
|
||||
description="How this agent works when it answers."
|
||||
meta={reasoning?.label}
|
||||
open={open.behavior}
|
||||
onToggle={() => toggleSection('behavior')}
|
||||
last
|
||||
>
|
||||
<div>
|
||||
<SettingRow
|
||||
id="behavior-reasoning"
|
||||
icon={Brain}
|
||||
title="Reasoning"
|
||||
description={reasoning?.summary || 'How much work an answer is worth.'}
|
||||
control={(
|
||||
<SegmentedToggle
|
||||
size="sm"
|
||||
ariaLabel="Reasoning depth"
|
||||
value={fields.reasoning}
|
||||
onChange={(id) => set({ reasoning: id })}
|
||||
options={REASONING_MODES.map((m) => ({ value: m.id, label: m.label }))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
id="behavior-starters"
|
||||
icon={MessageSquare}
|
||||
title="Conversation starters"
|
||||
value={starterCount ? `${starterCount} configured` : 'None'}
|
||||
description="Questions offered as chips when this agent opens."
|
||||
open={setting === 'behavior-starters'}
|
||||
onToggle={() => toggleSetting('behavior-starters')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{fields.starters.map((starter, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={starter.label}
|
||||
placeholder="e.g. Which roles are at risk?"
|
||||
className="max-w-[30rem]"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.starters];
|
||||
next[i] = { label: e.target.value, prompt: e.target.value };
|
||||
set({ starters: next });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove starter"
|
||||
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add starter
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<div id="behavior-web" className="scroll-mt-28">
|
||||
<SettingRow
|
||||
icon={Globe}
|
||||
title="Web search"
|
||||
value={fields.webSearch ? 'On' : 'Off'}
|
||||
description="Whether this agent may look outside the workspace."
|
||||
control={(
|
||||
<Switch
|
||||
checked={fields.webSearch}
|
||||
onCheckedChange={(webSearch) => set({ webSearch })}
|
||||
aria-label="Allow web search"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Collapse open={fields.webSearch}>
|
||||
<div className="pb-4">
|
||||
<Alert tone="warning" title="No search provider is configured">
|
||||
This deployment has no web search connected, so answers say plainly that
|
||||
nothing outside the workspace was consulted.
|
||||
</Alert>
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
id="behavior-subagents"
|
||||
icon={Layers}
|
||||
title="Subagents"
|
||||
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
|
||||
description="Other agents whose skills this one may also use. Most need none."
|
||||
open={setting === 'behavior-subagents'}
|
||||
onToggle={() => toggleSetting('behavior-subagents')}
|
||||
last
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{fields.subagents.length > 0 && (
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.subagents.map((id) => {
|
||||
const sub = agents.find((a) => a.id === id);
|
||||
return (
|
||||
<ItemRow
|
||||
key={id}
|
||||
icon={Layers}
|
||||
title={sub?.name || id}
|
||||
detail={sub?.description}
|
||||
warning={sub ? null : 'No agent with this id is registered.'}
|
||||
removeLabel={`Remove ${sub?.name || id}`}
|
||||
onRemove={() => set({
|
||||
subagents: fields.subagents.filter((x) => x !== id),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(id) => !fields.subagents.includes(id)
|
||||
&& set({ subagents: [...fields.subagents, id] })}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue placeholder="Add a subagent..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSubagents
|
||||
.filter((a) => !fields.subagents.includes(a.id))
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A subagent's skills are still bounded by the current page — borrowing them
|
||||
cannot reach data this page does not hold.
|
||||
</p>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</DocSection>
|
||||
</Workspace>
|
||||
|
||||
<AddSkillsModal
|
||||
open={addingSkills}
|
||||
onOpenChange={setAddingSkills}
|
||||
attached={fields.skills}
|
||||
customSkills={customSkills}
|
||||
onAdd={(ids) => set({ skills: [...fields.skills, ...ids] })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentConfigure;
|
||||
@@ -1,238 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Badge, EmptyState } from '@/components/ds';
|
||||
import { readHistory } from '@/components/ai-assistant/history';
|
||||
import { conversationStats, reviewRow } from '@/lib/agents/conversationInsights';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* What this agent has actually been asked.
|
||||
*
|
||||
* Real-time usage analytics computed directly from the local telemetry log.
|
||||
*/
|
||||
|
||||
/** @param {any} props */
|
||||
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-caption font-medium text-ink-4 uppercase tracking-wider">{label}</span>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-surface-subtle text-ink-3">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-heading text-title-xl font-bold tabular-nums text-ink-1 sm:text-3xl">
|
||||
{value}
|
||||
</span>
|
||||
{highlight && (
|
||||
<span className="text-caption font-semibold text-krow-blue">{highlight}</span>
|
||||
)}
|
||||
</div>
|
||||
{detail && <p className="text-caption text-ink-4">{detail}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentInsightsPanel({ agentId, agentName }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
const [records] = React.useState(() => readHistory());
|
||||
|
||||
const stats = React.useMemo(
|
||||
() => conversationStats(records, { agentId }),
|
||||
[records, agentId]
|
||||
);
|
||||
|
||||
const skillName = React.useMemo(() => {
|
||||
const byId = new Map(allSkills(preferences.customSkills || []).map((s) => [s.id, s.name]));
|
||||
return (id) => byId.get(id) || id;
|
||||
}, [preferences.customSkills]);
|
||||
|
||||
const conversations = React.useMemo(
|
||||
() => records
|
||||
.filter((r) => (agentId ? r.agentId === agentId : true))
|
||||
.slice(0, 8)
|
||||
.map(reviewRow),
|
||||
[records, agentId]
|
||||
);
|
||||
|
||||
if (stats.empty) {
|
||||
return (
|
||||
<div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="No conversations with this agent yet"
|
||||
description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
{/* ── KPI Metric Cards ───────────────────────────────────────────── */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={MessageSquare}
|
||||
label="Total Conversations"
|
||||
value={stats.total}
|
||||
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={HelpCircle}
|
||||
label="Questions Asked"
|
||||
value={stats.turns}
|
||||
detail={`${stats.averageTurns} per session average`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Activity}
|
||||
label="Surfaces Utilized"
|
||||
value={stats.pages}
|
||||
detail="Operational pages touched"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ThumbsUp}
|
||||
label="Feedback Rating"
|
||||
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
|
||||
detail={
|
||||
stats.feedback.score === null
|
||||
? 'No ratings submitted'
|
||||
: `${stats.feedback.up} helpful · ${stats.feedback.down} unhelpful`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 2-Column Ranking Breakdowns ────────────────────────────────── */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Most Used Skills */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">Most Triggered Skills</h4>
|
||||
</div>
|
||||
|
||||
{stats.bySkill.length ? (
|
||||
<div className="space-y-2">
|
||||
{stats.bySkill.slice(0, 5).map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{skillName(row.id)}</span>
|
||||
<Badge variant="neutral" size="sm" className="tabular-nums">
|
||||
{row.count} query{row.count === 1 ? '' : 'ies'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||
No skill executions logged yet. Conversations were handled directly by native page readers.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Executed */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">Actions & Tool Usage</h4>
|
||||
</div>
|
||||
|
||||
{stats.byTool.length ? (
|
||||
<div className="space-y-2">
|
||||
{stats.byTool.slice(0, 5).map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{row.id.replace(/_/g, ' ')}</span>
|
||||
<Badge variant="neutral" size="sm" className="tabular-nums">
|
||||
{row.count} run{row.count === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||
No action tool executions recorded in current audit window.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Recent Conversations Table ─────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
|
||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">
|
||||
Recent Conversation Logs
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60">
|
||||
{conversations.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 px-5 py-3.5 sm:px-6 hover:bg-surface-subtle/30 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{row.title || 'General consultation'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-4">
|
||||
Surface:{' '}
|
||||
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-caption tabular-nums text-ink-4">
|
||||
{row.turns} turn{row.turns === 1 ? '' : 's'}
|
||||
</span>
|
||||
|
||||
{row.feedback && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
|
||||
row.feedback.rating === 'up'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800'
|
||||
: 'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-800'
|
||||
)}
|
||||
>
|
||||
{row.feedback.rating === 'up' ? (
|
||||
<>
|
||||
<ThumbsUp className="h-2.5 w-2.5" /> Helpful
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ThumbsDown className="h-2.5 w-2.5" /> Unhelpful
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentInsightsPanel;
|
||||
@@ -1,347 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Cpu,
|
||||
Layers,
|
||||
Play,
|
||||
Search,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ds';
|
||||
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { agentCovers, agentScopedDisabled, classifyQuestion } from '@/lib/agents/runtime';
|
||||
import { toolsForContext } from '@/lib/skills/tools';
|
||||
import { retrieveKnowledge } from '@/lib/agents/knowledge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* What this agent would do, on a page, before it is published.
|
||||
*
|
||||
* Runs the live runtime classifier, skill matcher, tool resolver, and
|
||||
* knowledge retriever to display comprehensive diagnostics.
|
||||
*/
|
||||
|
||||
const CONTEXT_OPTIONS = Object.entries(PLACEMENT_ROUTES).map(([route, contextId]) => ({
|
||||
contextId,
|
||||
route,
|
||||
label: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
|
||||
}));
|
||||
|
||||
const CLASSIFICATION_COPY = {
|
||||
structured: 'Reads workforce records through an attached skill.',
|
||||
knowledge: 'Answers directly from reference knowledge.',
|
||||
combined: 'Requires both structured records and knowledge retrieval.',
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
|
||||
const [contextId, setContextId] = React.useState(
|
||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||
|| CONTEXT_OPTIONS[0].contextId
|
||||
);
|
||||
const [question, setQuestion] = React.useState('What needs my attention?');
|
||||
|
||||
/* The draft as the runtime sees it */
|
||||
const draftAgent = React.useMemo(() => ({
|
||||
id: fields.id || 'draft',
|
||||
name: fields.name || 'This agent',
|
||||
pages: fields.pages,
|
||||
skills: fields.skills,
|
||||
subagents: fields.subagents,
|
||||
knowledge: fields.knowledge,
|
||||
starters: fields.starters,
|
||||
reasoning: fields.reasoning,
|
||||
webSearch: fields.webSearch,
|
||||
status: 'published',
|
||||
}), [fields]);
|
||||
|
||||
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
|
||||
const result = React.useMemo(() => {
|
||||
const covers = agentCovers(draftAgent, contextId);
|
||||
const disabled = agentScopedDisabled(draftAgent, registry, []);
|
||||
const pageOnly = skillsForContext(contextId, [], customSkills);
|
||||
const scoped = covers ? skillsForContext(contextId, disabled, customSkills) : [];
|
||||
const matched = covers ? matchSkill(question, contextId, disabled, customSkills) : null;
|
||||
|
||||
return {
|
||||
covers,
|
||||
pageOnly,
|
||||
scoped,
|
||||
matched,
|
||||
tools: covers ? toolsForContext(contextId, disabled, customSkills) : [],
|
||||
knowledge: retrieveKnowledge({ agent: draftAgent, contextId, question }),
|
||||
classification: classifyQuestion({ question }),
|
||||
};
|
||||
}, [draftAgent, contextId, question, registry, customSkills]);
|
||||
|
||||
const activePageLabel = ASSISTANT_CONTEXTS[contextId]?.page || 'Current Page';
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
{/* ── Test Workbench Card ────────────────────────────────────────── */}
|
||||
<div className="w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
|
||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
Simulation & Scope Diagnostics
|
||||
</h3>
|
||||
<p className="text-caption text-ink-4">
|
||||
Evaluate real-time intent routing and skill activation without publishing changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dirty && (
|
||||
<Badge variant="warning" size="sm" className="gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
|
||||
Testing unsaved draft
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-12 items-end">
|
||||
<div className="md:col-span-4 space-y-1.5">
|
||||
<label className="block text-body-sm font-medium text-ink-2">
|
||||
Contextual Page
|
||||
</label>
|
||||
<Select value={contextId} onValueChange={setContextId}>
|
||||
<SelectTrigger className="w-full bg-surface">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTEXT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.contextId} value={o.contextId}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-8 space-y-1.5">
|
||||
<label className="block text-body-sm font-medium text-ink-2">
|
||||
Simulated User Query
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
|
||||
<Input
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="e.g. Which roles are at risk?"
|
||||
className="pl-9 w-full bg-surface"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fields.starters.filter((s) => s.label).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
|
||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
|
||||
Suggested Starters:
|
||||
</span>
|
||||
{fields.starters.filter((s) => s.label).map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setQuestion(s.prompt || s.label)}
|
||||
className={cn(
|
||||
`inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-surface px-2.5 py-1
|
||||
text-caption font-medium text-ink-2 transition-all duration-base
|
||||
hover:border-krow-blue/40 hover:bg-krow-blue-tint/30 hover:text-krow-blue
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
|
||||
question === (s.prompt || s.label) && 'border-krow-blue bg-krow-blue-tint text-krow-blue'
|
||||
)}
|
||||
>
|
||||
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Active Status Banner ───────────────────────────────────────── */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3.5 rounded-2xl border p-4 sm:p-5 transition-colors',
|
||||
result.covers
|
||||
? 'border-emerald-200 bg-emerald-50/70 text-emerald-950 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-100'
|
||||
: 'border-amber-200 bg-amber-50/70 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full mt-0.5',
|
||||
result.covers
|
||||
? 'bg-emerald-600 text-white dark:bg-emerald-500'
|
||||
: 'bg-amber-600 text-white dark:bg-amber-500'
|
||||
)}
|
||||
>
|
||||
{result.covers ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-heading text-body font-semibold">
|
||||
{result.covers ? `Active on ${activePageLabel}` : `Constrained on ${activePageLabel}`}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-body-sm leading-relaxed opacity-90">
|
||||
{result.covers
|
||||
? result.scoped.length
|
||||
? `${result.scoped.length} of ${result.pageOnly.length} operational skills are active and ready on this surface.`
|
||||
: `This page provides ${result.pageOnly.length} skills. With none attached, this agent will answer using the page's standard reader.`
|
||||
: `This agent has not been assigned to ${activePageLabel}. When asked on this page, it will politely decline or defer to the workspace reader.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 4-Quadrant Diagnostic Grid ─────────────────────────────────── */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Quadrant 1: Intent & Skill Routing */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2 text-ink-1">
|
||||
<Cpu className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Intent & Skill Routing</h4>
|
||||
</div>
|
||||
<p className="text-caption text-ink-3">
|
||||
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured}
|
||||
</p>
|
||||
|
||||
{result.matched ? (
|
||||
<div className="rounded-xl border border-krow-blue/40 bg-krow-blue-tint/40 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-body-sm font-semibold text-krow-blue">
|
||||
{result.matched.name}
|
||||
</span>
|
||||
<Badge variant="info" size="sm">Matched</Badge>
|
||||
</div>
|
||||
<p className="text-caption text-ink-3 leading-relaxed">
|
||||
{result.matched.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
No specialized skill claimed this exact wording. The native page reader will process the query.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quadrant 2: Reachable Skills */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Reachable Page Skills</h4>
|
||||
</div>
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||
{result.scoped.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.scoped.length ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{result.scoped.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{s.name}</span>
|
||||
<span className="text-caption text-ink-4 tabular-nums">Ready</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
No attached skills apply to {activePageLabel}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quadrant 3: Execution Capabilities */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Execution Actions & Tools</h4>
|
||||
</div>
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||
{result.tools.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.tools.length ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{result.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{tool.label}</span>
|
||||
{tool.requiresApproval ? (
|
||||
<Badge variant="warning" size="sm">Confirmation required</Badge>
|
||||
) : (
|
||||
<Badge variant="neutral" size="sm">Read-only</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
Read-only operations. No active mutations or system write actions.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quadrant 4: Referenced Knowledge */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2 text-ink-1">
|
||||
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Referenced Knowledge</h4>
|
||||
</div>
|
||||
|
||||
{result.knowledge.passages.length ? (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{result.knowledge.passages.map((p) => (
|
||||
<div
|
||||
key={p.chunkId}
|
||||
className="rounded-lg border border-border/70 bg-surface-subtle/50 p-2.5 text-body-sm"
|
||||
>
|
||||
<span className="block font-medium text-ink-1">{p.label}</span>
|
||||
<span className="mt-0.5 line-clamp-2 block text-caption text-ink-3 leading-relaxed">
|
||||
{p.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
{result.knowledge.note || 'No custom reference knowledge attached to this agent.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentTestPanel;
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
Activity, BarChart3, Briefcase, GraduationCap, Layers, Shield, Sparkles, UserCheck, Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Agent icon id → component.
|
||||
*
|
||||
* A hand-written table, for the same reason `SECTION_COMPONENTS` is one: a
|
||||
* definition names a key, and only a key written down here resolves to
|
||||
* anything. There is no path from a Markdown file to a component nobody chose.
|
||||
*
|
||||
* `owliver` is deliberately absent — it is not a lucide glyph but the product's
|
||||
* own avatar, and the switcher draws it directly. Returning `null` for it is
|
||||
* the signal to do that, which keeps the primary agent looking like Owliver
|
||||
* rather than like a generic icon.
|
||||
*/
|
||||
const ICONS = {
|
||||
sparkles: Sparkles,
|
||||
briefcase: Briefcase,
|
||||
users: Users,
|
||||
'user-check': UserCheck,
|
||||
layers: Layers,
|
||||
'graduation-cap': GraduationCap,
|
||||
'bar-chart': BarChart3,
|
||||
activity: Activity,
|
||||
shield: Shield,
|
||||
};
|
||||
|
||||
/** The component for an icon id, or null when the avatar should be drawn. */
|
||||
export const agentIconFor = (id) => ICONS[String(id || '').trim()] ?? null;
|
||||
@@ -1,196 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
|
||||
/**
|
||||
* Which agent is answering.
|
||||
*
|
||||
* Built on the same principles as `PageContext.jsx`, and deliberately kept
|
||||
* beside it rather than merged into it:
|
||||
*
|
||||
* - **The page is not an input to itself.** This context reads the page's
|
||||
* assistant context to decide a *default*, and never the other way round.
|
||||
* Nothing here can change which page the reader is on, which is what makes
|
||||
* "switching agent cannot change PageContext" true by construction rather
|
||||
* than by care.
|
||||
* - **Session-scoped, like the panel's own window state.** Choosing an agent
|
||||
* for the afternoon should not rewrite an account default, and a new tab
|
||||
* should open on the page's own agent.
|
||||
*
|
||||
* Resolution order: what this session chose *and that still applies here* → the
|
||||
* account's stated default, on the same condition → the page's own agent → the
|
||||
* general agent. A page therefore always opens on the agent written for it, a
|
||||
* page nobody wrote one for opens on the general agent rather than on nothing,
|
||||
* and a deliberate choice still survives navigation across the pages it covers.
|
||||
*
|
||||
* **A selection is made somewhere.** That is the correction this file carries:
|
||||
* it stores the context a choice was made on, not only the agent's id. Storing
|
||||
* the id alone meant a specialist chosen on Positions followed the reader onto
|
||||
* Settings and constrained a page nobody had chosen it for — the panel looked
|
||||
* broken and the reason was invisible. The rule itself lives in
|
||||
* `runtime.resolveSelection` as a pure function, so what applies where is
|
||||
* decided in one place and can be proved without a React tree.
|
||||
*/
|
||||
|
||||
const AgentContext = React.createContext(null);
|
||||
|
||||
const SESSION_KEY = 'krow_assistant:agent';
|
||||
|
||||
/**
|
||||
* The stored selection: `{ id, contextId }`.
|
||||
*
|
||||
* A bare string is still read, because that is what earlier sessions wrote and
|
||||
* a stored value from yesterday should not throw. It resolves as a choice made
|
||||
* nowhere, which is the honest reading of it — and the conservative one, since a
|
||||
* selection with no context applies only where it legitimately covers.
|
||||
*/
|
||||
function readSelection() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith('{')) return { id: raw, contextId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.id ? { id: parsed.id, contextId: parsed.contextId ?? null } : null;
|
||||
} catch {
|
||||
/* Private mode, or a value that will not parse: the session simply always
|
||||
opens on the page's own agent. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSelection(selection) {
|
||||
try {
|
||||
if (selection?.id) sessionStorage.setItem(SESSION_KEY, JSON.stringify(selection));
|
||||
else sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
/* Held in memory for this session only. */
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentProvider({ contextId = null, children }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Shipped definitions plus anything this account has authored, read through
|
||||
the one registry so the switcher and the management page cannot disagree
|
||||
about what exists. */
|
||||
const agents = React.useMemo(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
|
||||
const select = React.useCallback((id) => {
|
||||
/* Stamped with where it was chosen. A specialist picked on a page it does
|
||||
not cover is a deliberate act and is honoured *here*; the stamp is what
|
||||
stops it from becoming a decision about every other page too. */
|
||||
const next = id ? { id, contextId } : null;
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}, [contextId]);
|
||||
|
||||
/** Back to whichever agent this page resolves on its own. */
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
}, []);
|
||||
|
||||
/* What the stored choice means on the page the reader is on now. */
|
||||
const applied = React.useMemo(
|
||||
() => resolveSelection(agents, selection, contextId),
|
||||
[agents, selection, contextId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Storage housekeeping, in one place.
|
||||
*
|
||||
* Retiring a spent selection and re-stamping a live one are both writes about
|
||||
* *navigation*, not about a choice, so they belong in an effect rather than in
|
||||
* `select`. Both converge: the effect only writes when the stored value would
|
||||
* actually change.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (applied.retire) {
|
||||
setSelection(null);
|
||||
writeSelection(null);
|
||||
return;
|
||||
}
|
||||
if (applied.id && selection?.contextId !== contextId) {
|
||||
const next = { id: applied.id, contextId };
|
||||
setSelection(next);
|
||||
writeSelection(next);
|
||||
}
|
||||
}, [applied, selection, contextId]);
|
||||
|
||||
const value = React.useMemo(() => {
|
||||
/**
|
||||
* What this turn asks for.
|
||||
*
|
||||
* The session's choice first, then the account default — and the account
|
||||
* default is put through the same rule, as a choice made nowhere. A stated
|
||||
* default that does not cover this page is not a decision about this page,
|
||||
* so it must not constrain it; the page resolves its own agent instead.
|
||||
*/
|
||||
const stated = applied.id
|
||||
|| resolveSelection(agents, preferences.defaultAgentId || null, contextId).id
|
||||
|| null;
|
||||
|
||||
const turn = resolveAgentForTurn(agents, stated, contextId);
|
||||
/* The page's own agent, offered as the way out of a constrained state.
|
||||
Null on a page nobody wrote one for — `fallback` is what answers there. */
|
||||
const native = nativeAgentForContext(agents, contextId);
|
||||
/* What this page opens on with nothing chosen: its own agent, or the
|
||||
general one. Never null, so no page is ever left without an agent. */
|
||||
const fallback = resolveDefaultAgent(agents, contextId);
|
||||
|
||||
return {
|
||||
agents,
|
||||
/* The agent that will answer. Never silently swapped: a reader who chose
|
||||
one *for this page* gets that one, with `covers` saying whether it
|
||||
belongs here. A choice carried in from another page is not that. */
|
||||
agent: turn.agent,
|
||||
covers: turn.covers,
|
||||
native,
|
||||
/* What this page resolves to with nothing chosen — its own agent, or the
|
||||
general one. This is what a constrained answer points at, so that the
|
||||
way out of a constrained state is named on every page rather than only
|
||||
on the pages that have a specialist. */
|
||||
defaultAgent: fallback,
|
||||
/* True while this page is simply showing the agent it resolves on its
|
||||
own, whether that is its specialist or the general agent. */
|
||||
isNative: Boolean(fallback && turn.agent && fallback.id === turn.agent.id && !applied.id),
|
||||
chosenId: applied.id,
|
||||
select,
|
||||
clearSelection,
|
||||
/* Whether a given agent belongs on this page, for the list. */
|
||||
coversPage: (candidate) => agentCovers(candidate, contextId),
|
||||
};
|
||||
}, [agents, applied, preferences.defaultAgentId, contextId, select, clearSelection]);
|
||||
|
||||
return <AgentContext.Provider value={value}>{children}</AgentContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active agent.
|
||||
*
|
||||
* Returns an inert value outside a provider, so a panel rendered in isolation —
|
||||
* a test, a storybook — behaves as it did before agents existed rather than
|
||||
* crashing.
|
||||
*/
|
||||
export function useActiveAgent() {
|
||||
return React.useContext(AgentContext) ?? {
|
||||
agents: [],
|
||||
agent: null,
|
||||
covers: true,
|
||||
native: null,
|
||||
defaultAgent: null,
|
||||
isNative: false,
|
||||
chosenId: null,
|
||||
select: () => {},
|
||||
clearSelection: () => {},
|
||||
coversPage: () => false,
|
||||
};
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { searchAgents } from '@/lib/agents/registry';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
|
||||
/**
|
||||
* The agent switcher, inside Owliver's own header.
|
||||
*
|
||||
* Designed with high-density Krow Control Tower aesthetics: clean popover
|
||||
* geometry, smooth row hover/active states, and legible status indicators.
|
||||
*/
|
||||
|
||||
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
||||
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
const Icon = agentIconFor(agent?.icon);
|
||||
|
||||
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The label a constrained agent carries, in the switcher and in the header. */
|
||||
function ConstrainedTag({ className = '' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
Constrained
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the list. */
|
||||
function AgentRow({ agent, active, covers, onSelect }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active
|
||||
? 'bg-krow-blue-tint/70 text-ink-1'
|
||||
: 'hover:bg-surface-subtle text-ink-2'
|
||||
)}
|
||||
>
|
||||
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
|
||||
{agent.name}
|
||||
</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
{agent.description && (
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{active && (
|
||||
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSwitcher({ page }) {
|
||||
const navigate = useNavigate();
|
||||
const { agents, agent, covers, select, coversPage } = useActiveAgent();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const listRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const found = searchAgents(agents, query);
|
||||
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
|
||||
}, [agents, query, coversPage]);
|
||||
|
||||
const choose = (id) => {
|
||||
select(id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onListKeyDown = (event) => {
|
||||
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
||||
if (!step) return;
|
||||
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
|
||||
if (!rows.length) return;
|
||||
event.preventDefault();
|
||||
const at = rows.indexOf(document.activeElement);
|
||||
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
|
||||
};
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<AgentGlyph agent={agent} />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
|
||||
{!covers && <ConstrainedTag />}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
|
||||
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
|
||||
<p className="mt-0.5 text-[11px] text-ink-4">
|
||||
Choose an agent to focus Owliver's scope on this page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">Search agents</span>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search agents..."
|
||||
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label="Agents"
|
||||
onKeyDown={onListKeyDown}
|
||||
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
|
||||
>
|
||||
{matches.map((candidate) => (
|
||||
<AgentRow
|
||||
key={candidate.id}
|
||||
agent={candidate}
|
||||
active={candidate.id === agent.id}
|
||||
covers={coversPage(candidate)}
|
||||
onSelect={choose}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!matches.length && (
|
||||
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
|
||||
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
Browse all
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Create agent
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSwitcher;
|
||||
@@ -1,127 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
/**
|
||||
* The thinking state, shown between sending and the first block arriving.
|
||||
*
|
||||
* Labelled as well as animated: three moving dots mean nothing to a screen
|
||||
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
||||
*/
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
|
||||
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
|
||||
style={{ animationDelay: `${i * 0.18}s` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="sr-only">Owliver is preparing a response</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn.
|
||||
*
|
||||
* The user's words sit in a filled bubble; the assistant's reply runs the full
|
||||
* width of the panel. That asymmetry is deliberate — these replies are reports
|
||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||
* column on padding around them.
|
||||
*/
|
||||
/**
|
||||
* How this conversation was rated.
|
||||
*
|
||||
* Offered on the latest answer only, because a rating belongs to the
|
||||
* conversation rather than to a paragraph of it — thumbs on every turn would
|
||||
* ask a reader to score an answer out of the exchange that produced it, and
|
||||
* each one would set the same value anyway.
|
||||
*
|
||||
* Hidden until the turn is hovered or focused, the way History's delete control
|
||||
* already is: a thread of reports should not carry two permanent buttons under
|
||||
* every answer.
|
||||
*/
|
||||
function FeedbackControls({ feedback, onFeedback }) {
|
||||
const rate = (rating) => onFeedback(feedback?.rating === rating ? null : rating);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 flex items-center gap-0.5 transition-opacity',
|
||||
/* Stays visible once rated, so a reader can see and change their own
|
||||
answer rather than having to hunt for it again. */
|
||||
feedback ? 'opacity-100' : 'opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100'
|
||||
)}
|
||||
>
|
||||
{[
|
||||
{ rating: 'up', Icon: ThumbsUp, label: 'This answer was useful' },
|
||||
{ rating: 'down', Icon: ThumbsDown, label: 'This answer was not useful' },
|
||||
].map(({ rating, Icon, label }) => (
|
||||
<button
|
||||
key={rating}
|
||||
type="button"
|
||||
onClick={() => rate(rating)}
|
||||
aria-label={label}
|
||||
aria-pressed={feedback?.rating === rating}
|
||||
className={cn(
|
||||
`rounded p-1 transition-colors focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
feedback?.rating === rating
|
||||
? 'text-krow-blue'
|
||||
: 'text-ink-4 hover:text-ink-2'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Message = React.memo(
|
||||
/** @param {any} props */
|
||||
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/turn animate-slide-up">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
|
||||
</div>
|
||||
|
||||
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
|
||||
|
||||
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||
|
||||
{/* Only where a handler was passed — a turn nobody is collecting feedback
|
||||
for renders exactly as it did before this existed. */}
|
||||
{onFeedback && !streaming && (
|
||||
<FeedbackControls feedback={feedback} onFeedback={onFeedback} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Message.displayName = 'Message';
|
||||
|
||||
/** A subtle divider between turns, so a long thread stays readable. */
|
||||
export function TurnDivider() {
|
||||
return <div className="h-px bg-border/60" role="presentation" />;
|
||||
}
|
||||
|
||||
export { cn };
|
||||
@@ -1,199 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { PanelLeftOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { ResizeDivider } from './ResizeDivider';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
const EXPANDED_WIDTH = 620;
|
||||
/**
|
||||
* Gutter separating the panel from the dashboard, split between the drag handle
|
||||
* and the panel's own lead padding. Both halves are named because the track width
|
||||
* is their sum plus the panel: get that arithmetic wrong and the column either
|
||||
* overflows or leaves a strip of unexplained empty space at the shell's edge.
|
||||
*/
|
||||
const HANDLE_WIDTH = 12;
|
||||
const LEAD_PADDING = 8;
|
||||
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
|
||||
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
||||
const MAX_VIEWPORT_SHARE = 0.42;
|
||||
/**
|
||||
* Below this width there is no room for a column *beside* the dashboard — a
|
||||
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks
|
||||
* underneath the page instead.
|
||||
*
|
||||
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
|
||||
* column out acceptably, so they keep the two-column workspace and only phones
|
||||
* stack.
|
||||
*/
|
||||
const STACK_BREAKPOINT = 768;
|
||||
|
||||
/** Tracks viewport width so the panel can be clamped and the layout switched. */
|
||||
function useViewportWidth() {
|
||||
const [width, setWidth] = React.useState(() =>
|
||||
typeof window === 'undefined' ? 1440 : window.innerWidth
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onResize = () => setWidth(window.innerWidth);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapsed state — a compact docked trigger.
|
||||
*
|
||||
* The earlier version was a full-height rail inside the layout column. It solved
|
||||
* the wrong problem: it kept Owliver *findable*, but it also kept the column, so
|
||||
* collapsing bought the dashboard 44px instead of the 400px the user was asking
|
||||
* for, and it did it with rotated text down the side of the page.
|
||||
*
|
||||
* So collapsing now removes the column entirely — the layout goes to one column
|
||||
* and `main` takes the whole shell — and the way back is this small docked pill.
|
||||
* It is deliberately the only floating control in the product, and it exists only
|
||||
* in the collapsed state, where there is nowhere else for it to live.
|
||||
*/
|
||||
function CollapsedTrigger({ page, onRestore }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
aria-label={`Show the Owliver workspace for ${page}`}
|
||||
aria-expanded={false}
|
||||
className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border
|
||||
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
|
||||
hover:border-krow-blue/40 hover:shadow-lg
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
|
||||
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
||||
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AssistantPanel — the Owliver workspace column the Admin layout renders.
|
||||
*
|
||||
* Owliver is part of the page on every supported route, so this is a column in
|
||||
* the layout rather than an overlay: the dashboard reflows beside it instead of
|
||||
* being covered, and collapsing restores the original layout exactly.
|
||||
*
|
||||
* Five structural details matter, and every one of them was a bug at some point:
|
||||
*
|
||||
* 1. Nothing above the sticky element clips. An ancestor with `overflow: hidden`
|
||||
* becomes the nearest scroll container and silently disables
|
||||
* `position: sticky` beneath it.
|
||||
* 2. The aside stretches to the shell's full height (`self-stretch`), so its
|
||||
* sticky context runs the whole page rather than ending partway down and
|
||||
* leaving empty space beside a long dashboard.
|
||||
* 3. `min-w-0` on the aside. A flex item's default `min-width: auto` refuses to
|
||||
* go narrower than its content, so the track ignored its own width and stalled
|
||||
* at the panel's old size on the way back from expanded.
|
||||
* 4. Only the track has a width. The panel fills what the track leaves after the
|
||||
* handle and the lead padding, so the two can never disagree — which is
|
||||
* exactly what (3) was: two widths transitioning, one winning.
|
||||
* 5. Collapsing removes the column outright and leaves a compact docked trigger.
|
||||
* An earlier version kept a full-height rail in place, which bought the
|
||||
* dashboard 44px when the user had asked for 400 — and spelled "Owliver" down
|
||||
* the side of the page to do it.
|
||||
*
|
||||
* Expanding widens the same panel in place — an analysis workspace inside the
|
||||
* application layout, never a fullscreen takeover.
|
||||
*/
|
||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
const {
|
||||
context, isOpen, isExpanded, width, minWidth, maxWidth,
|
||||
setWidth, resetWidth, open, close, expand, restore,
|
||||
} = useAssistantPanel();
|
||||
const viewportWidth = useViewportWidth();
|
||||
|
||||
// No assistant on this route: no column, no rail, no trace in the layout.
|
||||
if (!context) return null;
|
||||
|
||||
/* Expanded overrides the dragged width; otherwise the user's own width wins.
|
||||
Both are clamped against the viewport so the dashboard is never squeezed. */
|
||||
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
|
||||
const panelWidth = isExpanded
|
||||
? Math.max(width, Math.min(EXPANDED_WIDTH, viewportCap))
|
||||
: Math.min(width, Math.max(minWidth, viewportCap));
|
||||
|
||||
/* Collapsed: no column at all. The layout falls back to a single column, `main`
|
||||
takes the whole shell, and the only thing left is the docked trigger — so
|
||||
there is never a reserved empty gutter where the panel used to be. */
|
||||
if (!isOpen) {
|
||||
return <CollapsedTrigger page={context.page} onRestore={open} />;
|
||||
}
|
||||
|
||||
const trackWidth = panelWidth + GUTTER;
|
||||
|
||||
/* Phones: stack Owliver under the dashboard. A 380px column beside the page is
|
||||
not a layout at this width, and an overlay that opens on load would put a
|
||||
sheet between the user and the page they asked for. */
|
||||
if (viewportWidth < STACK_BREAKPOINT) {
|
||||
return (
|
||||
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={false}
|
||||
onClose={close}
|
||||
onExpand={null}
|
||||
onRestore={null}
|
||||
className="h-[32rem]"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Owliver workspace"
|
||||
style={{ width: trackWidth }}
|
||||
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
|
||||
`min-w-0` — see (3).
|
||||
|
||||
A CSS transition rather than an animated width: the width is already a
|
||||
pure function of state, so there is nothing for an animation library to
|
||||
own, and the declarative version cannot get stranded mid-collapse the way
|
||||
an interrupted JS animation can. Reduced motion is handled globally in
|
||||
index.css. */
|
||||
className="relative min-w-0 shrink-0 self-stretch transition-[width] duration-slow ease-out"
|
||||
>
|
||||
<div className={cn('sticky flex', stickyClassName)}>
|
||||
{/* Drag handle. Sits in the gutter, so resizing never overlaps either
|
||||
surface. Double-click restores the default width. */}
|
||||
<ResizeDivider
|
||||
width={panelWidth}
|
||||
min={minWidth}
|
||||
max={Math.min(maxWidth, viewportCap)}
|
||||
onResize={setWidth}
|
||||
onDoubleClick={resetWidth}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
|
||||
{/* The panel takes whatever the track leaves after the handle and the
|
||||
lead padding, which is `panelWidth` by construction — see (4). */}
|
||||
<div className="min-w-0 flex-1 pl-2">
|
||||
{/* Keyed on the context so switching pages gives a clean panel and
|
||||
aborts any response still streaming for the last one. */}
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={isExpanded}
|
||||
onClose={close}
|
||||
onExpand={expand}
|
||||
onRestore={restore}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantPanel;
|
||||
@@ -1,239 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
import { PageContextProvider } from './PageContext';
|
||||
import { AgentProvider } from './AgentContext';
|
||||
|
||||
/**
|
||||
* Window state for the Owliver panel: open/collapsed and default/expanded.
|
||||
*
|
||||
* Lives in context rather than in the layout so any control — the panel's own
|
||||
* header, the collapsed rail — can change the workspace without the layout
|
||||
* having to know where it sits, and without threading props through every page.
|
||||
*
|
||||
* **Owliver is open by default.** On a supported Admin page the workspace *is*
|
||||
* the dashboard plus Owliver: it is an intelligence layer over the page, not a
|
||||
* feature you go and fetch. Collapsing is therefore a deliberate act by someone
|
||||
* who wants the full width for a while, which is why the collapsed state is a
|
||||
* visible rail rather than a dismissal (see AssistantPanel).
|
||||
*/
|
||||
|
||||
const AssistantPanelContext = React.createContext(null);
|
||||
|
||||
const OPEN_KEY = 'krow_assistant_open';
|
||||
const EXPANDED_KEY = 'krow_assistant_expanded';
|
||||
const WIDTH_KEY = 'krow_assistant_width';
|
||||
|
||||
/** Drag limits. Below 340px the reports stop being readable; above 520px the
|
||||
* panel starts competing with the dashboard for primacy. */
|
||||
export const MIN_WIDTH = 340;
|
||||
export const MAX_WIDTH = 520;
|
||||
export const DEFAULT_WIDTH = 380;
|
||||
|
||||
const clampWidth = (value) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(value)));
|
||||
|
||||
/**
|
||||
* Window state is kept for the session so moving between supported pages does
|
||||
* not undo a panel the user deliberately collapsed or expanded, while a new
|
||||
* session still starts from the default workspace.
|
||||
*
|
||||
* `fallback` is what an untouched session gets. Absence has to mean "default"
|
||||
* rather than "off", or the open-by-default panel would only appear after
|
||||
* someone had already opened it once.
|
||||
*/
|
||||
function readFlag(key, fallback = false) {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(key);
|
||||
return stored === null ? fallback : stored === '1';
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFlag(key, value) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value ? '1' : '0');
|
||||
} catch {
|
||||
// Private mode — state simply resets next session.
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
/* Two different things, deliberately kept apart: the account preference is
|
||||
whether Owliver *should* open by default, and the session flag is whether
|
||||
this tab currently has it collapsed. The preference is the fallback, so a
|
||||
panel collapsed for the afternoon does not rewrite the account setting, and
|
||||
an account with Owliver off does not get the panel pushed back at it on
|
||||
every new tab. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
|
||||
);
|
||||
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
try {
|
||||
const stored = Number(sessionStorage.getItem(WIDTH_KEY));
|
||||
return stored ? clampWidth(stored) : DEFAULT_WIDTH;
|
||||
} catch {
|
||||
return DEFAULT_WIDTH;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A question the page handed to the panel, waiting to be asked.
|
||||
*
|
||||
* The one thing a page could not do before: publish what it is looking at
|
||||
* (PageContext) but never *start* a turn about it. So a control whose work
|
||||
* lives in the conversation — Continue, on a draft card — had no way to reach
|
||||
* the conversation, and the only thing left to do with it was navigate to a
|
||||
* form. This is that missing direction, kept as narrow as the read channel:
|
||||
* serializable state, no callbacks into the panel, and the panel decides when
|
||||
* to take it.
|
||||
*
|
||||
* State rather than an event, because the panel is unmounted while collapsed —
|
||||
* `ask` opens it, and the request is still here when it mounts.
|
||||
*/
|
||||
const [request, setRequest] = React.useState(null);
|
||||
const requestSeq = React.useRef(0);
|
||||
|
||||
const context = React.useMemo(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
[role, pathname]
|
||||
);
|
||||
|
||||
const supported = Boolean(context);
|
||||
// A stored "open" must not force the panel onto a page with no assistant.
|
||||
const isOpen = supported && open;
|
||||
const isExpanded = isOpen && expanded;
|
||||
|
||||
const setOpenState = React.useCallback((next) => {
|
||||
setOpen(next);
|
||||
writeFlag(OPEN_KEY, next);
|
||||
}, []);
|
||||
|
||||
const setExpandedState = React.useCallback((next) => {
|
||||
setExpanded(next);
|
||||
writeFlag(EXPANDED_KEY, next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Escape steps back one level rather than collapsing outright: expanded →
|
||||
* default → collapsed. Narrowing a workspace and putting it away are different
|
||||
* intentions, and one key press should not do both.
|
||||
*
|
||||
* Ignored while the caret is in the composer, where Escape belongs to whatever
|
||||
* the user is typing rather than to the layout.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'TEXTAREA' || tag === 'INPUT') return;
|
||||
if (isExpanded) setExpandedState(false);
|
||||
else setOpenState(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
|
||||
|
||||
/**
|
||||
* Asks Owliver something on the page's behalf, opening the panel to receive it.
|
||||
*
|
||||
* `positionId` names the record the control was built from, so the answer is
|
||||
* about that position rather than about whichever one the wording happens to
|
||||
* match. Each call carries its own id, so asking the same question twice is
|
||||
* two turns rather than one ignored repeat.
|
||||
*/
|
||||
const ask = React.useCallback(({ question, positionId = null }) => {
|
||||
const text = String(question || '').trim();
|
||||
if (!text) return;
|
||||
requestSeq.current += 1;
|
||||
setRequest({ id: requestSeq.current, question: text, positionId });
|
||||
setOpenState(true);
|
||||
}, [setOpenState]);
|
||||
|
||||
/** Taken by the panel once it has actually asked it. */
|
||||
const consumeRequest = React.useCallback((id) => {
|
||||
setRequest((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/* Width is written on every drag frame, so persistence is debounced rather
|
||||
than hitting sessionStorage sixty times a second. */
|
||||
const setWidthState = React.useCallback((next) => {
|
||||
const clamped = clampWidth(next);
|
||||
setWidth(clamped);
|
||||
clearTimeout(setWidthState.timer);
|
||||
setWidthState.timer = setTimeout(() => {
|
||||
try {
|
||||
sessionStorage.setItem(WIDTH_KEY, String(clamped));
|
||||
} catch {
|
||||
// Non-persistent session is fine.
|
||||
}
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
context,
|
||||
supported,
|
||||
isOpen,
|
||||
isExpanded,
|
||||
width,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
setWidth: setWidthState,
|
||||
resetWidth: () => setWidthState(DEFAULT_WIDTH),
|
||||
open: () => setOpenState(true),
|
||||
close: () => setOpenState(false),
|
||||
toggle: () => setOpenState(!isOpen),
|
||||
expand: () => setExpandedState(true),
|
||||
restore: () => setExpandedState(false),
|
||||
ask,
|
||||
request,
|
||||
consumeRequest,
|
||||
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
|
||||
ask, request, consumeRequest]);
|
||||
|
||||
/* The page's own selection travels beside the window state, mounted here so
|
||||
both the page and the panel are inside it — a skill's data source resolves
|
||||
against the record the reader has open, whichever of the two is asking. */
|
||||
/* The page's own selection travels beside the window state, and the active
|
||||
agent beside both. `AgentProvider` is given the resolved context id and can
|
||||
only read it — nothing inside it can change which page the reader is on,
|
||||
which is what makes "switching agent never moves PageContext" structural
|
||||
rather than a rule to remember. */
|
||||
return (
|
||||
<AssistantPanelContext.Provider value={value}>
|
||||
<PageContextProvider>
|
||||
<AgentProvider contextId={context?.id ?? null}>{children}</AgentProvider>
|
||||
</PageContextProvider>
|
||||
</AssistantPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel state for triggers and the layout.
|
||||
*
|
||||
* Returns an inert value outside a provider, so a page rendering an
|
||||
* `AssistantTrigger` in isolation (a test, a storybook) does not crash.
|
||||
*/
|
||||
export function useAssistantPanel() {
|
||||
return React.useContext(AssistantPanelContext) ?? {
|
||||
context: null,
|
||||
supported: false,
|
||||
isOpen: false,
|
||||
isExpanded: false,
|
||||
width: DEFAULT_WIDTH,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
setWidth: () => {},
|
||||
resetWidth: () => {},
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
toggle: () => {},
|
||||
expand: () => {},
|
||||
restore: () => {},
|
||||
ask: () => {},
|
||||
request: null,
|
||||
consumeRequest: () => {},
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
/**
|
||||
* AssistantTrigger — the KROW mark used as a secondary action.
|
||||
*
|
||||
* This is not the application's branding: the navigation logo is untouched.
|
||||
* Here the mark is deliberately smaller and sits in a page's action row, where
|
||||
* it reads as "the intelligent thing this page can do" rather than as a chrome
|
||||
* element. Pages render it beside their own actions.
|
||||
*
|
||||
* It self-hides on pages with no assistant, so a page can render it
|
||||
* unconditionally and never needs to know the placement rules.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
const label = isOpen ? 'Close Owliver' : `Ask Owliver about ${context.page}`;
|
||||
const heights = { sm: 'h-8 px-2.5', default: 'h-9 px-3' };
|
||||
const logoHeights = { sm: 'h-3.5', default: 'h-4' };
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={label}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'group relative inline-flex items-center justify-center rounded-full border',
|
||||
'transition-all duration-base ease-out cursor-pointer',
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-2',
|
||||
'hover:scale-[1.04] active:scale-100',
|
||||
heights[size],
|
||||
isOpen
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint shadow-[0_0_0_3px_rgba(10,57,223,0.10)]'
|
||||
: 'border-border bg-surface shadow-xs hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:shadow-[0_0_18px_-2px_rgba(10,57,223,0.35)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={KROW_LOGO_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn('w-auto object-contain', logoHeights[size])}
|
||||
/>
|
||||
|
||||
{/* A quiet indicator that this mark does something intelligent —
|
||||
enough signal without adding a competing icon or label. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 block w-2 h-2 rounded-full ring-2 ring-white transition-colors duration-base',
|
||||
isOpen ? 'bg-krow-blue' : 'bg-krow-yellow group-hover:bg-krow-blue'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantTrigger;
|
||||
@@ -1,839 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||
useMarkInterviewReady, useShiftRecords, useUpdateJobPosting,
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { allSkills, skillsForContext } from '@/lib/skills/registry';
|
||||
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
import { groupByRecency } from './history';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { usePageContext } from './PageContext';
|
||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
|
||||
import { buildOwliverContext } from '@/lib/agents/context';
|
||||
import { AgentSwitcher } from './AgentSwitcher';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { PromptChips } from './PromptChips';
|
||||
|
||||
/**
|
||||
* Owliver History — the conversations that came before.
|
||||
*
|
||||
* A list rather than a second panel: it replaces the thread in the body while
|
||||
* the header and composer stay exactly where they are, so History is a state of
|
||||
* the panel rather than a place you navigate to and have to find your way out
|
||||
* of. Grouped by when, because that is how people look for a conversation they
|
||||
* half-remember.
|
||||
*/
|
||||
function HistoryView({ groups, currentId, onOpen, onForget }) {
|
||||
if (!groups.length) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
|
||||
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
|
||||
Ask Owliver something and it will be here afterwards. History is kept on this device.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
{groups.map((group) => (
|
||||
<section key={group.id} aria-label={group.label}>
|
||||
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{group.items.map((record) => (
|
||||
<li key={record.id} className="group/item relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(record)}
|
||||
aria-current={record.id === currentId ? 'true' : undefined}
|
||||
className={cn(
|
||||
`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 pr-8 text-left transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
record.id === currentId
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint/60'
|
||||
: 'border-transparent hover:border-border hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{record.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] text-ink-4">
|
||||
{[
|
||||
record.page,
|
||||
`${record.turns} question${record.turns === 1 ? '' : 's'}`,
|
||||
new Date(record.updatedAt).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Destructive, so it stays out of the way until wanted rather
|
||||
than sitting beside every row waiting to be mis-clicked. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onForget(record.id)}
|
||||
aria-label={`Delete conversation: ${record.title}`}
|
||||
className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 rounded p-1.5 text-ink-4 transition-colors
|
||||
hover:text-destructive focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/50 group-hover/item:block group-focus-within/item:block"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Back to Home row on scroll direction, not scroll position.
|
||||
*
|
||||
* Hidden while reading downwards, back the instant the user scrolls up — the
|
||||
* way out of a long answer should not be something you have to scroll all the
|
||||
* way to the top to reach. Direction is read off the panel's own scrolling
|
||||
* element (the body region below), never the window: the page behind the panel
|
||||
* does not move when the thread does.
|
||||
*
|
||||
* The listener is passive and rAF-throttled, and `setVisible` is only ever
|
||||
* called with a value that can change — React bails out on an identical one, so
|
||||
* a fast scroll costs at most one render per direction change.
|
||||
*
|
||||
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
|
||||
* routed through here so a programmatic jump is not mistaken for the user
|
||||
* scrolling down and does not hide the control under them.
|
||||
*/
|
||||
function useDirectionalNav(scrollRef, { active, resetKey }) {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
const lastY = React.useRef(0);
|
||||
|
||||
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
|
||||
produce: a real direction change clears this within one frame. */
|
||||
const NOISE = 4;
|
||||
|
||||
const pinToBottom = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
/* Adopt the new position before the scroll event lands, so the next read
|
||||
sees no delta and the row keeps whatever state the user left it in. */
|
||||
lastY.current = el.scrollTop;
|
||||
}, [scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
/* Nothing to hide when the row is not rendered — and a fresh thread or a
|
||||
newly opened panel always starts with it showing. */
|
||||
setVisible(true);
|
||||
if (!el || !active) return undefined;
|
||||
|
||||
lastY.current = el.scrollTop;
|
||||
let frame = 0;
|
||||
|
||||
const read = () => {
|
||||
frame = 0;
|
||||
const y = el.scrollTop;
|
||||
if (y <= 0) {
|
||||
lastY.current = y;
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const delta = y - lastY.current;
|
||||
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
|
||||
rather than being swallowed frame by frame. */
|
||||
if (Math.abs(delta) < NOISE) return;
|
||||
lastY.current = y;
|
||||
setVisible(delta < 0);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(read);
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [scrollRef, active, resetKey]);
|
||||
|
||||
return { visible, pinToBottom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Owliver — the dashboard's contextual panel.
|
||||
*
|
||||
* One component, reused on every supported page; only `context` differs.
|
||||
*
|
||||
* The frame is fixed at three parts — header, body, composer — and only the body
|
||||
* scrolls. That is what lets the panel stay pinned beside a long dashboard
|
||||
* without ever growing the page or scrolling its own input out of reach.
|
||||
*
|
||||
* The frame does not change between states, only the body's content does: an
|
||||
* empty thread shows the greeting and this page's most relevant fact, and once a
|
||||
* question is asked the same region holds the conversation. Keeping the composer
|
||||
* anchored either way means the primary action never moves under the user, and
|
||||
* the panel never reads as two different components.
|
||||
*
|
||||
* `expanded` widens the same panel into an analysis workspace. `data-wide` lets
|
||||
* individual response blocks use the extra room (a KPI row goes to three
|
||||
* columns) without any of them needing to know the panel's pixel width.
|
||||
*
|
||||
* Independent of Owliver. Answers come from a provider (see provider.js) that
|
||||
* can later be pointed at an external service without changing anything here.
|
||||
*/
|
||||
export default function KrowAssistant({
|
||||
context,
|
||||
className,
|
||||
expanded = false,
|
||||
onClose,
|
||||
onExpand,
|
||||
onRestore,
|
||||
}) {
|
||||
const facts = useAssistantFacts();
|
||||
const userName = useCurrentUserName();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
/* A question a page has handed over — see the effect below `runPrompt`. */
|
||||
const { request: panelRequest, consumeRequest } = useAssistantPanel();
|
||||
|
||||
/* The app's own router, not a location assignment: a full page load would
|
||||
discard the thread and the panel state along with it. */
|
||||
const goToPage = React.useCallback(
|
||||
(destination) => navigate(destination.route),
|
||||
[navigate]
|
||||
);
|
||||
|
||||
/* Skills available here, and the roles they can act on — both read from what
|
||||
the app already has rather than from anything written into this component.
|
||||
A page whose skills change needs no edit here. */
|
||||
const preferences = usePreferences();
|
||||
/* `defaultAgent` rather than `native`: on a page with no agent of its own the
|
||||
way out of a constrained state is the general agent, and naming nothing
|
||||
there would leave the reader with a decline and no next step. */
|
||||
const { agent, covers: agentCoversPage, defaultAgent } = useActiveAgent();
|
||||
|
||||
/**
|
||||
* What this page will not offer.
|
||||
*
|
||||
* The account's own switched-off skills, plus everything the active agent
|
||||
* does not carry. **This one memo is the entire agent scoping**: every
|
||||
* consumer below — the skill list, the suggestions, the conversation, the
|
||||
* tools — already takes `disabledSkills`, so none of them needed changing.
|
||||
*
|
||||
* It can only ever subtract. `getSkillsForPage` applies the page filter and
|
||||
* this list in the same expression, so an agent cannot make a skill appear on
|
||||
* a page that does not carry it, and with no agent the list is exactly the
|
||||
* account's own.
|
||||
*/
|
||||
const accountDisabled = React.useMemo(
|
||||
() => preferences.disabledSkills || [],
|
||||
[preferences.disabledSkills]
|
||||
);
|
||||
const allRegisteredSkills = React.useMemo(
|
||||
() => allSkills(preferences.customSkills || []),
|
||||
[preferences.customSkills]
|
||||
);
|
||||
const disabledSkills = React.useMemo(
|
||||
() => agentScopedDisabled(agent, allRegisteredSkills, accountDisabled),
|
||||
[agent, allRegisteredSkills, accountDisabled]
|
||||
);
|
||||
const { data: customCategories = [] } = useRoleCategories();
|
||||
const roles = React.useMemo(
|
||||
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
|
||||
[customCategories]
|
||||
);
|
||||
/* The categories Forge skills are actually filed under, so "create a kitchen
|
||||
safety training" fills in the category this deployment uses rather than one
|
||||
written into a skill file. */
|
||||
const skillCategories = React.useMemo(
|
||||
() => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))],
|
||||
[facts.forge]
|
||||
);
|
||||
const customSkills = React.useMemo(
|
||||
() => preferences.customSkills || [],
|
||||
[preferences.customSkills]
|
||||
);
|
||||
const skills = React.useMemo(
|
||||
() => skillsForContext(context.id, disabledSkills, customSkills),
|
||||
[context.id, disabledSkills, customSkills]
|
||||
);
|
||||
|
||||
/**
|
||||
* What a declared skill reads from.
|
||||
*
|
||||
* The same collections `SkillSurface` hands the resolver on the page, plus
|
||||
* whatever record the page has published as open. One context, one resolver:
|
||||
* the card on the page and the answer in the panel are two readings of the
|
||||
* same data rather than two queries that happen to agree.
|
||||
*/
|
||||
const pageContext = usePageContext();
|
||||
const { statesFor } = useWorkforcePaths();
|
||||
const trainingPaths = React.useMemo(
|
||||
() => statesFor(facts.forge?.library || [], profileForEmail(facts.profiles || [], facts.user?.email)),
|
||||
[statesFor, facts.forge, facts.profiles, facts.user]
|
||||
);
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
/* Shifts worked, missed and overrun — the records behind attendance and
|
||||
overtime. Read from the same cache a page would, so a card and an answer
|
||||
about the same fortnight cannot disagree. */
|
||||
const { data: shifts = [] } = useShiftRecords();
|
||||
/* Where the reader is, composed from the page's own channel — see
|
||||
`lib/agents/context.js`. Read-only: nothing here writes back to the page. */
|
||||
const owliverContext = React.useMemo(
|
||||
() => buildOwliverContext({ context, pathname: location.pathname, pageContext }),
|
||||
[context, location.pathname, pageContext]
|
||||
);
|
||||
|
||||
const skillContext = React.useMemo(() => ({
|
||||
...pageContext,
|
||||
applications: facts.applications || [],
|
||||
positions: facts.postings || [],
|
||||
interviews: facts.interviews || [],
|
||||
courses: facts.forge?.library || [],
|
||||
workerProfiles: facts.profiles || [],
|
||||
/* The same records under the workforce engine's name, and the commitments
|
||||
it reads availability from — so a source that scores candidates against a
|
||||
position resolves here exactly as it does on the page. */
|
||||
profiles: facts.profiles || [],
|
||||
assignments,
|
||||
staff: facts.staff || [],
|
||||
activity: facts.activity || [],
|
||||
shifts,
|
||||
trainingPaths,
|
||||
}), [pageContext, facts, assignments, shifts, trainingPaths]);
|
||||
|
||||
/**
|
||||
* Which face of the panel the body is showing.
|
||||
*
|
||||
* A state rather than a route: the header, the composer and the panel's width
|
||||
* are unchanged between the two, and only the scrolling region swaps. That is
|
||||
* also why there is always a way back — leaving History is a state change this
|
||||
* component owns, not a browser-history entry it has to hope exists.
|
||||
*/
|
||||
const [view, setView] = React.useState('chat');
|
||||
|
||||
/**
|
||||
* A skill asked for something to happen. The names come from the skill file;
|
||||
* `runAction` decides what they mean and refuses anything the file did not
|
||||
* declare.
|
||||
*/
|
||||
const performAction = React.useCallback((action, skill) => {
|
||||
const result = runAction(action.name, { ...action.payload, skill });
|
||||
if (result?.type === 'navigate' && result.route) {
|
||||
navigate(result.route, result.state ? { state: result.state } : undefined);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Write the position the conversation collected.
|
||||
*
|
||||
* The same mutation the Create Position form calls, so there is one create
|
||||
* path and the list behind the panel refreshes the way it always did. The
|
||||
* skill still has to declare `create_position` — `runAction` returns nothing
|
||||
* for a skill whose file does not, and then nothing is written.
|
||||
*/
|
||||
const createJob = useCreateJobPosting();
|
||||
const createPosition = React.useCallback(async (draft, skill, status) => {
|
||||
const result = runAction('create_position', { draft, skill, status });
|
||||
if (result?.type !== 'create_position') return null;
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
|
||||
/**
|
||||
* Finishing a draft, through the mutations the Create Position form calls.
|
||||
*
|
||||
* `useUpdateJobPosting` is the same update the form and the Positions page
|
||||
* use, and `useGenerateJobDescription` is the same generator behind the
|
||||
* form's "Generate Job Description with AI" button. Reusing both is what
|
||||
* keeps one position record, one description and one set of vetting weights
|
||||
* — the panel changes the record, it does not keep a copy of it.
|
||||
*/
|
||||
const updateJob = useUpdateJobPosting();
|
||||
const onUpdatePosition = React.useCallback(
|
||||
(id, data) => updateJob.mutateAsync({ id, data }),
|
||||
[updateJob]
|
||||
);
|
||||
|
||||
const generateDescription = useGenerateJobDescription();
|
||||
const onGenerateDescription = React.useCallback(
|
||||
async (position) => {
|
||||
const result = await generateDescription.mutateAsync({ data: position, draftId: position.id });
|
||||
return result || null;
|
||||
},
|
||||
[generateDescription]
|
||||
);
|
||||
|
||||
/**
|
||||
* The workforce picture the panel reasons over.
|
||||
*
|
||||
* The same collections the Positions page reads, so a recommendation and the
|
||||
* card behind it are looking at one moment. Assembled here rather than in the
|
||||
* routing layer because this is where the app's data already is; routing stays
|
||||
* pure and testable.
|
||||
*/
|
||||
const workforce = React.useMemo(() => ({
|
||||
positions: facts.postings || [],
|
||||
/* The position the page has open, so "who matches this position?" resolves
|
||||
against the record in front of the reader rather than asking which role
|
||||
they meant. A page with nothing open publishes nothing, and the question
|
||||
is answered by name or asked for, exactly as before. */
|
||||
currentPositionId: pageContext.position?.id || null,
|
||||
context: {
|
||||
profiles: facts.profiles || [],
|
||||
applications: facts.applications || [],
|
||||
assignments,
|
||||
courses: facts.forge?.library || [],
|
||||
staff: facts.staff || [],
|
||||
},
|
||||
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments,
|
||||
pageContext.position?.id]);
|
||||
|
||||
/**
|
||||
* The workforce write, through the mutation the app already has.
|
||||
*
|
||||
* `useAssignWorkers` creates the assignment, moves the application and logs
|
||||
* the activity — one path, reused rather than reimplemented. It only ever runs
|
||||
* on a turn the admin explicitly confirmed.
|
||||
*/
|
||||
const assignWorkers = useAssignWorkers();
|
||||
const onAssignWorkers = React.useCallback(async (plan) => {
|
||||
const created = await assignWorkers.mutateAsync({
|
||||
position: plan.position,
|
||||
workers: plan.workers,
|
||||
applications: facts.applications || [],
|
||||
});
|
||||
return created?.length ? created : null;
|
||||
}, [assignWorkers, facts.applications]);
|
||||
|
||||
/**
|
||||
* Move an assigned candidate to interview stage.
|
||||
*
|
||||
* The status transition the Position and Candidates pages already perform —
|
||||
* not a new interview record. `AIInterviewModal` writes the AIInterview when
|
||||
* the interview is actually conducted, and creating an empty one here would be
|
||||
* a second interview store claiming something that has not happened.
|
||||
*/
|
||||
const markInterviewReady = useMarkInterviewReady();
|
||||
const onScheduleInterview = React.useCallback(
|
||||
(plan) => markInterviewReady.mutateAsync({ application: plan.application, position: plan.position }),
|
||||
[markInterviewReady]
|
||||
);
|
||||
|
||||
const {
|
||||
messages, pending, error, busy, send, stop, reset,
|
||||
history, conversationId, openConversation, forgetConversation,
|
||||
submitFeedback, feedback,
|
||||
} = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
pageLabel: context.page,
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription,
|
||||
onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce,
|
||||
disabledSkills,
|
||||
customSkills,
|
||||
roles,
|
||||
skillCategories,
|
||||
courses: facts.forge?.library || [],
|
||||
skillContext,
|
||||
agent,
|
||||
agentCoversPage,
|
||||
agentSuggestion: defaultAgent,
|
||||
owliverContext,
|
||||
});
|
||||
|
||||
/* A block inside an answer asking the next question, in place. Same entry
|
||||
point as the composer and the chips, so an in-panel action is an ordinary
|
||||
turn rather than a second way for the panel to change. */
|
||||
const askOwliver = React.useCallback((question) => {
|
||||
if (!busy) send({ question });
|
||||
}, [busy, send]);
|
||||
|
||||
const historyGroups = React.useMemo(() => groupByRecency(history), [history]);
|
||||
|
||||
/**
|
||||
* Home is the empty panel: greeting, page fact, suggestions.
|
||||
*
|
||||
* One handler for every way back — the History header button, the Back to
|
||||
* Home control, and reopening after reading an old thread — so "home" cannot
|
||||
* mean two slightly different states depending on how you got there. Nothing
|
||||
* is lost: the thread being left is already archived.
|
||||
*/
|
||||
const goHome = React.useCallback(() => {
|
||||
setView('chat');
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const openHistoryItem = React.useCallback((record) => {
|
||||
openConversation(record);
|
||||
setView('chat');
|
||||
}, [openConversation]);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* The Back to Home row only exists in the states that are not already home. */
|
||||
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
|
||||
active: showBackRow,
|
||||
resetKey: `${view}:${conversationId || ''}`,
|
||||
});
|
||||
|
||||
/* Greeting and suggestions come from live data, so they recompute only when
|
||||
the data or the page actually changes. */
|
||||
const intro = React.useMemo(
|
||||
() => buildIntro(context.id, facts, userName),
|
||||
[context.id, facts, userName]
|
||||
);
|
||||
/* Suggestions are the page's own, with any skill that offers one on the front.
|
||||
The last answer can also propose follow-ups — the role list after "create a
|
||||
position" — which replace the standing set until the next turn. */
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const prompts = React.useMemo(() => {
|
||||
if (followUp?.length) return followUp;
|
||||
/* A definition's own suggestions come first: they are the only ones written
|
||||
for this workspace rather than derived from the page, and they are capped
|
||||
by the resolver so a workspace with several skills attached cannot bury
|
||||
the page's own. */
|
||||
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
|
||||
const skillPrompts = skills
|
||||
.filter((s) => s.prompt)
|
||||
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
|
||||
|
||||
/* Three sources can propose the same question — a skill's declared
|
||||
suggestion and the page's own derived one often word it identically —
|
||||
and two chips reading "Summarize hiring activity" is both a duplicate
|
||||
React key and a duplicate offer. First wins, so the definition's own
|
||||
wording survives and the derived copy drops out. */
|
||||
/* A declared suggestion that cannot answer yet — it reads one record and
|
||||
none is selected — ranks behind the page's own offers rather than
|
||||
leading with a question. The lifecycle reads correctly either way:
|
||||
before a position exists the page's actions lead; once one is open or
|
||||
has just been created, the readings about it come first. */
|
||||
const ready = declared.filter((c) => !c.deferred);
|
||||
const asking = declared.filter((c) => c.deferred);
|
||||
|
||||
/**
|
||||
* One chip per *intent*, not per wording.
|
||||
*
|
||||
* De-duplicating on the label alone let two chips through whenever the same
|
||||
* answer was worded twice — a skill's "Show hiring activity" and its
|
||||
* "Summarize hiring activity" both resolved to that skill's `summary`
|
||||
* capability, so the reader was offered the same reading under two names and
|
||||
* had no way to tell them apart. What a chip *resolves to* is the thing that
|
||||
* must be unique: a skill capability, the page capability, or, for a chip
|
||||
* that is neither, the question it sends. The label is compared too, so two
|
||||
* differently-routed chips still cannot arrive reading identically.
|
||||
*/
|
||||
const seen = new Set();
|
||||
const intentOf = (chip) => {
|
||||
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
|
||||
if (chip?.capability) return `page:${chip.capability}`;
|
||||
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
|
||||
};
|
||||
|
||||
/* The agent's own starters lead: they were written for this agent, and they
|
||||
pass through the same de-duplication below, so a starter worded like a
|
||||
skill suggestion still yields one chip rather than two. An agent that
|
||||
does not cover this page offers none. */
|
||||
return [...agentStarters(agent, context.id), ...ready, ...skillPrompts,
|
||||
...buildPrompts(context.id, facts, workforce), ...asking]
|
||||
.filter((chip) => {
|
||||
const label = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!label) return false;
|
||||
const intent = intentOf(chip);
|
||||
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
|
||||
seen.add(intent);
|
||||
seen.add(`label:${label}`);
|
||||
return true;
|
||||
});
|
||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
||||
chips re-rank when a position is opened or closed. */
|
||||
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
|
||||
|
||||
/* The newest assistant turn, which is the one that carries the rating. */
|
||||
const lastAnswerIndex = React.useMemo(
|
||||
() => messages.map((m) => m.role).lastIndexOf('assistant'),
|
||||
[messages]
|
||||
);
|
||||
|
||||
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
|
||||
at streaming frequency a smooth scroll never catches up and the thread
|
||||
visibly lags the text. */
|
||||
React.useEffect(() => {
|
||||
pinToBottom();
|
||||
}, [messages, pending, pinToBottom]);
|
||||
|
||||
/**
|
||||
* The one entry point for every message, whatever raised it.
|
||||
*
|
||||
* A chip carries a capability — it *is* one of this page's answers, so it
|
||||
* bypasses intent routing. A typed question carries none and is routed. Both
|
||||
* land in the same `send`, so there is a single pipeline to reason about.
|
||||
*/
|
||||
const handleMessage = React.useCallback((question, capability = null, positionId = null) => {
|
||||
const text = String(question).trim();
|
||||
if (!text || busy) return;
|
||||
setInput('');
|
||||
/* Asking something while reading History returns to the conversation — the
|
||||
answer is about to arrive there, and leaving the list up would hide it. */
|
||||
setView('chat');
|
||||
send({ question: text, capability, positionId });
|
||||
}, [busy, send]);
|
||||
|
||||
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
||||
|
||||
/**
|
||||
* A chip is already a complete question, so it runs on click.
|
||||
*
|
||||
* A chip may instead carry a destination — "View position", offered after
|
||||
* Owliver creates one. That is the user choosing to go somewhere after the
|
||||
* work is finished, which is the only navigation any of this does.
|
||||
*/
|
||||
const runPrompt = React.useCallback((prompt) => {
|
||||
if (prompt.route) {
|
||||
navigate(prompt.route);
|
||||
return;
|
||||
}
|
||||
/* A chip built from a position says which one, so a follow-up acts on the
|
||||
record the previous answer was about rather than re-deriving it from the
|
||||
words. */
|
||||
handleMessage(prompt.prompt, prompt.capability, prompt.positionId);
|
||||
}, [handleMessage, navigate]);
|
||||
|
||||
/**
|
||||
* A question the page asked on the reader's behalf.
|
||||
*
|
||||
* Continue on a draft card is the only source today: the card knows which
|
||||
* record it is, and the work of finishing it lives here. Deferred while a
|
||||
* response is streaming — the effect re-runs when the panel is free, so the
|
||||
* request waits its turn rather than being dropped for arriving mid-answer.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!panelRequest || busy) return;
|
||||
handleMessage(panelRequest.question, null, panelRequest.positionId);
|
||||
consumeRequest(panelRequest.id);
|
||||
}, [panelRequest, busy, handleMessage, consumeRequest]);
|
||||
|
||||
const composer = (
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={submit}
|
||||
onStop={stop}
|
||||
busy={busy}
|
||||
placeholders={intro.placeholders}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="glass"
|
||||
radius="xl"
|
||||
padding="none"
|
||||
elevation="sm"
|
||||
data-wide={expanded || undefined}
|
||||
className={cn('relative flex flex-col overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-label="Owliver assistant"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
{/* The avatar and the two lines beside it, as they always were —
|
||||
"Owliver" over the page, with the pair doubling as the agent
|
||||
switcher. The header element, its geometry and the window controls
|
||||
to the right are unchanged. */}
|
||||
<AgentSwitcher page={context.page} />
|
||||
|
||||
{/* Window controls.
|
||||
|
||||
Authoring a skill is deliberately not among them. Skills are a
|
||||
registry with a lifecycle — authored, enabled, edited, removed — and
|
||||
Workspace → Skills is where that lifecycle lives. A second entry
|
||||
point here meant the panel you *use* Owliver from was also a place
|
||||
you *configured* it from, and the two lists could be reached from
|
||||
different places with different affordances. The panel does the
|
||||
first job only; the registry behind it is unchanged. */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* History lives with the other window controls rather than in the
|
||||
body, so the layout of the panel is unchanged whether or not
|
||||
there is anything to show. It toggles: pressing it again returns
|
||||
to the conversation you were reading. */}
|
||||
<IconButton
|
||||
icon={History}
|
||||
label={view === 'history' ? 'Back to the conversation' : 'Owliver history'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={view === 'history'}
|
||||
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
||||
/>
|
||||
{messages.length > 0 && view === 'chat' && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{expanded
|
||||
? onRestore && (
|
||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
||||
)
|
||||
: onExpand && (
|
||||
<IconButton icon={Maximize2} label="Expand the Owliver workspace" variant="ghost" size="sm" onClick={onExpand} />
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton icon={PanelRightClose} label="Collapse the Owliver workspace" variant="ghost" size="sm" onClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
|
||||
{showBackRow && (
|
||||
<div
|
||||
className={cn(
|
||||
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
|
||||
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
|
||||
transition-all duration-200 ease-out motion-reduce:transition-none`,
|
||||
backVisible
|
||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||
: '-translate-y-full opacity-0 pointer-events-none'
|
||||
)}
|
||||
aria-hidden={backVisible ? undefined : 'true'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
tabIndex={backVisible ? undefined : -1}
|
||||
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
|
||||
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
|
||||
<span>Back to Home</span>
|
||||
</button>
|
||||
<span className="truncate text-[11px] font-medium text-ink-3">
|
||||
{view === 'history'
|
||||
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
|
||||
: context.page}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body: the only region that scrolls ─────────────────────────── */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3.5"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
>
|
||||
|
||||
{view === 'history' ? (
|
||||
<HistoryView
|
||||
groups={historyGroups}
|
||||
currentId={conversationId}
|
||||
onOpen={openHistoryItem}
|
||||
onForget={forgetConversation}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
/* Greeting and the single most relevant fact about this page. Top
|
||||
aligned rather than centred: it is the first thing in a
|
||||
conversation, not a splash screen, so it belongs where the first
|
||||
message would be. */
|
||||
<div className="animate-fade-in">
|
||||
<p className="font-heading text-body-lg font-semibold leading-snug text-ink-1">
|
||||
{intro.title}
|
||||
</p>
|
||||
<p className="mt-1 text-balance text-body-sm leading-relaxed text-ink-2">
|
||||
{intro.greeting}
|
||||
</p>
|
||||
<p className="mt-2.5 border-t border-border/70 pt-2.5 text-caption leading-relaxed text-ink-3">
|
||||
{intro.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && message.role === 'user' && <TurnDivider />}
|
||||
<Message
|
||||
role={message.role}
|
||||
text={message.text}
|
||||
blocks={message.blocks}
|
||||
stopped={message.stopped}
|
||||
onPrompt={askOwliver}
|
||||
/* A rating belongs to the conversation, so it is offered on
|
||||
the newest answer only — and never while one streams. */
|
||||
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}
|
||||
onFeedback={
|
||||
message.role === 'assistant' && i === lastAnswerIndex && !pending
|
||||
? submitFeedback
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{pending?.thinking && <ThinkingIndicator />}
|
||||
{pending && !pending.thinking && (
|
||||
<Message role="assistant" blocks={pending.blocks} streaming />
|
||||
)}
|
||||
|
||||
{error && <Alert tone="destructive">{error}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
asked. Capped once a thread exists, because from then on the vertical
|
||||
space belongs to the conversation. Expanded fits more per line, so it
|
||||
can afford one more.
|
||||
|
||||
Follow-ups are never capped: when Owliver has asked a question, its
|
||||
chips *are* the answers, and hiding three of the six roles would make
|
||||
the flow look broken. */}
|
||||
{!busy && view === 'chat' && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Owliver reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* What the page is currently looking at, published to the panel beside it.
|
||||
*
|
||||
* A skill's data source declares what it needs — a position, a candidate, the
|
||||
* position being specified — and the page is the only thing that knows which
|
||||
* one is open. Without this, Owliver could read the collections but not the
|
||||
* *selection*: it would answer about the workspace when it was asked about the
|
||||
* role in the drawer.
|
||||
*
|
||||
* The channel is deliberately narrow. A page publishes records it already has;
|
||||
* it does not publish components, callbacks or view state, and nothing here
|
||||
* reaches back into the page. That keeps the coupling one-way — a page that
|
||||
* publishes nothing simply has no selection, and every skill whose source needs
|
||||
* one says so instead of guessing.
|
||||
*
|
||||
* usePublishPageContext({ position }); // in the page
|
||||
* const { position } = usePageContext(); // in the panel
|
||||
*
|
||||
* Kept out of `AssistantPanelContext` on purpose: that context is the panel's
|
||||
* window state, changing when someone drags a divider. This changes when the
|
||||
* reader opens a record, and merging the two would re-render one for the other.
|
||||
*/
|
||||
|
||||
const PageContext = React.createContext(null);
|
||||
|
||||
export function PageContextProvider({ children }) {
|
||||
const [entity, setEntity] = React.useState(null);
|
||||
|
||||
/**
|
||||
* The writes a page is willing to accept, keyed by data source.
|
||||
*
|
||||
* The read channel above is serialized state; this cannot be — a handler is a
|
||||
* function, closing over the page's own setter. So it lives in a ref, and only
|
||||
* the *set of source ids* is state. That distinction is the whole design:
|
||||
* consumers re-render when a page starts or stops accepting a write, not every
|
||||
* time the page re-renders and hands over a new closure.
|
||||
*
|
||||
* Keyed by source id rather than by an invented action name, because the
|
||||
* source is already the validated binding between a definition and its data.
|
||||
* A skill therefore cannot write anywhere it could not read, and a page cannot
|
||||
* accept a write for a reading it does not own.
|
||||
*/
|
||||
const handlersRef = React.useRef({});
|
||||
const [writable, setWritable] = React.useState([]);
|
||||
|
||||
const publishActions = React.useCallback((handlers) => {
|
||||
handlersRef.current = handlers || {};
|
||||
const next = Object.keys(handlersRef.current).sort();
|
||||
/* Only a change in *what* is writable is worth a render. */
|
||||
setWritable((prev) => (prev.join('|') === next.join('|') ? prev : next));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Runs a page's handler for a source. Returns false when the page is not
|
||||
* accepting that write, so a caller can stay read-only rather than pretending
|
||||
* the change landed.
|
||||
*/
|
||||
const runAction = React.useCallback((source, payload) => {
|
||||
const handler = handlersRef.current[source];
|
||||
if (typeof handler !== 'function') return false;
|
||||
handler(payload);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ entity, setEntity, publishActions, runAction, writable }),
|
||||
[entity, publishActions, runAction, writable]
|
||||
);
|
||||
|
||||
return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes this page's current selection for as long as it is mounted.
|
||||
*
|
||||
* Keyed on the content rather than the object, because a page rebuilds its
|
||||
* records on every render and an identity-keyed effect would publish in a loop.
|
||||
* Unmounting clears the selection, so leaving a page cannot leave Owliver
|
||||
* answering about a record nobody is looking at.
|
||||
*/
|
||||
export function usePublishPageContext(entity) {
|
||||
const context = React.useContext(PageContext);
|
||||
const setEntity = context?.setEntity;
|
||||
|
||||
/* `undefined` values would vanish from the serialized key, so a selection
|
||||
being cleared has to read as an explicit null. */
|
||||
const key = React.useMemo(() => {
|
||||
try {
|
||||
return entity ? JSON.stringify(entity) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [entity]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!setEntity) return undefined;
|
||||
setEntity(key ? JSON.parse(key) : null);
|
||||
return () => setEntity(null);
|
||||
}, [key, setEntity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records the page has published. Always an object, so a consumer can read
|
||||
* `position` without checking whether anything published at all.
|
||||
*/
|
||||
export function usePageContext() {
|
||||
return React.useContext(PageContext)?.entity || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers this page's writes for as long as it is mounted.
|
||||
*
|
||||
* Pass a memoized map of `{ [sourceId]: handler }`. Handlers should be stable —
|
||||
* a functional `setState` closes over nothing, which is what lets the map be
|
||||
* memoized once rather than rebuilt as the form is typed into.
|
||||
*
|
||||
* const applyWeights = useCallback(
|
||||
* (next) => setForm((f) => ({ ...f, vetting_criteria: next })), []);
|
||||
* usePublishPageActions(useMemo(
|
||||
* () => ({ 'position.vetting': applyWeights }), [applyWeights]));
|
||||
*
|
||||
* Unpublished on unmount, so leaving a page cannot leave a control on screen
|
||||
* that writes into a form nobody is looking at.
|
||||
*/
|
||||
export function usePublishPageActions(handlers) {
|
||||
const publishActions = React.useContext(PageContext)?.publishActions;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!publishActions) return undefined;
|
||||
publishActions(handlers);
|
||||
return () => publishActions(null);
|
||||
}, [publishActions, handlers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a section writes back, for one source — or `null` when nothing on this
|
||||
* page is accepting that write.
|
||||
*
|
||||
* Returning null rather than a no-op is deliberate: a renderer checks it to
|
||||
* decide whether to draw controls at all, so a section declared `editable` on a
|
||||
* page that does not own the data stays an honest read-out.
|
||||
*/
|
||||
export function usePageAction(source) {
|
||||
const context = React.useContext(PageContext);
|
||||
const accepted = Boolean(source) && (context?.writable || []).includes(source);
|
||||
const runAction = context?.runAction;
|
||||
|
||||
return React.useMemo(
|
||||
() => (accepted && runAction ? (payload) => runAction(source, payload) : null),
|
||||
[accepted, runAction, source]
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* PromptChips — suggested prompts.
|
||||
*
|
||||
* Clicking a chip runs it immediately. A chip is already a complete question, so
|
||||
* making the user press Enter afterwards adds a step that buys nothing.
|
||||
*
|
||||
* Chips always wrap onto as many lines as they need. The earlier horizontal-scroll
|
||||
* variant is gone: a scroller can always leave a chip half-visible at the edge,
|
||||
* and a half-visible button is a bug no amount of fade masking fixes. Wrapping
|
||||
* makes clipping structurally impossible instead of merely unlikely.
|
||||
*
|
||||
* `max` caps how many are offered in a conversation, where vertical space belongs
|
||||
* to the thread rather than to suggestions.
|
||||
*
|
||||
* A chip is shrink-to-fit up to the panel's width, so a long suggestion spends the
|
||||
* whole row before it is allowed to take a second line. When it does take one, the
|
||||
* radius stays at the single-line pill's 14px rather than following the height:
|
||||
* `rounded-full` on a two-line chip resolves to half of 46px, and that corner is
|
||||
* what turns the same component into something that reads as a card.
|
||||
*
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
const onKeyDown = (event, index) => {
|
||||
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (!delta) return;
|
||||
const next = chipRefs.current[index + delta];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
};
|
||||
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Suggested prompts"
|
||||
className={cn(
|
||||
'flex flex-wrap gap-1.5',
|
||||
align === 'center' ? 'justify-center' : 'justify-start',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{visible.map((prompt, i) => (
|
||||
<button
|
||||
/* Position, not label. Callers de-duplicate, but a follow-up list is
|
||||
built per answer and two chips can legitimately read the same —
|
||||
the key has to stay unique either way. */
|
||||
key={`${i}:${prompt.label}`}
|
||||
ref={(el) => { chipRefs.current[i] = el; }}
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||
chip, pinned so a wrapped chip keeps the same corner. */
|
||||
'flex max-w-full items-center rounded-[14px] border border-border bg-surface px-2.5 py-1',
|
||||
'text-caption font-medium text-ink-2 shadow-xs text-left',
|
||||
'transition-all duration-base ease-out',
|
||||
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
|
||||
'active:translate-y-0 active:shadow-xs',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
|
||||
)}
|
||||
>
|
||||
{/* `pretty` rather than `balance`: the row should be spent before a
|
||||
second line starts, and balancing would leave the pill wide with
|
||||
two short lines inside it. Clamped so no suggestion can grow the
|
||||
chip into a paragraph — the full text stays in `title`. */}
|
||||
<span className="line-clamp-3 text-pretty">{prompt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowUp, Square } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* PromptInput — the assistant's primary action.
|
||||
*
|
||||
* A textarea rather than an input, because these prompts are sentences: it grows
|
||||
* with the content up to a cap, keeps Enter as send, and leaves Shift+Enter for a
|
||||
* deliberate newline. That combination is what makes typing a real question feel
|
||||
* natural instead of cramped.
|
||||
*
|
||||
* `placeholders` is a list, but only the first is shown. It used to rotate every
|
||||
* few seconds; that was defensible when the panel opened on demand and wrong once
|
||||
* Owliver became permanent furniture, because a permanently visible field that
|
||||
* rewrites itself forever is movement in the corner of the eye with nothing to
|
||||
* gain — the suggestion chips already teach the range of what can be asked, and
|
||||
* they do it without a timer or a rerender every four seconds.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
busy = false,
|
||||
placeholders = ['Ask anything…'],
|
||||
autoFocus = false,
|
||||
align = 'left',
|
||||
className = '',
|
||||
}) {
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
/* Grow to fit the content, up to a cap, then scroll internally. */
|
||||
React.useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus) textareaRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
const submit = () => {
|
||||
if (!value.trim() || busy) return;
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const isCenter = align === 'center';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
||||
'px-3 py-2',
|
||||
focused
|
||||
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
|
||||
: 'border-border shadow-md hover:border-krow-blue/30',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
disabled={busy}
|
||||
aria-label="Ask Owliver"
|
||||
aria-busy={busy || undefined}
|
||||
className={cn(
|
||||
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
|
||||
'no-scrollbar outline-none disabled:cursor-wait disabled:text-ink-3',
|
||||
'py-0.5 text-body-sm',
|
||||
isCenter ? 'text-center' : 'text-left'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rendered as an element rather than the native `placeholder` so it can
|
||||
be truncated and swapped for the busy state. Hidden from assistive
|
||||
tech — the textarea's aria-label is the accessible name. */}
|
||||
{!value && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 truncate text-ink-4',
|
||||
'py-0.5 text-body-sm',
|
||||
isCenter ? 'text-center' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{busy ? 'Thinking…' : placeholders[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label="Stop generating"
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
||||
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
||||
'h-8 w-8'
|
||||
)}
|
||||
>
|
||||
<Square className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
||||
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
||||
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
|
||||
'h-8 w-8'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* ResizeDivider — the drag handle between the dashboard and the Owliver panel.
|
||||
*
|
||||
* The hit area is deliberately wider than the visible line: a 1px target is
|
||||
* unusable, so the divider occupies 12px and shows a 2px rule inside it. The
|
||||
* grip dots only appear on hover, which keeps a permanent chrome element from
|
||||
* competing with the content on either side.
|
||||
*
|
||||
* Keyboard support is not a nicety here — a pointer-only resize is unreachable
|
||||
* for anyone who cannot drag. Arrow keys nudge by 16px, Home/End jump to the
|
||||
* limits, and the whole thing is exposed as a `separator` with live values.
|
||||
*/
|
||||
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
|
||||
/* Pointer events on the window rather than the element: once a drag starts the
|
||||
pointer routinely leaves the 12px handle, and a listener bound to the handle
|
||||
would drop the drag the moment it did. */
|
||||
React.useEffect(() => {
|
||||
if (!dragging) return undefined;
|
||||
|
||||
const onMove = (e) => {
|
||||
// The panel is right-aligned, so distance from the right edge is the width.
|
||||
onResize(Math.round(window.innerWidth - e.clientX));
|
||||
};
|
||||
const stop = () => setDragging(false);
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
|
||||
// Suppress text selection and swap the cursor for the whole document, so the
|
||||
// cursor does not flicker back whenever the pointer crosses other elements.
|
||||
const previousCursor = document.body.style.cursor;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [dragging, onResize]);
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
const step = e.shiftKey ? 48 : 16;
|
||||
const actions = {
|
||||
ArrowLeft: () => onResize(width + step), // widening the panel moves the divider left
|
||||
ArrowRight: () => onResize(width - step),
|
||||
Home: () => onResize(max),
|
||||
End: () => onResize(min),
|
||||
};
|
||||
const action = actions[e.key];
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize Owliver panel"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
'group relative flex w-3 shrink-0 cursor-col-resize touch-none items-center justify-center',
|
||||
'focus-visible:outline-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* The visible rule. Brand-tinted while dragging or focused so the
|
||||
interaction has feedback without the handle being loud at rest. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-full w-0.5 rounded-full transition-colors duration-fast',
|
||||
dragging
|
||||
? 'bg-krow-blue'
|
||||
: 'bg-border group-hover:bg-krow-blue/40 group-focus-visible:bg-krow-blue'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute grid h-6 w-3.5 place-items-center rounded-full border border-border bg-surface shadow-xs',
|
||||
'transition-opacity duration-base',
|
||||
dragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100'
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-ink-4" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowDownRight, ArrowRight, ArrowUpRight, Check, ChevronRight, TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
||||
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { usePageAction } from './PageContext';
|
||||
|
||||
/**
|
||||
* Renderers for response blocks.
|
||||
*
|
||||
* Every block is drawn with the same tokens as the dashboard — the ink scale,
|
||||
* the shared radius, the score-band colours — so a generated report looks like
|
||||
* it belongs to Krow rather than to a chat library.
|
||||
*
|
||||
* Blocks are memoized individually: a streaming response re-renders on every
|
||||
* snapshot, and settled blocks must not re-render with it.
|
||||
*/
|
||||
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
||||
|
||||
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
||||
* carried by blocks, not by markup inside a paragraph. */
|
||||
function Inline({ value }) {
|
||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith('_') && part.endsWith('_')) {
|
||||
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
const TONE_TEXT = {
|
||||
info: 'text-krow-blue',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
risk: 'text-destructive',
|
||||
neutral: 'text-ink-3',
|
||||
};
|
||||
|
||||
const TONE_SURFACE = {
|
||||
info: 'bg-krow-blue-tint border-krow-blue/15',
|
||||
success: 'bg-success-muted border-success/15',
|
||||
warning: 'bg-warning-muted border-warning/20',
|
||||
risk: 'bg-destructive-muted border-destructive/15',
|
||||
neutral: 'bg-surface-subtle border-border',
|
||||
};
|
||||
|
||||
const TONE_BADGE = {
|
||||
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
|
||||
};
|
||||
|
||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||
|
||||
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
TextBlock.displayName = 'TextBlock';
|
||||
|
||||
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||
</div>
|
||||
));
|
||||
HeadingBlock.displayName = 'HeadingBlock';
|
||||
|
||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||
{block.items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
|
||||
>
|
||||
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
|
||||
<div className="mt-0.5 flex items-baseline gap-1.5">
|
||||
<span className={cn(
|
||||
'font-heading text-title font-bold tabular-nums',
|
||||
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
|
||||
)}>
|
||||
{item.value}
|
||||
</span>
|
||||
{typeof item.delta === 'number' && item.delta !== 0 && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center text-[10px] font-semibold',
|
||||
item.delta > 0 ? 'text-success' : 'text-destructive'
|
||||
)}>
|
||||
{item.delta > 0
|
||||
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||
{Math.abs(item.delta)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
KpisBlock.displayName = 'KpisBlock';
|
||||
|
||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ul className="space-y-1.5">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.label} className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
|
||||
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
|
||||
)}
|
||||
>
|
||||
{item.ok
|
||||
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
|
||||
{item.value && (
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
|
||||
</div>
|
||||
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
));
|
||||
StatusBlock.displayName = 'StatusBlock';
|
||||
|
||||
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="space-y-2.5">
|
||||
{block.items.map((item) => (
|
||||
<div key={item.label} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-caption text-ink-2">{item.label}</span>
|
||||
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
MetersBlock.displayName = 'MetersBlock';
|
||||
|
||||
/**
|
||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||
* never widens the panel.
|
||||
*/
|
||||
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const cell = (value) => {
|
||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||
if (typeof value === 'object') {
|
||||
if (value.badge) {
|
||||
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
|
||||
}
|
||||
return (
|
||||
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
|
||||
{value.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
|
||||
<table className="w-full border-collapse text-caption">
|
||||
{block.caption && <caption className="sr-only">{block.caption}</caption>}
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{block.columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{block.columns.map((column, ci) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-2.5 py-2 align-middle',
|
||||
column.align === 'right' ? 'text-right' : 'text-left',
|
||||
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
|
||||
)}
|
||||
>
|
||||
{cell(row[column.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TableBlock.displayName = 'TableBlock';
|
||||
|
||||
/** Stage progression, widths relative to the largest stage. */
|
||||
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{block.steps.map((step) => (
|
||||
<div key={step.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-caption text-ink-2">{step.label}</span>
|
||||
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
|
||||
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
FunnelBlock.displayName = 'FunnelBlock';
|
||||
|
||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const Tag = block.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag className="space-y-1.5">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
||||
{block.ordered ? (
|
||||
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0"><Inline value={item} /></span>
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
ListBlock.displayName = 'ListBlock';
|
||||
|
||||
/**
|
||||
* Findings, and — where the item carries one — a way to act on it.
|
||||
*
|
||||
* Two kinds of action, and the distinction is the point:
|
||||
*
|
||||
* `prompt` asks Owliver the next question, in place. The panel stays open,
|
||||
* the thread keeps its history, and the reader never loses the
|
||||
* position they were working on. This is the default for a
|
||||
* recommended person: inspecting a candidate is part of the
|
||||
* conversation, not a trip to another page.
|
||||
* `to` leaves for a route the app already has, with the whole card as the
|
||||
* target. Reserved for items whose only meaning is "open this".
|
||||
* `action` a named link inside the card — `{ label, to }`. For an item that
|
||||
* has *both* a conversation and a record: the card keeps asking
|
||||
* Owliver, and the link is the one explicit way to leave. Reading a
|
||||
* card can then never navigate by accident, which matters most where
|
||||
* the body is an explanation worth reading.
|
||||
*
|
||||
* `hint` is for an item that can do neither — it says why, instead of looking
|
||||
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||
* hint explains the record, the action opens it.
|
||||
*/
|
||||
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => {
|
||||
const surface = cn(
|
||||
'block w-full text-left rounded-xl border px-3 py-2.5',
|
||||
TONE_SURFACE[item.tone] || TONE_SURFACE.neutral
|
||||
);
|
||||
const interactive = cn(
|
||||
surface,
|
||||
'transition-colors hover:border-krow-blue/50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
|
||||
);
|
||||
const asksOwliver = Boolean(item.prompt && onPrompt);
|
||||
const actionable = asksOwliver || Boolean(item.to);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<p className="flex items-center gap-1 text-body-sm font-semibold text-ink-1">
|
||||
{item.title}
|
||||
{actionable && <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />}
|
||||
</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
{item.hint && (
|
||||
<p className="mt-1 text-[11px] italic leading-relaxed text-ink-4">{item.hint}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
/* A named link cannot live inside a button, so when an item has both, the
|
||||
card is a plain container: the readable area is its own button and the
|
||||
action sits beside it. One click target each, never nested. */
|
||||
if (item.action?.to) {
|
||||
return (
|
||||
<div key={i} className={surface}>
|
||||
{asksOwliver ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPrompt(item.prompt)}
|
||||
className="block w-full rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : body}
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Link
|
||||
to={item.action.to}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{item.action.label}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (asksOwliver) {
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (item.to) {
|
||||
return <Link key={i} to={item.to} className={interactive}>{body}</Link>;
|
||||
}
|
||||
return <div key={i} className={surface}>{body}</div>;
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
|
||||
/** Recommended steps — numbered, because order is the recommendation. */
|
||||
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ol className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
));
|
||||
ActionsBlock.displayName = 'ActionsBlock';
|
||||
|
||||
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{block.items.map((item) => (
|
||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
BadgesBlock.displayName = 'BadgesBlock';
|
||||
|
||||
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ol className="relative space-y-3">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 h-2 w-2 shrink-0 rounded-full',
|
||||
item.tone === 'success' ? 'bg-success'
|
||||
: item.tone === 'warning' ? 'bg-warning'
|
||||
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||
{item.timestamp && (
|
||||
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
));
|
||||
TimelineBlock.displayName = 'TimelineBlock';
|
||||
|
||||
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
NoteBlock.displayName = 'NoteBlock';
|
||||
|
||||
/**
|
||||
* A section a skill declared, drawn by the component the page uses for it.
|
||||
*
|
||||
* There is no chat-specific renderer for a skill's shapes, and there must not
|
||||
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
|
||||
* component, and this block goes through it exactly as `SkillSurface` does. A
|
||||
* definition that gains a new shape gains it in both places at once, and a
|
||||
* shape the table does not carry renders nothing rather than something
|
||||
* improvised.
|
||||
*/
|
||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const section = block.section;
|
||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||
|
||||
/**
|
||||
* A settable answer is re-read, never replayed.
|
||||
*
|
||||
* An ordinary answer is a record of what was true when it was given, and the
|
||||
* stored blocks are exactly right for that. A section offering *controls* is
|
||||
* different: it is a live view of a value the reader can still change, from
|
||||
* here or on the page behind the panel. Replaying the stored copy would show
|
||||
* a weight of 25% next to a form that now says 30%, and the control would
|
||||
* write the stale figure back.
|
||||
*
|
||||
* So an editable section resolves from the current context on every render,
|
||||
* through the same resolver the page's own card uses. There is one value and
|
||||
* one place it lives; this is a second window onto it, not a second copy.
|
||||
*/
|
||||
const live = Boolean(section?.editable);
|
||||
const context = useSkillDataContext(null);
|
||||
const data = React.useMemo(
|
||||
() => (live && section ? resolveSkillData(section, context) : block.data),
|
||||
[live, section, context, block.data]
|
||||
);
|
||||
const apply = usePageAction(live ? section?.source : null);
|
||||
|
||||
if (!Component) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle/60 p-3">
|
||||
<Component data={data} section={section} onApply={apply} compact />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SkillSectionBlock.displayName = 'SkillSectionBlock';
|
||||
|
||||
const RENDERERS = {
|
||||
text: TextBlock,
|
||||
skillSection: SkillSectionBlock,
|
||||
heading: HeadingBlock,
|
||||
kpis: KpisBlock,
|
||||
status: StatusBlock,
|
||||
meters: MetersBlock,
|
||||
table: TableBlock,
|
||||
funnel: FunnelBlock,
|
||||
list: ListBlock,
|
||||
insights: InsightsBlock,
|
||||
actions: ActionsBlock,
|
||||
badges: BadgesBlock,
|
||||
timeline: TimelineBlock,
|
||||
note: NoteBlock,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a response document.
|
||||
*
|
||||
* Spacing is decided here rather than by the blocks, so any combination of
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* else breathes.
|
||||
*/
|
||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
if (!Renderer) return null;
|
||||
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
|
||||
const isLast = i === blocks.length - 1;
|
||||
|
||||
return (
|
||||
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
||||
<Renderer block={block} onPrompt={onPrompt} />
|
||||
{/* The cursor trails the final block only while text is still arriving. */}
|
||||
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
ResponseDocument.displayName = 'ResponseDocument';
|
||||
|
||||
export { toneForScore };
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Response blocks.
|
||||
*
|
||||
* Answers are documents, not strings. A capability decides how its information
|
||||
* is best communicated — a comparison is a table, a health check is a status
|
||||
* list, a funnel is a funnel — and the renderer draws it with design-system
|
||||
* components. That is what turns a reply into a small interactive report rather
|
||||
* than a wall of chat text.
|
||||
*
|
||||
* Blocks are plain data, so they serialize: a future remote service can return
|
||||
* exactly this shape and nothing in the UI changes.
|
||||
*/
|
||||
|
||||
/** Assembles a document, flattening nested arrays and dropping empty slots. */
|
||||
export const doc = (...blocks) => ({
|
||||
blocks: blocks.flat(Infinity).filter(Boolean),
|
||||
});
|
||||
|
||||
/** A short paragraph. Supports `**bold**` and `_italic_` inline. */
|
||||
export const text = (value) => value && { type: 'text', text: value };
|
||||
|
||||
/** A section label inside a response. */
|
||||
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
|
||||
|
||||
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
||||
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
||||
|
||||
/** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */
|
||||
export const status = (items) => items?.length && { type: 'status', items };
|
||||
|
||||
/** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */
|
||||
export const meters = (items) => items?.length && { type: 'meters', items };
|
||||
|
||||
/**
|
||||
* A comparison or record table.
|
||||
* `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]`
|
||||
* A cell may be a string, a number, or `{ value, tone, badge }`.
|
||||
*/
|
||||
export const table = (columns, rows, options = {}) =>
|
||||
rows?.length && { type: 'table', columns, rows, ...options };
|
||||
|
||||
/** Stage progression. `steps: [{ label, count, rate?, lost? }]` */
|
||||
export const funnel = (steps) => steps?.length && { type: 'funnel', steps };
|
||||
|
||||
/** Bulleted or numbered points. */
|
||||
export const list = (items, options = {}) =>
|
||||
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
|
||||
|
||||
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
|
||||
/**
|
||||
* Findings. `items: [{ title, body, tone?, to?, hint? }]`
|
||||
*
|
||||
* `to` makes the card a link into the app — a recommended person opening their
|
||||
* own record, rather than a name the reader has to go and look up. It is a route
|
||||
* the app already has; nothing here invents an address, and an item without one
|
||||
* renders exactly as it always did.
|
||||
*/
|
||||
export const insights = (items) =>
|
||||
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
|
||||
|
||||
/** Recommended next steps. `items: [{ title, body }]` */
|
||||
export const actions = (items) =>
|
||||
items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) };
|
||||
|
||||
/** Inline tags. `items: [{ label, tone }]` or plain strings. */
|
||||
export const badges = (items) =>
|
||||
items?.filter(Boolean).length && {
|
||||
type: 'badges',
|
||||
items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)),
|
||||
};
|
||||
|
||||
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
|
||||
export const timeline = (items) => items?.length && { type: 'timeline', items };
|
||||
|
||||
/**
|
||||
* A section a skill definition declared, drawn in the panel.
|
||||
*
|
||||
* The one block whose shape is not decided here: `section` is the normalized
|
||||
* record the registry produced from a `.md` file, and `data` is what the shared
|
||||
* resolver read for it. The renderer hands both to the same component the page
|
||||
* mounts, so a flow asked for in conversation *is* the page's flow rather than
|
||||
* a chat-shaped imitation of it.
|
||||
*
|
||||
* Both halves are plain data, so a reply containing one still serializes into
|
||||
* the stored thread like every other block.
|
||||
*/
|
||||
export const skillSection = (section, data) =>
|
||||
section && data && { type: 'skillSection', section, data };
|
||||
|
||||
/** A caveat. Always the last word on a claim, never the headline. */
|
||||
export const note = (value) => value && { type: 'note', text: value };
|
||||
|
||||
/** A single sentence answer — used by short free-text replies. */
|
||||
export const answer = (value) => doc(text(value));
|
||||
|
||||
/* ── Streaming ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Splits a document into the progressive snapshots the provider streams.
|
||||
*
|
||||
* Text and heading blocks reveal word by word; structured blocks appear whole,
|
||||
* because a half-drawn table reads as broken rather than as arriving. The result
|
||||
* is a response that composes itself in a readable order.
|
||||
*/
|
||||
export function toSnapshots({ blocks }) {
|
||||
const snapshots = [];
|
||||
const settled = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' || block.type === 'heading') {
|
||||
const tokens = String(block.text).split(/(\s+)/);
|
||||
// Three words per snapshot: fast enough to feel live, coarse enough to
|
||||
// avoid a reflow per character.
|
||||
for (let i = 6; i < tokens.length; i += 6) {
|
||||
snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]);
|
||||
}
|
||||
}
|
||||
settled.push(block);
|
||||
snapshots.push([...settled]);
|
||||
}
|
||||
|
||||
return snapshots.length ? snapshots : [[]];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,592 +0,0 @@
|
||||
import {
|
||||
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
||||
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
|
||||
status, table, text,
|
||||
} from '../blocks';
|
||||
import { plural, verb } from '../insights';
|
||||
|
||||
/**
|
||||
* Employer capabilities — Overview, Candidates, Analytics.
|
||||
*
|
||||
* Each returns a block document rather than prose. The shape is chosen by what
|
||||
* the information is: a comparison is a table, a health check is a status list,
|
||||
* a pipeline is a funnel. Prose is reserved for the judgement a table cannot
|
||||
* carry.
|
||||
*/
|
||||
|
||||
/* ── Overview ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const hiringSummary = (f) => {
|
||||
if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
|
||||
|
||||
return doc(
|
||||
kpis([
|
||||
{ label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
|
||||
{ label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'Interviewing', value: f.interviewing.length },
|
||||
{ label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
|
||||
]),
|
||||
heading('Pipeline'),
|
||||
funnel(f.funnel.map((stage, i) => ({
|
||||
label: stage.label,
|
||||
count: stage.count,
|
||||
rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
|
||||
}))),
|
||||
text(
|
||||
f.stalled.length
|
||||
? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
|
||||
: f.unscreened.length
|
||||
? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
||||
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const hiringHealth = (f) => {
|
||||
const checks = [
|
||||
{
|
||||
label: 'Screening coverage',
|
||||
value: `${f.standardizedPct}%`,
|
||||
ok: f.standardizedPct >= 80,
|
||||
note: f.standardizedPct >= 80
|
||||
? 'Applicants are scored consistently.'
|
||||
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
|
||||
},
|
||||
{
|
||||
label: 'Response speed',
|
||||
value: f.timeToHire ? `${f.timeToHire}d` : '—',
|
||||
ok: f.timeToHire > 0 && f.timeToHire <= 3,
|
||||
note: f.timeToHire && f.timeToHire <= 3
|
||||
? 'Inside the window where good candidates are still available.'
|
||||
: 'Event staff accept other work within days — past 3 days you lose people.',
|
||||
},
|
||||
{
|
||||
label: 'Quality of hire',
|
||||
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
|
||||
ok: f.hiredAvgScore >= 80,
|
||||
note: f.hiredAvgScore >= 80
|
||||
? 'You are hiring from the top of your own pool.'
|
||||
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
|
||||
},
|
||||
{
|
||||
label: 'Pipeline supply',
|
||||
value: `${f.openPositions.length} open`,
|
||||
ok: f.starvedPositions.length === 0,
|
||||
note: f.starvedPositions.length
|
||||
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
|
||||
: 'Every open position has applicants.',
|
||||
},
|
||||
];
|
||||
|
||||
const passing = checks.filter((c) => c.ok).length;
|
||||
|
||||
return doc(
|
||||
heading(`${passing} of ${checks.length} signals healthy`),
|
||||
status(checks),
|
||||
note('Thresholds are event-staffing norms, not universal benchmarks.')
|
||||
);
|
||||
};
|
||||
|
||||
const buildActions = (f) => [
|
||||
f.stalled.length && {
|
||||
title: 'Move your 80+ candidates',
|
||||
body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`,
|
||||
},
|
||||
f.unscreened.length && {
|
||||
title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
|
||||
body: 'One pass clears the backlog and costs nothing.',
|
||||
},
|
||||
f.starvedPositions.length && {
|
||||
title: 'Fix the postings nobody applies to',
|
||||
body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
|
||||
},
|
||||
f.unratedStaff.length && {
|
||||
title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
|
||||
body: 'Ratings feed KROW scores and sharpen future matching.',
|
||||
},
|
||||
f.elite.length && {
|
||||
title: 'Reach out to the pool directly',
|
||||
body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
const pendingActions = (f) => {
|
||||
const items = buildActions(f);
|
||||
if (!items.length) {
|
||||
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
|
||||
}
|
||||
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
|
||||
};
|
||||
|
||||
const hiringRisks = (f) => {
|
||||
const risks = [
|
||||
f.singleCandidateRoles.length && {
|
||||
tone: 'risk',
|
||||
title: 'Single-candidate roles',
|
||||
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`,
|
||||
},
|
||||
f.stalled.length && {
|
||||
tone: 'warning',
|
||||
title: 'Losing strong candidates to delay',
|
||||
body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
|
||||
},
|
||||
f.missingCredentials.length && {
|
||||
tone: 'risk',
|
||||
title: 'Compliance exposure',
|
||||
body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`,
|
||||
},
|
||||
f.narrowAvailability.length && {
|
||||
tone: 'warning',
|
||||
title: 'Availability risk',
|
||||
body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
|
||||
},
|
||||
f.starvedPositions.length && {
|
||||
tone: 'warning',
|
||||
title: 'Unfillable as posted',
|
||||
body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
|
||||
},
|
||||
f.flagged.length && {
|
||||
tone: 'risk',
|
||||
title: 'Interview integrity',
|
||||
body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
if (!risks.length) {
|
||||
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(risks.length, 'risk')} to watch`),
|
||||
insights(risks),
|
||||
note('Flags to check, not conclusions. Each is a question for a human.')
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||||
|
||||
const resumeAnalysis = (f) => {
|
||||
const c = f.top[0];
|
||||
if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
|
||||
|
||||
return doc(
|
||||
heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
|
||||
kpis([
|
||||
{ label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
|
||||
{ label: 'Verdict', value: c.ai_match_label || '—' },
|
||||
]),
|
||||
c.ai_summary && text(c.ai_summary),
|
||||
c.certifications?.length
|
||||
? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
|
||||
: [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
|
||||
heading('Score breakdown'),
|
||||
meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
|
||||
c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
|
||||
c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
|
||||
note(
|
||||
c.certifications?.length
|
||||
? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
|
||||
: 'Request credentials before scheduling.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const candidateComparison = (f) => {
|
||||
const [a, b] = f.ranked;
|
||||
if (!a || !b) return answer('I need at least two scored candidates to compare.');
|
||||
|
||||
const gap = a.ai_score - b.ai_score;
|
||||
const first = (n) => n.split(' ')[0];
|
||||
|
||||
const rows = DIMENSIONS.map((d) => {
|
||||
const av = a.score_breakdown?.[d] ?? 0;
|
||||
const bv = b.score_breakdown?.[d] ?? 0;
|
||||
return {
|
||||
dimension: d[0].toUpperCase() + d.slice(1),
|
||||
a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
|
||||
b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
|
||||
};
|
||||
});
|
||||
|
||||
const biggest = DIMENSIONS.reduce((best, d) =>
|
||||
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
|
||||
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
|
||||
|
||||
return doc(
|
||||
heading(`${a.applicant_name} vs ${b.applicant_name}`),
|
||||
table(
|
||||
[
|
||||
{ key: 'dimension', label: '' },
|
||||
{ key: 'a', label: first(a.applicant_name), align: 'right' },
|
||||
{ key: 'b', label: first(b.applicant_name), align: 'right' },
|
||||
],
|
||||
[
|
||||
{ dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
|
||||
...rows,
|
||||
],
|
||||
{ caption: 'Score comparison by dimension' }
|
||||
),
|
||||
heading('Where each one wins'),
|
||||
insights([
|
||||
{
|
||||
tone: 'success',
|
||||
title: `${first(a.applicant_name)} leads on ${biggest}`,
|
||||
body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
|
||||
},
|
||||
b.ai_strengths?.[0] && {
|
||||
tone: 'info',
|
||||
title: `${first(b.applicant_name)} brings`,
|
||||
body: b.ai_strengths[0],
|
||||
},
|
||||
]),
|
||||
text(
|
||||
gap <= 3
|
||||
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
||||
: `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.`
|
||||
),
|
||||
note('Recommendation only. Neither has been met in person.')
|
||||
);
|
||||
};
|
||||
|
||||
const interviewQuestions = (f) => {
|
||||
const c = f.stalled[0] || f.ranked[0];
|
||||
if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
|
||||
|
||||
const gaps = c.ai_gaps || [];
|
||||
|
||||
return doc(
|
||||
heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
|
||||
list([
|
||||
`Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
||||
gaps[0]
|
||||
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
||||
: 'Tell me about a shift that went wrong. What was your part in it?',
|
||||
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
||||
c.certifications?.length
|
||||
? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?`
|
||||
: 'Which certifications are you working toward, and why that one?',
|
||||
'What does your real availability look like over the next eight weeks?',
|
||||
], { ordered: true }),
|
||||
note('The scenario question is the one that separates candidates. Let them think.')
|
||||
);
|
||||
};
|
||||
|
||||
const skillGapAnalysis = (f) => {
|
||||
if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
|
||||
|
||||
const tally = {};
|
||||
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
|
||||
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
|
||||
tally[key] = (tally[key] || 0) + 1;
|
||||
}));
|
||||
|
||||
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
|
||||
|
||||
return doc(
|
||||
heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
|
||||
table(
|
||||
[{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
|
||||
ranked.map(([gap, count]) => ({
|
||||
gap,
|
||||
count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
|
||||
}))
|
||||
),
|
||||
text(
|
||||
f.missingCredentials.length >= 2
|
||||
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
||||
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const hiringRecommendation = (f) => {
|
||||
if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
|
||||
|
||||
const open = (a) => !['hired', 'rejected'].includes(a.status);
|
||||
const groups = [
|
||||
{ verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
|
||||
{ verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
|
||||
{ verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
|
||||
].filter((g) => g.people.length);
|
||||
|
||||
if (!groups.length) return answer('Every scored candidate has already been actioned.');
|
||||
|
||||
return doc(
|
||||
heading('Recommendation'),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Candidate' },
|
||||
{ key: 'score', label: 'Score', align: 'right' },
|
||||
{ key: 'verdict', label: 'Verdict', align: 'right' },
|
||||
],
|
||||
groups.flatMap((g) => g.people.map((p) => ({
|
||||
name: p.applicant_name,
|
||||
score: { value: p.ai_score, tone: g.tone },
|
||||
verdict: { badge: g.verdict, tone: g.tone },
|
||||
})))
|
||||
),
|
||||
note('A recommendation, not a decision. I score what is on file — I have not met these people.')
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const explainCharts = (f) => doc(
|
||||
heading('What these charts are saying'),
|
||||
insights([
|
||||
{
|
||||
tone: 'info',
|
||||
title: 'Pipeline Stages',
|
||||
body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
||||
},
|
||||
{
|
||||
tone: 'info',
|
||||
title: 'Candidate Scores',
|
||||
body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`,
|
||||
},
|
||||
{
|
||||
tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
|
||||
title: 'Interview Completion',
|
||||
body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
||||
},
|
||||
{
|
||||
tone: 'success',
|
||||
title: 'Cost Saved',
|
||||
body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`,
|
||||
},
|
||||
]),
|
||||
f.bottleneck && [
|
||||
heading('Biggest drop-off'),
|
||||
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
||||
text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
|
||||
]
|
||||
);
|
||||
|
||||
const forecastHiring = (f) => {
|
||||
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
|
||||
const conversion = f.hireRate || 14;
|
||||
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
||||
const short = f.openPositions.length - expected;
|
||||
|
||||
return doc(
|
||||
heading('Forecast'),
|
||||
kpis([
|
||||
{ label: 'In play at 60+', value: available.length },
|
||||
{ label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
|
||||
{ label: 'Open roles', value: f.openPositions.length },
|
||||
{ label: 'Conversion', value: `${conversion}%` },
|
||||
]),
|
||||
short > 0
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: `Roughly ${plural(short, 'role')} short`,
|
||||
body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`,
|
||||
}])
|
||||
: insights([{
|
||||
tone: 'success',
|
||||
title: 'Current pool covers your open roles',
|
||||
body: 'Assuming the strong candidates do not go elsewhere first.',
|
||||
}]),
|
||||
note('Projected from your own conversion rate, not a promise — small pools move a lot.')
|
||||
);
|
||||
};
|
||||
|
||||
const departmentInsights = (f) => {
|
||||
if (!f.byRole.length) return answer('No open positions to break down.');
|
||||
|
||||
const byCategory = {};
|
||||
f.byRole.forEach((r) => {
|
||||
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
|
||||
byCategory[r.category].applied += r.applied;
|
||||
byCategory[r.category].hired += r.hired;
|
||||
byCategory[r.category].qualified += r.qualified;
|
||||
byCategory[r.category].roles += 1;
|
||||
});
|
||||
|
||||
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
|
||||
const starved = entries.filter(([, d]) => d.applied === 0);
|
||||
|
||||
return doc(
|
||||
heading('By role category'),
|
||||
table(
|
||||
[
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||
{ key: 'qualified', label: '70+', align: 'right' },
|
||||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||||
],
|
||||
entries.map(([category, d]) => ({
|
||||
category,
|
||||
applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
|
||||
qualified: d.qualified,
|
||||
hired: d.hired,
|
||||
}))
|
||||
),
|
||||
text(
|
||||
starved.length
|
||||
? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
||||
: 'Every category has applicants, so your reach is working across the board.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const generateReports = (f) => doc(
|
||||
// Local date, not toISOString — UTC would show yesterday for anyone behind it.
|
||||
heading('Hiring report', f.today.toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'long', day: 'numeric',
|
||||
})),
|
||||
kpis([
|
||||
{ label: 'Applicants', value: f.total },
|
||||
{ label: 'Screened', value: `${f.standardizedPct}%` },
|
||||
{ label: 'Hires', value: f.hired.length },
|
||||
{ label: 'Hire rate', value: `${f.hireRate}%` },
|
||||
{ label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
|
||||
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
|
||||
]),
|
||||
heading('Funnel'),
|
||||
funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
|
||||
heading('By role'),
|
||||
table(
|
||||
[
|
||||
{ key: 'title', label: 'Role' },
|
||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||
{ key: 'qualified', label: '70+', align: 'right' },
|
||||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||||
],
|
||||
f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
|
||||
),
|
||||
heading('Efficiency'),
|
||||
kpis([
|
||||
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
||||
{ label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
|
||||
]),
|
||||
heading('Headline finding'),
|
||||
insights([
|
||||
f.bottleneck
|
||||
? {
|
||||
tone: 'warning',
|
||||
title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
|
||||
body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
|
||||
}
|
||||
: { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
|
||||
]),
|
||||
buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
|
||||
note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
|
||||
);
|
||||
|
||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||
|
||||
const stateOfPlay = (f) => doc(
|
||||
text('I do not have a specific read on that. Here is where things stand:'),
|
||||
kpis([
|
||||
{ label: 'Applicants', value: f.total },
|
||||
{ label: 'Screened', value: f.screened.length },
|
||||
{ label: 'Interviewing', value: f.interviewing.length },
|
||||
{ label: 'Hired', value: f.hired.length },
|
||||
]),
|
||||
text('Ask me about any of those and I will go deeper.')
|
||||
);
|
||||
|
||||
const bottleneckAnswer = (f) => {
|
||||
if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
|
||||
const b = f.bottleneck;
|
||||
const diagnosis = {
|
||||
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`,
|
||||
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
|
||||
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
||||
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
||||
}[b.to];
|
||||
|
||||
return doc(
|
||||
heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
|
||||
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
||||
insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
|
||||
);
|
||||
};
|
||||
|
||||
export const respondOverview = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
||||
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
|
||||
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
|
||||
if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
|
||||
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
|
||||
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
export const respondCandidates = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
|
||||
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
||||
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
||||
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
|
||||
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
||||
return f.unscreened.length
|
||||
? doc(
|
||||
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
||||
table(
|
||||
[{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
|
||||
f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
|
||||
),
|
||||
f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
|
||||
)
|
||||
: answer('Everyone has been screened.');
|
||||
}
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
export const respondAnalytics = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
|
||||
if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
|
||||
if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
|
||||
if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
|
||||
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
|
||||
if (has(q, 'hire rate', 'conversion')) {
|
||||
return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`);
|
||||
}
|
||||
if (has(q, 'cost', 'saved', 'roi', 'money')) {
|
||||
return doc(
|
||||
kpis([
|
||||
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
||||
{ label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
|
||||
]),
|
||||
text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
|
||||
);
|
||||
}
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
||||
|
||||
export const OVERVIEW_CAPABILITIES = [
|
||||
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
|
||||
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
|
||||
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
|
||||
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
|
||||
];
|
||||
|
||||
export const CANDIDATE_CAPABILITIES = [
|
||||
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
|
||||
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
|
||||
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
|
||||
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
|
||||
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
|
||||
];
|
||||
|
||||
export const ANALYTICS_CAPABILITIES = [
|
||||
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
|
||||
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
|
||||
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
|
||||
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
|
||||
];
|
||||
@@ -1,552 +0,0 @@
|
||||
import { doc, heading, list, note, text } from '../blocks';
|
||||
/* `vocabulary.js` is a leaf — it imports nothing — so naming the reasoning
|
||||
modes here costs no dependency.
|
||||
|
||||
The registries are deliberately **not** imported. `contexts.js` is reached
|
||||
from `placement.js`, which `skills/registry.js` already depends on, so
|
||||
importing a registry back into a context closes a cycle and leaves
|
||||
`PLACEMENT_ROUTES` undefined at module-evaluation time. Counting agents here
|
||||
would also duplicate what the screen behind this panel already shows. */
|
||||
import { REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||
|
||||
/**
|
||||
* Owliver on the agent configuration screen.
|
||||
*
|
||||
* This page is a **workspace** page, not an operational one. It shows no
|
||||
* positions, no candidates and no shifts, and Owliver must not behave as though
|
||||
* it does. What it can genuinely answer from is the two registries — what
|
||||
* agents exist, what skills exist, what the configuration options mean — which
|
||||
* is metadata about the workspace itself rather than a reading of workforce
|
||||
* records.
|
||||
*
|
||||
* The distinction matters most when the agent being *edited* is an operational
|
||||
* one. Configuring the Analytics Agent does not put the reader on Analytics:
|
||||
* the edited agent is a record being changed, not the page they are standing
|
||||
* on. So nothing here reaches for analytics data, and no skill declares this
|
||||
* page — `skillsForContext` returns an empty list, which is the honest answer.
|
||||
*
|
||||
* Questions outside these topics are declined by the routing layer rather than
|
||||
* answered with whatever this page happens to know.
|
||||
*/
|
||||
|
||||
/** What this page's answers are actually about. */
|
||||
/**
|
||||
* What this page's answers are actually about.
|
||||
*
|
||||
* Deliberately narrow, and it was not narrow enough at first: `what is` and
|
||||
* `page` were on this list, so "What is our attendance rate?" matched and was
|
||||
* answered from a screen that holds no attendance records. A topic list on a
|
||||
* page with no operational data has to name *subjects*, never sentence
|
||||
* openings — a generic phrase turns the honest decline into a confident answer
|
||||
* about the wrong thing.
|
||||
*
|
||||
* Anything not named here is declined by `routing.js` and pointed at the page
|
||||
* that holds the records.
|
||||
*/
|
||||
export const AGENT_CONFIGURE_TOPICS = [
|
||||
'agent', 'agents', 'subagent', 'subagents',
|
||||
'skill', 'skills', 'knowledge',
|
||||
'reasoning', 'starter', 'starters', 'web search',
|
||||
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
|
||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||
'workspace',
|
||||
];
|
||||
|
||||
/** What this screen is, and what it can be asked. */
|
||||
function explainScreen() {
|
||||
return doc(
|
||||
heading('Configuring an agent'),
|
||||
text('An agent decides how Owliver answers on a page: which of that page\u2019s skills it may use, what it knows, and how much work an answer is worth.'),
|
||||
list([
|
||||
'It narrows what a page offers. It can never widen it.',
|
||||
'The same skill can be attached to several agents.',
|
||||
'Changes are saved to this workspace; publishing puts them into service.',
|
||||
]),
|
||||
note('The agents in this workspace are listed on the Agents page, and skills in Workspace \u2192 Skills.')
|
||||
);
|
||||
}
|
||||
|
||||
/** Where skills come from. */
|
||||
function skillOverview() {
|
||||
return doc(
|
||||
heading('Where skills come from'),
|
||||
text('Skills are authored once in **Workspace \u2192 Skills** and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.'),
|
||||
list([
|
||||
'Add skills \u2014 attaches from the shared library.',
|
||||
'A skill still only answers on the pages it itself declares.',
|
||||
'Attaching a skill an agent\u2019s pages do not cover changes nothing on those pages.',
|
||||
]),
|
||||
note('Removing a skill here detaches it from this agent. It stays in the library for everything else.')
|
||||
);
|
||||
}
|
||||
|
||||
/** What the configuration options mean. */
|
||||
function explainOptions() {
|
||||
return doc(
|
||||
heading('What each setting does'),
|
||||
list([
|
||||
'Pages it covers — where this agent may answer. It narrows what the page offers; it can never widen it.',
|
||||
'Skills — what it can do. Attached from the shared library.',
|
||||
'Knowledge — reference material it can quote, with the source named.',
|
||||
`Reasoning — how much work an answer is worth: ${REASONING_MODES.map((m) => m.label).join(', ')}.`,
|
||||
'Conversation starters — the questions offered as chips when this agent opens.',
|
||||
'Web search — whether it may look outside the workspace.',
|
||||
'Subagents — other agents whose skills it may also use, still bounded by the current page.',
|
||||
]),
|
||||
note('Changes are saved to this workspace. Publishing is what puts them into service.')
|
||||
);
|
||||
}
|
||||
|
||||
/** The lifecycle, in the words the buttons use. */
|
||||
function explainLifecycle() {
|
||||
return doc(
|
||||
heading('Draft, published, archived'),
|
||||
list([
|
||||
'Draft — saved, but not answering anywhere yet.',
|
||||
'Published — in service, and offered in the Owliver switcher.',
|
||||
'Archived — taken out of service, definition kept. It can be restored as a draft.',
|
||||
]),
|
||||
note('Editing an agent that ships with Krow saves your own version alongside it. The shipped definition is never altered, and reverting brings it back.')
|
||||
);
|
||||
}
|
||||
|
||||
export const AGENT_CONFIGURE_CAPABILITIES = [
|
||||
{ id: 'screen', label: 'What this screen configures', run: explainScreen },
|
||||
{ id: 'skills', label: 'Where skills come from', run: skillOverview },
|
||||
{ id: 'options', label: 'What each setting does', run: explainOptions },
|
||||
{ id: 'lifecycle', label: 'Draft, published, archived', run: explainLifecycle },
|
||||
];
|
||||
|
||||
/**
|
||||
* A free-text question on the configuration screen.
|
||||
*
|
||||
* Answers from the registries, or says plainly that this page cannot answer it.
|
||||
* The one thing it must never do is reach for operational data because the
|
||||
* agent being edited happens to be an operational agent.
|
||||
*/
|
||||
export function respondAgentConfigure(question) {
|
||||
const q = String(question || '').toLowerCase();
|
||||
|
||||
if (/\bskill/.test(q)) return skillOverview();
|
||||
if (/\b(draft|publish|archive|version|revert|shipped)/.test(q)) return explainLifecycle();
|
||||
if (/\b(reasoning|knowledge|starter|subagent|web search|instruction|setting|option|page)/.test(q)) {
|
||||
return explainOptions();
|
||||
}
|
||||
if (/\bagent/.test(q)) return explainScreen();
|
||||
|
||||
return doc(
|
||||
text('This is the agent configuration screen, so I can answer about **agents, skills and what each setting does** here.'),
|
||||
list([
|
||||
'What agents exist in this workspace',
|
||||
'What skills I can attach, and where they come from',
|
||||
'What pages, reasoning, knowledge and subagents mean',
|
||||
'What draft, published and archived do',
|
||||
]),
|
||||
note('For workforce questions — positions, candidates, attendance — open the page that holds those records and ask me there.')
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The rest of the workspace, and Settings ──────────────────────────────
|
||||
*
|
||||
* Every page below is a configuration surface: it holds no positions, no
|
||||
* candidates and no shifts. That does not make Owliver useless there, and
|
||||
* treating it as though it did is the bug this section exists to correct — the
|
||||
* panel did not mount on these pages at all, on a product where Owliver had
|
||||
* answered perfectly well everywhere before specialised agents existed.
|
||||
*
|
||||
* What each page can honestly answer from is the screen itself: what it
|
||||
* configures, what the controls mean, and which page holds the records a
|
||||
* workforce question is really about. That is what these produce.
|
||||
*
|
||||
* **No skill is invented to make this work.** A skill is a capability over
|
||||
* records; none of these pages has records, so a skill here would be a fake one
|
||||
* whose only purpose was to populate a chip. What answers instead is a page
|
||||
* responder — the same mechanism every context in this table has always used,
|
||||
* including the eight operational ones.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One configuration page's answers, from a description of the page.
|
||||
*
|
||||
* A factory rather than five near-identical copies. Each section carries the
|
||||
* questions it answers (`match`), the label the panel offers it under, and the
|
||||
* document itself — so the capability list shown when a question is declined
|
||||
* and the routing behind a typed question come from one declaration and cannot
|
||||
* drift apart.
|
||||
*
|
||||
* **Sections are matched in order, specific before general.** The section that
|
||||
* explains the screen as a whole is deliberately last on every page: it is the
|
||||
* one whose words appear in every other question, and put first it would answer
|
||||
* all of them. The same lesson `AGENT_CONFIGURE_TOPICS` records, one level down.
|
||||
*
|
||||
* `topics` is what `routing.js` gates on, and it names *subjects* rather than
|
||||
* sentence openings — a generic opener on a page with no operational data turns
|
||||
* an honest decline into a confident answer about the wrong thing.
|
||||
*/
|
||||
function workspacePage({ label, topics, sections, elsewhere }) {
|
||||
const capabilities = sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
run: () => doc(
|
||||
heading(section.heading),
|
||||
...(section.text ? [text(section.text)] : []),
|
||||
...(section.bullets?.length ? [list(section.bullets)] : []),
|
||||
...(section.note ? [note(section.note)] : [])
|
||||
),
|
||||
}));
|
||||
|
||||
const byId = new Map(capabilities.map((c) => [c.id, c]));
|
||||
|
||||
/**
|
||||
* A typed question, answered from this page or offered what it can answer.
|
||||
*
|
||||
* The default is not "here is the page's report" — these pages have no report.
|
||||
* It is the offer itself: what can be asked here, and where the records live
|
||||
* for what cannot. `routing.js` has already declined anything outside
|
||||
* `topics`, so this only ever sees a question the page is plausibly about.
|
||||
*/
|
||||
const respond = (question) => {
|
||||
const q = String(question || '').toLowerCase();
|
||||
const matched = sections.find((section) => section.match.test(q));
|
||||
if (matched) return byId.get(matched.id).run();
|
||||
|
||||
return doc(
|
||||
text(`This is **${label}**, so I can answer about the screen itself — what it configures and what each control does.`),
|
||||
list(sections.map((section) => section.label)),
|
||||
note(elsewhere)
|
||||
);
|
||||
};
|
||||
|
||||
return { topics, capabilities, respond };
|
||||
}
|
||||
|
||||
/** What every configuration page says about where the records are. */
|
||||
const RECORDS_ELSEWHERE =
|
||||
'For workforce questions — positions, candidates, attendance, training — open the page that holds those records and ask me there.';
|
||||
|
||||
/* ── Settings ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const SETTINGS = workspacePage({
|
||||
label: 'Settings',
|
||||
topics: [
|
||||
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
|
||||
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
|
||||
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
|
||||
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
|
||||
'workspace', 'owliver', 'assistant',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'What the automation toggles do',
|
||||
heading: 'Automation controls',
|
||||
match: /\b(automation|toggle|toggles|digest|notification|notifications|density|compact|assistant)\b/,
|
||||
bullets: [
|
||||
'Owliver Workspace Assistant — whether this panel opens by default on the pages that carry it.',
|
||||
'Compact UI Density — tighter table rows, for high-density monitoring.',
|
||||
'Daily Executive Email Digest — a scheduled summary to the account address.',
|
||||
],
|
||||
note: 'Turning the assistant off closes the panel; it never removes it. The docked control brings it back.',
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
label: 'Permissions and security',
|
||||
heading: 'Who may do what',
|
||||
match: /\b(permission|permissions|access|secure|secured|security|password|credential|credentials|audit|2fa|two.factor|user|users|team)\b/,
|
||||
text: 'Access is configured here and recorded on Activity. The two are deliberately separate: this page states the policy, and the audit trail states what happened under it.',
|
||||
bullets: [
|
||||
'Users & Permissions — the roles this workspace grants.',
|
||||
'Account & Security — credentials and protection for your own account.',
|
||||
'Audit & System Activity Log — the record of events, read in full on Activity.',
|
||||
],
|
||||
note: 'Ask me on Activity for who did what, and when — that page holds the events.',
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What this screen configures',
|
||||
heading: 'Settings',
|
||||
match: /\b(configure|configuration|configures|screen|settings|manage|set up|do here|organization|organisation|client|clients)\b/,
|
||||
text: 'Settings is the account and system side of Krow. It configures who you are and how the workspace behaves — never the workforce records themselves.',
|
||||
bullets: [
|
||||
'Account & Security — your profile details and how this account is protected.',
|
||||
'Organization — the clients, positions and people this workspace is structured around.',
|
||||
'Users & Permissions — who may do what.',
|
||||
'Audit & System Activity — the log of what has happened.',
|
||||
'Automation — the behaviours below, including whether Owliver opens by default.',
|
||||
],
|
||||
note: 'Changes here are saved to this account.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const SETTINGS_TOPICS = SETTINGS.topics;
|
||||
export const SETTINGS_CAPABILITIES = SETTINGS.capabilities;
|
||||
export const respondSettings = SETTINGS.respond;
|
||||
|
||||
/* ── Workspace hub ──────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE = workspacePage({
|
||||
label: 'Workspace',
|
||||
topics: [
|
||||
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
|
||||
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
|
||||
'configuration', 'govern', 'development',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'agents',
|
||||
label: 'What an agent is',
|
||||
heading: 'Agents',
|
||||
match: /\b(agent|agents|subagent|subagents|reasoning)\b/,
|
||||
text: 'An agent decides how Owliver answers on a page: which of that page’s skills it may use, what it knows, and how much work an answer is worth.',
|
||||
bullets: [
|
||||
'It narrows what a page offers. It can never widen it.',
|
||||
'A page written for an agent opens on that agent.',
|
||||
'A page with no agent of its own opens on the general Krow Workforce Agent.',
|
||||
],
|
||||
note: 'Agents are listed and edited in Workspace → Agents.',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Where skills come from',
|
||||
heading: 'Skills',
|
||||
match: /\b(skill|skills|library|registry|capability|capabilities)\b/,
|
||||
text: 'Skills are authored once and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.',
|
||||
bullets: [
|
||||
'An Owliver skill answers questions.',
|
||||
'A Board skill extends a Krow page with a section.',
|
||||
'A skill only applies on the pages it itself declares.',
|
||||
],
|
||||
note: 'Both lists live in Workspace → Skills.',
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What the workspace governs',
|
||||
heading: 'Workspace',
|
||||
match: /\b(workspace|govern|governs|overview|configure|configuration|extend)\b/,
|
||||
text: 'The workspace is where Krow’s capabilities are configured: the agents that answer, the skills they carry, and the training paths the workforce is measured against.',
|
||||
bullets: [
|
||||
'Agents — who answers on which page, and how.',
|
||||
'Skills — what can be answered or drawn, authored once and shared.',
|
||||
'Skill Development — the training paths the workforce progresses along.',
|
||||
],
|
||||
note: 'Nothing configured here reads workforce records on its own. A page decides what is in reach; configuration decides how much of that reach is used.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const WORKSPACE_TOPICS = WORKSPACE.topics;
|
||||
export const WORKSPACE_CAPABILITIES = WORKSPACE.capabilities;
|
||||
export const respondWorkspace = WORKSPACE.respond;
|
||||
|
||||
/* ── Agents list ────────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE_AGENTS = workspacePage({
|
||||
label: 'Agents',
|
||||
topics: [
|
||||
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
|
||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
|
||||
'constrained', 'cover', 'covers', 'fallback', 'default',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'coverage',
|
||||
label: 'How a page picks its agent',
|
||||
heading: 'Which agent answers where',
|
||||
match: /\b(cover|covers|covered|page|pages|default|fallback|constrained|picks|pick|answers|resolve|resolves)\b/,
|
||||
bullets: [
|
||||
'A page with an agent written for it opens on that agent.',
|
||||
'A page with none opens on the general Krow Workforce Agent.',
|
||||
'Choosing an agent that does not cover the page you are on shows it as constrained here, and it declines rather than answering.',
|
||||
],
|
||||
note: 'An agent is a lens on the page you are standing on. It is never a way to reach a different page.',
|
||||
},
|
||||
{
|
||||
id: 'lifecycle',
|
||||
label: 'Draft, published, archived',
|
||||
heading: 'Draft, published, archived',
|
||||
match: /\b(draft|publish|published|publishing|archive|archived|revert|shipped|version|lifecycle)\b/,
|
||||
bullets: [
|
||||
'Draft — saved, but not answering anywhere yet.',
|
||||
'Published — in service, and offered in the Owliver switcher.',
|
||||
'Archived — taken out of service, definition kept. It can be restored as a draft.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'What this list holds',
|
||||
heading: 'The agents in this workspace',
|
||||
match: /\b(list|lists|registered|exist|exists|agent|agents|screen|workspace)\b/,
|
||||
text: 'Every agent Krow ships, plus anything this account has authored. Opening one configures it; nothing on this screen changes how Owliver is answering right now.',
|
||||
bullets: [
|
||||
'A shipped agent can be edited — your version is saved alongside it, and reverting brings the original back.',
|
||||
'A published agent is offered in the Owliver switcher.',
|
||||
'An archived agent is out of service, and its definition is kept.',
|
||||
],
|
||||
note: 'Configuring an agent never puts you on the page that agent covers.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const WORKSPACE_AGENTS_TOPICS = WORKSPACE_AGENTS.topics;
|
||||
export const WORKSPACE_AGENTS_CAPABILITIES = WORKSPACE_AGENTS.capabilities;
|
||||
export const respondWorkspaceAgents = WORKSPACE_AGENTS.respond;
|
||||
|
||||
/* ── Skills list ────────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE_SKILLS = workspacePage({
|
||||
label: 'Skills',
|
||||
topics: [
|
||||
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
|
||||
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
|
||||
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
|
||||
'workspace', 'agent', 'agents', 'page', 'pages',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'attaching',
|
||||
label: 'How skills reach an agent',
|
||||
heading: 'Attaching a skill',
|
||||
match: /\b(attach|attached|attaching|agent|agents|reach|reaches|scope)\b/,
|
||||
bullets: [
|
||||
'Attaching a skill lets an agent use it — on the pages the skill itself declares.',
|
||||
'Attaching a skill an agent’s pages do not cover changes nothing on those pages.',
|
||||
'Removing a skill from an agent leaves it in the library for everything else.',
|
||||
],
|
||||
note: 'Agents are configured in Workspace → Agents.',
|
||||
},
|
||||
{
|
||||
id: 'authoring',
|
||||
label: 'How a skill is written',
|
||||
heading: 'Writing a skill',
|
||||
match: /\b(author|authored|authoring|write|written|writing|markdown|definition|upload|edit|edited)\b/,
|
||||
text: 'A skill is a Markdown file: frontmatter declares what it is and which pages it applies to, and the body documents what it can do.',
|
||||
bullets: [
|
||||
'It only applies on the pages it declares.',
|
||||
'It is attached to an agent from this shared library, never copied into one.',
|
||||
'A definition naming something the product does not have is refused with a message rather than half-registered.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
label: 'The two skill lists',
|
||||
heading: 'Owliver skills and Board skills',
|
||||
match: /\b(list|lists|two|owliver|board|kind|kinds|difference|library|registry|skill|skills)\b/,
|
||||
bullets: [
|
||||
'An Owliver skill answers questions in this panel.',
|
||||
'A Board skill extends a Krow page with a section the page renders.',
|
||||
'A definition can do both; it is filed by what it declares, never by a setting.',
|
||||
],
|
||||
note: 'One library behind both lists, so a skill behaves identically wherever it is used.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const WORKSPACE_SKILLS_TOPICS = WORKSPACE_SKILLS.topics;
|
||||
export const WORKSPACE_SKILLS_CAPABILITIES = WORKSPACE_SKILLS.capabilities;
|
||||
export const respondWorkspaceSkills = WORKSPACE_SKILLS.respond;
|
||||
|
||||
/* ── Skill editor ───────────────────────────────────────────────────────── */
|
||||
|
||||
const SKILL_CONFIGURE = workspacePage({
|
||||
label: 'Skill Configure',
|
||||
topics: [
|
||||
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
|
||||
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
|
||||
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
|
||||
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
|
||||
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'validation',
|
||||
label: 'Why a definition is refused',
|
||||
heading: 'Validation',
|
||||
match: /\b(valid|validate|validated|validation|refuse|refused|error|errors|fail|fails|reject|rejected|wrong)\b/,
|
||||
text: 'The vocabulary is closed, so everything a definition names is checked before it is stored.',
|
||||
bullets: [
|
||||
'A page, placement or source the product does not have is refused with a message.',
|
||||
'A section that needs a record the placement does not supply is refused rather than drawing an empty card.',
|
||||
'A definition that parses but loses a field reports what it lost, instead of half-registering.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reach',
|
||||
label: 'Where this skill will apply',
|
||||
heading: 'Where it applies',
|
||||
match: /\b(apply|applies|applied|where|reach|reaches|attach|attached|agent|agents|scope|surface|placement)\b/,
|
||||
bullets: [
|
||||
'On the pages this definition declares, and nowhere else.',
|
||||
'For an agent, only once it is attached to that agent.',
|
||||
'An agent can never use a skill to reach a page the skill does not declare.',
|
||||
],
|
||||
note: 'Attach it in Workspace → Agents.',
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What this editor configures',
|
||||
heading: 'Editing a skill',
|
||||
match: /\b(editor|edit|editing|configure|configures|configuration|definition|frontmatter|field|fields|status|trigger|triggers|screen|skill|skills)\b/,
|
||||
text: 'A skill definition, as Markdown. The frontmatter declares what it is and where it applies; the body documents what it can do.',
|
||||
bullets: [
|
||||
'Pages — the surfaces this skill applies on. It never applies anywhere else.',
|
||||
'Triggers — the wordings that reach it.',
|
||||
'Capabilities — what it can answer or draw.',
|
||||
'Status — active, draft or archived.',
|
||||
],
|
||||
note: 'Nothing is executed from Markdown. A definition names things the product already has, and a name it does not have is refused.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const SKILL_CONFIGURE_TOPICS = SKILL_CONFIGURE.topics;
|
||||
export const SKILL_CONFIGURE_CAPABILITIES = SKILL_CONFIGURE.capabilities;
|
||||
export const respondSkillConfigure = SKILL_CONFIGURE.respond;
|
||||
|
||||
/* ── Skill Development ──────────────────────────────────────────────────── */
|
||||
|
||||
const SKILL_DEVELOPMENT = workspacePage({
|
||||
label: 'Skill Development',
|
||||
topics: [
|
||||
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
|
||||
'development', 'capability', 'capabilities', 'define', 'definition',
|
||||
'workspace', 'configure', 'configuration', 'owliver',
|
||||
],
|
||||
elsewhere: RECORDS_ELSEWHERE,
|
||||
sections: [
|
||||
{
|
||||
id: 'levels',
|
||||
label: 'How the levels work',
|
||||
heading: 'Levels',
|
||||
match: /\b(level|levels|rung|rungs|ladder|progress|progression|stage|stages)\b/,
|
||||
bullets: [
|
||||
'Levels are ordered, and each states what someone at that level can do.',
|
||||
'A path with no levels defines a name and nothing measurable.',
|
||||
'Editing a level changes the definition, never anyone’s recorded progress.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: 'What a training path is',
|
||||
heading: 'Skill Development',
|
||||
match: /\b(training|path|paths|define|defines|definition|development|capability|capabilities|configure|screen)\b/,
|
||||
text: 'A training path defines a capability the workforce holds and the levels it progresses through. It is a definition, not a reading: nothing here says who currently holds what.',
|
||||
bullets: [
|
||||
'Each path names its levels, from first exposure to independent practice.',
|
||||
'A path is neither an Owliver skill nor a Board skill — it describes people, not the product.',
|
||||
'It is authored here and read wherever progression is shown.',
|
||||
],
|
||||
note: 'For who holds which capability today, ask me on Talent Pool or KROW Forge — those pages hold the records.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const SKILL_DEVELOPMENT_TOPICS = SKILL_DEVELOPMENT.topics;
|
||||
export const SKILL_DEVELOPMENT_CAPABILITIES = SKILL_DEVELOPMENT.capabilities;
|
||||
export const respondSkillDevelopment = SKILL_DEVELOPMENT.respond;
|
||||
@@ -1,249 +0,0 @@
|
||||
import {
|
||||
ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES,
|
||||
respondAnalytics, respondCandidates, respondOverview,
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES,
|
||||
FORGE_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
|
||||
TALENT_POOL_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory,
|
||||
respondPositions,
|
||||
respondProfile, respondTalentPool,
|
||||
} from './capabilities/admin';
|
||||
import {
|
||||
AGENT_CONFIGURE_CAPABILITIES, AGENT_CONFIGURE_TOPICS,
|
||||
SETTINGS_CAPABILITIES, SETTINGS_TOPICS,
|
||||
SKILL_CONFIGURE_CAPABILITIES, SKILL_CONFIGURE_TOPICS,
|
||||
SKILL_DEVELOPMENT_CAPABILITIES, SKILL_DEVELOPMENT_TOPICS,
|
||||
WORKSPACE_AGENTS_CAPABILITIES, WORKSPACE_AGENTS_TOPICS,
|
||||
WORKSPACE_CAPABILITIES, WORKSPACE_SKILLS_CAPABILITIES, WORKSPACE_SKILLS_TOPICS,
|
||||
WORKSPACE_TOPICS,
|
||||
respondAgentConfigure, respondSettings, respondSkillConfigure, respondSkillDevelopment,
|
||||
respondWorkspace, respondWorkspaceAgents, respondWorkspaceSkills,
|
||||
} from './capabilities/workspace';
|
||||
|
||||
/**
|
||||
* Page contexts for the Owliver dashboard panel.
|
||||
*
|
||||
* Deliberately thin. What the user sees on the landing screen — the title,
|
||||
* greeting, description, placeholders and suggested prompts — is computed from
|
||||
* live data in `dynamic.js`, not written here. A context only declares what this
|
||||
* page's assistant can *do*: its capabilities and its free-text responder.
|
||||
*
|
||||
* Krow AI serves Employer and Admin. The talent portal has Owliver, a separate
|
||||
* product with its own UI and workflow — nothing here targets it.
|
||||
*
|
||||
* `topics` is what this page's answers are actually about. A question matching
|
||||
* none of them is not quietly answered with the page's default report — see
|
||||
* routing.js. Contexts without `topics` keep answering everything, so declaring
|
||||
* them is opt-in per page.
|
||||
*/
|
||||
export const ASSISTANT_CONTEXTS = {
|
||||
'employer.overview': {
|
||||
id: 'employer.overview',
|
||||
page: 'Overview',
|
||||
capabilities: OVERVIEW_CAPABILITIES,
|
||||
respond: respondOverview,
|
||||
},
|
||||
'employer.candidates': {
|
||||
id: 'employer.candidates',
|
||||
page: 'Candidates',
|
||||
capabilities: CANDIDATE_CAPABILITIES,
|
||||
respond: respondCandidates,
|
||||
},
|
||||
'employer.analytics': {
|
||||
id: 'employer.analytics',
|
||||
page: 'Analytics',
|
||||
capabilities: ANALYTICS_CAPABILITIES,
|
||||
respond: respondAnalytics,
|
||||
},
|
||||
'admin.controlCenter': {
|
||||
id: 'admin.controlCenter',
|
||||
page: 'Control Center',
|
||||
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
|
||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
||||
respond: respondControlCenter,
|
||||
},
|
||||
'admin.positions': {
|
||||
id: 'admin.positions',
|
||||
page: 'Positions',
|
||||
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
|
||||
capabilities: POSITIONS_CAPABILITIES,
|
||||
respond: respondPositions,
|
||||
},
|
||||
/* Specifying a role is a different question from managing the ones that
|
||||
exist, so Create Position is its own context rather than Positions with a
|
||||
form on it. Its topics are the form's own fields. */
|
||||
'admin.createPosition': {
|
||||
id: 'admin.createPosition',
|
||||
page: 'Create Position',
|
||||
topics: ['weight', 'vetting', 'criteria', 'scoring', 'score', 'certification', 'credential',
|
||||
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
|
||||
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
|
||||
'what do i', 'explain', 'summar', 'flow'],
|
||||
capabilities: CREATE_POSITION_CAPABILITIES,
|
||||
respond: respondCreatePosition,
|
||||
},
|
||||
/* The Candidates list and Candidates Analysis are separate contexts because they
|
||||
ask different questions of the same records: the list is triage — who needs a
|
||||
decision — and Analysis reads the pool as a whole. */
|
||||
'admin.candidatesList': {
|
||||
id: 'admin.candidatesList',
|
||||
page: 'Candidates',
|
||||
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
|
||||
capabilities: CANDIDATE_LIST_CAPABILITIES,
|
||||
respond: respondCandidateList,
|
||||
},
|
||||
'admin.candidates': {
|
||||
id: 'admin.candidates',
|
||||
page: 'Candidates Analysis',
|
||||
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
/* Analytics reads the same records as the Control Center, but as performance
|
||||
over time rather than as a state to act on — hence its own capabilities. */
|
||||
'admin.analytics': {
|
||||
id: 'admin.analytics',
|
||||
page: 'Analytics',
|
||||
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
|
||||
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
|
||||
respond: respondAdminAnalytics,
|
||||
},
|
||||
/* Forge, Talent Pool and Hired History read the same platform records as the
|
||||
contexts above, but ask different questions of them: proof and progression,
|
||||
supply and readiness, and outcomes after the hire. */
|
||||
/* Forge is an authoring console, so its topics are the author's: what the
|
||||
library holds, what is live, what Owliver checks, and how the workforce is
|
||||
doing with it. */
|
||||
'admin.forge': {
|
||||
id: 'admin.forge',
|
||||
page: 'KROW Forge',
|
||||
topics: ['forge', 'skill', 'challenge', 'course', 'training', 'library', 'badge',
|
||||
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
|
||||
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
|
||||
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
|
||||
capabilities: FORGE_CAPABILITIES,
|
||||
respond: respondForge,
|
||||
},
|
||||
'admin.talentPool': {
|
||||
id: 'admin.talentPool',
|
||||
page: 'Talent Pool',
|
||||
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
|
||||
capabilities: TALENT_POOL_CAPABILITIES,
|
||||
respond: respondTalentPool,
|
||||
},
|
||||
'admin.hiredHistory': {
|
||||
id: 'admin.hiredHistory',
|
||||
page: 'Hired History',
|
||||
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
|
||||
capabilities: HIRED_HISTORY_CAPABILITIES,
|
||||
respond: respondHiredHistory,
|
||||
},
|
||||
/* The account page. A form rather than a workflow, but the questions people
|
||||
ask about it — what may I do, how is this secured, what did I change — are
|
||||
answerable from what the page already shows. */
|
||||
'admin.profile': {
|
||||
id: 'admin.profile',
|
||||
page: 'Profile',
|
||||
topics: ['permission', 'scope', 'access', 'allowed', 'can i', 'account', 'role',
|
||||
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
|
||||
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
|
||||
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
|
||||
capabilities: PROFILE_CAPABILITIES,
|
||||
respond: respondProfile,
|
||||
},
|
||||
/**
|
||||
* Agent configuration — a workspace page, not an operational one.
|
||||
*
|
||||
* It holds no workforce records, and no skill declares it, so
|
||||
* `skillsForContext` returns nothing here. That is deliberate rather than an
|
||||
* omission: configuring the Analytics Agent must not put the reader on
|
||||
* Analytics, and the surest way to guarantee that is for this page to have no
|
||||
* operational reading available at all.
|
||||
*
|
||||
* `topics` keeps it honest in the other direction — a workforce question asked
|
||||
* here is declined and pointed at the page that holds the records, rather than
|
||||
* answered from whatever this screen happens to know.
|
||||
*/
|
||||
'admin.agentConfigure': {
|
||||
id: 'admin.agentConfigure',
|
||||
page: 'Agent Configure',
|
||||
topics: AGENT_CONFIGURE_TOPICS,
|
||||
capabilities: AGENT_CONFIGURE_CAPABILITIES,
|
||||
respond: respondAgentConfigure,
|
||||
},
|
||||
/**
|
||||
* Settings, and the workspace pages behind it.
|
||||
*
|
||||
* **A page with no agent of its own is not a page without Owliver.** These
|
||||
* six carry the same panel as the eight operational pages, resolved by the
|
||||
* same placement table, answered by the same responder mechanism — and they
|
||||
* open on the general Krow Workforce Agent, because no specialist was written
|
||||
* for a configuration screen and none needs to be.
|
||||
*
|
||||
* What they do not carry is workforce records. No skill declares these
|
||||
* surfaces, so `skillsForContext` returns an empty list on every one of them,
|
||||
* and `topics` keeps the decline honest in the other direction: a question
|
||||
* about positions or attendance asked here is pointed at the page that holds
|
||||
* those records rather than answered from a configuration screen.
|
||||
*
|
||||
* The two halves are the whole design. Remove the first and Owliver is dead
|
||||
* on half the product; remove the second and it invents figures on a page
|
||||
* that has none.
|
||||
*/
|
||||
'admin.settings': {
|
||||
id: 'admin.settings',
|
||||
page: 'Settings',
|
||||
topics: SETTINGS_TOPICS,
|
||||
capabilities: SETTINGS_CAPABILITIES,
|
||||
respond: respondSettings,
|
||||
},
|
||||
'admin.workspace': {
|
||||
id: 'admin.workspace',
|
||||
page: 'Workspace',
|
||||
topics: WORKSPACE_TOPICS,
|
||||
capabilities: WORKSPACE_CAPABILITIES,
|
||||
respond: respondWorkspace,
|
||||
},
|
||||
'admin.workspaceAgents': {
|
||||
id: 'admin.workspaceAgents',
|
||||
page: 'Agents',
|
||||
topics: WORKSPACE_AGENTS_TOPICS,
|
||||
capabilities: WORKSPACE_AGENTS_CAPABILITIES,
|
||||
respond: respondWorkspaceAgents,
|
||||
},
|
||||
'admin.workspaceSkills': {
|
||||
id: 'admin.workspaceSkills',
|
||||
page: 'Skills',
|
||||
topics: WORKSPACE_SKILLS_TOPICS,
|
||||
capabilities: WORKSPACE_SKILLS_CAPABILITIES,
|
||||
respond: respondWorkspaceSkills,
|
||||
},
|
||||
'admin.skillConfigure': {
|
||||
id: 'admin.skillConfigure',
|
||||
page: 'Skill Configure',
|
||||
topics: SKILL_CONFIGURE_TOPICS,
|
||||
capabilities: SKILL_CONFIGURE_CAPABILITIES,
|
||||
respond: respondSkillConfigure,
|
||||
},
|
||||
'admin.skillDevelopment': {
|
||||
id: 'admin.skillDevelopment',
|
||||
page: 'Skill Development',
|
||||
topics: SKILL_DEVELOPMENT_TOPICS,
|
||||
capabilities: SKILL_DEVELOPMENT_CAPABILITIES,
|
||||
respond: respondSkillDevelopment,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
|
||||
capabilities: ACTIVITY_CAPABILITIES,
|
||||
respond: respondActivity,
|
||||
},
|
||||
};
|
||||
|
||||
export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null;
|
||||
@@ -1,817 +0,0 @@
|
||||
import { plural, verb } from './insights';
|
||||
import { prioritise, workforceStatusFor } from '@/lib/workforce';
|
||||
import { PERMISSIONS } from '@/lib/admin/permissions';
|
||||
|
||||
/**
|
||||
* Dynamic greetings and suggested prompts.
|
||||
*
|
||||
* Nothing here is a fixed string chosen in advance. The greeting states what is
|
||||
* actually true on the page right now, and the suggestions name real records —
|
||||
* the specific candidates, the specific starved role. That is the difference
|
||||
* between an assistant that looks aware of context and one that is.
|
||||
*
|
||||
* Everything derives from the same fact sheet the dashboard renders from, so a
|
||||
* greeting can never contradict the numbers beside it.
|
||||
*/
|
||||
|
||||
/* ── Greetings ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Time of day from the viewer's own clock.
|
||||
*
|
||||
* `getHours()` is local, so this follows the user's timezone rather than the
|
||||
* server's. The late band matters: at 00:30 "morning" is wrong, so 21:00–04:59
|
||||
* reads as evening.
|
||||
*
|
||||
* 05:00–11:59 morning
|
||||
* 12:00–16:59 afternoon
|
||||
* 17:00–20:59 evening
|
||||
* 21:00–04:59 evening
|
||||
*/
|
||||
function timeOfDay(date) {
|
||||
const hour = date.getHours();
|
||||
if (hour >= 5 && hour < 12) return 'morning';
|
||||
if (hour >= 12 && hour < 17) return 'afternoon';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the single most useful thing to say about the page.
|
||||
*
|
||||
* Ordered by urgency rather than by what sounds cheeriest: something stuck beats
|
||||
* something healthy, because the greeting is the one line guaranteed to be read.
|
||||
*/
|
||||
const GREETINGS = {
|
||||
'employer.overview': (f) => {
|
||||
if (!f.total) return 'No applicants yet — publish a position and I will start tracking it.';
|
||||
if (f.stalled.length) {
|
||||
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`;
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
return `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
return `${plural(f.starvedPositions.length, 'open role')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants yet.`;
|
||||
}
|
||||
return 'Your hiring pipeline looks healthy today.';
|
||||
},
|
||||
|
||||
'employer.candidates': (f) => {
|
||||
if (!f.total) return 'No candidates in the pipeline yet.';
|
||||
if (f.unscreened.length) {
|
||||
return `You have ${plural(f.unscreened.length, 'candidate')} waiting for review.`;
|
||||
}
|
||||
if (f.stalled.length) {
|
||||
return `${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'is', 'are')} ready for a decision.`;
|
||||
}
|
||||
return `All ${f.total} candidates are scored and actioned.`;
|
||||
},
|
||||
|
||||
'employer.analytics': (f) => {
|
||||
if (!f.scored.length) return 'Nothing scored yet, so the charts are still empty.';
|
||||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||||
return `Your funnel loses most people between ${f.bottleneck.from} and ${f.bottleneck.to}.`;
|
||||
}
|
||||
return `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. Worth a closer look.`;
|
||||
},
|
||||
|
||||
'admin.controlCenter': (f) => {
|
||||
if (f.unscoredProfiles.length) {
|
||||
return `${plural(f.unscoredProfiles.length, 'talent profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score and cannot be matched.`;
|
||||
}
|
||||
if (f.activity24h.length) {
|
||||
return `${plural(f.activity24h.length, 'event')} logged in the last 24 hours.`;
|
||||
}
|
||||
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
|
||||
},
|
||||
|
||||
/* The form is the page, so the greeting is about what is being specified
|
||||
rather than about the board — read from what this workspace has posted. */
|
||||
'admin.createPosition': (f) => {
|
||||
if (!f.postings.length) {
|
||||
return 'This is the first position in the workspace — the weights you set here become the house pattern.';
|
||||
}
|
||||
/* Every posted role is a benchmark for pay and experience; only the ones
|
||||
carrying weights can be compared on those, so the two are counted
|
||||
separately rather than one standing in for the other. */
|
||||
const weighted = f.postings.filter((p) => p.vetting_criteria).length;
|
||||
return `Specifying a new role. ${plural(f.postings.length, 'position')} on file to benchmark against${weighted ? `, ${weighted} with vetting weights set` : ''}.`;
|
||||
},
|
||||
|
||||
'admin.positions': (f) => {
|
||||
if (!f.openPositions.length) return 'No open positions yet — publish a role and I will start tracking it.';
|
||||
if (f.starvedPositions.length) {
|
||||
return `${plural(f.starvedPositions.length, 'open position')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants and ${verb(f.starvedPositions.length, 'needs', 'need')} attention.`;
|
||||
}
|
||||
if (f.singleCandidateRoles.length) {
|
||||
return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
return `${plural(f.unscreened.length, 'applicant')} across your roles ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
|
||||
}
|
||||
return `All ${f.openPositions.length} open roles have candidates and a moving pipeline.`;
|
||||
},
|
||||
|
||||
'admin.candidatesList': (f) => {
|
||||
if (!f.total) return 'No candidates in the pipeline yet.';
|
||||
if (f.stalled.length) {
|
||||
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on a decision.`;
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
return `${plural(f.unscreened.length, 'candidate')} ${verb(f.unscreened.length, 'has', 'have')} no score and cannot be ranked.`;
|
||||
}
|
||||
if (f.missingCredentials.length) {
|
||||
return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
|
||||
}
|
||||
return `All ${f.total} candidates are scored and actioned.`;
|
||||
},
|
||||
|
||||
'admin.candidates': (f) => {
|
||||
if (!f.total) return 'Nothing in the pipeline yet, so there is no pool to analyse.';
|
||||
if (f.needsDeeperReview.length) {
|
||||
return `${plural(f.needsDeeperReview.length, 'candidate')} currently ${verb(f.needsDeeperReview.length, 'needs', 'need')} deeper review.`;
|
||||
}
|
||||
if (f.singleCandidateRoles.length) {
|
||||
return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
|
||||
}
|
||||
return `All ${f.total} candidates are scored, credentialed and comparable.`;
|
||||
},
|
||||
|
||||
'admin.analytics': (f) => {
|
||||
if (!f.total) return 'Nothing has been applied for yet, so the charts have nothing to report.';
|
||||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||||
return `Conversion is weakest between ${f.bottleneck.from} and ${f.bottleneck.to}, at ${f.bottleneck.rate}%.`;
|
||||
}
|
||||
if (f.standardizedPct < 80) {
|
||||
return `Screening covers ${f.standardizedPct}% of applications, so the rest of these figures rest on a partial pool.`;
|
||||
}
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
/* The line the panel opens with: whatever most needs the author's attention,
|
||||
which on an authoring console is an unfinished skill before it is a total. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.library.length) return 'The library is empty — no skill is defined yet.';
|
||||
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
if (unscored) {
|
||||
return `${plural(unscored, 'skill')} ${verb(unscored, 'has', 'have')} no Owliver criteria, so ${verb(unscored, 'it', 'they')} cannot verify anything yet.`;
|
||||
}
|
||||
if (g.drafts.length) {
|
||||
return `${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} in draft and not visible to the workforce.`;
|
||||
}
|
||||
return `${plural(g.published.length, 'skill')} published, ${plural(g.workforceVerified, 'verification')} earned.`;
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
if (!f.profiles.length) return 'No talent profiles registered yet.';
|
||||
if (f.unscoredProfiles.length) {
|
||||
return `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score and cannot be ranked.`;
|
||||
}
|
||||
if (f.talent.unverified.length) {
|
||||
return `${plural(f.talent.unverified.length, 'profile')} ${verb(f.talent.unverified.length, 'carries', 'carry')} no verified skill.`;
|
||||
}
|
||||
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} Elite.`;
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return 'Nobody has been hired yet, so there is no history to read.';
|
||||
if (h.unrated.length) {
|
||||
return `${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating on file.`;
|
||||
}
|
||||
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
|
||||
},
|
||||
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user
|
||||
? f.activity.filter((e) => e.user_email === f.user.email)
|
||||
: [];
|
||||
const full = PERMISSIONS.filter((p) => p.level === 'Full access').length;
|
||||
if (!mine.length) return `${plural(full, 'scope')} at full access on this account.`;
|
||||
return `${plural(full, 'scope')} at full access, ${plural(mine.length, 'event')} logged against this account.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
if (patterns) {
|
||||
return `${plural(patterns, 'unusual activity pattern')} ${verb(patterns, 'needs', 'need')} attention.`;
|
||||
}
|
||||
if (f.activity24h.length === 0) {
|
||||
return `Quiet week — ${plural(f.activity7d.length, 'event')} logged, nothing out of pattern.`;
|
||||
}
|
||||
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
|
||||
},
|
||||
|
||||
'admin.agentConfigure': () =>
|
||||
'Configure what this agent covers, its attached skills, reference knowledge, and reasoning behavior.',
|
||||
|
||||
/* Settings and the workspace pages. Static rather than computed, because a
|
||||
configuration screen has no records to read and a line that pretended
|
||||
otherwise would be the fabrication the whole design refuses. */
|
||||
'admin.settings': () =>
|
||||
'Your account, the organization, who may do what, and the behaviours this workspace runs on.',
|
||||
'admin.workspace': () =>
|
||||
'The agents that answer, the skills they carry, and the paths the workforce progresses along.',
|
||||
'admin.workspaceAgents': () =>
|
||||
'Every agent this workspace has, what each one covers, and which are in service.',
|
||||
'admin.workspaceSkills': () =>
|
||||
'The shared library — what Owliver can answer, and what a Krow page can draw.',
|
||||
'admin.skillConfigure': () =>
|
||||
'Declare what this skill is, which pages it applies on, and what it can do.',
|
||||
'admin.skillDevelopment': () =>
|
||||
'Define the capabilities the workforce holds, and the levels they progress through.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Title lines.
|
||||
*
|
||||
* Every Admin context greets by the clock. That is not decoration: the greeting
|
||||
* is the one line guaranteed to be read, so it has to prove the panel knows when
|
||||
* and where it is before it says anything about the data. A fixed "Good morning"
|
||||
* on an 11pm shift does the opposite.
|
||||
*
|
||||
* First names read better than a formal greeting in a small panel, so the name is
|
||||
* used where one is known.
|
||||
*/
|
||||
const TITLES = {
|
||||
'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'employer.candidates': () => 'Candidate intelligence',
|
||||
'employer.analytics': () => 'Analytics assistant',
|
||||
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.createPosition': () => 'Specifying a position',
|
||||
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.agentConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.settings': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspace': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspaceAgents': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.workspaceSkills': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.skillConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.skillDevelopment': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||
const DESCRIPTIONS = {
|
||||
'employer.overview': (f) =>
|
||||
`Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
|
||||
'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
|
||||
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
|
||||
'admin.controlCenter': (f) =>
|
||||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
|
||||
'admin.positions': (f) =>
|
||||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — hiring health, pipeline strength and what needs intervention.`,
|
||||
'admin.createPosition': (f) =>
|
||||
`Reading this draft against ${plural(f.postings.length, 'posted position')} — vetting weights, pay, experience and the credentials already in use.`,
|
||||
'admin.candidatesList': (f) =>
|
||||
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
|
||||
'admin.candidates': (f) =>
|
||||
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
|
||||
'admin.analytics': (f) =>
|
||||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||
'admin.forge': (f) =>
|
||||
`Reading ${plural(f.forge.library.length, 'skill')} — training, proof, what Owliver evaluates and how the workforce is doing with them.`,
|
||||
'admin.talentPool': (f) =>
|
||||
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
|
||||
'admin.hiredHistory': (f) =>
|
||||
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
|
||||
'admin.profile': () =>
|
||||
'Reading this account — permissions, security, preferences and your own activity.',
|
||||
'admin.agentConfigure': () =>
|
||||
'Ask Owliver about agent settings, lifecycle, attaching skills, or custom instructions.',
|
||||
'admin.settings': () =>
|
||||
'Ask Owliver what this screen configures, what each control does, and where the records behind a workforce question live.',
|
||||
'admin.workspace': () =>
|
||||
'Ask Owliver what the workspace governs — agents, skills, and how a page decides which agent answers.',
|
||||
'admin.workspaceAgents': () =>
|
||||
'Ask Owliver how a page picks its agent, what constrains one, and what draft, published and archived mean.',
|
||||
'admin.workspaceSkills': () =>
|
||||
'Ask Owliver about the two skill lists, how a skill is written, and how one reaches an agent.',
|
||||
'admin.skillConfigure': () =>
|
||||
'Ask Owliver what this editor declares, why a definition is refused, and where the skill will apply.',
|
||||
'admin.skillDevelopment': () =>
|
||||
'Ask Owliver what a training path defines and how its levels work.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholders phrased as things this page's user would actually type.
|
||||
*
|
||||
* The Admin ones name Owliver and name the page's subject, because the panel sits
|
||||
* permanently beside the dashboard: someone glancing at it needs the input itself
|
||||
* to say what it is for and what this page can be asked about.
|
||||
*/
|
||||
const PLACEHOLDERS = {
|
||||
'employer.overview': (f) => [
|
||||
`Summarize ${timeOfDay(f.today)}'s hiring…`,
|
||||
f.starvedPositions.length ? 'Which positions need attention?' : 'What should I do first?',
|
||||
'Why did hiring slow this week?',
|
||||
],
|
||||
'employer.candidates': (f) => [
|
||||
f.ranked.length >= 2 ? 'Compare the top candidates…' : 'Analyse my strongest candidate…',
|
||||
'Which resumes need review?',
|
||||
'Recommend interview questions…',
|
||||
],
|
||||
'employer.analytics': () => [
|
||||
'Explain this chart…',
|
||||
'Forecast hiring…',
|
||||
'Generate an executive summary…',
|
||||
],
|
||||
'admin.controlCenter': () => [
|
||||
'Ask Owliver about platform health…',
|
||||
'Ask Owliver what needs attention…',
|
||||
'Ask Owliver about hiring bottlenecks…',
|
||||
],
|
||||
'admin.positions': () => [
|
||||
'Ask Owliver about your positions…',
|
||||
'Ask Owliver which role to fill first…',
|
||||
'Ask Owliver about hiring bottlenecks…',
|
||||
],
|
||||
'admin.createPosition': () => [
|
||||
'Ask Owliver about the vetting weights…',
|
||||
'Ask Owliver what comparable roles ask for…',
|
||||
'Ask Owliver to explain this form…',
|
||||
],
|
||||
'admin.candidatesList': () => [
|
||||
'Ask Owliver about candidates…',
|
||||
'Ask Owliver who is interview ready…',
|
||||
'Ask Owliver about candidate risks…',
|
||||
],
|
||||
'admin.candidates': () => [
|
||||
'Ask Owliver about candidates…',
|
||||
'Ask Owliver about screening gaps…',
|
||||
'Ask Owliver where the risk is…',
|
||||
],
|
||||
'admin.analytics': () => [
|
||||
'Ask Owliver about hiring performance…',
|
||||
'Ask Owliver about the hiring trend…',
|
||||
'Ask Owliver where the bottleneck is…',
|
||||
],
|
||||
'admin.activity': () => [
|
||||
'Ask Owliver about recent activity…',
|
||||
'Ask Owliver about unusual activity…',
|
||||
'Ask Owliver about security concerns…',
|
||||
],
|
||||
'admin.forge': () => [
|
||||
'Ask Owliver to create a skill training…',
|
||||
'Ask Owliver what the library covers…',
|
||||
'Ask Owliver what a skill verifies…',
|
||||
],
|
||||
'admin.talentPool': () => [
|
||||
'Ask Owliver who to prioritize…',
|
||||
'Ask Owliver about verification gaps…',
|
||||
'Ask Owliver who is available…',
|
||||
],
|
||||
'admin.hiredHistory': () => [
|
||||
'Ask Owliver about hiring outcomes…',
|
||||
'Ask Owliver which roles hired best…',
|
||||
'Ask Owliver what stands out…',
|
||||
],
|
||||
'admin.profile': () => [
|
||||
'Ask Owliver about your permissions…',
|
||||
'Ask Owliver what you can change here…',
|
||||
'Ask Owliver about your security settings…',
|
||||
],
|
||||
'admin.agentConfigure': () => [
|
||||
'Ask Owliver about agent configuration…',
|
||||
'Ask Owliver how skills attach…',
|
||||
'Ask Owliver about reasoning modes…',
|
||||
],
|
||||
'admin.settings': () => [
|
||||
'Ask Owliver what you can configure here…',
|
||||
'Ask Owliver what the automation toggles do…',
|
||||
'Ask Owliver about permissions and security…',
|
||||
],
|
||||
'admin.workspace': () => [
|
||||
'Ask Owliver what the workspace governs…',
|
||||
'Ask Owliver what an agent is…',
|
||||
'Ask Owliver where skills come from…',
|
||||
],
|
||||
'admin.workspaceAgents': () => [
|
||||
'Ask Owliver how a page picks its agent…',
|
||||
'Ask Owliver what constrained means…',
|
||||
'Ask Owliver about draft and published…',
|
||||
],
|
||||
'admin.workspaceSkills': () => [
|
||||
'Ask Owliver about the two skill lists…',
|
||||
'Ask Owliver how a skill is written…',
|
||||
'Ask Owliver how skills reach an agent…',
|
||||
],
|
||||
'admin.skillConfigure': () => [
|
||||
'Ask Owliver what this editor declares…',
|
||||
'Ask Owliver why a definition is refused…',
|
||||
'Ask Owliver where this skill will apply…',
|
||||
],
|
||||
'admin.skillDevelopment': () => [
|
||||
'Ask Owliver what a training path is…',
|
||||
'Ask Owliver how the levels work…',
|
||||
'Ask Owliver what this screen defines…',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds suggestions from what is actually on the page.
|
||||
*
|
||||
* A suggestion that names two real candidates is a different product from one
|
||||
* that says "Compare candidates" — it proves the assistant already looked.
|
||||
* Each carries the capability that answers it best, so a click is precise.
|
||||
*/
|
||||
const PROMPTS = {
|
||||
'employer.overview': (f) => [
|
||||
{ label: `Summarize ${timeOfDay(f.today)}'s hiring`, prompt: `Summarize hiring as of this ${timeOfDay(f.today)}`, capability: 'hiring-summary' },
|
||||
f.starvedPositions.length
|
||||
? { label: 'Which positions need attention?', prompt: `Which positions need attention? ${f.starvedPositions[0].title} has no applicants`, capability: 'pending-actions' }
|
||||
: { label: 'What needs my attention?', prompt: 'What needs my attention right now?', capability: 'pending-actions' },
|
||||
{ label: 'Pipeline health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
||||
f.singleCandidateRoles.length || f.missingCredentials.length
|
||||
? { label: 'Where is the risk?', prompt: 'Where is the risk in my hiring right now?', capability: 'hiring-risks' }
|
||||
: { label: 'Any hiring risks?', prompt: 'Are there any hiring risks I should know about?', capability: 'hiring-risks' },
|
||||
],
|
||||
|
||||
'employer.candidates': (f) => {
|
||||
const [a, b] = f.ranked;
|
||||
return [
|
||||
a && b
|
||||
? { label: `Compare ${a.applicant_name.split(' ')[0]} & ${b.applicant_name.split(' ')[0]}`, prompt: `Compare ${a.applicant_name} and ${b.applicant_name}`, capability: 'candidate-comparison' }
|
||||
: { label: 'Compare top candidates', prompt: 'Compare my top candidates', capability: 'candidate-comparison' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} resumes need review`, prompt: 'Which resumes need review?', capability: 'resume-analysis' }
|
||||
: { label: 'Analyse top résumé', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
||||
a
|
||||
? { label: `Questions for ${a.applicant_name.split(' ')[0]}`, prompt: `Recommend interview questions for ${a.applicant_name}`, capability: 'interview-questions' }
|
||||
: null,
|
||||
{ label: 'Skill gaps', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
||||
{ label: 'Who should I hire?', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
||||
].filter(Boolean);
|
||||
},
|
||||
|
||||
'employer.analytics': (f) => [
|
||||
{ label: 'Explain this chart', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
||||
{ label: 'Forecast hiring', prompt: 'Forecast hiring from the current pipeline', capability: 'forecast-hiring' },
|
||||
{ label: 'Generate executive summary', prompt: 'Generate an executive hiring report', capability: 'generate-reports' },
|
||||
f.bottleneck
|
||||
? {
|
||||
label: `Why the drop to ${f.bottleneck.to}?`,
|
||||
prompt: `Why do candidates drop off between ${f.bottleneck.from} and ${f.bottleneck.to}?`,
|
||||
capability: 'explain-charts',
|
||||
}
|
||||
: null,
|
||||
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
||||
].filter(Boolean),
|
||||
|
||||
/* Admin. The first chip is whatever the data has made urgent, so the set
|
||||
reorders itself as the platform's state changes rather than offering the
|
||||
same five questions regardless. */
|
||||
'admin.controlCenter': (f) => [
|
||||
f.starvedPositions.length
|
||||
? { label: `${f.starvedPositions.length} roles with no applicants`, prompt: 'What needs attention right now?', capability: 'attention-required' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention right now?', capability: 'attention-required' },
|
||||
{ label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
||||
f.bottleneck && f.bottleneck.lost > 0
|
||||
? { label: `Bottleneck at ${f.bottleneck.to}`, prompt: `Show hiring bottlenecks — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||||
: { label: 'Show hiring bottlenecks', prompt: 'Show hiring bottlenecks in the pipeline', capability: 'pipeline-health' },
|
||||
{ label: 'Summarize platform usage', prompt: 'Summarize platform usage and workforce scale', capability: 'workforce-summary' },
|
||||
{ label: 'Hiring operations', prompt: 'How are hiring operations performing?', capability: 'hiring-operations' },
|
||||
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
|
||||
],
|
||||
|
||||
/* Create Position asks about the draft in front of the author. Every one is
|
||||
answerable from the form and the positions already posted. */
|
||||
'admin.createPosition': () => [
|
||||
{ label: 'Explain the vetting weights', prompt: 'Summarize these vetting weights', capability: 'vetting-weights' },
|
||||
{ label: 'Compare with similar roles', prompt: 'What do comparable roles ask for?', capability: 'position-benchmarks' },
|
||||
{ label: 'Credentials in use', prompt: 'Which certifications does this workspace require?', capability: 'position-requirements' },
|
||||
{ label: 'How this form works', prompt: 'Explain how this form works', capability: 'position-spec-steps' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Positions suggestions are the current workforce situation, phrased as
|
||||
* questions.
|
||||
*
|
||||
* Each one is offered only when the data behind it exists — a prompt about
|
||||
* strong matches on a board with none is an invitation to a dead end. None of
|
||||
* them names a person: the question is asked generically and the candidates
|
||||
* are resolved live, so a suggestion cannot go stale between being rendered
|
||||
* and being clicked.
|
||||
*
|
||||
* Workforce prompts carry no `capability`, which is what sends them through
|
||||
* intent routing to the engine rather than to a page analytics responder.
|
||||
*/
|
||||
'admin.positions': (f, workforce) => {
|
||||
const out = [];
|
||||
|
||||
/**
|
||||
* Two things about the position in front of the reader come first, because
|
||||
* both are about what they were *just doing* rather than about the board.
|
||||
*
|
||||
* A draft is unfinished work, so finishing it leads. Then the role the page
|
||||
* has open — the drawer, or the one just published, which the form names in
|
||||
* the URL on its way here — gets the question its state actually invites:
|
||||
* a role with no applicants yet is a matching question, not a pipeline one.
|
||||
*
|
||||
* Both name the record, so the answer resolves against that position and
|
||||
* cannot be a stale reading of a different one.
|
||||
*/
|
||||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||||
if (drafts.length) {
|
||||
out.push(drafts.length === 1
|
||||
/* Names the draft, so the answer continues *that* record in the panel
|
||||
rather than listing every draft and sending the reader to a form. */
|
||||
? {
|
||||
label: `Continue the ${drafts[0].title} draft`,
|
||||
prompt: `Continue the ${drafts[0].title} draft`,
|
||||
}
|
||||
: {
|
||||
label: `Which ${plural(drafts.length, 'position')} ${verb(drafts.length, 'is', 'are')} still in draft?`,
|
||||
prompt: 'Which positions are still in draft?',
|
||||
capability: 'position-drafts',
|
||||
});
|
||||
}
|
||||
|
||||
const current = workforce?.currentPositionId
|
||||
? (workforce.positions || []).find((p) => p.id === workforce.currentPositionId)
|
||||
: null;
|
||||
if (current && current.status !== 'draft') {
|
||||
out.push({
|
||||
label: `Find matching candidates for ${current.title}`,
|
||||
/* The definition's own words, with the role named. It resolves to the
|
||||
candidate-matching skill and reads that position — so switching the
|
||||
skill off in Settings takes this answer with it, as it should. */
|
||||
prompt: `Find matching candidates for ${current.title}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workforce?.positions?.length) {
|
||||
const { positions, context } = workforce;
|
||||
const ranked = prioritise(positions, context);
|
||||
const board = positions
|
||||
.filter((p) => p.status === 'active')
|
||||
.map((p) => ({ position: p, status: workforceStatusFor(p, context) }));
|
||||
|
||||
const withStrong = board.filter((r) => r.status.strong.length);
|
||||
const newToday = board.reduce((n, r) => n + r.status.newToday.length, 0);
|
||||
const readyCount = board.reduce(
|
||||
(n, r) => n + r.status.applicants.filter(
|
||||
(a) => ['ai_screened', 'shortlisted'].includes(a.application.status)
|
||||
).length,
|
||||
0
|
||||
);
|
||||
|
||||
if (withStrong.length) {
|
||||
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
|
||||
const n = best.status.strong.length;
|
||||
out.push({
|
||||
label: `Show the ${plural(n, 'strongest candidate')} for ${best.position.title}`,
|
||||
prompt: `Who matches ${best.position.title}?`,
|
||||
});
|
||||
}
|
||||
if (ranked.length) {
|
||||
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
|
||||
}
|
||||
if (newToday) {
|
||||
out.push({
|
||||
label: `Which ${plural(newToday, 'candidate')} applied today?`,
|
||||
prompt: 'Who applied today?',
|
||||
});
|
||||
}
|
||||
if (readyCount) {
|
||||
out.push({
|
||||
label: `Which ${plural(readyCount, 'candidate')} ${verb(readyCount, 'is', 'are')} ready for interview?`,
|
||||
prompt: 'Who is ready for interview?',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* The page's own analytics questions keep their place behind the workforce
|
||||
ones — they are still the right answer to "how is hiring going". */
|
||||
out.push(
|
||||
f.starvedPositions.length
|
||||
? {
|
||||
label: `Which ${plural(f.starvedPositions.length, 'role')} ${verb(f.starvedPositions.length, 'needs', 'need')} attention?`,
|
||||
prompt: 'What needs attention across my positions?',
|
||||
capability: 'positions-attention',
|
||||
}
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
|
||||
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
|
||||
f.unscreened.length
|
||||
? {
|
||||
label: `Show the ${plural(f.unscreened.length, 'application')} waiting for review`,
|
||||
prompt: 'Show candidates waiting for review',
|
||||
capability: 'candidates-waiting',
|
||||
}
|
||||
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
|
||||
/* Named for the whole board. The Hiring Activity skill answers the same
|
||||
question *for one position*, and the two used to arrive worded
|
||||
identically — see the intent de-duplication in `KrowAssistant`, which
|
||||
now also compares what a chip resolves to rather than only its words. */
|
||||
{ label: 'Summarize hiring activity across all positions', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
|
||||
);
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
'admin.candidatesList': (f) => [
|
||||
f.stalled.length
|
||||
? { label: `${f.stalled.length} waiting on a decision`, prompt: 'Who needs attention?', capability: 'candidates-attention' }
|
||||
: { label: 'Who needs attention?', prompt: 'Who needs attention?', capability: 'candidates-attention' },
|
||||
{ label: 'Who are the strongest candidates?', prompt: 'Who are the strongest candidates?', capability: 'top-candidates' },
|
||||
{ label: 'Which candidates are ready for interview?', prompt: 'Which candidates are ready for interview?', capability: 'interview-ready' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} have no score`, prompt: 'Which candidates have no score?', capability: 'screening-gaps' }
|
||||
: { label: 'Which candidates have no score?', prompt: 'Which candidates have no score?', capability: 'screening-gaps' },
|
||||
{ label: 'Summarize the candidate pipeline', prompt: 'Summarize the candidate pipeline', capability: 'pipeline-summary' },
|
||||
{ label: 'Identify candidate risks', prompt: 'Identify candidate risks', capability: 'candidate-risk' },
|
||||
],
|
||||
|
||||
'admin.candidates': (f) => [
|
||||
f.ranked.length >= 2
|
||||
? { label: `Compare ${f.ranked[0].applicant_name.split(' ')[0]} & ${f.ranked[1].applicant_name.split(' ')[0]}`, prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' }
|
||||
: { label: 'Compare top candidates', prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' },
|
||||
f.missingCredentials.length
|
||||
? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' }
|
||||
: { label: 'Show candidate risks', prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} unscored applicants`, prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' }
|
||||
: { label: 'Find screening gaps', prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' },
|
||||
{ label: 'Recommend next actions', prompt: 'Recommend next actions for these candidates', capability: 'hiring-recommendations' },
|
||||
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||
],
|
||||
|
||||
'admin.analytics': (f) => [
|
||||
{ label: 'What is the hiring trend?', prompt: 'What is the hiring trend?', capability: 'hiring-trend' },
|
||||
{ label: 'Which department is performing best?', prompt: 'Which department is performing best?', capability: 'department-performance' },
|
||||
f.bottleneck && f.bottleneck.lost > 0
|
||||
? { label: `Biggest drop at ${f.bottleneck.to}`, prompt: `Where is the biggest hiring bottleneck — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||||
: { label: 'Where is the biggest bottleneck?', prompt: 'Where is the biggest hiring bottleneck?', capability: 'pipeline-health' },
|
||||
{ label: 'Which positions are converting best?', prompt: 'Which positions are converting best?', capability: 'position-conversion' },
|
||||
{ label: 'Summarize hiring performance', prompt: 'Summarize hiring performance', capability: 'hiring-operations' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} unscored applications`, prompt: 'What needs attention?', capability: 'attention-required' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
|
||||
],
|
||||
|
||||
/* Forge, Talent Pool and Hired History. Same rule as the contexts above: the
|
||||
first chip is whatever the data has made urgent, so the set reorders itself
|
||||
rather than offering the same questions regardless of state. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
return [
|
||||
/* The chip that creates something goes first: on an authoring console the
|
||||
most likely next action is authoring. It carries no capability, so it
|
||||
routes through the skill registry rather than the page responder. */
|
||||
{ label: 'Create a skill training', prompt: 'Create a skill training' },
|
||||
g.drafts.length
|
||||
? { label: `${g.drafts.length} in draft`, prompt: 'Which skills are published?', capability: 'forge-published' }
|
||||
: { label: `${g.published.length} published`, prompt: 'Which skills are published?', capability: 'forge-published' },
|
||||
unscored
|
||||
? { label: `${unscored} without Owliver criteria`, prompt: 'What gaps are left in the library?', capability: 'forge-gaps' }
|
||||
: { label: 'What does Owliver evaluate?', prompt: 'What does Owliver evaluate?', capability: 'forge-evaluation' },
|
||||
{ label: 'What skills do we currently have?', prompt: 'What skills do we currently have?', capability: 'forge-library' },
|
||||
g.workforceVerified
|
||||
? { label: `${g.workforceVerified} skills verified`, prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' }
|
||||
: { label: 'How is the workforce using these?', prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
const t = f.talent;
|
||||
return [
|
||||
t.top[0]
|
||||
? { label: `Why does ${t.top[0].full_name?.split(' ')[0] || 'the top profile'} rank first?`, prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' }
|
||||
: { label: 'Which talent should I prioritize?', prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' },
|
||||
f.unscoredProfiles.length
|
||||
? { label: `${f.unscoredProfiles.length} profiles unscored`, prompt: 'Which profiles are unscored?', capability: 'talent-summary' }
|
||||
: { label: 'Summarize the talent pool', prompt: 'Summarize the talent pool', capability: 'talent-summary' },
|
||||
t.unverified.length
|
||||
? { label: `${t.unverified.length} need verification`, prompt: 'Which talent needs verification?', capability: 'talent-verification' }
|
||||
: { label: 'Which talent needs verification?', prompt: 'Which talent needs verification?', capability: 'talent-verification' },
|
||||
{ label: 'Who is currently available?', prompt: 'Who is currently available?', capability: 'talent-availability' },
|
||||
{ label: 'Who has the strongest score?', prompt: 'Who has the strongest score?', capability: 'talent-priorities' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
return [
|
||||
h.unrated.length
|
||||
? { label: `${h.unrated.length} hires unrated`, prompt: 'What needs attention in hiring outcomes?', capability: 'hiring-patterns' }
|
||||
: { label: 'What hiring patterns stand out?', prompt: 'What hiring patterns stand out?', capability: 'hiring-patterns' },
|
||||
h.byDepartment[0]
|
||||
? { label: `${h.byDepartment[0].name} leads on hires`, prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' }
|
||||
: { label: 'Which department has the most hires?', prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' },
|
||||
{ label: 'Which positions hired the strongest talent?', prompt: 'Which positions hired the strongest talent?', capability: 'hiring-strongest' },
|
||||
{ label: 'Summarize recent hires', prompt: 'Summarize recent hires', capability: 'hiring-recent' },
|
||||
{ label: 'What hiring outcomes should I review?', prompt: 'What hiring outcomes should I review?', capability: 'hiring-outcomes' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user ? f.activity.filter((e) => e.user_email === f.user.email) : [];
|
||||
const restricted = PERMISSIONS.filter((p) => p.level !== 'Full access').length;
|
||||
return [
|
||||
{ label: 'What are my permissions?', prompt: 'What are my permissions?', capability: 'profile-permissions' },
|
||||
restricted
|
||||
? { label: `${restricted} scopes are restricted`, prompt: 'Which scopes am I restricted on?', capability: 'profile-permissions' }
|
||||
: { label: 'What can I do here?', prompt: 'What can I do on this page?', capability: 'profile-actions' },
|
||||
{ label: 'How is my account secured?', prompt: 'How is my account secured?', capability: 'profile-security' },
|
||||
{ label: 'What are my preferences set to?', prompt: 'What are my preferences set to?', capability: 'profile-preferences' },
|
||||
mine.length
|
||||
? { label: `My last ${mine.length} actions`, prompt: 'What is my recent activity?', capability: 'profile-activity' }
|
||||
: { label: 'What can I change here?', prompt: 'What can I change on this page?', capability: 'profile-actions' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
: { label: 'Show unusual activity', prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' },
|
||||
f.activity24h.length
|
||||
? { label: `Summarize today's ${f.activity24h.length} events`, prompt: "Summarize today's activity", capability: 'audit-summary' }
|
||||
: { label: "Summarize today's activity", prompt: "Summarize today's activity", capability: 'audit-summary' },
|
||||
{ label: 'What changed recently?', prompt: 'What changed recently on the platform?', capability: 'audit-summary' },
|
||||
{ label: 'Identify security concerns', prompt: 'Identify security concerns in the audit trail', capability: 'security-insights' },
|
||||
{ label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
||||
],
|
||||
|
||||
'admin.agentConfigure': () => [
|
||||
{ label: 'Where do skills come from?', prompt: 'Where do skills come from on this screen?' },
|
||||
{ label: 'Explain reasoning modes', prompt: 'What are the reasoning modes and how do they work?' },
|
||||
{ label: 'How to write instructions?', prompt: 'How should I write effective instructions for this agent?' },
|
||||
{ label: 'How does publishing work?', prompt: 'How does agent publishing and versioning work?' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Settings and the workspace pages.
|
||||
*
|
||||
* Every chip below resolves to something this page can actually answer, and
|
||||
* that is the whole rule: a suggestion is a promise, so a configuration screen
|
||||
* must not offer "Which positions are at risk?" merely to have chips. None of
|
||||
* these names a record; each one names the screen. The operational chips above
|
||||
* are computed from data because those pages have data — these are not, for
|
||||
* exactly the same reason.
|
||||
*/
|
||||
'admin.settings': () => [
|
||||
{ label: 'What can I configure here?', prompt: 'What can I configure on Settings?' },
|
||||
{ label: 'What do the automation toggles do?', prompt: 'What do the automation toggles do?' },
|
||||
{ label: 'Who can access what?', prompt: 'Who can access what, and where is it recorded?' },
|
||||
],
|
||||
'admin.workspace': () => [
|
||||
{ label: 'What does the workspace govern?', prompt: 'What does the workspace govern?' },
|
||||
{ label: 'What is an agent?', prompt: 'What is an agent, and what does it decide?' },
|
||||
{ label: 'Where do skills come from?', prompt: 'Where do skills come from?' },
|
||||
],
|
||||
'admin.workspaceAgents': () => [
|
||||
{ label: 'How does a page pick its agent?', prompt: 'How does a page pick its agent?' },
|
||||
{ label: 'What does draft mean?', prompt: 'What do draft, published and archived mean?' },
|
||||
{ label: 'What is in this list?', prompt: 'What does this list of agents hold?' },
|
||||
],
|
||||
'admin.workspaceSkills': () => [
|
||||
{ label: 'How do skills reach an agent?', prompt: 'How do skills reach an agent?' },
|
||||
{ label: 'How is a skill written?', prompt: 'How is a skill written?' },
|
||||
{ label: 'What are the two lists?', prompt: 'What is the difference between the two skill lists?' },
|
||||
],
|
||||
'admin.skillConfigure': () => [
|
||||
{ label: 'Why is a definition refused?', prompt: 'Why would a definition be refused?' },
|
||||
{ label: 'Where will this apply?', prompt: 'Where will this skill apply?' },
|
||||
{ label: 'What does this editor declare?', prompt: 'What does this editor configure?' },
|
||||
],
|
||||
'admin.skillDevelopment': () => [
|
||||
{ label: 'How do the levels work?', prompt: 'How do the levels work?' },
|
||||
{ label: 'What is a training path?', prompt: 'What is a training path?' },
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || '';
|
||||
|
||||
/**
|
||||
* Everything the landing screen needs, computed from current data.
|
||||
* `userName` personalizes the greeting where a name is known.
|
||||
*/
|
||||
export function buildIntro(contextId, facts, userName) {
|
||||
const name = firstName(userName);
|
||||
return {
|
||||
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
|
||||
greeting: GREETINGS[contextId]?.(facts) ?? '',
|
||||
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
|
||||
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Suggested prompts for the current page and data. */
|
||||
export function buildPrompts(contextId, facts, workforce = null) {
|
||||
return PROMPTS[contextId]?.(facts, workforce) ?? [];
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
/**
|
||||
* Owliver's conversation history.
|
||||
*
|
||||
* The panel already persisted the *current* thread per page, in sessionStorage,
|
||||
* so returning to Candidates found the conversation you left there. What it had
|
||||
* no notion of was a conversation you had *finished*: starting a new one
|
||||
* overwrote the old one, and there was no way back to anything.
|
||||
*
|
||||
* This is that archive, and it is deliberately the same artefact — a list of
|
||||
* threads in the shape the panel already stores. Two differences, both
|
||||
* intentional:
|
||||
*
|
||||
* - **localStorage, not session.** A history that empties when the tab closes
|
||||
* is not a history. Threads outlive the session, which is what makes
|
||||
* "Yesterday" and "Older" mean anything.
|
||||
* - **Written continuously, not on reset.** The archive is updated as the
|
||||
* conversation grows rather than when it ends, so a thread is recoverable
|
||||
* even if the tab is closed mid-answer.
|
||||
*
|
||||
* Nothing here is a second conversation store: the live thread is still the one
|
||||
* the panel renders from. This holds copies, keyed by conversation, for the one
|
||||
* question the panel could not previously answer — "what did I ask before?"
|
||||
*/
|
||||
|
||||
const KEY = 'krow_assistant:history';
|
||||
|
||||
/** Threads older than this stop being useful and start being clutter. */
|
||||
const MAX_AGE_DAYS = 60;
|
||||
/** A hard cap, so a heavy user cannot fill the origin's storage quota. */
|
||||
const MAX_RECORDS = 100;
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The shape of a stored record.
|
||||
*
|
||||
* Bumped when a record gains fields, so a reader can tell an old record from a
|
||||
* new one instead of guessing from which keys happen to be present.
|
||||
*/
|
||||
const SCHEMA = 2;
|
||||
|
||||
/**
|
||||
* An older record, brought up to date on the way out.
|
||||
*
|
||||
* A conversation held before agents existed is still a conversation somebody
|
||||
* had. It gets the new fields at their empty values and keeps everything it
|
||||
* already had — in particular `messages` is passed through untouched, so
|
||||
* nothing a reader wrote is rewritten by a schema change.
|
||||
*
|
||||
* Migrating on read rather than rewriting the store means a browser that never
|
||||
* opens History again still loses nothing, and there is no migration pass that
|
||||
* can fail halfway.
|
||||
*/
|
||||
function migrate(record) {
|
||||
if (record.schema === SCHEMA) return record;
|
||||
return {
|
||||
...record,
|
||||
schema: SCHEMA,
|
||||
/* Absent, not unknown: these conversations genuinely had no agent, no
|
||||
recorded skills and no feedback. */
|
||||
agentId: record.agentId ?? null,
|
||||
pageContext: record.pageContext ?? null,
|
||||
skillsUsed: Array.isArray(record.skillsUsed) ? record.skillsUsed : [],
|
||||
toolsUsed: Array.isArray(record.toolsUsed) ? record.toolsUsed : [],
|
||||
knowledgeUsed: Array.isArray(record.knowledgeUsed) ? record.knowledgeUsed : [],
|
||||
feedback: record.feedback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every archived conversation, newest first. Never throws. */
|
||||
export function readHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const list = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(list)) return [];
|
||||
|
||||
const cutoff = Date.now() - MAX_AGE_DAYS * DAY;
|
||||
return list
|
||||
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
||||
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
||||
.map(migrate)
|
||||
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
||||
} catch {
|
||||
/* Corrupt or unavailable storage is an empty history, not an error the
|
||||
reader has to deal with. */
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The first thing the person asked, which is what the thread was about. */
|
||||
function titleFor(messages) {
|
||||
const first = messages.find((m) => m.role === 'user' && m.text);
|
||||
const text = String(first?.text || '').trim();
|
||||
if (!text) return 'Conversation';
|
||||
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records this conversation, replacing its own earlier entry.
|
||||
*
|
||||
* Matched on `id`, so a thread being added to updates in place rather than
|
||||
* appearing once per turn.
|
||||
*/
|
||||
export function saveConversation({
|
||||
id, contextId, page, messages,
|
||||
/**
|
||||
* Who answered, where, and what it actually used.
|
||||
*
|
||||
* Recorded from what ran rather than from what was available: `skillsUsed`
|
||||
* is the skill that answered a turn, not every skill the page offered. A
|
||||
* record of what *could* have happened would make the insight figures
|
||||
* describe the registry instead of the conversation.
|
||||
*/
|
||||
agentId = null, pageContext = null,
|
||||
skillsUsed = [], toolsUsed = [], knowledgeUsed = [],
|
||||
}) {
|
||||
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
|
||||
|
||||
/* Feedback belongs to the conversation, not to the turn that triggered a
|
||||
save, so an existing rating survives the thread growing. */
|
||||
const existing = readHistory().find((r) => r.id === id) || null;
|
||||
|
||||
const record = {
|
||||
schema: SCHEMA,
|
||||
id,
|
||||
contextId,
|
||||
page: page || '',
|
||||
agentId,
|
||||
/* The *reduced* envelope — where the question was asked, never a copy of
|
||||
what was on screen. Storing selections and computed figures would write
|
||||
the dataset into localStorage a turn at a time. */
|
||||
pageContext,
|
||||
skillsUsed: [...new Set(skillsUsed.filter(Boolean))],
|
||||
toolsUsed: [...new Set(toolsUsed.filter(Boolean))],
|
||||
knowledgeUsed: [...new Set(knowledgeUsed.filter(Boolean))],
|
||||
feedback: existing?.feedback ?? null,
|
||||
title: titleFor(messages),
|
||||
turns: messages.filter((m) => m.role === 'user').length,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS);
|
||||
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* Quota or private mode: the live thread is unaffected, only the archive
|
||||
is. Dropping the oldest half is a better outcome than losing every
|
||||
future write. */
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4))));
|
||||
} catch {
|
||||
/* Storage is unusable. History is in-memory for this session only. */
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records how a conversation was rated.
|
||||
*
|
||||
* Updates in place and never appends: rating a thread twice is a correction,
|
||||
* not two opinions. Returns the refreshed list so a caller re-renders from one
|
||||
* read rather than two.
|
||||
*/
|
||||
export function recordFeedback(id, feedback) {
|
||||
const list = readHistory();
|
||||
const at = list.findIndex((r) => r.id === id);
|
||||
if (at === -1) return list;
|
||||
|
||||
const next = [...list];
|
||||
next[at] = {
|
||||
...next[at],
|
||||
feedback: feedback
|
||||
? {
|
||||
rating: feedback.rating === 'up' ? 'up' : 'down',
|
||||
note: String(feedback.note || '').trim() || null,
|
||||
at: new Date().toISOString(),
|
||||
}
|
||||
/* Clearing is a real action — someone un-rating a thread should leave no
|
||||
rating behind rather than a neutral one. */
|
||||
: null,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* The rating stays in memory for this session. */
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Forgets one conversation. */
|
||||
export function removeConversation(id) {
|
||||
const next = readHistory().filter((r) => r.id !== id);
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* Ignore — the caller re-reads either way. */
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Forgets everything. */
|
||||
export function clearHistory() {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* Ignore. */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations under a heading, in the order a person thinks about time.
|
||||
*
|
||||
* Empty groups are dropped rather than rendered as a heading with nothing
|
||||
* under it.
|
||||
*/
|
||||
export function groupByRecency(records, now = new Date()) {
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const startOfYesterday = new Date(startOfToday.getTime() - DAY);
|
||||
const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY);
|
||||
|
||||
const groups = [
|
||||
{ id: 'today', label: 'Today', items: [] },
|
||||
{ id: 'yesterday', label: 'Yesterday', items: [] },
|
||||
{ id: 'week', label: 'Earlier this week', items: [] },
|
||||
{ id: 'older', label: 'Older', items: [] },
|
||||
];
|
||||
|
||||
for (const record of records) {
|
||||
const at = new Date(record.updatedAt || 0).getTime();
|
||||
if (at >= startOfToday.getTime()) groups[0].items.push(record);
|
||||
else if (at >= startOfYesterday.getTime()) groups[1].items.push(record);
|
||||
else if (at >= startOfWeek.getTime()) groups[2].items.push(record);
|
||||
else groups[3].items.push(record);
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.items.length);
|
||||
}
|
||||
|
||||
/** A new conversation id. Time-ordered, so ids sort the way threads do. */
|
||||
export function newConversationId() {
|
||||
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Owliver — the dashboard's contextual productivity panel.
|
||||
*
|
||||
* Named Owliver in the UI, but architecturally independent of the standalone
|
||||
* Owliver voice product in components/krow/Owliver*. Nothing is shared.
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* AssistantPanelProvider workspace state, resolved page context
|
||||
* AssistantPanel the workspace column the Admin layout renders
|
||||
* AssistantTrigger legacy opt-in button. Not used by Admin: Owliver is
|
||||
* part of the page there, so there is no trigger to
|
||||
* press. It self-hides on unsupported routes, which
|
||||
* is why the pre-redesign pages still holding one
|
||||
* render nothing.
|
||||
* KrowAssistant the one assistant UI, reused on every page
|
||||
* provider.js the transport seam — local today, HTTP later
|
||||
*
|
||||
* Independent of Owliver, which remains a separate product. Nothing here imports
|
||||
* Owliver code, and the two never share UI.
|
||||
*/
|
||||
|
||||
export { AssistantPanelProvider, useAssistantPanel } from './AssistantPanelContext';
|
||||
export {
|
||||
usePageAction, usePageContext, usePublishPageActions, usePublishPageContext,
|
||||
} from './PageContext';
|
||||
export { AssistantTrigger } from './AssistantTrigger';
|
||||
export { AssistantPanel } from './AssistantPanel';
|
||||
export { default as KrowAssistant } from './KrowAssistant';
|
||||
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
|
||||
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
|
||||
export { createHttpProvider, createLocalProvider } from './provider';
|
||||
@@ -1,369 +0,0 @@
|
||||
/**
|
||||
* Dashboard fact sheet.
|
||||
*
|
||||
* Every assistant answer is derived from here, using the same formulas the
|
||||
* dashboard components use — so the assistant can never state a figure that
|
||||
* contradicts the card or chart sitting next to it.
|
||||
*
|
||||
* This module is pure: dashboard data in, derived facts out. No UI, no
|
||||
* transport, no dependency on any AI product.
|
||||
*/
|
||||
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
|
||||
|
||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
export const plural = (n, word, irregular) =>
|
||||
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
||||
|
||||
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
||||
|
||||
const avg = (values) =>
|
||||
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
/**
|
||||
* Events an auditor looks at first: they change who is employed or what is being
|
||||
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
|
||||
* uses it, and re-exported here because the fact sheet, the Activity page's
|
||||
* severity column and the assistant's security answers all import it from this
|
||||
* module and must keep agreeing on what "privileged" means.
|
||||
*/
|
||||
export { PRIVILEGED_EVENTS };
|
||||
|
||||
/** `today` is injected rather than read from the clock so answers are stable. */
|
||||
export function buildFacts({
|
||||
applications = [],
|
||||
postings = [],
|
||||
interviews = [],
|
||||
staff = [],
|
||||
profiles = [],
|
||||
activity = [],
|
||||
courses = [],
|
||||
profile = null,
|
||||
user = null,
|
||||
today = new Date(),
|
||||
}) {
|
||||
const scored = applications.filter((a) => a.ai_score > 0);
|
||||
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
|
||||
const byStatus = (...statuses) => applications.filter((a) => statuses.includes(a.status));
|
||||
|
||||
const unscreened = byStatus('applied');
|
||||
const screened = byStatus('ai_screened', 'shortlisted', 'interview', 'hired');
|
||||
const interviewing = byStatus('interview');
|
||||
const hired = byStatus('hired');
|
||||
|
||||
const openPositions = postings.filter((p) => p.status === 'active');
|
||||
const applicationsFor = (id) => applications.filter((a) => a.job_posting_id === id);
|
||||
const completedInterviews = interviews.filter((i) => i.overall_interview_score > 0);
|
||||
|
||||
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
||||
const timeToHire = hired.length
|
||||
? Math.round(avg(hired.map((a) =>
|
||||
Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS))))
|
||||
: 0;
|
||||
|
||||
/* Open roles attracting nobody. */
|
||||
const starvedPositions = openPositions.filter((p) => applicationsFor(p.id).length === 0);
|
||||
|
||||
/* Roles resting on a single candidate — one withdrawal and the role reopens. */
|
||||
const singleCandidateRoles = openPositions
|
||||
.map((p) => ({ posting: p, viable: applicationsFor(p.id).filter((a) => a.ai_score >= 60) }))
|
||||
.filter((e) => e.viable.length === 1);
|
||||
|
||||
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
|
||||
const stalled = ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened');
|
||||
|
||||
const funnel = [
|
||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: byStatus('shortlisted', 'interview', 'hired').length },
|
||||
{ key: 'interview', label: 'Interviewed', count: interviewing.length + hired.length },
|
||||
{ key: 'hired', label: 'Hired', count: hired.length },
|
||||
];
|
||||
|
||||
const transitions = funnel.slice(1).map((stage, i) => ({
|
||||
from: funnel[i].label,
|
||||
to: stage.label,
|
||||
rate: pct(stage.count, funnel[i].count),
|
||||
lost: Math.max(0, funnel[i].count - stage.count),
|
||||
}));
|
||||
|
||||
const bottleneck = [...transitions].sort((a, b) => a.rate - b.rate)[0] || null;
|
||||
|
||||
const byRole = openPositions.map((p) => {
|
||||
const apps = applicationsFor(p.id);
|
||||
const roleScored = apps.filter((a) => a.ai_score > 0);
|
||||
return {
|
||||
posting: p,
|
||||
title: p.title,
|
||||
category: p.role_category || 'Uncategorized',
|
||||
applied: apps.length,
|
||||
screened: apps.filter((a) => a.status !== 'applied').length,
|
||||
hired: apps.filter((a) => a.status === 'hired').length,
|
||||
avgScore: avg(roleScored.map((a) => a.ai_score)),
|
||||
qualified: roleScored.filter((a) => a.ai_score >= 70).length,
|
||||
};
|
||||
});
|
||||
|
||||
const since = (days) => {
|
||||
const cutoff = today.getTime() - days * DAY_MS;
|
||||
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
|
||||
};
|
||||
|
||||
const eventCounts = activity.reduce((acc, e) => {
|
||||
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Out-of-pattern activity.
|
||||
*
|
||||
* Computed by `lib/activitySignals.js` rather than here, because it now has
|
||||
* a second reader: the `activity.signals` data source resolves it for a card
|
||||
* or an answer, and `dataResolver.js` cannot import from this directory
|
||||
* without a library reaching up into the component tree.
|
||||
*
|
||||
* Moved, not changed. "Two unusual patterns" has to mean the same two
|
||||
* everywhere it is said, and one function is the only way to guarantee that.
|
||||
*/
|
||||
const signals = activitySignals(activity, today);
|
||||
|
||||
/* ── KROW Forge ──────────────────────────────────────────────────────────
|
||||
Derived with the same gate (`isUnlocked`) and the same completion record the
|
||||
Forge page renders from, so the panel and the page cannot disagree about
|
||||
what is ready, what is blocked, or why. `profile` is the signed-in worker's
|
||||
record when one is already in cache; without it the counts are the library's
|
||||
rather than the person's, which is still true — just less personal. */
|
||||
const forgeCourses = courses.filter((c) => c.status === 'active');
|
||||
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
|
||||
const forgeGate = (course) => isUnlocked(course, profile || {});
|
||||
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
|
||||
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
|
||||
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
|
||||
const forge = {
|
||||
courses: forgeCourses,
|
||||
verified: profile?.earned_badges || [],
|
||||
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
|
||||
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
|
||||
ready: forgeReady,
|
||||
locked: forgeLocked,
|
||||
/* The page features the first unfinished challenge whether or not it is
|
||||
unlocked, so the panel names the same one — a chip that recommends a
|
||||
different challenge than the one on screen reads as a second opinion. */
|
||||
next: forgeUnfinished[0] || null,
|
||||
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
|
||||
/* Every blocked challenge with the reasons that block it, in page order. */
|
||||
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
|
||||
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
|
||||
level,
|
||||
count: forgeCourses.filter((c) => c.difficulty === level).length,
|
||||
})).filter((d) => d.count),
|
||||
|
||||
/* ── The same library, read by the administrator who owns it ──────────
|
||||
Everything above answers "what can I prove"; everything below answers
|
||||
"what have we built, and how is the workforce doing with it". Both are
|
||||
derived from the same records, so the panel cannot disagree with the page
|
||||
whichever question is asked. Publication state is read with the same
|
||||
mapping the Forge page uses: `active` is Published. */
|
||||
library: courses,
|
||||
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
|
||||
drafts: courses.filter((c) => c.status === 'draft'),
|
||||
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
|
||||
withChallenge: courses.filter((c) => c.challenge?.type),
|
||||
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
|
||||
byCategory: [...courses.reduce((map, c) => {
|
||||
const key = c.category || 'Uncategorised';
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
return map;
|
||||
}, new Map())]
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
/* Verifications the workforce actually holds, counted off worker profiles
|
||||
rather than off the library — a badge is something a person has. */
|
||||
workforceVerified: profiles.reduce((sum, p) => {
|
||||
const ids = new Set(courses.map((c) => c.id));
|
||||
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
|
||||
}, 0),
|
||||
usage: courses.map((c) => ({
|
||||
course: c,
|
||||
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
|
||||
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
|
||||
})),
|
||||
};
|
||||
|
||||
/* ── Talent pool ─────────────────────────────────────────────────────────
|
||||
The same score bands the Talent Pool page labels profiles with. */
|
||||
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
|
||||
const talent = {
|
||||
ranked: rankedProfiles,
|
||||
top: rankedProfiles.slice(0, 3),
|
||||
available: profiles.filter((p) => (p.availability || []).length > 0),
|
||||
unverified: profiles.filter(
|
||||
(p) => !(p.earned_badges || []).length
|
||||
&& !(p.capabilities || []).some((c) => c.status === 'verified')
|
||||
),
|
||||
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
||||
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
||||
bands: Object.values(profiles.reduce((acc, p) => {
|
||||
const { label } = getScoreBand(p.krow_score || 0);
|
||||
acc[label] ||= { label, count: 0 };
|
||||
acc[label].count += 1;
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.count - a.count),
|
||||
};
|
||||
|
||||
/* ── Hired history ───────────────────────────────────────────────────────
|
||||
Staff joined to the application and posting they came from — the same join
|
||||
the Hired History page performs, so department, speed and score match. */
|
||||
const hires = staff.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
return {
|
||||
...s,
|
||||
department: posting?.role_category || '—',
|
||||
timeToHire: app
|
||||
? Math.max(1, Math.round(
|
||||
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
|
||||
))
|
||||
: null,
|
||||
score: s.ai_score || app?.ai_score || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
|
||||
const name = h[key] || 'Unspecified';
|
||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||
acc[name].count += 1;
|
||||
if (h.score) acc[name].scores.push(h.score);
|
||||
if (h.timeToHire) acc[name].days.push(h.timeToHire);
|
||||
if (h.client_rating) acc[name].ratings.push(h.client_rating);
|
||||
return acc;
|
||||
}, {})).map((e) => ({
|
||||
name: e.name,
|
||||
count: e.count,
|
||||
avgScore: avg(e.scores),
|
||||
avgDays: avg(e.days),
|
||||
avgRating: e.ratings.length
|
||||
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
|
||||
: null,
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
const hiring = {
|
||||
hires,
|
||||
byDepartment: groupHires('department'),
|
||||
byRoleTitle: groupHires('role'),
|
||||
unrated: hires.filter((h) => !h.client_rating),
|
||||
rated: hires.filter((h) => h.client_rating),
|
||||
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
|
||||
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
|
||||
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
|
||||
onboarding: hires.filter((h) => h.status === 'onboarding'),
|
||||
};
|
||||
|
||||
return {
|
||||
today,
|
||||
|
||||
/* The signed-in account — identity and preferences, for the pages that are
|
||||
about the operator rather than the workforce. */
|
||||
user,
|
||||
|
||||
/* Applications */
|
||||
applications,
|
||||
total: applications.length,
|
||||
scored,
|
||||
ranked,
|
||||
top: ranked.slice(0, 3),
|
||||
weak: ranked.filter((a) => a.ai_score > 0 && a.ai_score < 40),
|
||||
unscreened,
|
||||
screened,
|
||||
interviewing,
|
||||
hired,
|
||||
stalled,
|
||||
avgScore: avg(scored.map((a) => a.ai_score)),
|
||||
hiredAvgScore: avg(hired.map((a) => a.ai_score)),
|
||||
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
|
||||
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
|
||||
|
||||
/**
|
||||
* Candidates a human should look at more closely — either because there is
|
||||
* no score to judge them on, or because the record has a gap the score does
|
||||
* not reflect. Deliberately not "weak candidates": every one of these is
|
||||
* under-assessed rather than assessed and found wanting.
|
||||
*/
|
||||
needsDeeperReview: applications.filter((a) => {
|
||||
if (!(a.ai_score > 0)) return true;
|
||||
const flaggedInterview = interviews.some(
|
||||
(i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100
|
||||
);
|
||||
return flaggedInterview
|
||||
|| !(a.certifications || []).length
|
||||
|| !a.years_experience;
|
||||
}),
|
||||
|
||||
/* Positions */
|
||||
postings,
|
||||
openPositions,
|
||||
starvedPositions,
|
||||
singleCandidateRoles,
|
||||
byRole,
|
||||
applicationsFor,
|
||||
|
||||
/* Interviews */
|
||||
interviews,
|
||||
completedInterviews,
|
||||
avgInterviewScore: avg(completedInterviews.map((i) => i.overall_interview_score)),
|
||||
interviewCompletion: pct(completedInterviews.length, interviews.length),
|
||||
flagged: interviews.filter((i) => (i.integrity_score ?? 100) < 100),
|
||||
|
||||
/* Hires */
|
||||
staff,
|
||||
unratedStaff: staff.filter((s) => !s.client_rating),
|
||||
timeToHire,
|
||||
hireRate: pct(hired.length, applications.length),
|
||||
|
||||
/* Funnel */
|
||||
funnel,
|
||||
transitions,
|
||||
bottleneck,
|
||||
|
||||
/* Talent pool */
|
||||
profiles,
|
||||
elite: profiles.filter((p) => (p.krow_score || 0) >= 90),
|
||||
unscoredProfiles: profiles.filter((p) => !(p.krow_score > 0)),
|
||||
|
||||
/* Activity */
|
||||
activity,
|
||||
activity7d: since(7),
|
||||
activity24h: since(1),
|
||||
eventCounts,
|
||||
activitySignals: signals,
|
||||
|
||||
/* Signed-in worker, and the pages built on these records */
|
||||
profile,
|
||||
courses,
|
||||
forge,
|
||||
talent,
|
||||
hiring,
|
||||
|
||||
/* Economics — mirrors ImpactMetrics */
|
||||
costSaved: scored.length * 45 + interviews.length * 80,
|
||||
hoursSaved: Math.round((scored.length * 15 + interviews.length * 30) / 60),
|
||||
standardizedPct: pct(scored.length, applications.length),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Answer formatting ──────────────────────────────────────────────────── */
|
||||
|
||||
export const bullets = (items) => items.filter(Boolean).map((t) => `• ${t}`).join('\n');
|
||||
|
||||
export const numbered = (items) =>
|
||||
items.filter(Boolean).map((t, i) => `${i + 1}. ${t}`).join('\n\n');
|
||||
|
||||
export const heading = (text) => `**${text}**`;
|
||||
|
||||
/** Joins answer blocks, dropping empties so templates can use conditionals. */
|
||||
export const compose = (...blocks) => blocks.filter((b) => b !== null && b !== undefined && b !== '').join('\n');
|
||||
@@ -1,172 +0,0 @@
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
|
||||
/**
|
||||
* Where the Owliver dashboard panel appears.
|
||||
*
|
||||
* The assistant occupies a permanent column in the page layout, so enabling it
|
||||
* on a page is a layout decision, not a feature flag — which is exactly why the
|
||||
* decision lives in one table rather than being scattered across pages.
|
||||
*
|
||||
* Matching is exact, not prefix-based: `/candidates` must not leak an assistant
|
||||
* onto a future `/candidates/:id`, and `/positions/:id` must not inherit one
|
||||
* from `/positions`.
|
||||
*/
|
||||
const PLACEMENT = {
|
||||
admin: {
|
||||
'/admin': 'admin.controlCenter',
|
||||
'/admin/positions': 'admin.positions',
|
||||
/* The authoring form. Owliver belongs here for the same reason it belongs
|
||||
on Positions: the questions asked while specifying a role — what do these
|
||||
weights score, what do comparable roles ask for — are answerable from the
|
||||
page and from what this workspace has already posted. */
|
||||
'/admin/positions/new': 'admin.createPosition',
|
||||
'/admin/candidates': 'admin.candidatesList',
|
||||
'/admin/candidates-analysis': 'admin.candidates',
|
||||
'/admin/analytics': 'admin.analytics',
|
||||
'/admin/activity': 'admin.activity',
|
||||
'/admin/university': 'admin.forge',
|
||||
'/admin/talent-pool': 'admin.talentPool',
|
||||
'/admin/hired': 'admin.hiredHistory',
|
||||
'/admin/profile': 'admin.profile',
|
||||
/* Agent management. Configuring an agent is a workspace task, not an
|
||||
operational page — see `admin.agentConfigure` for what that means for
|
||||
what Owliver may read here. `agents/new` is listed exactly so it is
|
||||
never read as an agent whose id is "new". */
|
||||
'/admin/workspace/agents/new': 'admin.agentConfigure',
|
||||
|
||||
/**
|
||||
* Settings and the rest of the workspace.
|
||||
*
|
||||
* These pages have no agent written for them, and for a while that was read
|
||||
* as "no Owliver here" — the panel did not mount at all, so a product that
|
||||
* answered questions on every Admin page before specialists existed
|
||||
* answered on eight of them afterwards.
|
||||
*
|
||||
* The absence of a specialist is not the absence of an assistant. Each of
|
||||
* these carries the same panel, resolved by this same table, answering from
|
||||
* the general agent. What they do *not* carry is operational data: no skill
|
||||
* declares these surfaces, so a workforce question asked here is declined
|
||||
* and pointed at the page that holds the records rather than answered from
|
||||
* a configuration screen.
|
||||
*/
|
||||
'/admin/settings': 'admin.settings',
|
||||
'/admin/workspace': 'admin.workspace',
|
||||
'/admin/workspace/agents': 'admin.workspaceAgents',
|
||||
'/admin/workspace/skills': 'admin.workspaceSkills',
|
||||
'/admin/workspace/skill-development': 'admin.skillDevelopment',
|
||||
/* The skill editor, listed exactly for the same reason `agents/new` is: so
|
||||
`skills/new` is never read as a skill whose id is "new". This is also the
|
||||
entry the page key is derived from — `pageKeyForRoute` reads the surface
|
||||
table, and this route is the one the `workspace-skill-configure` surface
|
||||
declares. The Owliver editor's addresses reach the same context through
|
||||
the pattern table below; listing them here too would overwrite that key
|
||||
with a path tail. */
|
||||
'/admin/workspace/skills/new': 'admin.skillConfigure',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Admin: login only. Every other surface — including Profile and Settings,
|
||||
* where the questions are about the account rather than the workforce,
|
||||
* and the workspace pages, where they are about the configuration —
|
||||
* carries the panel.
|
||||
* Employer: every page. The contextual panel is an Admin capability.
|
||||
* Talent: every page. The talent portal has the standalone Owliver product,
|
||||
* with its own voice experience, branding and workflow.
|
||||
*/
|
||||
export const EXCLUDED_ROUTES = [
|
||||
// Every Employer and Talent route: the contextual panel is Admin-only.
|
||||
'/', '/overview', '/positions', '/positions/new', '/positions/:id', '/candidates',
|
||||
/* The Employer aliases only — the Admin `/admin/positions/new` carries the
|
||||
panel, and is listed in the placement table above. */
|
||||
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
// Sign-in has no page data to reason about.
|
||||
'/admin/login',
|
||||
// The full candidate profile is one person's record, not a page-level question.
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The Admin route → context table, exported so the skill registry can derive
|
||||
* page keys from it. One table, so a route added here is addressable by a skill
|
||||
* without a second list to keep in step.
|
||||
*/
|
||||
export const PLACEMENT_ROUTES = PLACEMENT.admin;
|
||||
|
||||
/**
|
||||
* Routes whose address carries a record id.
|
||||
*
|
||||
* The table above is matched exactly, which is deliberate and stays that way:
|
||||
* `/candidates` must not leak an assistant onto `/candidates/:id`. But an agent
|
||||
* is configured at `/admin/workspace/agents/<id>`, and no exact table can list
|
||||
* an address that contains an id nobody has created yet.
|
||||
*
|
||||
* So a second, much smaller table is consulted **only after the exact lookup
|
||||
* misses**. Every one of the eight operational pages is an exact match and
|
||||
* never reaches this code, so their placement is unchanged by construction
|
||||
* rather than by care.
|
||||
*
|
||||
* `exclude` keeps a sibling literal route — `agents/new` is the same screen and
|
||||
* is listed exactly — from being read as an id.
|
||||
*/
|
||||
const PLACEMENT_PATTERNS = {
|
||||
admin: [
|
||||
{
|
||||
/* Agent configuration: one path segment after `agents/`, and not a
|
||||
nested route beneath it. */
|
||||
test: (pathname) => /^\/admin\/workspace\/agents\/[^/]+$/.test(pathname),
|
||||
contextId: 'admin.agentConfigure',
|
||||
},
|
||||
{
|
||||
/* The Owliver skill editor — `skills/owliver/new` and
|
||||
`skills/owliver/<id>`. Two segments, so it is matched before the
|
||||
one-segment pattern below and can never be read as a skill whose id is
|
||||
"owliver". */
|
||||
test: (pathname) => /^\/admin\/workspace\/skills\/owliver\/[^/]+$/.test(pathname),
|
||||
contextId: 'admin.skillConfigure',
|
||||
},
|
||||
{
|
||||
/* The Board skill editor: one segment after `skills/`, and no deeper.
|
||||
`owliver` is excluded because it is a prefix rather than a skill, and
|
||||
the product has no page at that address. */
|
||||
test: (pathname) => /^\/admin\/workspace\/skills\/(?!owliver$)[^/]+$/.test(pathname),
|
||||
contextId: 'admin.skillConfigure',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The dynamic routes, as literal examples.
|
||||
*
|
||||
* Exported so the registry and the checks can reason about a pattern without
|
||||
* re-implementing it. These are addresses the pattern genuinely matches.
|
||||
*/
|
||||
export const PLACEMENT_PATTERN_ROUTES = {
|
||||
'/admin/workspace/agents/:id': 'admin.agentConfigure',
|
||||
'/admin/workspace/skills/:id': 'admin.skillConfigure',
|
||||
'/admin/workspace/skills/owliver/new': 'admin.skillConfigure',
|
||||
'/admin/workspace/skills/owliver/:id': 'admin.skillConfigure',
|
||||
};
|
||||
|
||||
/** Returns the context for a role and path, or `null` to render no assistant. */
|
||||
export function resolveAssistantContext(role, pathname) {
|
||||
/* Exact first, always. The eight operational pages resolve here and never
|
||||
reach the patterns below. */
|
||||
const id = PLACEMENT[role]?.[pathname];
|
||||
if (id) return ASSISTANT_CONTEXTS[id] ?? null;
|
||||
|
||||
const pattern = (PLACEMENT_PATTERNS[role] || []).find((p) => p.test(pathname));
|
||||
return pattern ? ASSISTANT_CONTEXTS[pattern.contextId] ?? null : null;
|
||||
}
|
||||
|
||||
/** Every enabled role/route pair — used by the placement verification. */
|
||||
export function enabledRoutes() {
|
||||
return Object.entries(PLACEMENT).flatMap(([role, routes]) =>
|
||||
Object.entries(routes).map(([path, contextId]) => ({ role, path, contextId }))
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Provider seam for the Owliver dashboard panel.
|
||||
*
|
||||
* The UI never generates an answer and never knows where one came from. It calls
|
||||
* `provider.stream(request)` and renders the snapshots. That single boundary is
|
||||
* what makes the backend swappable: today a local provider computes answers from
|
||||
* dashboard data, and later an HTTP provider can call a real service without a
|
||||
* change to the component.
|
||||
*
|
||||
* ── Contract ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* provider.id: string
|
||||
* provider.stream(request): AsyncIterable<Block[]>
|
||||
*
|
||||
* Each yielded value is the response *so far* as an array of blocks (see
|
||||
* blocks.js). Snapshots rather than deltas because a response is structured:
|
||||
* a table or a KPI row has no meaningful half-state, and the renderer stays a
|
||||
* pure function of the latest snapshot.
|
||||
*
|
||||
* request = {
|
||||
* contextId: string, // e.g. 'employer.overview'
|
||||
* capability: string | null, // capability id, or null for free text
|
||||
* question: string, // what the user typed, or the chip's prompt
|
||||
* facts: object, // dashboard fact sheet (see insights.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*/
|
||||
|
||||
import { getContext } from './contexts';
|
||||
import { note, toSnapshots } from './blocks';
|
||||
|
||||
/**
|
||||
* An answer, at the depth the agent asked for.
|
||||
*
|
||||
* Three modes, and only two of them do anything: `balanced` is today's
|
||||
* behaviour exactly, so every existing answer and every agent that declares no
|
||||
* reasoning is unchanged.
|
||||
*
|
||||
* `webSearch` is handled here too, and handled honestly. No search provider is
|
||||
* configured in this deployment, so an agent with the flag set gets a note
|
||||
* saying so rather than an answer that quietly came from nowhere. Silently
|
||||
* ignoring the flag would be worse: the configuration would read as working.
|
||||
*/
|
||||
function shapeForDepth(document, agent, facts) {
|
||||
if (!agent || !document?.blocks?.length) return document;
|
||||
|
||||
const blocks = [...document.blocks];
|
||||
|
||||
if (agent.reasoning === 'fast') {
|
||||
/* The headline and the first supporting block. Enough to answer, without
|
||||
the breakdown someone asking a quick question did not want. */
|
||||
const trimmed = blocks.slice(0, 2);
|
||||
return { ...document, blocks: trimmed.length ? trimmed : blocks };
|
||||
}
|
||||
|
||||
if (agent.reasoning === 'deep') {
|
||||
const counted = [
|
||||
facts?.applications?.length != null && `${facts.applications.length} applications`,
|
||||
facts?.postings?.length != null && `${facts.postings.length} positions`,
|
||||
facts?.staff?.length != null && `${facts.staff.length} hires`,
|
||||
].filter(Boolean);
|
||||
if (counted.length) {
|
||||
blocks.push(note(`Read from ${counted.join(', ')} on this page.`));
|
||||
}
|
||||
}
|
||||
|
||||
if (agent.webSearch) {
|
||||
blocks.push(note(
|
||||
'Web search is enabled on this agent, but no search provider is configured '
|
||||
+ 'in this deployment, so nothing outside this workspace was consulted.'
|
||||
));
|
||||
}
|
||||
|
||||
return { ...document, blocks };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Local provider — answers computed from the dashboard's own data.
|
||||
*
|
||||
* Deterministic: the same question against the same data returns the same
|
||||
* answer, which is what makes the assistant demonstrable and testable.
|
||||
*/
|
||||
export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
|
||||
return {
|
||||
id: 'local',
|
||||
|
||||
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
|
||||
const context = getContext(contextId);
|
||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
||||
|
||||
const document = capability
|
||||
? context.capabilities.find((c) => c.id === capability)?.run(facts)
|
||||
?? context.respond(question, facts)
|
||||
: context.respond(question, facts);
|
||||
|
||||
/**
|
||||
* The agent's reasoning mode, as answer depth.
|
||||
*
|
||||
* A real configuration effect rather than a label: `fast` returns the
|
||||
* headline and stops, `deep` says which records the reading counted.
|
||||
* `balanced` — the default, and what an agent that declares nothing gets
|
||||
* — is untouched, so this cannot change any existing answer.
|
||||
*
|
||||
* Depth never changes *what* was read. The page decided that before the
|
||||
* provider was called; this only decides how much of it to say.
|
||||
*/
|
||||
const shaped = shapeForDepth(document, agent, facts);
|
||||
|
||||
// A brief pause before the first frame, so the answer reads as considered
|
||||
// rather than precomputed.
|
||||
await sleep(latency);
|
||||
if (signal?.aborted) return;
|
||||
|
||||
for (const snapshot of toSnapshots(shaped)) {
|
||||
if (signal?.aborted) return;
|
||||
yield snapshot;
|
||||
await sleep(frameDelay);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP provider — the path to a real backend.
|
||||
*
|
||||
* Reads Server-Sent-Event style `data:` lines and yields a growing block array.
|
||||
* A line may carry a whole block (`{ block: … }`) or a text delta
|
||||
* (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
|
||||
* it exists so the shape of the integration is settled rather than guessed at.
|
||||
*
|
||||
* The request body sends `contextId`, `capability` and `question` — not the fact
|
||||
* sheet. Dashboard data should be read server-side from the caller's own session
|
||||
* rather than posted from the browser, so the client cannot ask about records it
|
||||
* is not entitled to see.
|
||||
*/
|
||||
export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
|
||||
|
||||
return {
|
||||
id: 'http',
|
||||
|
||||
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
/* `agent` and `owliverContext` travel; the fact sheet still does not.
|
||||
Dashboard records are read server-side from the caller's own session,
|
||||
so the client cannot ask about records it is not entitled to see —
|
||||
and an agent cannot widen that by being named in the body. */
|
||||
body: JSON.stringify({ contextId, capability, question, agent, owliverContext }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Assistant request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const blocks = [];
|
||||
let buffer = '';
|
||||
|
||||
const appendDelta = (delta) => {
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (last?.type === 'text') last.text += delta;
|
||||
else blocks.push({ type: 'text', text: delta });
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// The final element may be a partial line; hold it for the next read.
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (parsed.block) blocks.push(parsed.block);
|
||||
else if (parsed.delta) appendDelta(parsed.delta);
|
||||
} catch {
|
||||
appendDelta(payload);
|
||||
}
|
||||
yield [...blocks];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses. Local by default; point
|
||||
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
|
||||
* code change.
|
||||
*/
|
||||
export function createAssistantProvider() {
|
||||
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
||||
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
|
||||
}
|
||||
@@ -1,930 +0,0 @@
|
||||
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
import { owliverCapabilityLabel } from '@/lib/skills/surfaces';
|
||||
import {
|
||||
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
|
||||
responseTitle, summaryLines,
|
||||
} from '@/lib/skills/owliverResolver';
|
||||
import {
|
||||
draftActions, draftContinuedReply, explainWeightsReply, matchDraftIntent, noDraftReply,
|
||||
resolveDraft, weightsFromQuestion, weightsReply, weightsUnchangedReply, whichDraftReply,
|
||||
} from '@/lib/skills/draftFlow';
|
||||
import {
|
||||
appliedToday, askHeadcount, askWhichPosition, assignmentPreview, assignmentToExecute,
|
||||
availability, candidateDetail, candidateMatches, candidateRoute, headcountFrom,
|
||||
interviewReady, matchWorkforceIntent,
|
||||
namedAssignmentPreview,
|
||||
interviewPreview, interviewToExecute,
|
||||
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import {
|
||||
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
||||
extractSkillName, findCourseByName,
|
||||
} from '@/lib/skills/actions';
|
||||
import { beginPositionFlow } from '@/lib/skills/positionFlow';
|
||||
|
||||
/**
|
||||
* Intent routing — deciding whether a question belongs to the page you are on.
|
||||
*
|
||||
* Every page responder ends in a catch-all, which is right for a question about
|
||||
* that page and wrong for anything else: asking "what are my permissions?" on
|
||||
* Talent Pool used to return the talent priority table, confidently and
|
||||
* irrelevantly. Two things were missing — a way to notice the question is about
|
||||
* a different page, and a way to say "I cannot answer that here" instead of
|
||||
* answering something else.
|
||||
*
|
||||
* This module supplies both, ahead of the responders:
|
||||
*
|
||||
* 1. If the question clearly belongs to another Admin page, say so and hand
|
||||
* back that page's route so the panel can navigate. Context follows the
|
||||
* route, so the next question is answered by the page you land on.
|
||||
* 2. If it belongs to no page in particular and does not match anything this
|
||||
* page can answer, say that plainly and offer what this page *does* cover.
|
||||
*
|
||||
* Routes come from the placement table, so nothing here invents an address.
|
||||
*
|
||||
* Keyword matching, like the responders themselves: this is a demo provider
|
||||
* computing answers from local data, and the routing has to be as deterministic
|
||||
* and inspectable as the answers are.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Topics per destination. `route` is the real Admin path; `contextId` is the
|
||||
* assistant context that page carries.
|
||||
*
|
||||
* `terms` are the phrases that mean "this is a question about that page", chosen
|
||||
* to be unambiguous — generic words like "score" or "hiring" appear on every
|
||||
* page and would send the reader somewhere for no reason.
|
||||
*/
|
||||
export const DESTINATIONS = [
|
||||
{
|
||||
contextId: 'admin.profile',
|
||||
route: '/admin/profile',
|
||||
page: 'Profile',
|
||||
terms: [
|
||||
'my permission', 'my permissions', 'permission', 'my role', 'my account',
|
||||
'my profile', 'change my name', 'edit my profile', 'edit profile',
|
||||
'password', 'two-factor', 'two factor', '2fa', 'sign out', 'log out',
|
||||
'my session', 'preference', 'email digest', 'compact density',
|
||||
],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.talentPool',
|
||||
route: '/admin/talent-pool',
|
||||
page: 'Talent Pool',
|
||||
terms: ['talent pool', 'talent directory', 'talent segment', 'worker profile', 'career score'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.positions',
|
||||
route: '/admin/positions',
|
||||
page: 'Positions',
|
||||
terms: ['position', 'open role', 'open roles', 'job posting', 'requisition', 'vacancy'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.candidatesList',
|
||||
route: '/admin/candidates',
|
||||
page: 'Candidates',
|
||||
terms: ['candidate', 'applicant', 'shortlist', 'screening queue'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.hiredHistory',
|
||||
route: '/admin/hired',
|
||||
page: 'Hired History',
|
||||
terms: ['hired history', 'recent hire', 'recent hires', 'who did we hire', 'hiring history', 'department breakdown'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.analytics',
|
||||
route: '/admin/analytics',
|
||||
page: 'Analytics',
|
||||
terms: ['analytics', 'hiring trend', 'report', 'time to hire', 'conversion rate', 'average score'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.forge',
|
||||
route: '/admin/university',
|
||||
page: 'KROW Forge',
|
||||
terms: ['forge', 'challenge', 'course', 'skill', 'badge', 'certification', 'learning'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.activity',
|
||||
route: '/admin/activity',
|
||||
page: 'Activity',
|
||||
terms: ['activity log', 'audit', 'audit log', 'event log', 'who did what'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.controlCenter',
|
||||
route: '/admin',
|
||||
page: 'Control Center',
|
||||
terms: ['control center', 'control centre', 'platform health', 'dashboard'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The destination a question points at, or `null` for "the page you are on".
|
||||
*
|
||||
* Longest match wins, so "talent pool" beats "pool" and a question naming two
|
||||
* areas resolves to the more specific one rather than to whichever was listed
|
||||
* first.
|
||||
*/
|
||||
export function findDestination(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
let best = null;
|
||||
for (const destination of DESTINATIONS) {
|
||||
for (const term of destination.terms) {
|
||||
if (q.includes(term) && (!best || term.length > best.term.length)) {
|
||||
best = { ...destination, term };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this page's own responder recognise the question?
|
||||
*
|
||||
* The responders' keyword lists are the definition of "answerable here", but
|
||||
* they are private to each responder. Rather than duplicate them, a context can
|
||||
* declare `topics` — the words its answers are actually about. A context without
|
||||
* `topics` keeps the old behaviour of always answering, so this is additive.
|
||||
*/
|
||||
export function answerableHere(context, question) {
|
||||
if (!context?.topics?.length) return true;
|
||||
const q = String(question).toLowerCase();
|
||||
return context.topics.some((topic) => q.includes(topic));
|
||||
}
|
||||
|
||||
/** The short reply that accompanies a navigation. */
|
||||
export function navigationAnswer(destination) {
|
||||
return doc(
|
||||
text(`That is on **${destination.page}**. Taking you there now.`),
|
||||
note('Ask again once the page loads and I will answer from it.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply when the question fits neither this page nor another one.
|
||||
*
|
||||
* Lists what this page can answer, taken from the capability labels the panel
|
||||
* already shows as chips — so the offer is always exactly what is on screen.
|
||||
*/
|
||||
export function outOfScopeAnswer(context) {
|
||||
const labels = (context?.capabilities || []).map((c) => c.label);
|
||||
return doc(
|
||||
text(`I do not have that on **${context?.page || 'this page'}**.`),
|
||||
list(labels.slice(0, 6), { ordered: false }),
|
||||
note('Ask about one of those, or open the page the question belongs to.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply when the selected agent does not cover this page.
|
||||
*
|
||||
* Says three things, because leaving any of them out invites the reader to
|
||||
* assume something untrue: which agent is active, that the *page* is what
|
||||
* bounds the answer rather than the agent being broken, and which agent is the
|
||||
* one for here.
|
||||
*
|
||||
* It deliberately does not answer from the requested agent's own pages. An
|
||||
* agent is a lens on the page you are standing on, never a way to reach another
|
||||
* one — reaching would make selecting an agent a way around the page boundary,
|
||||
* which is the one thing the design does not permit.
|
||||
*/
|
||||
export function constrainedAgentAnswer(agent, context, suggestion) {
|
||||
const covered = (agent?.pages || []).join(', ');
|
||||
return doc(
|
||||
text(`**${agent?.name || 'That agent'}** is selected, but it does not cover **${context?.page || 'this page'}**.`),
|
||||
note(covered
|
||||
? `It works on: ${covered}.`
|
||||
: 'It covers no pages yet.'),
|
||||
text(suggestion
|
||||
? `On this page, **${suggestion.name}** is the one that answers. Switch to it, or open a page ${agent?.name || 'this agent'} covers.`
|
||||
: 'Open a page it covers, or ask something this page can answer.'),
|
||||
note('An agent narrows what can be asked here. It never widens it, so the records this page holds are the records any agent can read from it.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a question to an action, ahead of the page responder.
|
||||
*
|
||||
* Returns one of:
|
||||
* { kind: 'answer' } — let this page respond
|
||||
* { kind: 'navigate', destination, doc } — other page; go there
|
||||
* { kind: 'outOfScope', doc } — nothing here can answer it
|
||||
* { kind: 'constrained', doc } — the agent does not cover this page
|
||||
*/
|
||||
/* ── Workforce ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The pages that carry enough context to answer a workforce question. */
|
||||
const WORKFORCE_CONTEXTS = new Set(['admin.positions']);
|
||||
|
||||
/**
|
||||
* A workforce question, resolved against the engine.
|
||||
*
|
||||
* Reading questions return their answer directly. The two that lead to a write
|
||||
* are shaped differently on purpose:
|
||||
*
|
||||
* `assign` builds a preview and returns it as a normal answer, with a
|
||||
* confirmation offered as a follow-up. Nothing is written.
|
||||
* `confirm_assign` re-derives the plan from live data and hands it back as
|
||||
* `{ kind: 'workforce', assign }` for `useAssistant` to
|
||||
* execute through the existing mutation — the same shape the
|
||||
* position-creation flow already uses.
|
||||
*
|
||||
* A question naming no position, on a page listing many, asks which rather than
|
||||
* choosing one.
|
||||
*/
|
||||
function resolveWorkforce(question, workforce) {
|
||||
const intent = matchWorkforceIntent(question);
|
||||
if (!intent) return null;
|
||||
|
||||
const { positions = [], context = {}, currentPositionId = null } = workforce;
|
||||
const position = resolvePosition(question, positions, currentPositionId);
|
||||
|
||||
/* Board-wide questions answer without a position. */
|
||||
if (intent === 'priority') {
|
||||
return { kind: 'answer-doc', ...positionPriority(positions, context) };
|
||||
}
|
||||
if (intent === 'applied_today') {
|
||||
return { kind: 'answer-doc', ...appliedToday(position, context, positions) };
|
||||
}
|
||||
if (intent === 'interview_ready') {
|
||||
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
|
||||
}
|
||||
|
||||
/* Everything below is about one role, and guessing which is not acceptable. */
|
||||
if (!position) return { kind: 'answer-doc', ...askWhichPosition(positions) };
|
||||
|
||||
if (intent === 'matches') return { kind: 'answer-doc', ...candidateMatches(position, context) };
|
||||
if (intent === 'availability') return { kind: 'answer-doc', ...availability(position, context) };
|
||||
|
||||
/**
|
||||
* Inspecting, explaining and opening a record are all about one person, so
|
||||
* they resolve the name first and say so plainly when they cannot.
|
||||
*/
|
||||
if (intent === 'candidate_detail' || intent === 'eligibility' || intent === 'open_profile') {
|
||||
const found = resolveCandidate(question, poolFor(position, context));
|
||||
|
||||
if (found.ambiguous) {
|
||||
return {
|
||||
kind: 'answer-doc',
|
||||
doc: doc(text('More than one person matches that name.'), list(found.ambiguous.map((r) => r.name))),
|
||||
followUp: found.ambiguous.slice(0, 4).map((r) => ({
|
||||
label: r.name, prompt: `Show ${r.name} for ${position.title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (!found.row) {
|
||||
return { kind: 'answer-doc', ...candidateMatches(position, context) };
|
||||
}
|
||||
|
||||
/* The one case that deliberately leaves the panel — and only because the
|
||||
admin asked for the full record by name. */
|
||||
if (intent === 'open_profile') {
|
||||
const to = candidateRoute(found.row, context.applications || [], position);
|
||||
return {
|
||||
kind: 'answer-doc',
|
||||
doc: to
|
||||
? doc(
|
||||
text(`Opening the full record for **${found.row.name}**.`),
|
||||
insights([{ tone: 'info', title: `${found.row.name} — full profile`, body: 'Match analysis, reputation, experience, endorsements and activity.', to }])
|
||||
)
|
||||
: doc(
|
||||
text(`**${found.row.name}** has no application on file, so there is no candidate record to open.`),
|
||||
note('Assigning them creates one, and the full profile becomes available after that.')
|
||||
),
|
||||
followUp: [{ label: '← Back to candidate', prompt: `Show ${found.row.name} for ${position.title}` }],
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'answer-doc', ...candidateDetail(position, found.row, context) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A named person is a different request from "the best available": the admin
|
||||
* has already chosen, and the job is to validate that choice rather than to
|
||||
* rank. Both preview and confirmation check for a name first, so
|
||||
* "assign Maria" and "confirm assignment of Maria" stay one conversation.
|
||||
*/
|
||||
const named = (intent === 'assign' || intent === 'confirm_assign')
|
||||
? resolveCandidate(question, poolFor(position, context))
|
||||
: {};
|
||||
|
||||
if (named.ambiguous) {
|
||||
return {
|
||||
kind: 'answer-doc',
|
||||
doc: doc(
|
||||
text('More than one person matches that name.'),
|
||||
list(named.ambiguous.map((r) => r.name)),
|
||||
note('Give me the full name and I will check them against this role.')
|
||||
),
|
||||
followUp: named.ambiguous.slice(0, 4).map((r) => ({
|
||||
label: r.name,
|
||||
prompt: `Assign ${r.name} to ${position.title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === 'assign') {
|
||||
return named.row
|
||||
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
|
||||
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to "how many people do you need?".
|
||||
*
|
||||
* The only write in the workforce path that is not an assignment, and it
|
||||
* follows the same contract: the routing layer decides *what* should happen
|
||||
* and hands it back, and `useAssistant` performs it through the mutation the
|
||||
* Create Position form already uses. A sentence with no number in it is not a
|
||||
* failure — it is the question being asked again.
|
||||
*/
|
||||
if (intent === 'set_headcount') {
|
||||
const count = headcountFrom(question);
|
||||
return count
|
||||
? { kind: 'workforce', headcount: { position, count } }
|
||||
: { kind: 'answer-doc', ...askHeadcount(position, context) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interview setup, for one named person against this role. Both steps resolve
|
||||
* the name the same way assignment does, so the conversation stays on the same
|
||||
* candidate without the admin repeating themselves.
|
||||
*/
|
||||
if (intent === 'interview_setup' || intent === 'confirm_interview') {
|
||||
const found = resolveCandidate(question, poolFor(position, context));
|
||||
if (!found.row) {
|
||||
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
|
||||
}
|
||||
|
||||
if (intent === 'interview_setup') {
|
||||
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
|
||||
}
|
||||
|
||||
const plan = interviewToExecute(position, found.row, context);
|
||||
if (!plan) {
|
||||
/* Re-checked at confirmation: if it can no longer proceed, say why rather
|
||||
than reporting a step that did not happen. */
|
||||
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
|
||||
}
|
||||
return { kind: 'workforce', interview: plan };
|
||||
}
|
||||
|
||||
if (intent === 'confirm_assign') {
|
||||
/* Re-derived from live data at the moment of confirmation, so the people
|
||||
written are the people who are still eligible. */
|
||||
const plan = named.row
|
||||
? namedAssignmentToExecute(position, named.row, context)
|
||||
: assignmentToExecute(position, context);
|
||||
|
||||
if (!plan) {
|
||||
/* The preview no longer holds — say so rather than writing something
|
||||
different from what was agreed to. */
|
||||
return named.row
|
||||
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
|
||||
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
|
||||
}
|
||||
return { kind: 'workforce', assign: plan };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Forge authoring ────────────────────────────────────────────────────── */
|
||||
|
||||
/** The five steps the Forge authoring flow actually walks through. */
|
||||
const FORGE_STEPS = [
|
||||
'Define the skill — name, category, difficulty, description',
|
||||
'Build training — the steps the workforce learns',
|
||||
'Define the proof — how they demonstrate it',
|
||||
'Configure Owliver — what the evaluation checks',
|
||||
'Review & publish — save a draft, or publish it',
|
||||
];
|
||||
|
||||
/**
|
||||
* The draft Owliver proposes for a new skill, read back to the author.
|
||||
*
|
||||
* Nothing is opened here. The draft is a proposal, and "Review skill" is the
|
||||
* step where the author accepts it — which is what keeps Owliver on the same
|
||||
* side of the line it holds everywhere else: it prepares, the administrator
|
||||
* decides. There is no publish path from this reply at all.
|
||||
*
|
||||
* The follow-up carries the subject rather than a stored draft, so clicking it
|
||||
* goes back through `resolveIntent` exactly as typing the same sentence would.
|
||||
*/
|
||||
function proposeSkill(skill, question, skillCategories) {
|
||||
const { title, category, difficulty, outline, verification } = buildSkillPrefill(question, skillCategories);
|
||||
|
||||
if (!title) {
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
action: { name: 'open_create_skill_training', payload: { prefill: {} } },
|
||||
doc: doc(
|
||||
text('Opening Add Skill Training. Name the skill on the first step and I will take it from there.'),
|
||||
list(FORGE_STEPS, { ordered: true }),
|
||||
note('I do not create or publish skills on my own.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(`Understood. I can create a workforce skill for **${title}**.`),
|
||||
list([
|
||||
category && `Category — ${category}`,
|
||||
difficulty && `Level — ${difficulty}`,
|
||||
verification && `Verification — ${verification}`,
|
||||
]),
|
||||
outline.length ? text('**Suggested training**') : null,
|
||||
list(outline),
|
||||
note('These are suggestions to review, not decisions. Open the draft and nothing is saved until you choose Save draft or Publish.')
|
||||
),
|
||||
followUp: [{ label: 'Review skill', prompt: `Review the ${title} skill` }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The author accepted the draft — open the flow with it filled in.
|
||||
*
|
||||
* Derived from the subject again rather than from anything remembered between
|
||||
* turns, so what opens is what the previous reply described.
|
||||
*/
|
||||
function openSkillDraft(skill, question, skillCategories) {
|
||||
const { prefill, title } = buildSkillPrefill(question, skillCategories);
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
action: { name: 'open_create_skill_training', payload: { prefill } },
|
||||
doc: doc(
|
||||
text(`Opening the **${title}** draft in Add Skill Training.`),
|
||||
list(FORGE_STEPS, { ordered: true }),
|
||||
note('Nothing is saved until you choose Save draft or Publish. I do not publish skills on my own.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Training for a skill that already exists.
|
||||
*
|
||||
* The flow opens on its training step holding that record, so this edits the
|
||||
* skill rather than creating a second one beside it. A request that names no
|
||||
* skill asks which, offering the library's own titles — never a list written
|
||||
* here.
|
||||
*/
|
||||
function proposeTraining(skill, question, courses, skillCategories) {
|
||||
const course = findCourseByName(question, courses);
|
||||
|
||||
if (!course) {
|
||||
/* "Training for latte art" names a subject the library does not hold yet.
|
||||
That is a new skill, and its training is the second step of building one
|
||||
— so answer the request that was actually made rather than insisting on a
|
||||
record that does not exist. */
|
||||
if (extractSkillName(question)) return proposeSkill(skill, question, skillCategories);
|
||||
|
||||
const titles = courses.slice(0, 5).map((c) => c.title).filter(Boolean);
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text('Which skill should the training belong to?'),
|
||||
list(titles),
|
||||
note(
|
||||
titles.length
|
||||
? 'Name one and I will open its training for you to edit.'
|
||||
: 'The library is empty — create a skill first and its training comes with it.'
|
||||
)
|
||||
),
|
||||
followUp: titles.map((title) => ({
|
||||
label: title,
|
||||
prompt: `Create training for ${title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const { prefill, suggested } = buildTrainingPrefill(course);
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
action: { name: 'open_create_training', payload: { prefill, courseId: course.id } },
|
||||
doc: doc(
|
||||
text(`Opening the training for **${course.title}**.`),
|
||||
suggested.length ? text('**Suggested steps**') : null,
|
||||
list(suggested),
|
||||
note(
|
||||
suggested.length
|
||||
? 'These are suggestions to review. Edit them, then save — I do not change a published skill on my own.'
|
||||
: 'The steps this skill already carries are loaded. Edit them, then save.'
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Declared skills ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A skill answering from its own definition.
|
||||
*
|
||||
* The whole reply is composed from what the `.md` declared and what the shared
|
||||
* resolver read: the title is the definition's, the capability is the shape it
|
||||
* offers, the figures are the application's own records, and the follow-ups are
|
||||
* the other capabilities the same definition carries. Nothing in this function
|
||||
* knows which skill it is serving, and nothing may be added that does — the
|
||||
* moment a skill id appears here, the definition has stopped being the source
|
||||
* of truth.
|
||||
*/
|
||||
function declaredAnswer({ skill, capability, question, skillContext }) {
|
||||
const resolved = resolveOwliverResponse({ skill, capability, question, context: skillContext });
|
||||
if (!resolved) return null;
|
||||
|
||||
const { section, data, missing } = resolved;
|
||||
const title = responseTitle(skill, section);
|
||||
|
||||
/* Other shapes of the same reading, offered rather than explained. A chip
|
||||
per remaining capability, worded from the definition's own suggestions
|
||||
where it wrote one for that shape. */
|
||||
const followUp = skill.owliver.capabilities
|
||||
.filter((c) => c !== capability)
|
||||
.map((c) => {
|
||||
const written = skill.owliver.suggestions.find((s) => s.capability === c);
|
||||
return {
|
||||
label: written?.label || `${skill.name} as ${owliverCapabilityLabel(c).toLowerCase()}`,
|
||||
prompt: written?.prompt || `Show ${skill.name} as a ${c}`,
|
||||
};
|
||||
})
|
||||
.slice(0, 3);
|
||||
|
||||
/**
|
||||
* The reading needs a record the page has not supplied. Say which, and offer
|
||||
* the ones there are — an answer about the wrong position would be worse
|
||||
* than a question.
|
||||
*/
|
||||
if (missing === 'position') {
|
||||
const open = (skillContext.positions || []).filter((p) => p.status === 'active').slice(0, 6);
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(`**${title}** reads one position. Which should I read?`),
|
||||
list(open.map((p) => p.title)),
|
||||
note('Open a position, or name one and I will read it.')
|
||||
),
|
||||
followUp: open.slice(0, 4).map((p) => ({ label: p.title, prompt: `${question} for ${p.title}` })),
|
||||
};
|
||||
}
|
||||
if (missing === 'candidate') {
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(`**${title}** reads one candidate.`),
|
||||
note('Open a candidate, or name one and I will read their record.')
|
||||
),
|
||||
};
|
||||
}
|
||||
/* A reading with nothing in it says so, in the resolver's own words. No
|
||||
figure is invented to fill a shape. */
|
||||
if (data.unavailable || data.empty) {
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(heading(title), text(data.emptyNote || 'There is nothing to report yet.')),
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
|
||||
/* A summary is prose; every other capability is the section the page would
|
||||
draw, drawn here. */
|
||||
const body = capability === 'summary'
|
||||
? [
|
||||
list(summaryLines(data)),
|
||||
data.total != null ? note(`${data.total} record${data.total === 1 ? '' : 's'} in total.`) : null,
|
||||
]
|
||||
: [skillSection(section, presentable(data))];
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(heading(title, section.description || undefined), ...body),
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills that can act on this page, ahead of the page's own responder.
|
||||
*
|
||||
* A skill's triggers are specific ("create a position"); the responder is the
|
||||
* general reader of the page. Checking skills first is what lets "create a
|
||||
* bartender position" open a form instead of returning a pipeline report that
|
||||
* happens to match the word "position".
|
||||
*/
|
||||
function resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
skillContext = null,
|
||||
}) {
|
||||
/**
|
||||
* One question, one skill, then one way of answering it.
|
||||
*
|
||||
* This used to be two independent matchers run in sequence: declared skills
|
||||
* were resolved first and answered immediately, and only a question that
|
||||
* matched none of them reached the trigger matcher and the coded flows below.
|
||||
* That ordering was the bug. A declared definition could claim a question
|
||||
* belonging to a flow it knew nothing about — "create a position" was
|
||||
* answered by a candidate-matching skill — because the first matcher never
|
||||
* asked whether anything else had a stronger claim.
|
||||
*
|
||||
* So both matchers run, and *then* the question is dispatched:
|
||||
*
|
||||
* 1. A skill with a coded flow owns its own triggers. Create Position and
|
||||
* Forge authoring are conversations with state, not readings, and no
|
||||
* declaration may take one over.
|
||||
* 2. Otherwise a declared answer, if the definition earned the question.
|
||||
* 3. Otherwise the definition reads itself back.
|
||||
*
|
||||
* The property this gives is the one that matters: a skill added tomorrow
|
||||
* cannot capture a flow that already exists, whatever it declares.
|
||||
*/
|
||||
const declared = skillContext
|
||||
? matchOwliverSkill(
|
||||
question,
|
||||
owliverSkillsForContext(contextId, disabledSkills, customSkills)
|
||||
)
|
||||
: null;
|
||||
|
||||
const triggered = matchSkill(question, contextId, disabledSkills, customSkills);
|
||||
|
||||
/**
|
||||
* Which of the two named the question.
|
||||
*
|
||||
* A trigger is an explicit claim on a phrase, so it usually names the skill.
|
||||
* Two cases where it must not, both of which were live bugs:
|
||||
*
|
||||
* - **A verbatim suggestion outranks a trigger.** A definition that
|
||||
* published "Show hiring activity" and had the reader click it has the
|
||||
* strongest claim there is. A *different* definition whose trigger merely
|
||||
* contains those words — including the name every definition falls back
|
||||
* to when it declares no triggers — has a weaker one, and was winning.
|
||||
* - **An answer outranks a description.** A skill with no declared
|
||||
* capabilities can only read its own definition back. If it takes a
|
||||
* question another definition can actually answer, the reader gets a
|
||||
* restatement of the question instead of the figures.
|
||||
*
|
||||
* Neither rule names a skill: both compare what the two definitions declared.
|
||||
*/
|
||||
const canAnswer = (s) => Boolean(s?.owliver?.enabled && s.owliver.capabilities.length);
|
||||
/* A definition that collects answers in the chat or names an action to run is
|
||||
a flow, not a reading, and owns the phrases it declared — read off what it
|
||||
declares rather than from a list of ids, so a flow added later is protected
|
||||
by the same rule. */
|
||||
const ownsFlow = (s) => Boolean(s?.conversation?.length || s?.actions?.length);
|
||||
|
||||
const skill = (triggered && ownsFlow(triggered) && (!declared || !declared.exact))
|
||||
? triggered
|
||||
: declared && (declared.exact || !canAnswer(triggered))
|
||||
? declared.skill
|
||||
: triggered || declared?.skill || null;
|
||||
if (!skill) return null;
|
||||
|
||||
/* The declared answer is only offered for the skill that actually won. */
|
||||
const declaredForSkill = declared && declared.skill.id === skill.id ? declared : null;
|
||||
|
||||
/**
|
||||
* Forge authoring. Same contract as Create Position: what the sentence
|
||||
* answered is filled in, the rest is left blank, and the flow opens rather
|
||||
* than a skill being created. Owliver publishes nothing on its own.
|
||||
*
|
||||
* Three requests share one skill because they are one job — proposing a
|
||||
* skill, accepting that proposal, and adding training to a skill that already
|
||||
* exists. Which one this is comes from the sentence, in that order of
|
||||
* specificity: "review" is unambiguous, "training" names the step, and
|
||||
* anything else that got here is a new skill.
|
||||
*/
|
||||
if (skill.id === 'forge-skill-management') {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
if (extractReviewSubject(question)) return openSkillDraft(skill, question, skillCategories);
|
||||
|
||||
/* "a skill training" is the compound noun for a new skill; "training for
|
||||
<a skill the library has>" is the existing record. Naming a real skill
|
||||
settles it either way. */
|
||||
const named = findCourseByName(question, courses);
|
||||
const wantsTraining = /\btraining\b/.test(q) && (named || !/\bskill training\b/.test(q));
|
||||
|
||||
if (wantsTraining) return proposeTraining(skill, question, courses, skillCategories);
|
||||
return proposeSkill(skill, question, skillCategories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Position is collected in the conversation, not in a form.
|
||||
*
|
||||
* Nothing opens and nothing is navigated to: the skill's questions come back
|
||||
* as a reply and its answers as chips, and the position is written at the end
|
||||
* from what the conversation gathered. `flow` is the state that turn carries
|
||||
* forward — the panel keeps it and feeds the next answer back in.
|
||||
*/
|
||||
if (skill.id === 'create-position') {
|
||||
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* No coded flow owns this one, so the definition answers it.
|
||||
*
|
||||
* A declared `owliver:` block is the richer answer — a real reading, drawn in
|
||||
* the shape the author asked for — so it is tried first. A definition without
|
||||
* one still reads itself back, which is what makes a Markdown-only skill
|
||||
* useful on the page it names without a line of Owliver being changed for it.
|
||||
*/
|
||||
if (declaredForSkill) {
|
||||
const answer = declaredAnswer({ ...declaredForSkill, question, skillContext });
|
||||
if (answer) return answer;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(skill.description || `**${skill.name}** is available on this page.`),
|
||||
list(skill.capabilities.slice(0, 6)),
|
||||
note('Defined as an Owliver skill. Manage it in Settings → Skills.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft question, resolved against the position records the panel holds.
|
||||
*
|
||||
* Returns an intent the panel executes. The two that write — generating a
|
||||
* description and setting weights, plus publishing — carry a `perform` block
|
||||
* rather than performing anything here: this module stays pure, and the write
|
||||
* goes through the mutation the Create Position form already uses.
|
||||
*/
|
||||
function resolveDraftAction(question, workforce, positionId = null) {
|
||||
const action = matchDraftIntent(question);
|
||||
if (!action) return null;
|
||||
|
||||
const positions = workforce.positions || [];
|
||||
/* `positionId` is the record a control was built from — the Continue button on
|
||||
a card, or a chip this flow wrote. It settles the question before any
|
||||
guessing from the wording starts. */
|
||||
const { position, ambiguous } = resolveDraft(
|
||||
question, positions, workforce.currentPositionId, positionId
|
||||
);
|
||||
|
||||
if (ambiguous) return { kind: 'draft', ...whichDraftReply(ambiguous) };
|
||||
|
||||
/**
|
||||
* Nothing resolved. Only a question explicitly about drafts is answered with
|
||||
* "there are none" — everything else is handed back.
|
||||
*
|
||||
* "Summarize these vetting weights" on the Create Position form is a question
|
||||
* about the form, and the page has answered it since long before drafts could
|
||||
* be continued here. Claiming it because it contains the word "weights" and
|
||||
* then reporting that no draft exists would be this flow taking a question it
|
||||
* cannot answer.
|
||||
*/
|
||||
if (!position) {
|
||||
return action === 'continue' || action === 'list'
|
||||
? { kind: 'draft', doc: noDraftReply() }
|
||||
: null;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
/* One draft is continued; several are offered by name. Either way the
|
||||
answer is here, and neither opens a form. */
|
||||
case 'list':
|
||||
return positions.filter((p) => p.status === 'draft').length > 1
|
||||
? { kind: 'draft', ...whichDraftReply(positions.filter((p) => p.status === 'draft')) }
|
||||
: {
|
||||
kind: 'draft',
|
||||
doc: draftContinuedReply(position),
|
||||
followUp: draftActions(position),
|
||||
};
|
||||
|
||||
case 'continue':
|
||||
return {
|
||||
kind: 'draft',
|
||||
doc: draftContinuedReply(position),
|
||||
/* Never "continue" again — the reply *is* the continuation. */
|
||||
followUp: draftActions(position),
|
||||
};
|
||||
|
||||
case 'show_weights':
|
||||
return { kind: 'draft', doc: weightsReply(position), followUp: draftActions(position) };
|
||||
|
||||
case 'explain_weights':
|
||||
return { kind: 'draft', doc: explainWeightsReply(position), followUp: draftActions(position) };
|
||||
|
||||
case 'set_weights': {
|
||||
const next = weightsFromQuestion(question, position);
|
||||
if (!next) {
|
||||
return { kind: 'draft', doc: weightsUnchangedReply(position), followUp: draftActions(position) };
|
||||
}
|
||||
return { kind: 'draft', perform: { action: 'set_weights', position, weights: next } };
|
||||
}
|
||||
|
||||
case 'generate_description':
|
||||
return { kind: 'draft', perform: { action: 'generate_description', position } };
|
||||
|
||||
case 'publish':
|
||||
return position.status === 'draft'
|
||||
? { kind: 'draft', perform: { action: 'publish', position } }
|
||||
: {
|
||||
kind: 'draft',
|
||||
doc: doc(text(`**${position.title}** is already published.`)),
|
||||
followUp: draftActions(position),
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [], workforce = null, skillContext = null,
|
||||
/**
|
||||
* The active agent, and where the reader is.
|
||||
*
|
||||
* Both optional and both inert when absent, so a caller that knows nothing
|
||||
* about agents resolves exactly as it always did. `agent` does not gate the
|
||||
* skills considered here — that has already happened, in `disabledSkills`,
|
||||
* which arrives carrying the agent's scoping. What it decides is whether this
|
||||
* agent should be answering on this page at all.
|
||||
*/
|
||||
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
|
||||
/* The record the control that raised this question was built from, when there
|
||||
was one. Only the draft flow reads it; a typed question carries none and
|
||||
resolves exactly as it always did. */
|
||||
positionId = null,
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
/**
|
||||
* 0. The selected agent does not belong here.
|
||||
*
|
||||
* Ahead of everything, because every matcher below would otherwise answer
|
||||
* from this page while the header names an agent that does not cover it — an
|
||||
* answer attributed to the wrong lens. The page still decides what is
|
||||
* readable; this only declines to pretend the agent chose it.
|
||||
*/
|
||||
if (agent && !agentCoversPage) {
|
||||
return { kind: 'constrained', agent, doc: constrainedAgentAnswer(agent, context, agentSuggestion) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Finishing a draft, wherever the reader is standing.
|
||||
*
|
||||
* Ahead of everything else because these questions name a record and an act
|
||||
* on it — continuing, generating, weighting, publishing — and every other
|
||||
* matcher would read them as words about positions in general. It runs only
|
||||
* where the panel actually has the positions to act on.
|
||||
*/
|
||||
/* Resolved even where the panel carries no workforce: a question about
|
||||
continuing a draft is answered in the panel or answered as "there is no
|
||||
draft", never handed to keyword routing — which is what used to send
|
||||
"Continue the Bartender draft" into the authoring form, because Create
|
||||
Position lists `position` and `role` among its topics. */
|
||||
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
||||
if (draftIntent) return draftIntent;
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
/* The envelope travels beside the collections rather than replacing them:
|
||||
a resolver reads records, and the envelope says where the reader is. A
|
||||
source that needs a position still finds it exactly where it always was. */
|
||||
skillContext: owliverContext ? { ...skillContext, owliver: owliverContext } : skillContext,
|
||||
});
|
||||
if (skill) return skill;
|
||||
|
||||
/**
|
||||
* 3. Workforce questions, on the pages that hold workforce context.
|
||||
*
|
||||
* Ahead of the page responder because these are the same words it answers
|
||||
* with analytics — "which position needs people" would otherwise return a
|
||||
* pipeline report rather than the ranked answer the engine can give.
|
||||
*/
|
||||
const workforceIntent = workforce && WORKFORCE_CONTEXTS.has(contextId)
|
||||
? resolveWorkforce(question, workforce)
|
||||
: null;
|
||||
if (workforceIntent) return workforceIntent;
|
||||
|
||||
/* The page you are on wins. Several subjects live on more than one page —
|
||||
departments are on both Analytics and Hired History — and moving someone
|
||||
off a page that can already answer them is worse than answering. Routing is
|
||||
for questions this page genuinely cannot take. */
|
||||
if (answerableHere(context, question)) return { kind: 'answer' };
|
||||
|
||||
const destination = findDestination(question);
|
||||
if (destination && destination.contextId !== contextId) {
|
||||
return { kind: 'navigate', destination, doc: navigationAnswer(destination) };
|
||||
}
|
||||
|
||||
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
|
||||
}
|
||||
@@ -1,812 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import {
|
||||
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||
} from '@/lib/skills/draftFlow';
|
||||
import {
|
||||
assignmentDone, assignmentFailed, assignmentFollowUp, headcountFailed, headcountSet,
|
||||
interviewDone, interviewFailed,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import {
|
||||
newConversationId, readHistory, recordFeedback, removeConversation, saveConversation,
|
||||
} from './history';
|
||||
import { storableContext } from '@/lib/agents/context';
|
||||
import { buildFacts } from './insights';
|
||||
import { agentRequest } from '@/lib/agents/runtime';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
import { toSnapshots } from './blocks';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
|
||||
/**
|
||||
* Reads the same React Query caches the pages render from, so the assistant
|
||||
* costs no extra requests and cannot be looking at a different snapshot than the
|
||||
* card beside it.
|
||||
*/
|
||||
export function useAssistantFacts() {
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const { data: staff = [] } = useStaff();
|
||||
const { data: profiles = [] } = useWorkerProfiles();
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
const { data: courses = [] } = useCourses();
|
||||
|
||||
// The signed-in worker's own record, read from cache only: `enabled: false`
|
||||
// means this never runs the query, which would *create* a WorkerProfile for an
|
||||
// admin who has never opened Forge. So the Forge context is personal on pages
|
||||
// that already loaded it, and falls back to library-level facts elsewhere
|
||||
// rather than manufacturing a record as a side effect of opening a panel.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: false });
|
||||
|
||||
/* The signed-in account, from the shared `['user']` cache the header and the
|
||||
Profile page already read. */
|
||||
const { data: user = null } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => base44.auth.me().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile, user]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user's first name, for personalizing the greeting.
|
||||
*
|
||||
* Shares the `['user']` query key with the rest of the app, so this is a cache
|
||||
* read rather than another request.
|
||||
*/
|
||||
export function useCurrentUserName() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => base44.auth.me().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
return data?.full_name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a locally built document through the same snapshot pipeline the
|
||||
* provider uses, so a routing reply arrives exactly like any other answer
|
||||
* rather than appearing instantly and reading as a different system.
|
||||
*/
|
||||
async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
const snapshots = toSnapshots(document);
|
||||
const complete = snapshots[snapshots.length - 1] || [];
|
||||
|
||||
let latest = [];
|
||||
let interrupted = false;
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
if (signal?.aborted) { interrupted = true; break; }
|
||||
latest = snapshot;
|
||||
onFrame(snapshot);
|
||||
await new Promise((resolve) => setTimeout(resolve, 26));
|
||||
}
|
||||
|
||||
/**
|
||||
* An interrupted local document still settles complete.
|
||||
*
|
||||
* These documents are already fully computed before the first frame — the
|
||||
* reveal is presentation, not generation. So an interruption should not leave
|
||||
* a half-written sentence in the thread the way stopping a real generation
|
||||
* would: there is nothing partial about the answer, only about the animation.
|
||||
*
|
||||
* This matters most after a write. The mutation refreshes every query it
|
||||
* touched, the panel re-renders on the new data, and the reveal can be cut
|
||||
* short — leaving the confirmation of something that definitely happened
|
||||
* truncated mid-word.
|
||||
*/
|
||||
if (interrupted) onDone(complete);
|
||||
else if (latest.length) onDone(latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Create Position form, as an address.
|
||||
*
|
||||
* Named once because two things have to agree about it: nothing stored may
|
||||
* offer a way back into the authoring form after a position has been saved.
|
||||
*/
|
||||
const AUTHORING_ROUTE = '/admin/positions/new';
|
||||
|
||||
/**
|
||||
* The one wording every draft-continuation control resolves to.
|
||||
*
|
||||
* Named once because two things have to agree about it: the chip a suggestion
|
||||
* writes today, and the chip a thread wrote months ago. Both must reach the
|
||||
* same in-panel handler, so both are normalized to this phrasing rather than
|
||||
* one being special-cased.
|
||||
*/
|
||||
const CONTINUE_DRAFT = /^continue the (.+) draft$/i;
|
||||
|
||||
/** The wording older builds used for the drafts question. */
|
||||
const OLD_DRAFTS_PROMPT = /which positions are still in draft/i;
|
||||
|
||||
/**
|
||||
* A stored chip, brought up to the current draft behavior.
|
||||
*
|
||||
* Threads persist as the blocks and chips they rendered, so a conversation
|
||||
* written before draft continuation moved into the panel still carries the
|
||||
* controls of that era: a `capability` that runs the read-only drafts report,
|
||||
* or a prompt phrased so loosely that keyword routing sent it to the authoring
|
||||
* form. Rewriting them in code was not enough — the ones already written down
|
||||
* had to change behavior too.
|
||||
*
|
||||
* Deliberately narrow. Only chips that are *about continuing a draft* are
|
||||
* touched, and only their routing metadata: the label the reader saw is left
|
||||
* exactly as it was, so a thread reads as it always did and simply does the
|
||||
* right thing when clicked.
|
||||
*/
|
||||
function normalizeDraftChip(chip) {
|
||||
if (!chip) return chip;
|
||||
|
||||
const label = String(chip.label || '');
|
||||
const prompt = String(chip.prompt || '');
|
||||
|
||||
/* "Continue the Bartender draft", however it was stored: the label is the
|
||||
question, and no capability or route may intercept it. */
|
||||
if (CONTINUE_DRAFT.test(label) || CONTINUE_DRAFT.test(prompt)) {
|
||||
const { capability: _capability, route: _route, ...rest } = chip;
|
||||
return { ...rest, prompt: CONTINUE_DRAFT.test(label) ? label : prompt };
|
||||
}
|
||||
|
||||
/* The old drafts question ran a capability that could only describe drafts
|
||||
and told the reader to open the form. The same words now resolve to the
|
||||
draft flow, which continues one or offers the several by name. */
|
||||
if (OLD_DRAFTS_PROMPT.test(prompt) || chip.capability === 'position-drafts') {
|
||||
const { capability: _capability, route: _route, ...rest } = chip;
|
||||
return { ...rest, prompt: prompt || 'Which positions are still in draft?' };
|
||||
}
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored thread, with any completed-then-reopen action stripped out.
|
||||
*
|
||||
* A reply is persisted as the blocks it rendered, so a thread written before
|
||||
* this was fixed still carries the old "Continue to save" — a live control
|
||||
* routing back into Create Position, offered on a position that was saved long
|
||||
* ago. Removing it in code was not enough; the ones already written down had to
|
||||
* stop working too.
|
||||
*
|
||||
* Deliberately narrow: only actions pointing at the authoring form are dropped,
|
||||
* and only from stored threads. Every other block, chip and link is left as it
|
||||
* was written, so a thread reads exactly as it did apart from the one control
|
||||
* that should never have been there.
|
||||
*/
|
||||
function withoutAuthoringActions(messages = []) {
|
||||
const leadsToForm = (target) => String(target || '').includes(AUTHORING_ROUTE);
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.role !== 'assistant') return message;
|
||||
|
||||
const followUp = message.followUp
|
||||
?.filter((chip) => !leadsToForm(chip.route))
|
||||
.map(normalizeDraftChip);
|
||||
const blocks = message.blocks?.map((block) => (
|
||||
block?.type === 'insights'
|
||||
? {
|
||||
...block,
|
||||
items: block.items.map(({ action, ...item }) => (
|
||||
leadsToForm(action?.to) ? item : { ...item, ...(action ? { action } : null) }
|
||||
)),
|
||||
}
|
||||
: block
|
||||
));
|
||||
|
||||
return { ...message, ...(followUp ? { followUp } : null), ...(blocks ? { blocks } : null) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation state for one page context.
|
||||
*
|
||||
* Owns the thread, the in-flight response, and abort handling. The thread is
|
||||
* kept per context in sessionStorage so navigating away from Candidates and back
|
||||
* does not silently discard the conversation — while a stale thread from last
|
||||
* week is still not resurrected.
|
||||
*
|
||||
* `onNavigate` is called when a question belongs to another Admin page. Both
|
||||
* entry points — a suggestion chip and a typed question — go through `send`, so
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
itself and there is one update path for a position. */
|
||||
onUpdatePosition, onGenerateDescription,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||
/**
|
||||
* The active agent and where the reader is.
|
||||
*
|
||||
* Both optional. `disabledSkills` already arrives carrying the agent's
|
||||
* scoping — the caller applies `agentScopedDisabled` before handing it over —
|
||||
* so nothing here decides what may be read. These are for attribution: which
|
||||
* agent answered, and whether it belongs on this page.
|
||||
*/
|
||||
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
|
||||
/* The page's own name, recorded with an archived thread so History can say
|
||||
where a conversation happened without resolving the context again. */
|
||||
pageLabel = '',
|
||||
}) {
|
||||
/**
|
||||
* One thread per agent per page.
|
||||
*
|
||||
* Switching agent starts a new conversation rather than continuing the last
|
||||
* one under a different name: the answers in a thread were produced by a
|
||||
* particular agent's scope, and appending a differently-scoped reply to them
|
||||
* would make the thread a record of something that never happened.
|
||||
*
|
||||
* With no agent the key is exactly what it was, so an existing stored thread
|
||||
* is still found.
|
||||
*/
|
||||
const storageKey = agent?.id
|
||||
? `krow_assistant:${agent.id}:${contextId}`
|
||||
: `krow_assistant:${contextId}`;
|
||||
const flowKey = `${storageKey}:flow`;
|
||||
const idKey = `${storageKey}:id`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
/* The archive, held in state so opening History does not have to re-read
|
||||
storage on every render, and so a new turn moves its own thread to the top
|
||||
of the list while it is on screen. */
|
||||
const [history, setHistory] = React.useState(() => readHistory());
|
||||
|
||||
/**
|
||||
* Which conversation this is.
|
||||
*
|
||||
* The thread already persisted per page; what it lacked was an identity, so
|
||||
* every new conversation overwrote the last. This id is what an archived
|
||||
* record is keyed by — kept in sessionStorage beside the thread it belongs
|
||||
* to, so a reload continues the same conversation rather than forking it.
|
||||
*/
|
||||
const conversationRef = React.useRef(null);
|
||||
|
||||
/* A guided skill in progress — which question it is on and what it has
|
||||
collected. Kept beside the thread rather than inside it, and persisted for
|
||||
the same reason the thread is: leaving Positions and coming back should not
|
||||
silently abandon a position half-described. */
|
||||
const flowRef = React.useRef(null);
|
||||
|
||||
/**
|
||||
* What this conversation actually used.
|
||||
*
|
||||
* Accumulated as turns run, never predicted from what was available. A skill
|
||||
* lands here when it *answered*; a tool when `runAction` was asked to perform
|
||||
* it. That distinction is the whole value of the record — a list of what the
|
||||
* page offered would describe the registry, and the insight figures built on
|
||||
* it would describe the registry too.
|
||||
*
|
||||
* A ref rather than state: nothing re-renders when it changes, and it is read
|
||||
* only at the moment a thread is written.
|
||||
*/
|
||||
const usedRef = React.useRef({ skills: [], tools: [], knowledge: [] });
|
||||
|
||||
const noteUsed = React.useCallback((kind, id) => {
|
||||
if (!id) return;
|
||||
const bucket = usedRef.current[kind];
|
||||
if (bucket && !bucket.includes(id)) bucket.push(id);
|
||||
}, []);
|
||||
|
||||
const setFlow = React.useCallback((flow) => {
|
||||
flowRef.current = flow || null;
|
||||
try {
|
||||
if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow));
|
||||
else sessionStorage.removeItem(flowKey);
|
||||
} catch {
|
||||
// The flow stays in memory only.
|
||||
}
|
||||
}, [flowKey]);
|
||||
|
||||
/* Load the stored thread, and reload when the context changes. */
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
setMessages(raw ? withoutAuthoringActions(JSON.parse(raw)) : []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
try {
|
||||
conversationRef.current = sessionStorage.getItem(idKey) || null;
|
||||
} catch {
|
||||
conversationRef.current = null;
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(flowKey);
|
||||
flowRef.current = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
flowRef.current = null;
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
/* A different page or agent is a different conversation, so what the last
|
||||
one used does not carry over. */
|
||||
usedRef.current = { skills: [], tools: [], knowledge: [] };
|
||||
}, [storageKey, flowKey, idKey]);
|
||||
|
||||
/**
|
||||
* Writes the thread, and records it.
|
||||
*
|
||||
* One function rather than two calls at each site: a thread that is persisted
|
||||
* but not archived is a conversation History cannot show, and every path that
|
||||
* adds a turn goes through here. The conversation is given its id on its
|
||||
* first turn, so an untouched panel leaves nothing behind.
|
||||
*/
|
||||
const persist = React.useCallback((next) => {
|
||||
setMessages(next);
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// Quota or private mode — the thread stays in memory only.
|
||||
}
|
||||
|
||||
if (!next.length) return;
|
||||
|
||||
if (!conversationRef.current) {
|
||||
conversationRef.current = newConversationId();
|
||||
try {
|
||||
sessionStorage.setItem(idKey, conversationRef.current);
|
||||
} catch {
|
||||
// The id stays in memory: this conversation archives under one id for
|
||||
// as long as the panel is mounted, which is the common case.
|
||||
}
|
||||
}
|
||||
|
||||
setHistory(saveConversation({
|
||||
id: conversationRef.current,
|
||||
contextId,
|
||||
page: pageLabel,
|
||||
messages: next,
|
||||
agentId: agent?.id ?? null,
|
||||
/* Where the question was asked, reduced — see `storableContext`. */
|
||||
pageContext: owliverContext ? storableContext(owliverContext) : null,
|
||||
skillsUsed: usedRef.current.skills,
|
||||
toolsUsed: usedRef.current.tools,
|
||||
knowledgeUsed: usedRef.current.knowledge,
|
||||
}));
|
||||
}, [storageKey, idKey, contextId, pageLabel, agent, owliverContext]);
|
||||
|
||||
/* Abort any in-flight response when the context changes or we unmount. */
|
||||
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
|
||||
|
||||
// The stream callback runs outside render, so it needs the latest thread
|
||||
// rather than the value captured when the request started.
|
||||
const messagesRef = React.useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const send = React.useCallback(async ({ question, capability = null, positionId = null }) => {
|
||||
const text = String(question).trim();
|
||||
if (!text) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const withUser = [...messagesRef.current, { role: 'user', text }];
|
||||
messagesRef.current = withUser;
|
||||
persist(withUser);
|
||||
setError(null);
|
||||
setPending({ blocks: [], thinking: true });
|
||||
|
||||
/**
|
||||
* A guided skill in progress takes the message first: while Owliver is
|
||||
* asking the questions, "Chennai" is an answer rather than a question about
|
||||
* the page. The skill has to still be enabled — switching it off in Settings
|
||||
* ends the flow rather than letting it run on without permission.
|
||||
*/
|
||||
let intent = null;
|
||||
if (flowRef.current) {
|
||||
const skill = skillsForContext(contextId, disabledSkills, customSkills)
|
||||
.find((s) => s.id === flowRef.current.skillId);
|
||||
|
||||
if (!skill) setFlow(null);
|
||||
else {
|
||||
intent = {
|
||||
kind: 'flow',
|
||||
skill,
|
||||
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* A chip is this page's own capability by construction, so it never routes
|
||||
away. A typed question might belong somewhere else entirely. */
|
||||
if (!intent) {
|
||||
intent = capability
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The one step that writes. It happens before the reply rather than after,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
* be true when it is said.
|
||||
*/
|
||||
if (intent.kind === 'flow' && intent.create) {
|
||||
let created = null;
|
||||
try {
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
||||
} catch {
|
||||
created = null;
|
||||
}
|
||||
|
||||
if (created?.id) {
|
||||
/**
|
||||
* What can now be asked about the position that was just made.
|
||||
*
|
||||
* A live position is something the page's skills can read; a draft is
|
||||
* not finished being specified, so offering to analyse it would be
|
||||
* answering about a record the admin has not committed to. The registry
|
||||
* decides *which* skills can say something — see
|
||||
* `suggestionsForPosition` — and this decides only whether it is yet
|
||||
* the moment to ask them.
|
||||
*/
|
||||
const ready = created.status === 'active';
|
||||
intent = {
|
||||
...intent,
|
||||
flow: null,
|
||||
doc: positionCreatedReply(created),
|
||||
followUp: [
|
||||
...createdFollowUp(created),
|
||||
...(ready
|
||||
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
|
||||
: []),
|
||||
],
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
intent = {
|
||||
...intent,
|
||||
flow: { ...intent.flow, stage: 'review' },
|
||||
doc: positionFailedReply(),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishing a draft. Same contract as every other write in this file: it
|
||||
* happens before the reply, because the reply states the outcome.
|
||||
*
|
||||
* Each branch calls the mutation the form already uses and then says what
|
||||
* is now true of the record — no second store, and no navigation.
|
||||
*/
|
||||
if (intent.kind === 'draft' && intent.perform) {
|
||||
const { action, position } = intent.perform;
|
||||
|
||||
if (action === 'generate_description') {
|
||||
let result = null;
|
||||
try {
|
||||
result = await onGenerateDescription?.(position);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: result?.description ? descriptionReply(position, result) : descriptionFailedReply(position),
|
||||
followUp: draftActions(result?.description ? { ...position, description: result.description } : position),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'set_weights') {
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await onUpdatePosition?.(position.id, { vetting_criteria: intent.perform.weights });
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: saved ? weightsSetReply(position, intent.perform.weights) : weightsUnchangedReply(position),
|
||||
followUp: draftActions(saved || position),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'publish') {
|
||||
let published = null;
|
||||
try {
|
||||
published = await onUpdatePosition?.(position.id, { status: 'active' });
|
||||
} catch {
|
||||
published = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: published ? publishedReply(position) : publishFailedReply(position),
|
||||
followUp: published ? publishedFollowUp(published) : draftActions(position),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workforce write, on the same terms as the one above: it happens
|
||||
* before the reply, because the reply states the new counts and those have
|
||||
* to be true when they are read.
|
||||
*
|
||||
* `intent.assign` only exists on a turn the admin explicitly confirmed, and
|
||||
* the plan inside it was re-derived from live data at that moment — so this
|
||||
* writes what was agreed to or reports that it could not.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.assign) {
|
||||
let done = null;
|
||||
try {
|
||||
done = await onAssignWorkers?.(intent.assign);
|
||||
} catch {
|
||||
done = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
doc: done ? assignmentDone(intent.assign) : assignmentFailed(),
|
||||
followUp: done ? assignmentFollowUp(intent.assign) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The headcount a position never stated, supplied by the reader.
|
||||
*
|
||||
* Written through `useUpdateJobPosting` — the same mutation the Create
|
||||
* Position form and the Positions page call — so there is one position
|
||||
* record and one demand figure, not a number the panel remembers. The reply
|
||||
* is the assignment proposal the reader originally asked for, re-derived
|
||||
* against the saved record rather than against the number they typed, so
|
||||
* what they see is what was actually stored.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.headcount) {
|
||||
const { position: target, count } = intent.headcount;
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await onUpdatePosition?.(target.id, { headcount: count });
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
...(saved
|
||||
? headcountSet(saved, workforce?.context || {})
|
||||
: headcountFailed(target)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The interview step. Same contract as the assignment above: it runs before
|
||||
* the reply, only on a confirmed turn, and through the app's existing
|
||||
* status transition rather than a second interview store.
|
||||
*/
|
||||
if (intent.kind === 'workforce' && intent.interview) {
|
||||
let moved = null;
|
||||
try {
|
||||
moved = await onScheduleInterview?.(intent.interview);
|
||||
} catch {
|
||||
moved = null;
|
||||
}
|
||||
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'answer-doc',
|
||||
doc: moved ? interviewDone(intent.interview) : interviewFailed(),
|
||||
};
|
||||
}
|
||||
|
||||
/* A skill that collects its input in the chat carries the state of that
|
||||
collection on every turn — including the turn that starts it. */
|
||||
if ('flow' in intent) setFlow(intent.flow);
|
||||
|
||||
/* What this turn used, recorded from the resolved intent rather than from
|
||||
the page's offer. `intent.skill` is the definition that answered. */
|
||||
noteUsed('skills', intent.skill?.id);
|
||||
noteUsed('tools', intent.action?.name);
|
||||
|
||||
if (intent.kind !== 'answer') {
|
||||
try {
|
||||
await streamDocument({
|
||||
document: intent.doc,
|
||||
signal: controller.signal,
|
||||
onFrame: (blocks) => setPending({ blocks, thinking: false }),
|
||||
onDone: (blocks) => {
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks, followUp: intent.followUp },
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
/* Navigate after the reply is on screen, so the user reads why they moved.
|
||||
The panel re-resolves its context from the new route, which is what
|
||||
makes the next question answer from the page they land on. */
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
|
||||
/* A skill's action runs after its reply, for the same reason: the user
|
||||
should read why the form opened before it opens. */
|
||||
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
contextId, capability, question: text, facts, signal: controller.signal,
|
||||
/* What the agent *is*, never what it may read. The page settled that
|
||||
before this call, and `agentRequest` carries no records. */
|
||||
agent: agent ? agentRequest(agent, contextId) : null,
|
||||
owliverContext,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
latest = snapshot;
|
||||
setPending({ blocks: snapshot, thinking: false });
|
||||
}
|
||||
|
||||
// Keep whatever arrived before the user stopped it — discarding a
|
||||
// half-written answer loses what they were already reading. Stopping
|
||||
// before the first block, though, should leave no empty turn behind.
|
||||
if (latest.length) {
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
/* The reader gets one sentence; the developer gets the exception. A
|
||||
capability that throws used to leave no trace anywhere, so "I could
|
||||
not complete that" was the only evidence anything had gone wrong —
|
||||
and it named neither the action nor the reason. */
|
||||
if (import.meta.env.DEV) console.error('[owliver] action failed', e);
|
||||
setError('I could not complete that. Try again in a moment.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition,
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
/**
|
||||
* Back to an empty panel.
|
||||
*
|
||||
* The thread being cleared is not discarded — `persist` has already archived
|
||||
* every turn of it — so this releases the conversation rather than deleting
|
||||
* it, and the next turn starts a new one. That is what makes "New
|
||||
* conversation" and "Back to Home" safe: nothing is lost by leaving.
|
||||
*/
|
||||
const reset = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
messagesRef.current = [];
|
||||
setMessages([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
/* A new conversation abandons a half-collected position too — the questions
|
||||
it was answering are no longer on screen. */
|
||||
setFlow(null);
|
||||
conversationRef.current = null;
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(idKey);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, [storageKey, idKey, setFlow]);
|
||||
|
||||
/**
|
||||
* Reopens an archived conversation as the live thread.
|
||||
*
|
||||
* It resumes under its own id, so continuing an old conversation adds to that
|
||||
* record instead of forking a near-duplicate beside it. Any half-collected
|
||||
* skill flow is dropped: its questions belonged to the thread being left.
|
||||
*/
|
||||
const openConversation = React.useCallback((record) => {
|
||||
if (!record?.id || !Array.isArray(record.messages)) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
setPending(null);
|
||||
setError(null);
|
||||
setFlow(null);
|
||||
|
||||
/* Same rule as loading a stored thread: a conversation kept in History was
|
||||
written before this was fixed too, and reopening it must not hand back a
|
||||
route into the authoring form. */
|
||||
const messages = withoutAuthoringActions(record.messages);
|
||||
|
||||
messagesRef.current = messages;
|
||||
setMessages(messages);
|
||||
conversationRef.current = record.id;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(messages));
|
||||
sessionStorage.setItem(idKey, record.id);
|
||||
} catch {
|
||||
// The reopened thread stays in memory only.
|
||||
}
|
||||
}, [storageKey, idKey, setFlow]);
|
||||
|
||||
/** Forgets one archived conversation, clearing the panel if it is open. */
|
||||
const forgetConversation = React.useCallback((id) => {
|
||||
setHistory(removeConversation(id));
|
||||
if (conversationRef.current === id) reset();
|
||||
}, [reset]);
|
||||
|
||||
/**
|
||||
* Rates the conversation on screen.
|
||||
*
|
||||
* The conversation rather than the turn: a reader judging an answer is
|
||||
* judging the exchange that produced it, and a per-turn rating would ask them
|
||||
* to score a paragraph out of context. Returns false when there is nothing to
|
||||
* rate yet, so a caller can stay honest rather than pretending it landed.
|
||||
*/
|
||||
const submitFeedback = React.useCallback((rating, noteText = '') => {
|
||||
if (!conversationRef.current) return false;
|
||||
setHistory(recordFeedback(conversationRef.current, rating ? { rating, note: noteText } : null));
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/** How the conversation on screen is currently rated, or null. */
|
||||
const feedback = React.useMemo(
|
||||
() => history.find((r) => r.id === conversationRef.current)?.feedback ?? null,
|
||||
[history]
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
pending,
|
||||
error,
|
||||
busy: Boolean(pending),
|
||||
send,
|
||||
stop,
|
||||
reset,
|
||||
submitFeedback,
|
||||
feedback,
|
||||
/** Every archived conversation, newest first. */
|
||||
history,
|
||||
/** The conversation on screen, so History can mark it. */
|
||||
conversationId: conversationRef.current,
|
||||
openConversation,
|
||||
forgetConversation,
|
||||
providerId: provider.id,
|
||||
};
|
||||
}
|
||||
1193
src/components/assistant/DoormileAI.css
Normal file
1193
src/components/assistant/DoormileAI.css
Normal file
File diff suppressed because it is too large
Load Diff
158
src/components/assistant/DoormileAI/AIComposer.jsx
Normal file
158
src/components/assistant/DoormileAI/AIComposer.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { ChatDictationButton, useChatDictation } from './shim';
|
||||
|
||||
// ==============================|| Doormile AI — composer ||============================== //
|
||||
//
|
||||
// Auto-growing textarea (capped at ~5 lines by the stylesheet's max-height),
|
||||
// Enter to send, Shift+Enter for a newline. Hand-rolled rather than Astryx's
|
||||
// <ChatComposer> because the panel needs the compact anchored treatment and
|
||||
// a circular send button, neither of which that component exposes — the
|
||||
// dictation button it ships IS reused here rather than reinvented.
|
||||
|
||||
const MAX_ROWS_PX = 108;
|
||||
|
||||
// How long a dictating operator has to stay quiet before the message sends
|
||||
// itself. Long enough to think mid-sentence, short enough that you are not left
|
||||
// wondering whether it heard you.
|
||||
const SILENCE_MS = 3000;
|
||||
|
||||
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
const textareaRef = useRef(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// ---- dictation: send on silence ----
|
||||
// Speech arrives as a stream of partial transcripts, so "finished speaking"
|
||||
// is not an event the API gives us — it has to be inferred from the stream
|
||||
// going quiet. Every transcript update re-arms a timer; whichever one is not
|
||||
// followed by another within SILENCE_MS is the end of the sentence.
|
||||
//
|
||||
// Everything the timer touches is read through a ref. The callback is created
|
||||
// once and fires up to three seconds later, so a closed-over `value` or
|
||||
// `isBusy` would be whatever they were when dictation started, not when the
|
||||
// operator stopped talking — it would send a stale half-sentence, or send
|
||||
// while a previous answer was still streaming.
|
||||
const valueRef = useRef(value);
|
||||
const isBusyRef = useRef(isBusy);
|
||||
const dictationRef = useRef(null);
|
||||
const silenceTimer = useRef(0);
|
||||
|
||||
valueRef.current = value;
|
||||
isBusyRef.current = isBusy;
|
||||
|
||||
const clearSilence = useCallback(() => {
|
||||
if (silenceTimer.current) {
|
||||
clearTimeout(silenceTimer.current);
|
||||
silenceTimer.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const armSilence = useCallback(() => {
|
||||
clearSilence();
|
||||
silenceTimer.current = setTimeout(() => {
|
||||
silenceTimer.current = 0;
|
||||
// Stop the mic first. Sending while it is still listening would let the
|
||||
// next words land in an input the operator has already "sent".
|
||||
dictationRef.current?.stop();
|
||||
const text = valueRef.current.trim();
|
||||
if (text && !isBusyRef.current) onSubmit(text);
|
||||
}, SILENCE_MS);
|
||||
}, [clearSilence, onSubmit]);
|
||||
|
||||
const dictation = useChatDictation({
|
||||
// Fires on every partial AND final result, which is what makes this a
|
||||
// silence detector rather than an end-of-recognition one.
|
||||
onTranscript: (transcript) => {
|
||||
onChange(transcript);
|
||||
armSilence();
|
||||
},
|
||||
onResult: (transcript) => {
|
||||
onChange(transcript);
|
||||
armSilence();
|
||||
},
|
||||
// Stopped by hand, or the engine gave up: no pending auto-send.
|
||||
onEnd: clearSilence,
|
||||
onError: clearSilence
|
||||
});
|
||||
|
||||
dictationRef.current = dictation;
|
||||
|
||||
// A panel closed mid-sentence must not fire a message afterwards.
|
||||
useEffect(() => clearSilence, [clearSilence]);
|
||||
|
||||
// Grow with content, then let the stylesheet's max-height take over and
|
||||
// scroll. Runs before paint so there's no visible jump on the first line
|
||||
// break.
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
|
||||
}, [value]);
|
||||
|
||||
const canSend = value.trim().length > 0 && !isBusy;
|
||||
|
||||
const submit = () => {
|
||||
if (!canSend) return;
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack className="dai-composer-wrap" gap={1} padding={0}>
|
||||
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
aria-label="Ask Doormile AI about operations"
|
||||
/>
|
||||
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
|
||||
to the far left and the send button to the far right, leaving a
|
||||
wide dead gap between two things that do the same job — put a
|
||||
message in. Together they read as one action cluster. */}
|
||||
<HStack gap={1} padding={0} vAlign="center" justify="end">
|
||||
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
|
||||
<button
|
||||
type="button"
|
||||
className="dai-send"
|
||||
onClick={submit}
|
||||
disabled={!canSend}
|
||||
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text className="dai-hint">
|
||||
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIComposer.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
isBusy: PropTypes.bool,
|
||||
placeholder: PropTypes.string
|
||||
};
|
||||
|
||||
export default AIComposer;
|
||||
118
src/components/assistant/DoormileAI/AIFlowStep.jsx
Normal file
118
src/components/assistant/DoormileAI/AIFlowStep.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { Selector } from './shim';
|
||||
|
||||
// ==============================|| Doormile AI — one dropdown step ||============================== //
|
||||
//
|
||||
// Rendered when a conversational step is a `select`. The Create Order page uses
|
||||
// dropdowns for business location, category, weight and service tier, so the
|
||||
// bot does too — asking an operator to TYPE a location name they'd otherwise
|
||||
// pick from a list is slower and invites a name the resolver can't match.
|
||||
//
|
||||
// Options load asynchronously (locations, customers and tenants are fetched
|
||||
// live) so the list is never stale or invented. Two states matter and are
|
||||
// distinguished rather than merged into one spinner:
|
||||
//
|
||||
// • loading — still fetching
|
||||
// • empty — fetched successfully and there genuinely is nothing to pick,
|
||||
// which is a dead end the operator has to be told about (a
|
||||
// tenant with no saved locations cannot have an order created)
|
||||
const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
|
||||
const [options, setOptions] = useState(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setOptions(null);
|
||||
setFailed(false);
|
||||
setValue('');
|
||||
Promise.resolve(step.options?.(draft) ?? [])
|
||||
.then((list) => alive && setOptions(list))
|
||||
.catch(() => alive && setFailed(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step.id]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-field-err">I couldn’t load the options for that. Say “cancel” and try again.</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (options === null) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-action-note">Loading options…</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
{/* An empty list must still leave a way forward. The generic message
|
||||
plus a lone Cancel is a dead end, and it is reached exactly when
|
||||
something upstream failed — which is the worst moment to strip the
|
||||
operator of options. A step that can also accept a typed answer
|
||||
says so here via `emptyHint`. */}
|
||||
<Text className="dai-field-err">
|
||||
{step.id === 'pickuplocationid'
|
||||
? 'This tenant has no saved business locations — add one on the Tenants page before creating an order.'
|
||||
: step.emptyHint || 'There’s nothing to choose from here.'}
|
||||
</Text>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const chosen = options.find((o) => String(o.value) === String(value));
|
||||
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<Selector
|
||||
label={step.ask}
|
||||
isLabelHidden
|
||||
placeholder="Choose one"
|
||||
options={options.map((o) => ({ value: String(o.value), label: o.label }))}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
// A long customer list is unusable without search.
|
||||
hasSearch={options.length > 8}
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label="Continue"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!value || isBusy}
|
||||
// The whole option is passed back, not just its value — steps that
|
||||
// pick a record (a location, a customer) need the record itself to
|
||||
// fill several fields at once.
|
||||
onClick={() => onChoose(value, chosen)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIFlowStep.propTypes = {
|
||||
step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string, options: PropTypes.func }).isRequired,
|
||||
onChoose: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
draft: PropTypes.object,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIFlowStep;
|
||||
245
src/components/assistant/DoormileAI/AIMessage.jsx
Normal file
245
src/components/assistant/DoormileAI/AIMessage.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { memo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { IconButton } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { ChatToolCalls } from './shim';
|
||||
|
||||
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
||||
import AIFlowStep from './AIFlowStep';
|
||||
import AIRowsStep from './AIRowsStep';
|
||||
import { failedRowsCsv, downloadCsv } from '@/lib/assistant/bulkFile';
|
||||
|
||||
// ==============================|| Doormile AI — a single turn ||============================== //
|
||||
//
|
||||
// User turns are compact right-aligned bubbles. Assistant turns are NOT
|
||||
// bubbles — text sits directly on the panel surface with a small spark mark,
|
||||
// which is what keeps this from reading as a consumer chat app.
|
||||
//
|
||||
// The `sources` disclosure is the one thing that must not be dropped in the
|
||||
// name of visual quiet: it's the existing verifiability contract (every
|
||||
// number traceable to a real call). It's collapsed by default and expands to
|
||||
// the same <ChatToolCalls> the previous panel showed inline.
|
||||
|
||||
const UserMessage = ({ text }) => (
|
||||
<VStack className="dai-msg" gap={0} padding={0}>
|
||||
<Text className="dai-msg-user">{text}</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
UserMessage.propTypes = { text: PropTypes.string.isRequired };
|
||||
|
||||
const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
|
||||
const [showSources, setShowSources] = useState(false);
|
||||
const sourceCount = message.sourceCalls?.length || 0;
|
||||
|
||||
return (
|
||||
<VStack className="dai-msg dai-msg-row" gap={1.5} padding={0}>
|
||||
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
||||
<HStack gap={1} padding={0} vAlign="center">
|
||||
<Spark size="sm" />
|
||||
<Text className="dai-msg-name">Doormile AI</Text>
|
||||
</HStack>
|
||||
<HStack className="dai-msg-actions" gap={0} padding={0}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Copy answer"
|
||||
tooltip="Copy answer"
|
||||
icon={<Copy />}
|
||||
onClick={() => onCopy(message)}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{message.state ? (
|
||||
<StateBlock iconKey={message.state.iconKey} tone={message.state.tone} title={message.state.title} body={message.state.body} />
|
||||
) : (
|
||||
<VStack gap={1.5} padding={0}>
|
||||
{message.metric ? (
|
||||
<VStack gap={1.5} padding={0}>
|
||||
<Metric value={message.metric.value} label={message.metric.label} />
|
||||
</VStack>
|
||||
) : (
|
||||
<Text className="dai-msg-ai">{message.text}</Text>
|
||||
)}
|
||||
|
||||
<StatGrid stats={message.stats} />
|
||||
|
||||
{message.list && <AnswerList title={message.list.title} items={message.list.items} numbered={message.list.numbered !== false} />}
|
||||
|
||||
{message.detail &&
|
||||
(message.isDoc ? (
|
||||
<Text className="dai-doc">{message.detail}</Text>
|
||||
) : (
|
||||
<Text className="dai-msg-ai-detail">{message.detail}</Text>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Live-data footer + source disclosure. Small and muted — the point is
|
||||
that it's available, not that it's announced on every turn. */}
|
||||
{sourceCount > 0 && (
|
||||
<VStack gap={1} padding={0}>
|
||||
<HStack className="dai-msg-foot" gap={0} padding={0}>
|
||||
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
|
||||
{/* The chevron's offset and rotation live in the stylesheet — as inline
|
||||
styles they couldn't be transitioned, and `verticalAlign` only
|
||||
works while the button's content is inline, which is exactly the
|
||||
thing that made it wrap. */}
|
||||
<button type="button" className="dai-link dai-sources" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||
<span>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
</span>
|
||||
<ChevronDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* ---- Write gate ---------------------------------------------------
|
||||
Real inputs, not a parsed sentence. The form shows every value that
|
||||
will be sent and submits only on Create. A form rehydrated from
|
||||
localStorage in a later session is marked 'stale' by the panel and
|
||||
renders read-only, so a write can never fire from a resurrected
|
||||
message. */}
|
||||
{/* A conversational step that needs a dropdown rather than typing. */}
|
||||
{message.liveStop && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button label="Stop" variant="secondary" size="sm" onClick={() => onStopLive(message)} />
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* The bulk create's rows turn — a whole file in one answer. */}
|
||||
{message.flowStep?.type === 'rows' && !message.flowStep.__answered && (
|
||||
<AIRowsStep
|
||||
isBusy={message.flowStep.__answered}
|
||||
onSubmit={(parsed) => onChooseStep(message, parsed)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.flowStep?.type === 'select' && (
|
||||
<AIFlowStep
|
||||
step={message.flowStep}
|
||||
draft={message.flowDraft}
|
||||
isBusy={message.flowStep.__answered}
|
||||
onChoose={(value, option) => onChooseStep(message, value, option)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign'].includes(message.form?.kind) && (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
{message.form.status === 'open' ? (
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={
|
||||
message.form.confirmLabel
|
||||
? message.form.confirmLabel
|
||||
: message.form.kind === 'confirmBulk'
|
||||
? `Create ${message.form.draft?.rows?.length || 0} orders`
|
||||
: message.form.kind === 'confirmOrder'
|
||||
? 'Create order'
|
||||
: message.form.kind === 'confirmAssign'
|
||||
? 'Assign rider'
|
||||
: 'Submit'
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
|
||||
{/* A second, wider commit — used when rows were excluded by
|
||||
default but including them is a legitimate choice. */}
|
||||
{message.form.altDraft && (
|
||||
<Button
|
||||
label={message.form.altLabel || 'Include everything'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.altDraft)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}>
|
||||
{message.form.status === 'done'
|
||||
? '✓ Created'
|
||||
: message.form.status === 'cancelled'
|
||||
? 'Cancelled — nothing was sent.'
|
||||
: message.form.status === 'failed'
|
||||
? message.form.error
|
||||
: 'Expired — ask again to recreate this.'}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{message.downloadRows?.length > 0 && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button
|
||||
label={`Download the ${message.downloadRows.length} row${message.downloadRows.length === 1 ? '' : 's'} that didn’t go through`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-unsendable.csv', failedRowsCsv(message.downloadRows))}
|
||||
/>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Follow-ups are NOT rendered here.
|
||||
|
||||
They used to appear twice: once under each answer and again in the
|
||||
strip above the composer. Two sets of chips offering overlapping
|
||||
questions in one panel is the assistant asking the same thing twice,
|
||||
and the in-message copy scrolled away the moment the next answer
|
||||
arrived — so the one that was always reachable is the one that was
|
||||
kept. `message.followUps` still carries the intent's suggestions;
|
||||
AIPanel feeds them into that single strip. */}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AssistantMessage.propTypes = {
|
||||
message: PropTypes.object.isRequired,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onSubmitForm: PropTypes.func.isRequired,
|
||||
onCancelAction: PropTypes.func.isRequired,
|
||||
onChooseStep: PropTypes.func.isRequired,
|
||||
onStopLive: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
|
||||
message.sender === 'user' ? (
|
||||
<UserMessage text={message.text} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
onCopy={onCopy}
|
||||
onSubmitForm={onSubmitForm}
|
||||
onCancelAction={onCancelAction}
|
||||
onChooseStep={onChooseStep}
|
||||
onStopLive={onStopLive}
|
||||
/>
|
||||
);
|
||||
|
||||
AIMessage.propTypes = {
|
||||
message: PropTypes.object.isRequired,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onSubmitForm: PropTypes.func.isRequired,
|
||||
onCancelAction: PropTypes.func.isRequired,
|
||||
onChooseStep: PropTypes.func.isRequired,
|
||||
onStopLive: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// Memoised, and this is not an optimisation garnish — it's what makes the
|
||||
// thread scrollable. Every scroll event and every composer keystroke re-renders
|
||||
// the panel, and without this each one re-rendered all 50 restored turns,
|
||||
// tool-call disclosures included. The panel keeps its handler identities stable
|
||||
// so this comparison actually holds.
|
||||
export default memo(AIMessage);
|
||||
1591
src/components/assistant/DoormileAI/AIPanel.jsx
Normal file
1591
src/components/assistant/DoormileAI/AIPanel.jsx
Normal file
File diff suppressed because it is too large
Load Diff
149
src/components/assistant/DoormileAI/AIParts.jsx
Normal file
149
src/components/assistant/DoormileAI/AIParts.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { AlertTriangle, Info, Sparkles } from 'lucide-react';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
|
||||
export const Spark = ({ size = 'sm' }) => (
|
||||
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
|
||||
<Sparkles className="text-brand w-4 h-4" />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
Spark.propTypes = { size: PropTypes.oneOf(['sm', 'md', 'lg']) };
|
||||
|
||||
// "● Live" — subtle, not a large pill.
|
||||
export const LiveIndicator = ({ label = 'Live' }) => (
|
||||
<HStack className="dai-live" padding={0} gap={0} vAlign="center" aria-label="Answers are read from live operational data">
|
||||
<HStack className="dai-live-dot" padding={0} gap={0} aria-hidden="true" />
|
||||
{label}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
LiveIndicator.propTypes = { label: PropTypes.string };
|
||||
|
||||
// Thinking state. Three dots plus two shimmer bars — enough to read as
|
||||
// "working", not enough to be a light show.
|
||||
export const TypingIndicator = () => (
|
||||
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="Doormile AI is thinking">
|
||||
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</HStack>
|
||||
<VStack gap={1} padding={0} style={{ width: '100%' }} aria-hidden="true">
|
||||
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '78%' }} />
|
||||
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '52%' }} />
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
// The headline number for a counting answer — "the primary number is large".
|
||||
export const Metric = ({ value, label }) => (
|
||||
<VStack gap={0.25} padding={0}>
|
||||
<Text className="dai-metric-value">{Number.isFinite(value) ? value.toLocaleString('en-IN') : value}</Text>
|
||||
<Text className="dai-metric-label">{label}</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
Metric.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
// Compact breakdown grid. `color` is a status hex supplied by the caller —
|
||||
// the one case the token system genuinely can't express (see CLAUDE.md §6.5),
|
||||
// so it stays an inline style rather than a class.
|
||||
export const StatGrid = ({ stats }) => {
|
||||
if (!stats?.length) return null;
|
||||
return (
|
||||
<HStack className="dai-stat-grid" padding={0} gap={0} role="list">
|
||||
{stats.map((s) => (
|
||||
<VStack key={s.label} className="dai-stat" gap={0.25} padding={0} role="listitem">
|
||||
<Text className="dai-stat-value" style={s.color ? { color: s.color } : undefined}>
|
||||
{s.value}
|
||||
</Text>
|
||||
<Text className="dai-stat-label">{s.label}</Text>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
StatGrid.propTypes = {
|
||||
stats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
color: PropTypes.string
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// Icons are looked up by KEY, never passed in as a node. A message carrying a
|
||||
// React element would be destroyed by the JSON round-trip through
|
||||
// localStorage (elements don't serialise — $$typeof is a Symbol and is
|
||||
// silently dropped), and the rehydrated "element" would then crash the render
|
||||
// on the next panel open.
|
||||
const STATE_ICONS = {
|
||||
warning: <AlertTriangle />,
|
||||
info: <Info />
|
||||
};
|
||||
|
||||
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
|
||||
// left an operator unable to see the rest of an answer at all — the body
|
||||
// scrolls past ~12 rows so a long list can't stretch the panel.
|
||||
export const AnswerList = ({ title, items, numbered = true }) => {
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<VStack className="dai-list" gap={0} padding={0}>
|
||||
<HStack className="dai-list-head" padding={0} gap={0}>
|
||||
<span>{title}</span>
|
||||
<span>{items.length}</span>
|
||||
</HStack>
|
||||
<VStack className="dai-list-body" gap={0} padding={0} role="list">
|
||||
{items.map((item, i) => (
|
||||
<HStack className="dai-list-item" key={`${item.label}-${i}`} padding={0} gap={0} role="listitem">
|
||||
{/* Numbering makes sense for "the 14 tenants"; it is noise on a
|
||||
field/value dump like a submitted payload. */}
|
||||
{numbered ? <span className="dai-list-index">{i + 1}</span> : null}
|
||||
<span className="dai-list-label">{item.label}</span>
|
||||
{item.meta ? <span className="dai-list-meta">{item.meta}</span> : null}
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AnswerList.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string.isRequired, meta: PropTypes.string })),
|
||||
numbered: PropTypes.bool
|
||||
};
|
||||
|
||||
// Shared shell for the error / no-data states, so both read as the same kind
|
||||
// of thing rather than two separately-invented blocks.
|
||||
export const StateBlock = ({ iconKey, tone, title, body, action }) => (
|
||||
<VStack className="dai-state" gap={1.5} padding={0}>
|
||||
<HStack gap={1.5} padding={0} vAlign="start">
|
||||
<HStack className="dai-state-icon" padding={0} gap={0} aria-hidden="true" style={{ background: `${tone}14`, color: tone }}>
|
||||
{STATE_ICONS[iconKey] || STATE_ICONS.info}
|
||||
</HStack>
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-state-title">{title}</Text>
|
||||
<Text className="dai-state-body">{body}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
{action}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
StateBlock.propTypes = {
|
||||
iconKey: PropTypes.oneOf(['warning', 'info']),
|
||||
tone: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
body: PropTypes.string.isRequired,
|
||||
action: PropTypes.node
|
||||
};
|
||||
154
src/components/assistant/DoormileAI/AIRowsStep.jsx
Normal file
154
src/components/assistant/DoormileAI/AIRowsStep.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { TextArea } from './shim';
|
||||
import { FileInput } from './shim';
|
||||
|
||||
import { parseBulkRows, BULK_MAX } from '@/lib/assistant/bulkOrderActions';
|
||||
import { parseBulkFile, templateCsv, downloadCsv } from '@/lib/assistant/bulkFile';
|
||||
|
||||
// ==============================|| Doormile AI — the rows turn ||============================== //
|
||||
//
|
||||
// One conversational turn that accepts a whole file. A sheet and a paste both
|
||||
// produce the same row array (bulkFile.js / parseBulkRows), so this hands back
|
||||
// one parse result either way and the flow doesn't care which was used.
|
||||
//
|
||||
// File and paste are offered together rather than behind a "file or paste?"
|
||||
// question, because that question costs a turn and answers nothing the operator
|
||||
// hasn't already decided by having a file or not.
|
||||
//
|
||||
// What is reported before the operator can continue, and why each matters:
|
||||
// • rows read — so a truncated or half-empty sheet is visible immediately
|
||||
// • columns NOT recognised, by name — an unread price column would otherwise
|
||||
// silently re-quote every row
|
||||
// • rows repeated inside the file — the bulk endpoint has no idempotency key,
|
||||
// so a duplicate that gets through is a second real rider at one door
|
||||
|
||||
const PLACEHOLDER = `Ramesh Kumar, 9876543210, 12 Cross Cut Rd Gandhipuram, 641012, Coimbatore, 120, 2 meal boxes
|
||||
Priya N, 9876500011, 45 RS Puram, 641002, Coimbatore, 95, documents`;
|
||||
|
||||
const AIRowsStep = ({ onSubmit, onCancel, isBusy }) => {
|
||||
const [file, setFile] = useState(null);
|
||||
const [fileParse, setFileParse] = useState(null);
|
||||
const [fileError, setFileError] = useState(null);
|
||||
const [raw, setRaw] = useState('');
|
||||
const [isReading, setIsReading] = useState(false);
|
||||
|
||||
const pasted = raw.trim() ? parseBulkRows(raw) : null;
|
||||
// A loaded file wins; clearing it falls back to whatever is in the textarea.
|
||||
const parsed = fileParse || pasted;
|
||||
|
||||
const handleFile = async (picked) => {
|
||||
const chosen = Array.isArray(picked) ? picked[0] : picked;
|
||||
setFile(chosen || null);
|
||||
setFileError(null);
|
||||
if (!chosen) {
|
||||
setFileParse(null);
|
||||
return;
|
||||
}
|
||||
setIsReading(true);
|
||||
try {
|
||||
const result = await parseBulkFile(chosen);
|
||||
setFileParse(result);
|
||||
setRaw('');
|
||||
} catch (err) {
|
||||
setFileParse(null);
|
||||
setFileError(err.message || 'Couldn’t read that file.');
|
||||
} finally {
|
||||
setIsReading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<FileInput
|
||||
label="Order sheet"
|
||||
isLabelHidden
|
||||
accept=".csv,.xls,.xlsx"
|
||||
value={file}
|
||||
onChange={handleFile}
|
||||
isDisabled={isBusy || isReading}
|
||||
/>
|
||||
|
||||
{!fileParse && (
|
||||
<TextArea label="Or paste rows" rows={4} value={raw} onChange={setRaw} placeholder={PLACEHOLDER} isDisabled={isBusy} />
|
||||
)}
|
||||
|
||||
{isReading && <Text className="dai-action-note">Reading the file…</Text>}
|
||||
{fileError && <Text className="dai-field-err">{fileError}</Text>}
|
||||
|
||||
{parsed && (
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-action-note">
|
||||
{fileParse ? `${fileParse.sheetName} — ` : ''}
|
||||
{parsed.rows.length} complete row{parsed.rows.length === 1 ? '' : 's'}
|
||||
{parsed.errors.length ? ` · ${parsed.errors.length} unusable` : ''}
|
||||
</Text>
|
||||
|
||||
{parsed.ignoredColumns?.length > 0 && (
|
||||
<Text className="dai-action-note">Columns I didn’t recognise, so didn’t use: {parsed.ignoredColumns.join(', ')}</Text>
|
||||
)}
|
||||
|
||||
{parsed.duplicates?.length > 0 && (
|
||||
<Text className="dai-field-err">
|
||||
{parsed.duplicates.length} row{parsed.duplicates.length === 1 ? '' : 's'} repeat an earlier phone and address (
|
||||
{parsed.duplicates.map((d) => `line ${d.line}`).join(', ')}) — they’d become separate orders.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Keyed by line AND index: one row can fail several validations, so
|
||||
`line` alone is not unique and React logged "two children with the
|
||||
same key" for every repeat — seen live as a 1/2/3 cycle. Same
|
||||
shape the list renderer in AIParts already uses. */}
|
||||
{parsed.errors.slice(0, 5).map((e, i) => (
|
||||
<Text key={`${e.line}-${i}`} className="dai-field-err">
|
||||
Line {e.line}: {e.reason}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <Text className="dai-action-note">…and {parsed.errors.length - 5} more unusable rows.</Text>}
|
||||
|
||||
{parsed.rows.length > BULK_MAX && (
|
||||
<Text className="dai-action-note">
|
||||
Over {BULK_MAX} — I’ll send them in {Math.ceil(parsed.rows.length / BULK_MAX)} batches.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={parsed?.rows.length ? `Use these ${parsed.rows.length} rows` : 'Continue'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!parsed?.rows.length || isBusy || isReading}
|
||||
onClick={() => onSubmit(parsed)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
<Button
|
||||
label="Template"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tooltip="Download a blank sheet with the exact column titles I read"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-template.csv', templateCsv())}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Text className="dai-action-note">
|
||||
Pasted rows: name, phone, address, pincode, city, price, description — comma, tab or pipe separated. Leave the price blank and I’ll
|
||||
quote it.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIRowsStep.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIRowsStep;
|
||||
90
src/components/assistant/DoormileAI/AIWelcome.jsx
Normal file
90
src/components/assistant/DoormileAI/AIWelcome.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
|
||||
import { CHIP_LABELS } from './pageContext';
|
||||
|
||||
// ==============================|| Doormile AI — welcome state ||============================== //
|
||||
//
|
||||
// Shown only when the thread is empty. Deliberately top-weighted and compact
|
||||
// so the panel never opens on a large blank region: greeting + one line of
|
||||
// what it can do, then straight into four suggestions.
|
||||
|
||||
const greeting = () => {
|
||||
const h = dayjs().hour();
|
||||
if (h < 12) return 'Good morning';
|
||||
if (h < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
// A chip, not a row.
|
||||
//
|
||||
// These used to be full-width rows: a 26px icon tile, the question, and a
|
||||
// reveal-on-hover arrow, stacked one per line. That reads fine for four
|
||||
// suggestions and falls apart at twenty — the panel opened on a wall of
|
||||
// identical boxes that had to be read top to bottom, and the whole welcome
|
||||
// state scrolled.
|
||||
//
|
||||
// As chips they flow into a block that is scanned rather than read: each one
|
||||
// is only as wide as its question, so the varying widths give the eye
|
||||
// something to land on, and twenty of them occupy a few rows instead of
|
||||
// twenty. The icon stays as a small inline glyph for recognition; the arrow
|
||||
// is gone, because on a chip it was decoration competing with the label.
|
||||
const SuggestionChip = ({ item, onAsk }) => {
|
||||
const Icon = item.icon;
|
||||
// Display short, ask long. `item.text` is what intents.js matches on, so it
|
||||
// is what gets asked and what assistive tech announces; the chip only shows
|
||||
// a trimmed version of it so twenty of them fit in a few rows.
|
||||
const label = CHIP_LABELS[item.text] || item.text;
|
||||
return (
|
||||
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)} aria-label={item.text} title={item.text}>
|
||||
<Icon className="dai-suggestion-icon" size={13} strokeWidth={2} aria-hidden="true" />
|
||||
<span className="dai-suggestion-text">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
SuggestionChip.propTypes = {
|
||||
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// Exported so AIPanel can render it directly above the composer rather than
|
||||
// inside the scrolling body. The chips are a way to START a message, so they
|
||||
// belong with the input, not buried in the welcome copy where they scrolled
|
||||
// out of reach the moment a conversation began.
|
||||
export const SuggestionChips = ({ items, onAsk }) => (
|
||||
<HStack className="dai-suggestions" wrap="wrap" gap={1.5} padding={0} vAlign="start">
|
||||
{items.map((item) => (
|
||||
<SuggestionChip key={item.text} item={item} onAsk={onAsk} />
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
SuggestionChips.propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIWelcome = () => {
|
||||
return (
|
||||
<VStack className="dai-welcome" gap={1.5} padding={0}>
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-welcome-greeting">{greeting()}</Text>
|
||||
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||
</VStack>
|
||||
<Text className="dai-welcome-note">
|
||||
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIWelcome.propTypes = {
|
||||
onAsk: PropTypes.func
|
||||
};
|
||||
|
||||
export default AIWelcome;
|
||||
39
src/components/assistant/DoormileAI/index.jsx
Normal file
39
src/components/assistant/DoormileAI/index.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import AIPanel from './AIPanel';
|
||||
import '../DoormileAI.css';
|
||||
|
||||
// ==============================|| Doormile AI — trigger ||============================== //
|
||||
//
|
||||
// Mounted in AppTopNav. Owns only the open/closed state; everything else
|
||||
// lives in AIPanel. Focus returns here when the panel closes, so keyboard
|
||||
// users land back where they started.
|
||||
|
||||
const DoormileAITrigger = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger inline-flex items-center justify-center rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
data-active={isOpen}
|
||||
aria-label="Open Doormile AI"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<Bot className="dai-trigger-mark text-slate-700" aria-hidden="true" />
|
||||
</button>
|
||||
<AIPanel isOpen={isOpen} onClose={close} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoormileAITrigger;
|
||||
413
src/components/assistant/DoormileAI/pageContext.jsx
Normal file
413
src/components/assistant/DoormileAI/pageContext.jsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
Clock3 as LuClock3,
|
||||
Package as LuPackage,
|
||||
Bike as LuBike,
|
||||
Truck as LuTruck,
|
||||
Building2 as LuBuilding2,
|
||||
CircleDot as LuCircleDot,
|
||||
Banknote as LuBanknote,
|
||||
Users as LuUsers,
|
||||
Layers as LuLayers,
|
||||
TimerOff as LuTimerOff,
|
||||
UserPlus as LuUserPlus,
|
||||
PackagePlus as LuPackagePlus,
|
||||
ListPlus as LuListPlus,
|
||||
Repeat as LuRepeat
|
||||
} from 'lucide-react';
|
||||
|
||||
// ==============================|| Doormile AI — page context ||============================== //
|
||||
//
|
||||
// Maps the current route to (a) a short context label shown under the panel
|
||||
// header and (b) the suggested questions offered in the welcome state, so the
|
||||
// assistant opens on questions relevant to what the operator is looking at
|
||||
// rather than a fixed Orders-shaped list.
|
||||
//
|
||||
// Every suggestion here MUST be a phrasing the deterministic matcher in
|
||||
// intents.js actually resolves — a suggestion chip that returns "I can't
|
||||
// answer that yet" is worse than no chip. When adding one, check it against
|
||||
// the INTENTS catalog first.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Short chip labels.
|
||||
//
|
||||
// Every `text` below is the exact phrasing the deterministic matcher in
|
||||
// intents.js resolves, so it CANNOT be shortened in place — that is the string
|
||||
// the click actually asks. This map only changes what the chip DISPLAYS.
|
||||
//
|
||||
// It exists because the welcome state offers up to twenty suggestions, and as
|
||||
// full sentences they are 130-255px wide in a ~393px panel: barely two fit per
|
||||
// row, so the "chips" laid out as fourteen near-full-width rows and read as a
|
||||
// list again. Shortened, three or four fit per row and the block becomes
|
||||
// something you scan.
|
||||
//
|
||||
// The full question stays the accessible name and the tooltip, so nothing is
|
||||
// lost for screen readers or for anyone unsure what a chip will ask. A
|
||||
// suggestion with no entry here simply shows its full text.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHIP_LABELS = {
|
||||
"Give me today's operations summary": "Today's summary",
|
||||
'How many orders today?': 'Orders today',
|
||||
'Which orders are delayed?': 'Delayed orders',
|
||||
'Create an order': 'Create order',
|
||||
'Create multiple orders': 'Bulk orders',
|
||||
'Total revenue today': 'Revenue today',
|
||||
'How many pending orders today?': 'Pending today',
|
||||
'How many cancelled orders today?': 'Cancelled today',
|
||||
'How many delivered orders today?': 'Delivered today',
|
||||
'Morning Batch orders today': 'Morning batch',
|
||||
'Orders today vs yesterday': 'Today vs yesterday',
|
||||
'Create a customer': 'Create customer',
|
||||
'How many riders are active?': 'Active riders',
|
||||
'How many vehicles are available?': 'Vehicles available',
|
||||
'Current hub status': 'Hub status',
|
||||
'Which hubs are experiencing delays?': 'Hub delays',
|
||||
'How many tenants do we have?': 'Tenants',
|
||||
'How many orders this week?': 'Orders this week',
|
||||
'Total revenue this week': 'Revenue this week',
|
||||
'How many customers do we have?': 'Customers',
|
||||
'Assign a rider': 'Assign order',
|
||||
"Repeat yesterday's orders": 'Repeat yesterday'
|
||||
};
|
||||
|
||||
// The one follow-up phrasing that is not already a page suggestion. Verified
|
||||
// against ASSIGN_TRIGGER in assignActions.js — it opens the assign-rider flow.
|
||||
const ASSIGN_TEXT = 'Assign a rider';
|
||||
|
||||
// Recreates a previous day's run rather than building an order from scratch.
|
||||
// This is the phrasing intents.js documents for it ("repeat yesterday's
|
||||
// orders") and it is matched by REPEAT_TRIGGER in repeatRuns.js, which is
|
||||
// ordered AHEAD of createOrder/createBulkOrders precisely so a sentence
|
||||
// containing both "repeat" and "orders" recalls the run instead of opening a
|
||||
// blank create form.
|
||||
const REPEAT_TEXT = "Repeat yesterday's orders";
|
||||
|
||||
// Not a question — an OUTCOME. AIPanel sets this as the follow-up key when a
|
||||
// create or repeat run actually succeeds, so the next steps offered are the
|
||||
// ones that follow from an order existing, rather than the ones that follow
|
||||
// from having asked to make one. Assigning a rider only makes sense on this
|
||||
// side of the write.
|
||||
export const ORDER_CREATED = '__orderCreated';
|
||||
|
||||
// The same write, but with every new order already given a rider — a repeat run
|
||||
// that handed each copy back to whoever ran it yesterday.
|
||||
//
|
||||
// It needs its own key purely so ASSIGN_TEXT can be left out. Offering "Assign
|
||||
// order" after the bot has just assigned them all presents finished work as a
|
||||
// pending step, and an operator who takes the suggestion is walked into
|
||||
// reassigning orders that are already out.
|
||||
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';
|
||||
|
||||
const ORDERS = {
|
||||
label: 'Orders',
|
||||
suggestions: [
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuPackagePlus, text: 'Create an order' },
|
||||
{ icon: LuRepeat, text: REPEAT_TEXT }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuListPlus, text: 'Create multiple orders' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' },
|
||||
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
||||
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||
{ icon: LuClock3, text: 'Orders today vs yesterday' }
|
||||
]
|
||||
};
|
||||
|
||||
const RIDERS = {
|
||||
label: 'Riders',
|
||||
suggestions: [
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' }
|
||||
]
|
||||
};
|
||||
|
||||
const VEHICLES = {
|
||||
label: 'Vehicles',
|
||||
suggestions: [
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||
};
|
||||
|
||||
const HUBS = {
|
||||
label: 'Hubs',
|
||||
suggestions: [
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' }
|
||||
],
|
||||
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||
};
|
||||
|
||||
const DISPATCH = {
|
||||
label: 'Dispatch',
|
||||
suggestions: [
|
||||
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' }
|
||||
]
|
||||
};
|
||||
|
||||
const TENANTS = {
|
||||
label: 'Tenants',
|
||||
suggestions: [
|
||||
{ icon: LuUsers, text: 'How many tenants do we have?' },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
|
||||
const REPORTS = {
|
||||
label: 'Reports',
|
||||
suggestions: [
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuClock3, text: 'Orders today vs yesterday' },
|
||||
{ icon: LuPackage, text: 'How many orders this week?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
|
||||
};
|
||||
|
||||
const DEFAULT_CONTEXT = {
|
||||
label: 'Operations',
|
||||
suggestions: [
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuBuilding2, text: 'Current hub status' }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
||||
]
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Follow-ups.
|
||||
//
|
||||
// What the suggestion strip offers AFTER a question has been asked. Showing
|
||||
// all twenty again is noise — the operator has already told you what they are
|
||||
// doing, so the next set should be the natural next steps for it. Creating an
|
||||
// order leads to assigning a rider and to what is still pending; asking about
|
||||
// revenue leads to the week and to the day-over-day comparison.
|
||||
//
|
||||
// Every string here is either one of the CHIP_LABELS questions above (all of
|
||||
// which the matcher already resolves) or ASSIGN_TEXT. That constraint is the
|
||||
// whole point: pageContext's rule is that a suggestion which returns "I can't
|
||||
// answer that yet" is worse than no suggestion, and a follow-up is MORE likely
|
||||
// to be clicked than a cold one, because it arrives exactly when it is
|
||||
// relevant. Do not add a phrasing here without checking it against INTENTS.
|
||||
// ---------------------------------------------------------------------------
|
||||
const FOLLOW_UPS = {
|
||||
// --- write actions ---
|
||||
// Note what is NOT here: assigning a rider.
|
||||
//
|
||||
// "Create an order" only STARTS the create conversation — no order exists at
|
||||
// that moment, so offering to assign one is offering to act on nothing. And
|
||||
// once the order really is created, AIPanel already drops the operator
|
||||
// straight into the assign flow itself (see the confirmOrder branch), because
|
||||
// that hand-off is mandatory rather than optional. A chip there would either
|
||||
// duplicate a step the product performs on its own or fire it too early.
|
||||
//
|
||||
// ASSIGN_TEXT belongs on the questions below that surface orders which
|
||||
// already EXIST and are waiting for a rider — and on ORDER_CREATED, which is
|
||||
// keyed off the write succeeding rather than off anything that was asked.
|
||||
'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'],
|
||||
'Create multiple orders': ['How many pending orders today?', 'How many orders today?'],
|
||||
[REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'],
|
||||
'Create a customer': ['Create an order', 'How many customers do we have?'],
|
||||
[ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'],
|
||||
[ORDER_CREATED]: [ASSIGN_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
||||
// No ASSIGN_TEXT: there is nothing left to assign.
|
||||
[ORDER_CREATED_ASSIGNED]: ['How many riders are active?', 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
||||
|
||||
// --- order volume ---
|
||||
"Give me today's operations summary": [
|
||||
'How many pending orders today?',
|
||||
'Which orders are delayed?',
|
||||
'Total revenue today',
|
||||
'How many riders are active?'
|
||||
],
|
||||
'How many orders today?': [
|
||||
'How many pending orders today?',
|
||||
'How many delivered orders today?',
|
||||
'Orders today vs yesterday',
|
||||
'Total revenue today'
|
||||
],
|
||||
'How many pending orders today?': [ASSIGN_TEXT, 'Which orders are delayed?', 'How many delivered orders today?'],
|
||||
'How many delivered orders today?': ['How many pending orders today?', 'Total revenue today', 'Orders today vs yesterday'],
|
||||
'How many cancelled orders today?': ['How many delivered orders today?', 'How many orders today?', 'Which orders are delayed?'],
|
||||
'Which orders are delayed?': [ASSIGN_TEXT, 'Which hubs are experiencing delays?', 'How many riders are active?'],
|
||||
'Morning Batch orders today': ['Which orders are delayed?', 'How many riders are active?', 'How many orders today?'],
|
||||
|
||||
// --- money and trend ---
|
||||
'Total revenue today': ['Total revenue this week', 'Orders today vs yesterday', 'How many orders today?'],
|
||||
'Total revenue this week': ['How many orders this week?', 'Total revenue today'],
|
||||
'Orders today vs yesterday': [REPEAT_TEXT, 'Total revenue today', 'How many orders this week?'],
|
||||
'How many orders this week?': ['Total revenue this week', 'How many orders today?'],
|
||||
|
||||
// --- fleet and network ---
|
||||
'How many riders are active?': ['How many vehicles are available?', 'Current hub status', ASSIGN_TEXT],
|
||||
'How many vehicles are available?': ['How many riders are active?', 'Current hub status'],
|
||||
'Current hub status': ['Which hubs are experiencing delays?', 'How many vehicles are available?', 'How many riders are active?'],
|
||||
'Which hubs are experiencing delays?': ['Current hub status', 'Which orders are delayed?', 'How many riders are active?'],
|
||||
|
||||
// --- accounts ---
|
||||
'How many tenants do we have?': ['How many customers do we have?', 'How many orders today?'],
|
||||
'How many customers do we have?': ['Create a customer', 'How many tenants do we have?']
|
||||
};
|
||||
|
||||
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
|
||||
// with the wrong label.
|
||||
const ROUTES = [
|
||||
['/doormile/dispatch', DISPATCH],
|
||||
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
|
||||
['/doormile/orders', ORDERS],
|
||||
['/doormile/riders', RIDERS],
|
||||
['/doormile/vehicles', VEHICLES],
|
||||
['/doormile/hubs', HUBS],
|
||||
['/doormile/tenants', TENANTS],
|
||||
['/doormile/reports', REPORTS],
|
||||
[
|
||||
'/doormile/customers',
|
||||
{
|
||||
label: 'Customers',
|
||||
suggestions: [
|
||||
{ icon: LuUserPlus, text: 'Create a customer' },
|
||||
{ icon: LuUsers, text: 'How many customers do we have?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' }
|
||||
],
|
||||
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
|
||||
}
|
||||
],
|
||||
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
|
||||
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
|
||||
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
|
||||
];
|
||||
|
||||
// ---- every question, from every page ---------------------------------------
|
||||
//
|
||||
// The catalog is the same wherever you are: the assistant can answer about
|
||||
// orders, riders, hubs and the rest regardless of which screen is open, so
|
||||
// hiding a question because you happen to be on Dispatch made it look narrower
|
||||
// than it is. What the page still decides is ORDER — its own questions lead,
|
||||
// then everything else follows, deduplicated by text.
|
||||
//
|
||||
// `more` is retired: AIWelcome renders one flat list now, so a second array
|
||||
// would only be a second place for a question to hide.
|
||||
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
|
||||
|
||||
// Every question declared anywhere. Two jobs: it is the icon index behind
|
||||
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
|
||||
// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
|
||||
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
|
||||
const EVERY_QUESTION = (() => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
ALL_CONTEXTS.forEach((c) => {
|
||||
[...(c.suggestions || []), ...(c.more || [])].forEach((q) => {
|
||||
if (seen.has(q.text)) return;
|
||||
seen.add(q.text);
|
||||
out.push(q);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The questions that ride along on EVERY page.
|
||||
//
|
||||
// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
|
||||
// Orders page offered "How many tenants do we have?" and "Current hub status"
|
||||
// above the composer, questions an operator working orders is not asking. The
|
||||
// strip is a shortcut, and a shortcut that lists everything is a menu.
|
||||
//
|
||||
// What is left is the daily operating picture: what came in, what is stuck,
|
||||
// what got out, what it earned, and the three ways to put work into the system.
|
||||
//
|
||||
// Dropping a question from THIS list does not remove it from the assistant —
|
||||
// it stays typeable, it stays a follow-up, and it still leads on its own page,
|
||||
// because withGlobalQuestions puts each page's own suggestions first. Hubs
|
||||
// still opens on hub questions; they just no longer follow you to Orders.
|
||||
// ---------------------------------------------------------------------------
|
||||
const GLOBAL_TEXTS = new Set([
|
||||
"Give me today's operations summary",
|
||||
'How many orders today?',
|
||||
'Which orders are delayed?',
|
||||
'How many pending orders today?',
|
||||
'How many delivered orders today?',
|
||||
'How many cancelled orders today?',
|
||||
'Morning Batch orders today',
|
||||
'Orders today vs yesterday',
|
||||
'Total revenue today',
|
||||
'How many riders are active?',
|
||||
'Create an order',
|
||||
REPEAT_TEXT,
|
||||
'Create multiple orders'
|
||||
]);
|
||||
|
||||
const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
|
||||
|
||||
// The page's own suggestions lead; the global set follows, deduplicated.
|
||||
const withGlobalQuestions = (context) => {
|
||||
const seen = new Set();
|
||||
const suggestions = [];
|
||||
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
|
||||
if (seen.has(q.text)) return;
|
||||
seen.add(q.text);
|
||||
suggestions.push(q);
|
||||
});
|
||||
return { ...context, suggestions, more: [] };
|
||||
};
|
||||
|
||||
export const getPageContext = (pathname = '') => {
|
||||
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
||||
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Follow-ups are stored as bare strings (they have to be — they are the exact
|
||||
// text that gets asked), so this turns one back into a renderable chip by
|
||||
// looking its icon up among the questions already declared above. Built from
|
||||
// EVERY_QUESTION rather than a second hand-written list, so a suggestion can
|
||||
// never end up with an icon here that disagrees with its icon on a page.
|
||||
// ---------------------------------------------------------------------------
|
||||
const QUESTION_BY_TEXT = new Map(EVERY_QUESTION.map((q) => [q.text, q]));
|
||||
QUESTION_BY_TEXT.set(ASSIGN_TEXT, { icon: LuBike, text: ASSIGN_TEXT });
|
||||
|
||||
// Returns [] for anything unrecognised — a typed question, or one with no
|
||||
// natural next step. The caller falls back to the page's own suggestions, so
|
||||
// the strip is never empty.
|
||||
export const getFollowUps = (askedText) => {
|
||||
const next = FOLLOW_UPS[askedText];
|
||||
if (!next) return [];
|
||||
return next.map((text) => QUESTION_BY_TEXT.get(text)).filter(Boolean);
|
||||
};
|
||||
|
||||
// Turns arbitrary follow-up strings into chips. Used for the intent-keyed
|
||||
// FOLLOW_UP_SUGGESTIONS in intents.js, whose phrasings ("What about
|
||||
// yesterday?", "This week") are conversational rather than one of the declared
|
||||
// questions, so they have no icon of their own.
|
||||
export const toChips = (texts = []) => texts.map((text) => QUESTION_BY_TEXT.get(text) || { icon: LuCircleDot, text });
|
||||
139
src/components/assistant/DoormileAI/shim.jsx
Normal file
139
src/components/assistant/DoormileAI/shim.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { DropdownMenu as KrowDropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
|
||||
export const HStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, vAlign, justify, wrap, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-row ${
|
||||
vAlign === 'start' ? 'items-start' : vAlign === 'end' ? 'items-end' : vAlign === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${wrap === 'wrap' ? 'flex-wrap' : ''} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const VStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, align, justify, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-col ${
|
||||
align === 'start' ? 'items-start' : align === 'end' ? 'items-end' : align === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const Text = ({ as: Component = 'span', children, className = '', style, ...props }) => {
|
||||
return (
|
||||
<Component className={`text-sm ${className}`} style={style} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconButton = ({ children, className = '', icon, onClick, tooltip, label, size, variant, isIconOnly, ...props }) => {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors ${
|
||||
size === 'sm' ? 'p-1.5' : 'p-2'
|
||||
} ${isIconOnly ? 'aspect-square' : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
title={tooltip || label}
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
{icon || children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({ button, items }) => (
|
||||
<KrowDropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton {...button} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{items?.map((it, i) => (
|
||||
<DropdownMenuItem key={i} onClick={it.onClick}>
|
||||
{it.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</KrowDropdownMenu>
|
||||
);
|
||||
|
||||
export const Button = ({ children, onClick, className = '' }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const Selector = ({ value, onChange, options = [], placeholder }) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder || 'Select...'}
|
||||
</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.value || o} value={o.value || o}>
|
||||
{o.label || o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
export const TextArea = ({ value, onChange, placeholder, rows = 3, className = '' }) => (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FileInput = ({ onChange, accept, className = '' }) => (
|
||||
<input
|
||||
type="file"
|
||||
onChange={onChange}
|
||||
accept={accept}
|
||||
className={`block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChatToolCalls = ({ calls }) => (
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
{calls?.map((c, i) => (
|
||||
<div key={i} className="mb-1">
|
||||
<strong>{c.tool || 'Tool'}</strong>
|
||||
<pre className="mt-1 overflow-x-auto text-[10px]">{JSON.stringify(c.args, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const useChatDictation = () => {
|
||||
return { isDictating: false, startDictation: () => {}, stopDictation: () => {}, toggleDictation: () => {} };
|
||||
};
|
||||
|
||||
export const ChatDictationButton = ({ isDictating, onToggle }) => (
|
||||
<button type="button" onClick={onToggle} className="p-2 text-gray-500 hover:text-blue-500" title="Dictation unavailable">
|
||||
🎤
|
||||
</button>
|
||||
);
|
||||
@@ -1,618 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Trophy,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
Table as TableIcon,
|
||||
Users,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
X,
|
||||
ChefHat,
|
||||
ShieldCheck,
|
||||
Wine,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Animated Flowchart & Department Performance Component
|
||||
*
|
||||
* Renders an interactive, animated flow chart mapping department performance,
|
||||
* AI scores, and hiring outcomes connected via glowing SVG flow paths.
|
||||
*/
|
||||
|
||||
// Helper to choose iconic representation per department
|
||||
function getDeptIcon(name = '') {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes('chef') || lower.includes('kitchen') || lower.includes('cook')) return ChefHat;
|
||||
if (lower.includes('security') || lower.includes('guard') || lower.includes('safety')) return ShieldCheck;
|
||||
if (lower.includes('bar') || lower.includes('drink') || lower.includes('mixologist')) return Wine;
|
||||
if (lower.includes('front') || lower.includes('desk') || lower.includes('concierge')) return Building2;
|
||||
if (lower.includes('event') || lower.includes('manager')) return Trophy;
|
||||
if (lower.includes('housekeeping') || lower.includes('clean')) return Sparkles;
|
||||
return Building2;
|
||||
}
|
||||
|
||||
// Helper to get color tones
|
||||
function getDeptColor(index, avgScore) {
|
||||
if (index === 0 && avgScore >= 90) {
|
||||
return {
|
||||
border: 'border-blue-500/40 hover:border-blue-500',
|
||||
bg: 'bg-gradient-to-br from-blue-50/80 via-white to-blue-50/30 dark:from-blue-950/30 dark:via-gray-900 dark:to-blue-900/10',
|
||||
badgeBg: 'bg-blue-600 text-white shadow-blue-500/25 shadow-md',
|
||||
ring: 'text-blue-600',
|
||||
glow: 'shadow-[0_0_25px_rgba(37,99,235,0.15)]',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
path: '#2563eb',
|
||||
};
|
||||
}
|
||||
if (index === 1) {
|
||||
return {
|
||||
border: 'border-indigo-500/30 hover:border-indigo-500/80',
|
||||
bg: 'bg-gradient-to-br from-indigo-50/50 via-white to-indigo-50/20 dark:from-indigo-950/20 dark:via-gray-900 dark:to-indigo-900/10',
|
||||
badgeBg: 'bg-indigo-600 text-white',
|
||||
ring: 'text-indigo-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(79,70,229,0.1)]',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
path: '#4f46e5',
|
||||
};
|
||||
}
|
||||
if (index === 2) {
|
||||
return {
|
||||
border: 'border-emerald-500/30 hover:border-emerald-500/80',
|
||||
bg: 'bg-gradient-to-br from-emerald-50/50 via-white to-emerald-50/20 dark:from-emerald-950/20 dark:via-gray-900 dark:to-emerald-900/10',
|
||||
badgeBg: 'bg-emerald-600 text-white',
|
||||
ring: 'text-emerald-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(16,185,129,0.1)]',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
path: '#10b981',
|
||||
};
|
||||
}
|
||||
if (index === 3) {
|
||||
return {
|
||||
border: 'border-purple-500/30 hover:border-purple-500/80',
|
||||
bg: 'bg-gradient-to-br from-purple-50/50 via-white to-purple-50/20 dark:from-purple-950/20 dark:via-gray-900 dark:to-purple-900/10',
|
||||
badgeBg: 'bg-purple-600 text-white',
|
||||
ring: 'text-purple-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(147,51,234,0.1)]',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
path: '#9333ea',
|
||||
};
|
||||
}
|
||||
if (index === 4) {
|
||||
return {
|
||||
border: 'border-amber-500/30 hover:border-amber-500/80',
|
||||
bg: 'bg-gradient-to-br from-amber-50/50 via-white to-amber-50/20 dark:from-amber-950/20 dark:via-gray-900 dark:to-amber-900/10',
|
||||
badgeBg: 'bg-amber-600 text-white',
|
||||
ring: 'text-amber-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(217,119,6,0.1)]',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
path: '#d97706',
|
||||
};
|
||||
}
|
||||
return {
|
||||
border: 'border-teal-500/30 hover:border-teal-500/80',
|
||||
bg: 'bg-gradient-to-br from-teal-50/50 via-white to-teal-50/20 dark:from-teal-950/20 dark:via-gray-900 dark:to-teal-900/10',
|
||||
badgeBg: 'bg-teal-600 text-white',
|
||||
ring: 'text-teal-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(20,184,166,0.1)]',
|
||||
text: 'text-teal-600 dark:text-teal-400',
|
||||
path: '#14b8a6',
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function DepartmentPerformance({ items = [], className = '' }) {
|
||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||
const [activeDept, setActiveDept] = useState(null);
|
||||
|
||||
// Rank departments by avgScore descending
|
||||
const ranked = useMemo(() => {
|
||||
return [...items].sort(
|
||||
(a, b) => (b.avgScore || 0) - (a.avgScore || 0) || (b.count || 0) - (a.count || 0)
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const totalHires = useMemo(() => items.reduce((acc, curr) => acc + (curr.count || 0), 0), [items]);
|
||||
const overallAvgScore = useMemo(() => {
|
||||
const scored = items.filter((i) => i.avgScore > 0);
|
||||
if (!scored.length) return 0;
|
||||
return Math.round(scored.reduce((acc, curr) => acc + curr.avgScore, 0) / scored.length);
|
||||
}, [items]);
|
||||
|
||||
if (!ranked.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-2xl border border-border bg-surface shadow-sm transition-all', className)}>
|
||||
{/* Header Bar & View Switcher */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
Department Performance & Hiring Flow
|
||||
</h3>
|
||||
<p className="text-caption text-ink-3">
|
||||
Interactive pipeline mapping AI score quality & hire velocity across departments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle Switch */}
|
||||
<div className="flex items-center rounded-xl bg-surface-subtle p-1 border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('flowchart')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-caption font-medium transition-all',
|
||||
viewMode === 'flowchart'
|
||||
? 'bg-surface text-ink-1 shadow-xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<Workflow className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>Flowchart</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('table')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-caption font-medium transition-all',
|
||||
viewMode === 'table'
|
||||
? 'bg-surface text-ink-1 shadow-xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<TableIcon className="h-3.5 w-3.5 text-ink-3" />
|
||||
<span>Scorecard</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executive Quick Stats Bar for Department Breakdown */}
|
||||
<div className="grid grid-cols-2 gap-3 border-b border-border bg-surface-subtle/50 px-4 py-3 sm:grid-cols-4 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Department</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked[0]?.name || 'Chef'} <span className="text-blue-600 font-semibold">({ranked[0]?.avgScore || 97})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Building2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Active Units</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked.length} Departments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Overall AI Index</p>
|
||||
<p className="font-heading text-body-sm font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{overallAvgScore} / 100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Fill Velocity</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{Math.min(...ranked.map((r) => r.avgTimeToHire || 2))}d Avg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="p-3 sm:p-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'flowchart' ? (
|
||||
<motion.div
|
||||
key="flowchart-view"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Flowchart Diagram Container — Redesigned into Full-Width Executive Dashboard Layout */}
|
||||
<div className="relative rounded-2xl border border-border/70 bg-gradient-to-br from-surface via-surface-subtle/30 to-surface p-4 sm:p-5 overflow-hidden shadow-xs space-y-5">
|
||||
|
||||
{/* 1. Top Integrated Flow Header: Pipeline Source (Left) & Hiring Outcomes (Right) */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Pipeline Source Node (Talent Engine) */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="relative rounded-2xl border border-blue-500/30 bg-gradient-to-r from-blue-50/60 via-surface to-surface dark:from-blue-950/30 dark:via-surface p-4 shadow-xs transition-all hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600 animate-pulse" />
|
||||
Pipeline Source
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Talent Engine
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-blue-100 dark:bg-blue-950 px-2.5 py-1 text-[10px] font-bold text-blue-700 dark:text-blue-400">
|
||||
100% AI Verified Engine
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Total Hires</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-ink-1 mt-0.5">
|
||||
{totalHires} <span className="text-caption font-normal text-ink-3">staff filled</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg AI Score</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
{overallAvgScore} <span className="text-caption font-normal text-ink-3">/ 100</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Hiring Outcomes Node */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="relative rounded-2xl border border-emerald-500/30 bg-gradient-to-r from-emerald-50/60 via-surface to-surface dark:from-emerald-950/30 dark:via-surface p-4 shadow-xs transition-all hover:border-emerald-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-teal-600 text-white shadow-md shadow-emerald-500/25">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Hiring Outcomes
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Verified Quality Impact
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2.5 py-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
|
||||
100% Active Placement
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Quality Tier</p>
|
||||
<p className="font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5 text-body-sm">
|
||||
95+ Top
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Placement</p>
|
||||
<p className="font-semibold text-ink-1 mt-0.5 text-body-sm">
|
||||
100% Verified
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg Rating</p>
|
||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mt-0.5 text-body-sm">
|
||||
4.9 / 5.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 2. Department Breakdown Grid (2-Column Grid eliminating empty vertical side spaces) */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1 uppercase tracking-wider text-[11px]">
|
||||
Department Performance Units ({ranked.length})
|
||||
</h4>
|
||||
<span className="text-[10px] text-ink-4">Click any department card for detailed staff roster</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{ranked.map((dept, index) => {
|
||||
const colors = getDeptColor(index, dept.avgScore);
|
||||
const IconComponent = getDeptIcon(dept.name);
|
||||
const isLeader = index === 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={dept.name}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.04 }}
|
||||
onClick={() => setActiveDept(dept)}
|
||||
whileHover={{ scale: 1.015, y: -2 }}
|
||||
className={cn(
|
||||
'group relative cursor-pointer rounded-2xl border p-4 backdrop-blur-sm transition-all duration-200 shadow-xs hover:shadow-md',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.glow
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left: Rank, Icon & Department Title */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface border border-border shadow-xs">
|
||||
<IconComponent className={cn('h-5.5 w-5.5', colors.text)} />
|
||||
{isLeader && (
|
||||
<div className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-amber-400 text-amber-950 shadow-xs">
|
||||
<Trophy className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold tabular-nums text-ink-4">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<h5 className="truncate font-heading text-body font-bold text-ink-1">
|
||||
{dept.name}
|
||||
</h5>
|
||||
{isLeader && (
|
||||
<span className="rounded-full bg-amber-100 dark:bg-amber-950/60 px-2 py-0.5 text-[9px] font-bold text-amber-700 dark:text-amber-400">
|
||||
#1 Leader
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-caption text-ink-3">
|
||||
<span className="font-semibold text-ink-2">{dept.count} {dept.count === 1 ? 'hire' : 'hires'}</span>
|
||||
{dept.avgTimeToHire ? (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{dept.avgTimeToHire}d fill velocity
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Score Ring & Navigation Arrow */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg className="h-11 w-11 -rotate-90">
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="17"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-border"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="17"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.5"
|
||||
strokeDasharray={106}
|
||||
strokeDashoffset={106 - (106 * (dept.avgScore || 0)) / 100}
|
||||
strokeLinecap="round"
|
||||
className={cn('transition-all duration-700 ease-out', colors.ring)}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{dept.avgScore || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="h-4 w-4 text-ink-4 group-hover:text-blue-600 transition-colors group-hover:translate-x-1 duration-200" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* Scorecard Table View */
|
||||
<motion.div
|
||||
key="table-view"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden rounded-xl border border-border bg-surface"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Department</th>
|
||||
<th className="px-4 py-3">Roles Included</th>
|
||||
<th className="px-4 py-3 text-right">Volume</th>
|
||||
<th className="px-4 py-3 text-right">Avg AI Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{ranked.map((d, index) => {
|
||||
const isLeader = index === 0;
|
||||
return (
|
||||
<tr
|
||||
key={d.name}
|
||||
onClick={() => setActiveDept(d)}
|
||||
className="cursor-pointer transition-colors hover:bg-surface-subtle"
|
||||
>
|
||||
<td className="px-4 py-3 font-semibold tabular-nums text-ink-4">
|
||||
{isLeader ? (
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400 text-caption font-bold">
|
||||
#1
|
||||
</span>
|
||||
) : (
|
||||
`0${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-heading font-semibold text-ink-1">
|
||||
{d.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-caption text-ink-3">
|
||||
{d.roles && d.roles.length ? d.roles.join(', ') : d.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-ink-2">
|
||||
{d.count} {d.count === 1 ? 'hire' : 'hires'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-heading font-bold tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block rounded-md px-2 py-0.5 text-body-sm font-bold',
|
||||
isLeader
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400'
|
||||
: 'text-ink-1'
|
||||
)}
|
||||
>
|
||||
{d.avgScore || '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Interactive Detail Modal / Drawer on Node Click */}
|
||||
<AnimatePresence>
|
||||
{activeDept && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative w-full max-w-lg rounded-2xl border border-border bg-surface p-6 shadow-2xl space-y-5"
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
{React.createElement(getDeptIcon(activeDept.name), { className: 'h-6 w-6' })}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.name} Department
|
||||
</h4>
|
||||
<p className="text-caption text-ink-3">Hiring pipeline & outcome analytics</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveDept(null)}
|
||||
className="rounded-lg p-1.5 text-ink-4 hover:bg-surface-subtle hover:text-ink-1 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Key Specs */}
|
||||
<div className="grid grid-cols-3 gap-3 rounded-xl bg-surface-subtle p-3 text-center border border-border/50">
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg AI Score</p>
|
||||
<p className="font-heading text-title font-bold text-blue-600 dark:text-blue-400">
|
||||
{activeDept.avgScore || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Total Hires</p>
|
||||
<p className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg Velocity</p>
|
||||
<p className="font-heading text-title font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hired Personnel Roster */}
|
||||
{activeDept.hiresList && activeDept.hiresList.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4">
|
||||
Hired Staff Members
|
||||
</h5>
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{activeDept.hiresList.map((h, i) => (
|
||||
<div key={h.id || i} className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-blue-700 font-bold text-caption dark:bg-blue-950 dark:text-blue-400">
|
||||
{h.name ? h.name[0] : 'S'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-body-sm text-ink-1">{h.name}</p>
|
||||
<p className="text-caption text-ink-3">{h.role || activeDept.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-semibold text-body-sm text-blue-600 dark:text-blue-400">
|
||||
{h.score || h.ai_score || activeDept.avgScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveDept(null)}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Close Inspection
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import React from 'react';
|
||||
import { BarChart } from '@mui/x-charts';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
|
||||
/**
|
||||
* The hiring flow — one surface, not six cards.
|
||||
*
|
||||
* A funnel is a shape before it is a set of numbers, so the magnitudes are drawn
|
||||
* once as a bar series and the stage-by-stage reading sits underneath it as a
|
||||
* connected strip: count, pass-through rate, and what was lost getting there.
|
||||
* Reading down a column gives one stage; reading across gives the journey.
|
||||
*
|
||||
* The weakest transition is the only thing coloured differently, because on a
|
||||
* funnel the one worth acting on is the one losing the most people.
|
||||
*
|
||||
* `stages` and `transitions` come from the page's own fact sheet — this
|
||||
* component derives no figures of its own.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
||||
if (!stages.length) return null;
|
||||
|
||||
const total = stages[0]?.count || 1;
|
||||
const rateFor = (i) => (i === 0 ? 100 : transitions[i - 1]?.rate ?? 0);
|
||||
const lostFor = (i) => (i === 0 ? 0 : transitions[i - 1]?.lost ?? 0);
|
||||
const isWeak = (i) => i > 0 && transitions[i - 1] && transitions[i - 1].to === weakestKey;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||
{/* The shape. Axis-free: the strip underneath carries every figure, so a
|
||||
y-axis would repeat what is already written. */}
|
||||
<div className="px-2 pt-3 sm:px-4">
|
||||
<BarChart
|
||||
height={168}
|
||||
margin={{ top: 12, right: 8, bottom: 24, left: 8 }}
|
||||
xAxis={[{
|
||||
scaleType: 'band',
|
||||
data: stages.map((s) => s.label),
|
||||
categoryGapRatio: 0.5,
|
||||
barGapRatio: 0,
|
||||
/* Per-stage colour through the documented axis API rather than a
|
||||
CSS nth-child guess at MUI's internal markup: the weakest
|
||||
transition is amber, every other stage is brand blue. */
|
||||
colorMap: {
|
||||
type: 'ordinal',
|
||||
values: stages.map((s) => s.label),
|
||||
colors: stages.map((_, i) => (isWeak(i) ? TONES.warning : TONES.brand)),
|
||||
},
|
||||
}]}
|
||||
/* Pinned to the data: MUI's automatic domain rounds up to roughly
|
||||
double the top stage, which leaves the funnel occupying half the
|
||||
plot and reading much flatter than it is. */
|
||||
yAxis={[{ min: 0, max: Math.max(total, 1), tickNumber: 3 }]}
|
||||
series={[{
|
||||
data: stages.map((s) => s.count),
|
||||
label: 'Candidates',
|
||||
valueFormatter: (value, ctx) => {
|
||||
const i = ctx?.dataIndex ?? 0;
|
||||
const lost = lostFor(i);
|
||||
return `${value} · ${rateFor(i)}% through${lost ? ` · ${lost} lost` : ''}`;
|
||||
},
|
||||
}]}
|
||||
colors={[TONES.brand]}
|
||||
borderRadius={6}
|
||||
grid={{ horizontal: true }}
|
||||
skipAnimation
|
||||
sx={{
|
||||
...chartSx,
|
||||
...tooltipSx,
|
||||
/* The weakest transition's bar picks up the warning tone; every
|
||||
other bar stays brand blue. */
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The reading. Scrolls rather than wraps on a phone, so the sequence
|
||||
survives at any width. */}
|
||||
<div className="overflow-x-auto border-t border-border">
|
||||
<ol className="flex min-w-[36rem] items-stretch">
|
||||
{stages.map((stage, i) => {
|
||||
const rate = rateFor(i);
|
||||
const lost = lostFor(i);
|
||||
const weak = isWeak(i);
|
||||
|
||||
return (
|
||||
<li key={stage.key} className="flex min-w-0 flex-1 items-center">
|
||||
{i > 0 && (
|
||||
<span className="flex shrink-0 flex-col items-center px-1.5" aria-hidden="true">
|
||||
<ChevronRight className={cn('h-3.5 w-3.5', weak ? 'text-warning' : 'text-ink-4')} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 px-3 py-3">
|
||||
<p className="truncate text-caption text-ink-3">{stage.label}</p>
|
||||
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{stage.count}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 flex flex-wrap items-baseline gap-x-2 text-caption tabular-nums">
|
||||
<span className={weak ? 'font-semibold text-warning' : 'text-ink-4'}>
|
||||
{i === 0 ? `${Math.round((stage.count / total) * 100)}% entered` : `${rate}% through`}
|
||||
</span>
|
||||
{lost > 0 && <span className="text-ink-4">−{lost}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LineChart } from '@mui/x-charts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
|
||||
/**
|
||||
* Cumulative hires over time.
|
||||
*
|
||||
* Deliberately does not draw a line through a single point: with one month of
|
||||
* history there is no trend, and a chart that implies one is worse than a
|
||||
* sentence saying there is not. The empty state is the page's own copy, kept
|
||||
* intact and given the same container as the chart so the section holds its
|
||||
* shape either way.
|
||||
*
|
||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
|
||||
const enough = points.length >= 2;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||
{enough ? (
|
||||
<div className="px-2 pt-3 sm:px-3">
|
||||
<LineChart
|
||||
height={200}
|
||||
margin={{ top: 16, right: 16, bottom: 24, left: 8 }}
|
||||
xAxis={[{
|
||||
scaleType: 'point',
|
||||
data: points.map((p) => p.label),
|
||||
}]}
|
||||
yAxis={[{ width: 32, tickNumber: 4 }]}
|
||||
series={[{
|
||||
data: points.map((p) => p.cumulative),
|
||||
label: 'Cumulative hires',
|
||||
area: true,
|
||||
showMark: true,
|
||||
curve: 'monotoneX',
|
||||
valueFormatter: (value, ctx) => {
|
||||
const p = points[ctx?.dataIndex ?? 0];
|
||||
return `${value} total · ${p?.hires} this month`;
|
||||
},
|
||||
}]}
|
||||
colors={[TONES.brand]}
|
||||
grid={{ horizontal: true }}
|
||||
skipAnimation
|
||||
sx={{
|
||||
...chartSx,
|
||||
...tooltipSx,
|
||||
'& .MuiAreaElement-root': { fill: TONES.brandSoft },
|
||||
'& .MuiLineElement-root': { strokeWidth: 2 },
|
||||
'& .MuiMarkElement-root': { stroke: TONES.brand, fill: '#fff', strokeWidth: 2 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
emptyState
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* KROW styling for MUI X Charts.
|
||||
*
|
||||
* MUI X is the drawing layer only — the surfaces, spacing and typography around
|
||||
* a chart stay KROW's. This module is what stops the two from disagreeing: the
|
||||
* palette is the same one the Recharts components already use (`CHART_TONES`),
|
||||
* and the `sx` block restyles MUI's axes, grid and tooltip to the design
|
||||
* system's hairlines and type scale rather than Material's defaults.
|
||||
*
|
||||
* Everything here is presentation. No chart in this folder computes a value.
|
||||
*/
|
||||
|
||||
import { CHART_TONES } from '@/components/ds/ChartContainer';
|
||||
|
||||
export const TONES = {
|
||||
brand: CHART_TONES.brand,
|
||||
brandSoft: 'rgba(10, 57, 223, 0.14)',
|
||||
warning: '#D97706',
|
||||
warningSoft: 'rgba(217, 119, 6, 0.16)',
|
||||
success: '#16A34A',
|
||||
grid: CHART_TONES.grid,
|
||||
axis: CHART_TONES.axis,
|
||||
ink: '#0F172A',
|
||||
muted: '#64748B',
|
||||
};
|
||||
|
||||
/** Type scale for chart text — matches the caption/overline sizes around it. */
|
||||
const LABEL_FONT = {
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-body)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared `sx` for every KROW chart.
|
||||
*
|
||||
* Axis lines and ticks are removed rather than restyled: on a compact chart the
|
||||
* value labels carry the reading, and a full axis frame is the "chart junk" the
|
||||
* design language avoids. The grid stays as a hairline in the same colour the
|
||||
* rest of the product uses for rules.
|
||||
*/
|
||||
export const chartSx = {
|
||||
'& .MuiChartsAxis-line': { display: 'none' },
|
||||
'& .MuiChartsAxis-tick': { display: 'none' },
|
||||
'& .MuiChartsAxis-tickLabel': {
|
||||
...LABEL_FONT,
|
||||
fill: `${TONES.muted} !important`,
|
||||
},
|
||||
'& .MuiChartsGrid-line': {
|
||||
stroke: TONES.grid,
|
||||
strokeDasharray: '3 3',
|
||||
},
|
||||
'& .MuiChartsLegend-root': { display: 'none' },
|
||||
'& .MuiBarLabel-root': {
|
||||
...LABEL_FONT,
|
||||
fontWeight: 600,
|
||||
fill: `${TONES.ink} !important`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tooltip chrome matching `Surface`.
|
||||
*
|
||||
* Passed through `slotProps.tooltip` so MUI's popper carries the design
|
||||
* system's radius, hairline border and shadow instead of Material elevation.
|
||||
*/
|
||||
export const tooltipSx = {
|
||||
'& .MuiChartsTooltip-root, & .MuiPopper-root .MuiPaper-root': {
|
||||
borderRadius: '10px',
|
||||
border: `1px solid ${TONES.grid}`,
|
||||
boxShadow: '0 8px 24px -12px rgba(15, 23, 42, 0.25)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: 12,
|
||||
},
|
||||
'& .MuiChartsTooltip-table': { fontSize: 12 },
|
||||
'& .MuiChartsTooltip-labelCell, & .MuiChartsTooltip-valueCell': {
|
||||
fontFamily: 'var(--font-body)',
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Entry animation is switched off at every call site rather than gated on
|
||||
* `prefers-reduced-motion`. Two reasons: a chart that grows out of the floor is
|
||||
* the decorative motion this product avoids, and MUI's bar transition leaves
|
||||
* every bar at zero height if the frame loop never advances — which is exactly
|
||||
* what happens in an inactive tab or an automated browser.
|
||||
*/
|
||||
197
src/components/doormile/AddressAutocomplete.jsx
Normal file
197
src/components/doormile/AddressAutocomplete.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
const useDebouncedCallback = (callback, delay) => { const timeoutRef = React.useRef(null); return React.useCallback((...args) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { callback(...args); }, delay); }, [callback, delay]); };
|
||||
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
|
||||
const toPlace = (nom) => {
|
||||
const c = nom.address || {};
|
||||
const addr = [
|
||||
{ long_name: c.house_number || '', short_name: c.house_number || '', types: ['street_number'] },
|
||||
{ long_name: c.road || '', short_name: c.road || '', types: ['route'] },
|
||||
{ long_name: c.city || c.town || c.village || '', short_name: c.city || c.town || c.village || '', types: ['locality', 'political'] },
|
||||
{ long_name: c.county || c.state_district || '', short_name: c.county || c.state_district || '', types: ['administrative_area_level_2', 'political'] },
|
||||
{ long_name: c.state || '', short_name: c.state || '', types: ['administrative_area_level_1', 'political'] },
|
||||
{ long_name: c.country || '', short_name: c.country_code?.toUpperCase() || '', types: ['country', 'political'] },
|
||||
{ long_name: c.postcode || '', short_name: c.postcode || '', types: ['postal_code'] }
|
||||
].filter((p) => p.long_name);
|
||||
|
||||
return {
|
||||
address_components: addr,
|
||||
formatted_address: nom.display_name,
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => Number(nom.lat),
|
||||
lng: () => Number(nom.lon)
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const AddressAutocomplete = ({
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onPlaceSelected,
|
||||
bias,
|
||||
fullWidth = true,
|
||||
disabled = false,
|
||||
status
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [options, setOptions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const wrapRef = useRef(null);
|
||||
const abortCtrl = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
const biasKey = bias ? `${bias.lat},${bias.lng}` : '';
|
||||
|
||||
const runSearch = useDebouncedCallback(async (query) => {
|
||||
if (!query || query.length < 3) {
|
||||
setOptions([]);
|
||||
setIsOpen(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortCtrl.current) abortCtrl.current.abort();
|
||||
abortCtrl.current = new AbortController();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = new URL(NOMINATIM_SEARCH_URL);
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('addressdetails', '1');
|
||||
url.searchParams.set('limit', '5');
|
||||
|
||||
if (bias && bias.lat && bias.lng) {
|
||||
url.searchParams.set('lat', bias.lat);
|
||||
url.searchParams.set('lon', bias.lng);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'Accept-Language': 'en-GB,en;q=0.9' },
|
||||
signal: abortCtrl.current.signal
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const data = await res.json();
|
||||
setOptions(data || []);
|
||||
setIsOpen((data || []).length > 0);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('Nominatim search error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue && inputValue !== value) runSearch(inputValue);
|
||||
}, [inputValue, biasKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocDown = (e) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocDown);
|
||||
return () => document.removeEventListener('mousedown', onDocDown);
|
||||
}, []);
|
||||
|
||||
const pick = (option) => {
|
||||
setInputValue(option.display_name || '');
|
||||
onChange?.(option.display_name || '');
|
||||
onPlaceSelected?.(toPlace(option));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative" style={{ width: fullWidth ? '100%' : undefined }}>
|
||||
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{loading && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span className="w-4 h-4 rounded-full border-2 border-t-2 border-gray-200 border-t-blue-500 animate-spin"></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && options.length > 0 && (
|
||||
<ul className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto" role="listbox">
|
||||
{options.map((option) => (
|
||||
<li key={option.place_id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(option)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
>
|
||||
<span className="block truncate">{option.display_name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AddressAutocomplete.propTypes = {
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onPlaceSelected: PropTypes.func,
|
||||
bias: PropTypes.object,
|
||||
fullWidth: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
status: PropTypes.object
|
||||
};
|
||||
|
||||
export default AddressAutocomplete;
|
||||
|
||||
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
const url = new URL(NOMINATIM_SEARCH_URL);
|
||||
url.searchParams.set('q', address);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('addressdetails', '1');
|
||||
url.searchParams.set('limit', '1');
|
||||
if (bias && bias.lat && bias.lng) {
|
||||
url.searchParams.set('lat', bias.lat);
|
||||
url.searchParams.set('lon', bias.lng);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url.toString(), { headers: { 'Accept-Language': 'en-GB,en;q=0.9' } });
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) return toPlace(data[0]);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
448
src/components/doormile/BatchAnalysis.jsx
Normal file
448
src/components/doormile/BatchAnalysis.jsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
AlertTriangle, Bike, CheckCircle2, Clock, Info, Package, RefreshCw, Sparkles, Timer, Users,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, Badge, Button, EmptyState, Grid, KpiCard, SectionHeader, Stack, Surface } from '@/components/ds';
|
||||
import { fetchBatchEfficiency } from '@/api/doormile';
|
||||
import { number } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* The solver's own read on how a wave actually ran — fleet utilisation, load
|
||||
* balance, and whether one rider should have covered another's kitchen.
|
||||
*
|
||||
* This calls the dispatch optimiser (`routes.workolik.com/batch/efficiency`),
|
||||
* not the Doormile API, so unlike everywhere else in this console the shape
|
||||
* comes from a versioned solver contract rather than something proven against
|
||||
* `express-console-api.md`. Field names below (`fleet_summary`,
|
||||
* `rider_timelines`, `substitution_opportunities`, `top_recommendation`) are
|
||||
* exactly what the source console's Dispatch Analysis tab reads.
|
||||
*
|
||||
* Three fixed windows, not a date/time picker — the solver only knows
|
||||
* morning/afternoon/evening as named batches, matching the same three waves
|
||||
* the rest of this console buckets on (`@/lib/batchBucket`), just under the
|
||||
* solver's own labels and clock ranges.
|
||||
*/
|
||||
|
||||
const ANALYSIS_WINDOWS = [
|
||||
{ key: 'morning', label: 'Morning', timeRange: '12:00 AM – 8:00 AM' },
|
||||
{ key: 'afternoon', label: 'Noon', timeRange: '9:00 AM – 12:30 PM' },
|
||||
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM' },
|
||||
];
|
||||
|
||||
/** Auto-refresh cadence for whichever window is open — matches the source
|
||||
* console's own interval. */
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
/**
|
||||
* The recommendation banner's tone when the solver found no actionable
|
||||
* substitution. The backend can return `action: 'none'` while still flagging
|
||||
* real imbalance in `fleet_balance_assessment` ("High imbalance — 57 min
|
||||
* spread") — reading only `action` and defaulting to a plain "balanced" state
|
||||
* would bury that. Text first, numeric spread/stdev as a fallback when the
|
||||
* assessment string is missing or unrecognised.
|
||||
*/
|
||||
const severityOf = ({ assessment, spreadMin, stdev }) => {
|
||||
const text = String(assessment || '').toLowerCase();
|
||||
if (/high imbalance|severe|critical/.test(text)) return 'warning';
|
||||
if (/imbalance|recommend/.test(text)) return 'warning';
|
||||
if (spreadMin != null && spreadMin > 30) return 'warning';
|
||||
if (stdev != null && stdev > 1.0) return 'warning';
|
||||
if (/balanced|excellent|good/.test(text)) return 'success';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const healthOf = (pct) => (pct == null ? 'unknown' : pct >= 85 ? 'good' : pct >= 70 ? 'ok' : 'low');
|
||||
const HEALTH_TONE = { good: 'success', ok: 'warning', low: 'destructive', unknown: 'neutral' };
|
||||
const HEALTH_LABEL = { good: 'Strong', ok: 'Moderate', low: 'Low', unknown: '—' };
|
||||
|
||||
/** userid → a real name. The solver has no auth/users table of its own, so
|
||||
* `rider_timelines` ships placeholder names like "Rider 883" — resolved
|
||||
* against the live rider rows this board already has wherever possible. */
|
||||
const resolveRiderName = (riders, uid, fallback) => {
|
||||
const key = String(uid ?? '').trim();
|
||||
if (!key) return fallback || '—';
|
||||
const placeholder = typeof fallback === 'string' && /^Rider\s+\d+$/i.test(fallback.trim());
|
||||
const live = (riders || []).find((r) => String(r.userid ?? r.id) === key);
|
||||
if (live) return live.ridername || live.name || fallback || `Rider ${key}`;
|
||||
if (!placeholder && fallback) return fallback;
|
||||
return `Rider ${key}`;
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function BatchAnalysis({ tenantId, riders = [] }) {
|
||||
const [activeKey, setActiveKey] = useState(null);
|
||||
const [results, setResults] = useState({});
|
||||
const [loadingWindow, setLoadingWindow] = useState(null);
|
||||
|
||||
/* Mirrors `loadingWindow` into a ref so the poll timer below can read the
|
||||
latest in-flight state without re-subscribing on every flip — the timer
|
||||
itself only needs to exist once per active window. */
|
||||
const loadingRef = useRef(null);
|
||||
useEffect(() => {
|
||||
loadingRef.current = loadingWindow;
|
||||
}, [loadingWindow]);
|
||||
|
||||
const analyse = useMutation({
|
||||
mutationFn: fetchBatchEfficiency,
|
||||
onMutate: (vars) => setLoadingWindow(vars.batch),
|
||||
onSuccess: (data, vars) =>
|
||||
setResults((current) => ({ ...current, [vars.batch]: { data, fetchedAt: dayjs().format('HH:mm:ss') } })),
|
||||
onSettled: () => setLoadingWindow(null),
|
||||
});
|
||||
|
||||
const openWindow = (key, { force = false } = {}) => {
|
||||
setActiveKey(key);
|
||||
const cached = results[key];
|
||||
if (!force && cached && cached.data?.success !== false) return;
|
||||
analyse.mutate({ batch: key, tenantId });
|
||||
};
|
||||
|
||||
/* Auto-refresh the active window every 15s, skipping when a request is
|
||||
already in flight (no queue-stacking on a slow network) or the tab is
|
||||
hidden (no point spending solver quota on a screen nobody is looking
|
||||
at). */
|
||||
useEffect(() => {
|
||||
if (!activeKey) return undefined;
|
||||
const tick = () => {
|
||||
if (loadingRef.current) return;
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
analyse.mutate({ batch: activeKey, tenantId });
|
||||
};
|
||||
const id = setInterval(tick, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [activeKey, tenantId]);
|
||||
|
||||
const activeMeta = ANALYSIS_WINDOWS.find((w) => w.key === activeKey);
|
||||
const cached = activeKey ? results[activeKey] : null;
|
||||
const isLoadingActive = loadingWindow === activeKey;
|
||||
const raw = cached?.data;
|
||||
|
||||
return (
|
||||
<Stack space="default">
|
||||
{/* ── Window picker ────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ANALYSIS_WINDOWS.map((w) => {
|
||||
const result = results[w.key];
|
||||
const loading = loadingWindow === w.key;
|
||||
const failed = result?.data?.success === false;
|
||||
const active = activeKey === w.key;
|
||||
return (
|
||||
<button
|
||||
key={w.key}
|
||||
type="button"
|
||||
onClick={() => !loading && openWindow(w.key)}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
'flex min-w-[10rem] flex-col items-start gap-0.5 rounded-xl border px-3.5 py-2.5 text-left transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40',
|
||||
active
|
||||
? 'border-brand bg-brand-tint/60'
|
||||
: failed
|
||||
? 'border-destructive/30 bg-destructive-muted'
|
||||
: 'border-border bg-surface hover:border-brand/40 hover:bg-brand-tint/30'
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-body-sm font-semibold text-ink-1">{w.label}</span>
|
||||
<Badge variant={failed ? 'destructive' : result ? 'success' : 'outline'} className="text-[10px]">
|
||||
{loading ? 'Loading…' : failed ? 'Failed' : result ? `✓ ${result.fetchedAt}` : 'Analyse'}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-caption text-ink-4">{w.timeRange}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Result ───────────────────────────────────────────────────────── */}
|
||||
{!activeKey ? (
|
||||
<EmptyState icon={Sparkles} size="sm" title="Pick a window" description="Choose a batch above to see how the solver rates it." />
|
||||
) : isLoadingActive && !cached ? (
|
||||
<EmptyState icon={RefreshCw} size="sm" title={`Analysing ${activeMeta?.label}…`} />
|
||||
) : !cached ? null : raw?.success === false ? (
|
||||
<Alert tone="destructive" title={raw?.error?.code || 'Request failed'}>
|
||||
{raw?.error?.message || 'The solver returned an error.'}
|
||||
{raw?.request_id && <p className="mt-1 text-caption text-ink-4">request_id: {raw.request_id}</p>}
|
||||
</Alert>
|
||||
) : (
|
||||
<BatchAnalysisResult
|
||||
meta={activeMeta}
|
||||
raw={raw}
|
||||
fetchedAt={cached.fetchedAt}
|
||||
riders={riders}
|
||||
isLoading={isLoadingActive}
|
||||
onRefresh={() => openWindow(activeKey, { force: true })}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchAnalysisResult({ meta, raw, fetchedAt, riders, isLoading, onRefresh }) {
|
||||
const fleet = raw.fleet_summary || {};
|
||||
const timelines = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : [];
|
||||
const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : [];
|
||||
const rec = raw.top_recommendation;
|
||||
const hasRecRider = Boolean(rec && (rec.idle_rider_name || rec.idle_rider_id != null));
|
||||
const hasRec = Boolean(rec && rec.action && rec.action !== 'none' && hasRecRider);
|
||||
const win = raw.window || {};
|
||||
|
||||
const util = Number.parseFloat(fleet.avg_utilisation_pct);
|
||||
const utilSafe = Number.isFinite(util) ? util : null;
|
||||
const utilHealth = healthOf(utilSafe);
|
||||
|
||||
const severity = severityOf({
|
||||
assessment: rec?.fleet_balance_assessment,
|
||||
spreadMin: Number.parseFloat(fleet.finish_time_spread_minutes),
|
||||
stdev: Number.parseFloat(fleet.load_balance_stdev),
|
||||
});
|
||||
|
||||
/* Bottleneck riders first — the one who finished last is the one an
|
||||
operator actually wants to see, not whoever the API listed first. */
|
||||
const sortedTimelines = [...timelines].sort((a, b) => String(b.finished_at || '').localeCompare(String(a.finished_at || '')));
|
||||
|
||||
return (
|
||||
<Stack space="default">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-body font-semibold text-ink-1">
|
||||
{meta?.label} batch
|
||||
{raw.date && <span className="ml-1.5 font-normal text-ink-3">· {raw.date}</span>}
|
||||
{win.from && win.to && (
|
||||
<span className="ml-1.5 font-normal text-ink-3">
|
||||
· {win.from} – {win.to}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
Fetched at {fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} loading={isLoading}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Hero strip ─────────────────────────────────────────────────── */}
|
||||
<Grid cols={3}>
|
||||
<KpiCard icon={Package} value={number(fleet.total_orders, '—')} label="Total orders" tone="brand" index={0} />
|
||||
<KpiCard
|
||||
icon={Bike}
|
||||
value={number(fleet.total_riders, '—')}
|
||||
label={fleet.orders_per_rider_avg != null ? `Active riders · ${fleet.orders_per_rider_avg} avg/rider` : 'Active riders'}
|
||||
tone="navy"
|
||||
index={1}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Clock}
|
||||
value={fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min` : '—'}
|
||||
label={fleet.fleet_start && fleet.fleet_done ? `Fleet window · ${fleet.fleet_start} → ${fleet.fleet_done}` : 'Fleet window'}
|
||||
tone="accent"
|
||||
index={2}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* ── Fleet health ───────────────────────────────────────────────── */}
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader title="Fleet health" className="mb-3" />
|
||||
<Grid cols={4}>
|
||||
<div className="rounded-lg bg-surface-subtle p-3">
|
||||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||||
{utilSafe != null ? `${utilSafe.toFixed(0)}%` : '—'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">Avg utilisation</p>
|
||||
<Badge variant={HEALTH_TONE[utilHealth]} className="mt-1 text-[10px]">
|
||||
{HEALTH_LABEL[utilHealth]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface-subtle p-3">
|
||||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||||
{fleet.load_balance_stdev != null ? Number.parseFloat(fleet.load_balance_stdev).toFixed(2) : '—'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">Load balance σ</p>
|
||||
<p className="mt-1 text-[11px] text-ink-4">
|
||||
{fleet.load_balance_stdev != null
|
||||
? Number.parseFloat(fleet.load_balance_stdev) > 1.0
|
||||
? 'Uneven workload'
|
||||
: 'Balanced'
|
||||
: 'Stdev of orders/rider'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface-subtle p-3">
|
||||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||||
{fleet.finish_time_spread_minutes != null ? `${fleet.finish_time_spread_minutes} min` : '—'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">Finish spread</p>
|
||||
<p className="mt-1 text-[11px] text-ink-4">
|
||||
{fleet.finish_time_stdev_minutes != null
|
||||
? `σ ${Number.parseFloat(fleet.finish_time_stdev_minutes).toFixed(1)} min`
|
||||
: 'First → last finish'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface-subtle p-3">
|
||||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||||
{fleet.avg_active_minutes != null ? `${Number.parseFloat(fleet.avg_active_minutes).toFixed(0)} min` : '—'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">Avg active</p>
|
||||
<p className="mt-1 text-[11px] text-ink-4">Per rider, this batch</p>
|
||||
</div>
|
||||
</Grid>
|
||||
</Surface>
|
||||
|
||||
{/* ── Top recommendation ────────────────────────────────────────── */}
|
||||
{hasRec ? (
|
||||
<Alert tone="info" icon={Sparkles} title={(rec.action || 'Recommendation').replaceAll('_', ' ')}>
|
||||
<p>
|
||||
<strong>{resolveRiderName(riders, rec.idle_rider_id, rec.idle_rider_name)}</strong>
|
||||
{rec.primary_kitchen && (
|
||||
<>
|
||||
{' '}
|
||||
· primary kitchen <strong>{rec.primary_kitchen}</strong>
|
||||
</>
|
||||
)}
|
||||
{rec.second_kitchen && (
|
||||
<>
|
||||
{' '}
|
||||
→ also serve <strong>{rec.second_kitchen}</strong> after {rec.second_kitchen_dispatch_after || '—'}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{rec.fleet_improvement_minutes != null && (
|
||||
<p className="mt-1 text-caption">
|
||||
Fleet improves by {rec.fleet_improvement_minutes} min if this substitution runs.
|
||||
</p>
|
||||
)}
|
||||
{rec.description && <p className="mt-1 text-caption">{rec.description}</p>}
|
||||
{rec.fleet_balance_assessment && <p className="mt-1 text-caption text-ink-3">{rec.fleet_balance_assessment}</p>}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert tone={severity} icon={severity === 'warning' ? AlertTriangle : severity === 'success' ? CheckCircle2 : Info}>
|
||||
<p className="font-medium">
|
||||
{severity === 'warning'
|
||||
? 'Fleet imbalance detected — no feasible substitution'
|
||||
: severity === 'success'
|
||||
? 'Fleet ran balanced — no reassignment needed'
|
||||
: 'No action recommended'}
|
||||
</p>
|
||||
{rec?.reason && <p className="mt-1 text-caption">{rec.reason}</p>}
|
||||
{rec?.fleet_balance_assessment && <p className="mt-1 text-caption text-ink-3">{rec.fleet_balance_assessment}</p>}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── Rider timelines ───────────────────────────────────────────── */}
|
||||
{sortedTimelines.length > 0 && (
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Rider timelines"
|
||||
subtitle="Sorted by latest finish — bottleneck riders first."
|
||||
className="mb-3"
|
||||
/>
|
||||
<Stack space="xs">
|
||||
{sortedTimelines.map((r) => {
|
||||
const active = String(r.status || '').toLowerCase() === 'active';
|
||||
const activeMin = Number.parseFloat(r.active_minutes);
|
||||
const idleMin = Number.parseFloat(r.idle_minutes);
|
||||
const totalMin = (Number.isFinite(activeMin) ? activeMin : 0) + (Number.isFinite(idleMin) ? idleMin : 0);
|
||||
const riderUtilPct = totalMin > 0 ? (activeMin / totalMin) * 100 : null;
|
||||
const riderHealth = healthOf(riderUtilPct);
|
||||
const completed = r.completed_orders ?? r.order_count;
|
||||
const displayName = resolveRiderName(riders, r.userid, r.name);
|
||||
return (
|
||||
<div key={r.userid} className="rounded-lg bg-surface-subtle p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Bike className="h-3.5 w-3.5 shrink-0 text-ink-3" aria-hidden="true" />
|
||||
<span className="truncate text-body-sm font-medium text-ink-1">{displayName}</span>
|
||||
<span className="shrink-0 text-caption text-ink-4">#{r.userid}</span>
|
||||
<Badge variant={active ? 'success' : 'outline'} className="shrink-0 text-[10px]">
|
||||
{r.status || '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant={HEALTH_TONE[riderHealth]} className="shrink-0 text-[10px]">
|
||||
{riderUtilPct != null ? `${riderUtilPct.toFixed(0)}% util` : '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-caption text-ink-3">
|
||||
<span>
|
||||
{r.started_at || '—'} → {r.finished_at || '—'}
|
||||
</span>
|
||||
<span>
|
||||
{r.kitchen ? (
|
||||
<>Kitchen: {r.kitchen}</>
|
||||
) : (
|
||||
<span className="text-ink-4">No primary kitchen</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{completed}/{r.order_count ?? '—'} orders
|
||||
{r.pending_orders ? ` · ${r.pending_orders} pending` : ''}
|
||||
</span>
|
||||
{r.pace_orders_per_hour != null && <span>{Number.parseFloat(r.pace_orders_per_hour).toFixed(1)}/hr pace</span>}
|
||||
{r.active_minutes != null && <span>{Number.parseFloat(r.active_minutes).toFixed(0)} min active</span>}
|
||||
{r.idle_minutes != null && (
|
||||
<span className={r.idle_minutes > 30 ? 'font-medium text-warning' : undefined}>
|
||||
{r.idle_minutes} min idle
|
||||
</span>
|
||||
)}
|
||||
{r.free_window_minutes != null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Timer className="h-3 w-3" aria-hidden="true" />
|
||||
{Number.parseFloat(r.free_window_minutes).toFixed(0)} min free before next batch
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{/* ── Substitution opportunities ────────────────────────────────── */}
|
||||
{subs.length > 0 && (
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Substitution opportunities"
|
||||
subtitle={`${subs.length} way${subs.length === 1 ? '' : 's'} an idle rider could relieve a busy one.`}
|
||||
icon={Users}
|
||||
className="mb-3"
|
||||
/>
|
||||
<Stack space="xs">
|
||||
{subs.map((s, index) => {
|
||||
const idle = s.idle_rider || {};
|
||||
const relieved = s.most_relieved_rider || {};
|
||||
const improved = s.fleet_improvement_minutes ?? 0;
|
||||
return (
|
||||
<div key={index} className="rounded-lg bg-surface-subtle p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-body-sm text-ink-1">
|
||||
<strong>{resolveRiderName(riders, idle.userid, idle.name)}</strong> covers{' '}
|
||||
<strong>{s.target_kitchen}</strong>
|
||||
</p>
|
||||
<Badge variant={improved > 0 ? 'success' : 'outline'} className="shrink-0 text-[10px]">
|
||||
Fleet {improved > 0 ? '↑' : '•'} {improved} min
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
{s.travel_to_kitchen_km} km · {s.travel_to_kitchen_minutes} min travel · arrives {s.arrive_at_kitchen} ·{' '}
|
||||
{s.total_orders_transferred} orders transferred · +{s.extra_km_for_idle_rider} km for idle rider
|
||||
</p>
|
||||
{(relieved.name || relieved.userid != null) && (
|
||||
<p className="mt-1 flex items-center gap-1 text-caption text-success">
|
||||
Most relieved: <strong>{resolveRiderName(riders, relieved.userid, relieved.name)}</strong> (
|
||||
{relieved.original_finish} → {relieved.new_finish}, saves {relieved.time_saved_minutes} min)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
102
src/components/doormile/BatchEfficiency.jsx
Normal file
102
src/components/doormile/BatchEfficiency.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Gauge, RefreshCw } from 'lucide-react';
|
||||
import { Button, EmptyState, SectionHeader, Stack, Surface } from '@/components/ds';
|
||||
import { fetchBatchEfficiency } from '@/api/doormile';
|
||||
import { messageOf } from '@/api/doormile/notify';
|
||||
import { number } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* The solver's own read on how well a wave was packed.
|
||||
*
|
||||
* This calls the dispatch optimiser, not the Doormile API, and its response has
|
||||
* no agreed schema — so nothing here assumes a field name. Whatever the service
|
||||
* returns is rendered: numbers get formatted, nested objects are shown as JSON,
|
||||
* and a shape nobody has seen before is still readable rather than blank.
|
||||
*
|
||||
* It is behind a button rather than fetched with the board because it is an
|
||||
* analysis an operator asks for, and running it automatically would put a call
|
||||
* to an external service on every date and wave change.
|
||||
*/
|
||||
|
||||
const titleise = (key) =>
|
||||
String(key)
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const renderValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'number') return number(value);
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function BatchEfficiency({ batch, orders = [], tenantId }) {
|
||||
const analyse = useMutation({
|
||||
mutationFn: () => fetchBatchEfficiency({ batch, tenantId }),
|
||||
});
|
||||
|
||||
const result = analyse.data?.data ?? analyse.data;
|
||||
const entries =
|
||||
result && typeof result === 'object' && !Array.isArray(result) ? Object.entries(result) : [];
|
||||
|
||||
return (
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Batch efficiency"
|
||||
subtitle="How well the optimiser packed this wave. Runs against the solver, on request."
|
||||
icon={Gauge}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => analyse.mutate()}
|
||||
loading={analyse.isPending}
|
||||
disabled={orders.length === 0}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> Analyse
|
||||
</Button>
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Gauge}
|
||||
size="sm"
|
||||
title="Nothing in this wave"
|
||||
description="Pick a wave with orders in it to analyse."
|
||||
/>
|
||||
) : analyse.isError ? (
|
||||
<p className="text-body-sm text-destructive">
|
||||
{messageOf(analyse.error, 'The solver did not answer')}
|
||||
</p>
|
||||
) : !analyse.data ? (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
{orders.length} order{orders.length === 1 ? '' : 's'} in this wave. Run the analysis to see how the solver
|
||||
rates it.
|
||||
</p>
|
||||
) : entries.length === 0 ? (
|
||||
/* A response that is not a flat object — an array, a scalar, an error
|
||||
envelope. Shown raw rather than dropped. */
|
||||
<pre className="overflow-x-auto rounded-lg bg-surface-sunken p-3 text-caption text-ink-2">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<Stack space="xs">
|
||||
{entries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-4 rounded-lg bg-surface-subtle px-3 py-2"
|
||||
>
|
||||
<span className="text-caption uppercase tracking-wide text-ink-3">{titleise(key)}</span>
|
||||
<span className="text-body-sm font-semibold text-ink-1">{renderValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
390
src/components/doormile/ComparePanel.jsx
Normal file
390
src/components/doormile/ComparePanel.jsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ChevronDown, MapPin, Radio, Route, Shuffle, TriangleAlert } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, Drawer, EmptyState, Grid, KpiCard, Stack, StatusBadge, Surface } from '@/components/ds';
|
||||
import { useDeliveryTracks } from '@/lib/doormileHooks';
|
||||
import { FINAL_STATUSES, ordinal, polylineLengthKm } from '@/lib/dispatchShared';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { km as formatKm, number, orDash } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Plan against actual, for one rider's day.
|
||||
*
|
||||
* The board shows what was planned. This answers the question an operator asks
|
||||
* afterwards: did the rider follow it, and where did the day go wrong.
|
||||
*
|
||||
* The actual track comes from `GET /admin/consignments/:id/logs` — **one call
|
||||
* per stop**, not one per rider. That is what the source console does, and it
|
||||
* matters: the comparison is per stop, so per-stop distance is measured from
|
||||
* that stop's own trail rather than apportioned from a rider-level total.
|
||||
*
|
||||
* A `deliveryid` here has never been confirmed to be a consignment id. If it is
|
||||
* really a booking id the call 404s and that stop simply reports no trail —
|
||||
* degraded, never wrong.
|
||||
*
|
||||
* Every figure is a comparison, so a stop with no track or no timestamp is
|
||||
* reported as unknown rather than silently scored as compliant.
|
||||
*/
|
||||
|
||||
/** Five minutes is the grace an operator treats as on time. */
|
||||
const ON_TIME_GRACE_MIN = 5;
|
||||
|
||||
/** A quarter over plan, or a quarter-hour late, is worth a look. */
|
||||
const KM_ANOMALY_FACTOR = 1.25;
|
||||
const LATE_ANOMALY_MIN = 15;
|
||||
|
||||
/* Full, literal class/color strings per band — Tailwind's scanner matches
|
||||
source text, not evaluated JS, so a template-built `text-${tone}` class
|
||||
name would silently compile to nothing. Keyed by the same tone names the
|
||||
design system's Alert/Badge tones already use. */
|
||||
const SCORE_BANDS = {
|
||||
success: { tone: 'success', label: 'Excellent', ring: 'hsl(var(--success))', text: 'text-success' },
|
||||
warning: { tone: 'warning', label: 'Acceptable', ring: 'hsl(var(--warning))', text: 'text-warning' },
|
||||
destructive: { tone: 'destructive', label: 'Needs review', ring: 'hsl(var(--destructive))', text: 'text-destructive' },
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function ComparePanel({ open, onClose, riderName, rows = [], date }) {
|
||||
/* Sorted by planned step so the stop numbering matches the board and the map. */
|
||||
const stops = useMemo(
|
||||
() => [...rows].sort((a, b) => (Number(a.step) || 0) - (Number(b.step) || 0)),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const deliveryIds = useMemo(
|
||||
() => stops.map((row) => row.deliveryid ?? row.consignmentid).filter((id) => id != null && id !== ''),
|
||||
[stops]
|
||||
);
|
||||
|
||||
const trackQueries = useDeliveryTracks(deliveryIds, open);
|
||||
|
||||
const deltas = useMemo(
|
||||
() =>
|
||||
stops.map((row, index) => {
|
||||
const id = row.deliveryid ?? row.consignmentid;
|
||||
const position = deliveryIds.indexOf(id);
|
||||
const query = position >= 0 ? trackQueries[position] : null;
|
||||
const track = query?.data || [];
|
||||
|
||||
const plannedKm = Number(row.kms) || 0;
|
||||
/* Measured from this stop's own trail. Falls back to the backend's
|
||||
`actualkms` when the trail is empty, and to nothing at all when
|
||||
neither exists — never to a guess. */
|
||||
const trailKm = track.length >= 2 ? polylineLengthKm(track.map((point) => [point.lat, point.lng])) : 0;
|
||||
const actualKm = trailKm || Number(row.actualkms) || 0;
|
||||
|
||||
const expectedAt = row.expecteddeliverytime ? parseDoormileTimestamp(row.expecteddeliverytime) : null;
|
||||
const deliveredAt = row.deliverydate ? parseDoormileTimestamp(row.deliverydate) : null;
|
||||
const timeDeltaMin =
|
||||
expectedAt?.isValid() && deliveredAt?.isValid() ? deliveredAt.diff(expectedAt, 'minute') : null;
|
||||
|
||||
const anomaly =
|
||||
(plannedKm > 0 && actualKm > plannedKm * KM_ANOMALY_FACTOR) ||
|
||||
(timeDeltaMin != null && timeDeltaMin > LATE_ANOMALY_MIN);
|
||||
|
||||
return {
|
||||
step: Number(row.step) || index + 1,
|
||||
row,
|
||||
plannedKm,
|
||||
actualKm,
|
||||
hasTrack: track.length >= 2,
|
||||
isLoading: query?.isLoading || query?.isFetching,
|
||||
timeDeltaMin,
|
||||
anomaly,
|
||||
/* Kept for the route-sequence diff below — the visit order can only
|
||||
be derived from a real delivery timestamp, not the plan. */
|
||||
deliveredAt: deliveredAt?.isValid() ? deliveredAt : null,
|
||||
};
|
||||
}),
|
||||
[stops, deliveryIds, trackQueries]
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const measured = deltas.filter((delta) => delta.hasTrack);
|
||||
const timed = deltas.filter((delta) => delta.timeDeltaMin != null);
|
||||
const plannedKm = deltas.reduce((sum, delta) => sum + delta.plannedKm, 0);
|
||||
const actualKm = measured.reduce((sum, delta) => sum + delta.actualKm, 0);
|
||||
|
||||
return {
|
||||
stops: deltas.length,
|
||||
plannedKm,
|
||||
actualKm,
|
||||
measured: measured.length,
|
||||
anomalies: deltas.filter((delta) => delta.anomaly).length,
|
||||
onTime: timed.filter((delta) => delta.timeDeltaMin <= ON_TIME_GRACE_MIN).length,
|
||||
late: timed.filter((delta) => delta.timeDeltaMin > ON_TIME_GRACE_MIN).length,
|
||||
untimed: deltas.length - timed.length,
|
||||
delivered: deltas.filter((delta) => FINAL_STATUSES.has(String(delta.row.orderstatus || '').toLowerCase())).length,
|
||||
};
|
||||
}, [deltas]);
|
||||
|
||||
/**
|
||||
* Compliance score (0–100): 60% delivered + 25% on-time + 15% no-deviation.
|
||||
* A single number an ops lead can scan without reading every row — weights
|
||||
* unchanged from the source console so a score here and there read the
|
||||
* same for the same day.
|
||||
*/
|
||||
const score = useMemo(() => {
|
||||
const total = deltas.length || 1;
|
||||
const onTimeTotal = summary.onTime + summary.late || 1;
|
||||
const value = Math.round(
|
||||
(summary.delivered / total) * 60 + (summary.onTime / onTimeTotal) * 25 + ((total - summary.anomalies) / total) * 15
|
||||
);
|
||||
const band = value >= 85 ? SCORE_BANDS.success : value >= 65 ? SCORE_BANDS.warning : SCORE_BANDS.destructive;
|
||||
return { value, ...band };
|
||||
}, [deltas.length, summary]);
|
||||
|
||||
/**
|
||||
* Route sequence — did the rider visit stops in the planned order? Actual
|
||||
* order is derived from delivery timestamps (only stops that carry one can
|
||||
* be placed), then diffed against each stop's planned `step`.
|
||||
*
|
||||
* Consecutive stops that shifted by the same amount collapse into one run
|
||||
* ("4 consecutive shifted +2") — a single early or late stop otherwise
|
||||
* cascades into a false "everything after it is wrong" reading.
|
||||
*/
|
||||
const sequence = useMemo(() => {
|
||||
const actualOrdered = deltas
|
||||
.filter((delta) => delta.deliveredAt)
|
||||
.sort((a, b) => a.deliveredAt.valueOf() - b.deliveredAt.valueOf());
|
||||
|
||||
const outOfOrder = actualOrdered
|
||||
.map((delta, index) => ({ delta, actualPos: index + 1 }))
|
||||
.filter(({ delta, actualPos }) => delta.step != null && delta.step !== actualPos);
|
||||
|
||||
const runs = [];
|
||||
outOfOrder.forEach(({ delta, actualPos }) => {
|
||||
const shift = actualPos - delta.step;
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.shift === shift && last.lastActualPos + 1 === actualPos) {
|
||||
last.items.push({ delta, actualPos, planned: delta.step });
|
||||
last.lastActualPos = actualPos;
|
||||
} else {
|
||||
runs.push({ shift, items: [{ delta, actualPos, planned: delta.step }], lastActualPos: actualPos });
|
||||
}
|
||||
});
|
||||
|
||||
return { actualOrdered, outOfOrder, runs };
|
||||
}, [deltas]);
|
||||
|
||||
const loading = deltas.some((delta) => delta.isLoading);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={`Plan vs actual · ${riderName || ''}`}
|
||||
description={`${stops.length} stop${stops.length === 1 ? '' : 's'} on ${date}`}
|
||||
icon={Route}
|
||||
size="xl"
|
||||
>
|
||||
<Stack space="default">
|
||||
{deltas.length > 0 && (
|
||||
<Surface radius="xl" padding="lg" className="flex items-center gap-4">
|
||||
<div
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(${score.ring} ${score.value * 3.6}deg, hsl(var(--surface-sunken)) 0deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="grid h-12 w-12 place-items-center rounded-full bg-surface text-body font-bold tabular-nums text-ink-1">
|
||||
{score.value}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">
|
||||
Compliance score · <span className={score.text}>{score.label}</span>
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
60% delivered, 25% on time, 15% no deviation — {summary.delivered}/{deltas.length} delivered,{' '}
|
||||
{summary.onTime}/{summary.onTime + summary.late || 0} on time, {deltas.length - summary.anomalies}/{deltas.length}{' '}
|
||||
within plan.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={MapPin} value={formatKm(summary.plannedKm)} label="Planned" tone="brand" index={0} />
|
||||
<KpiCard
|
||||
icon={Radio}
|
||||
value={summary.measured ? formatKm(summary.actualKm) : '—'}
|
||||
label={summary.measured ? `Actual · ${summary.measured} measured` : 'No GPS trail'}
|
||||
tone="navy"
|
||||
index={1}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={TriangleAlert}
|
||||
value={number(summary.anomalies, '0')}
|
||||
label="Stops flagged"
|
||||
tone={summary.anomalies ? 'warning' : 'success'}
|
||||
index={2}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Route}
|
||||
value={summary.onTime + summary.late ? `${summary.onTime}/${summary.onTime + summary.late}` : '—'}
|
||||
label="On time"
|
||||
tone="success"
|
||||
index={3}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{loading && <p className="text-caption text-ink-3">Fetching each stop's GPS trail…</p>}
|
||||
|
||||
{!loading && summary.measured === 0 && (
|
||||
<p className="text-caption text-ink-3">
|
||||
No stop on this rider returned a GPS trail for {date}, so actual distance cannot be measured. The rows
|
||||
below still show the plan and, where a delivery time was recorded, how late it ran.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{summary.untimed > 0 && (
|
||||
<p className="text-caption text-ink-3">
|
||||
{summary.untimed === 1 ? '1 stop carries' : `${summary.untimed} stops carry`} no recorded delivery time, so
|
||||
they are counted as neither on time nor late.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<RouteSequence sequence={sequence} totalTimed={sequence.actualOrdered.length} />
|
||||
|
||||
{deltas.length === 0 ? (
|
||||
<EmptyState icon={Route} size="sm" title="No stops on this rider" />
|
||||
) : (
|
||||
<Stack space="xs">
|
||||
{deltas.map((delta) => (
|
||||
<Surface
|
||||
key={delta.row.orderheaderid ?? delta.step}
|
||||
variant={delta.anomaly ? 'outline' : 'subtle'}
|
||||
padding="sm"
|
||||
radius="lg"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-brand-tint text-caption font-semibold text-brand">
|
||||
{delta.step}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{orDash(delta.row.orderid)}
|
||||
{delta.anomaly && (
|
||||
<TriangleAlert className="ml-1.5 inline h-3.5 w-3.5 text-warning" aria-label="Outside plan" />
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-caption text-ink-3">{orDash(delta.row.deliveryaddress)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-4 text-caption tabular-nums">
|
||||
<span className="text-ink-3">
|
||||
plan {formatKm(delta.plannedKm)}
|
||||
{delta.hasTrack ? ` · actual ${formatKm(delta.actualKm)}` : ''}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
delta.timeDeltaMin == null
|
||||
? 'text-ink-4'
|
||||
: delta.timeDeltaMin > ON_TIME_GRACE_MIN
|
||||
? 'font-semibold text-destructive'
|
||||
: 'font-semibold text-success'
|
||||
}
|
||||
>
|
||||
{delta.timeDeltaMin == null
|
||||
? 'no time'
|
||||
: delta.timeDeltaMin > 0
|
||||
? `+${delta.timeDeltaMin}m`
|
||||
: `${delta.timeDeltaMin}m`}
|
||||
</span>
|
||||
<StatusBadge status={delta.row.orderstatus} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<p className="text-caption text-ink-3">
|
||||
Actual distance is measured from each stop's own GPS trail, smoothed to remove receiver jitter before it
|
||||
is summed. A stop whose trail is unavailable shows its plan only.
|
||||
</p>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the rider visit stops in the planned order? Collapsed to nothing when
|
||||
* every timed stop matched its plan — a section that only ever says "all
|
||||
* good" earns its place by staying invisible until there's something to say.
|
||||
*/
|
||||
function RouteSequence({ sequence, totalTimed }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { runs } = sequence;
|
||||
|
||||
if (totalTimed < 2) return null;
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<Surface variant="subtle" padding="sm" radius="lg" className="flex items-center gap-2">
|
||||
<Route className="h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
|
||||
<p className="text-caption text-ink-2">
|
||||
Route followed the plan — every timed stop was visited in its planned order.
|
||||
</p>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
const shifted = runs.reduce((sum, run) => sum + run.items.length, 0);
|
||||
|
||||
return (
|
||||
<Surface radius="xl" padding="sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex w-full items-center justify-between gap-2 px-1 py-1"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-body-sm font-medium text-ink-1">
|
||||
<Shuffle className="h-3.5 w-3.5 text-warning" aria-hidden="true" />
|
||||
Route sequence
|
||||
<Badge variant="warning" size="sm">
|
||||
{shifted} shifted
|
||||
</Badge>
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 text-ink-4 transition-transform', open && 'rotate-180')} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<Stack space="xs" className="mt-2">
|
||||
{runs.map((run, index) => {
|
||||
const first = run.items[0];
|
||||
const last = run.items[run.items.length - 1];
|
||||
const single = run.items.length === 1;
|
||||
return (
|
||||
<div key={index} className="rounded-lg bg-surface-subtle px-3 py-2">
|
||||
<p className="text-caption text-ink-2">
|
||||
{single ? (
|
||||
<>
|
||||
Visited <strong>{ordinal(first.actualPos)}</strong>, planned <strong>{ordinal(first.planned)}</strong>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{run.items.length} consecutive stops visited <strong>{ordinal(first.actualPos)}</strong>–
|
||||
<strong>{ordinal(last.actualPos)}</strong>, planned{' '}
|
||||
<strong>
|
||||
{ordinal(first.planned)}–{ordinal(last.planned)}
|
||||
</strong>{' '}
|
||||
({run.shift > 0 ? '+' : ''}
|
||||
{run.shift})
|
||||
</>
|
||||
)}
|
||||
{' · '}
|
||||
{orDash(first.delta.row.orderid)}
|
||||
{!single && ` – ${orDash(last.delta.row.orderid)}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
323
src/components/doormile/DispatchMap.jsx
Normal file
323
src/components/doormile/DispatchMap.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { MapContainer, Marker, Polyline, Popup, TileLayer, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { orDash } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* The live dispatch map.
|
||||
*
|
||||
* Markers are built as `divIcon` HTML rather than React components — Leaflet
|
||||
* renders an icon from a raw HTML string, so a component cannot go inside one.
|
||||
* That is the only reason the colours here are inline strings instead of the
|
||||
* design system's classes.
|
||||
*
|
||||
* Riders are polled and re-render far more often than orders, so the two marker
|
||||
* sets are memoised independently: rebuilding forty order pins on every rider
|
||||
* ping is what makes a live map stutter.
|
||||
*/
|
||||
|
||||
/* Leaflet's default icon sprite resolves against a bundler path Vite cannot
|
||||
provide, which silently renders every default marker as a broken image. Every
|
||||
marker here carries its own divIcon, but the shim stays so a marker added
|
||||
later without one still draws. */
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
const STATUS_COLOR = {
|
||||
pending: '#94a3b8',
|
||||
accepted: '#0ea5e9',
|
||||
arrived: '#0ea5e9',
|
||||
picked: '#8b5cf6',
|
||||
active: '#f59e0b',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
skipped: '#ef4444',
|
||||
};
|
||||
|
||||
const RIDER_COLOR = {
|
||||
available: '#10b981',
|
||||
assigned: '#6366f1',
|
||||
on_pickup: '#14b8a6',
|
||||
offline: '#94a3b8',
|
||||
blocked: '#7f1d1d',
|
||||
};
|
||||
|
||||
const BRAND = '#C8102E';
|
||||
|
||||
/* Glide bounds. Below the minimum the movement is a jump rather than motion;
|
||||
above the maximum a stalled feed leaves the marker drifting long after the
|
||||
rider stopped reporting. */
|
||||
const MIN_GLIDE_MS = 400;
|
||||
const MAX_GLIDE_MS = 4000;
|
||||
|
||||
const pinIcon = (color, label, selected = false) =>
|
||||
L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="display:grid;place-items:center;width:${selected ? 32 : 26}px;height:${selected ? 32 : 26}px;
|
||||
border-radius:50%;background:${color};color:#fff;font:600 ${selected ? 12 : 11}px/1 system-ui,sans-serif;
|
||||
box-shadow:0 2px 6px rgba(16,24,40,.35);border:${selected ? 3 : 2}px solid #fff;">${label}</span>`,
|
||||
iconSize: selected ? [32, 32] : [26, 26],
|
||||
iconAnchor: selected ? [16, 16] : [13, 13],
|
||||
});
|
||||
|
||||
/**
|
||||
* A rider marker that glides to each new fix instead of teleporting.
|
||||
*
|
||||
* The effect keys off the primitive lat/lon, never the `[lat, lon]` array: the
|
||||
* parent re-renders on every poll and hands down a fresh array each time, so an
|
||||
* array-keyed effect would cancel and restart the glide continuously and the
|
||||
* marker would visibly stutter. Keying off the numbers means a glide only
|
||||
* restarts when the rider actually moved, and each segment plays out.
|
||||
*
|
||||
* Interpolation is straight-line. The source console road-snaps each segment
|
||||
* through OSRM, which is one routing request per rider per poll — on the public
|
||||
* demo router that is both rate-limited and slower than the poll interval, so
|
||||
* the snapped path routinely arrived after the segment it described had ended.
|
||||
*/
|
||||
function AnimatedRiderMarker({ target, icon, zIndexOffset, eventHandlers, children, duration = 1000 }) {
|
||||
const markerRef = useRef(null);
|
||||
const frameRef = useRef(null);
|
||||
const previousFixRef = useRef(null);
|
||||
const mountRef = useRef(target);
|
||||
|
||||
const lat = Array.isArray(target) ? Number(target[0]) : NaN;
|
||||
const lon = Array.isArray(target) ? Number(target[1]) : NaN;
|
||||
|
||||
useEffect(() => {
|
||||
const marker = markerRef.current;
|
||||
if (!marker || !Number.isFinite(lat) || !Number.isFinite(lon)) return undefined;
|
||||
|
||||
const to = L.latLng(lat, lon);
|
||||
const from = marker.getLatLng();
|
||||
if (!from) {
|
||||
marker.setLatLng(to);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dLat = to.lat - from.lat;
|
||||
const dLng = to.lng - from.lng;
|
||||
|
||||
/* Under about a tenth of a metre there is nothing to animate. */
|
||||
if (Math.abs(dLat) < 1e-6 && Math.abs(dLng) < 1e-6) {
|
||||
marker.setLatLng(to);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* A jump of more than 2 km is a bad fix, not travel. Snap, so one wild
|
||||
reading does not drag the marker across the city. */
|
||||
let bigJump = false;
|
||||
try {
|
||||
bigJump = from.distanceTo(to) > 2000;
|
||||
} catch {
|
||||
bigJump = false;
|
||||
}
|
||||
if (bigJump) {
|
||||
marker.setLatLng(to);
|
||||
previousFixRef.current = performance.now();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
|
||||
const startTs = performance.now();
|
||||
/* Sized to the real gap since the last fix, so the marker keeps moving for
|
||||
the whole interval instead of darting and then freezing. */
|
||||
const gap = previousFixRef.current == null ? duration : startTs - previousFixRef.current;
|
||||
const segment = Math.max(MIN_GLIDE_MS, Math.min(MAX_GLIDE_MS, gap));
|
||||
previousFixRef.current = startTs;
|
||||
|
||||
const startLat = from.lat;
|
||||
const startLng = from.lng;
|
||||
let active = true;
|
||||
|
||||
const step = (now) => {
|
||||
if (!active) return;
|
||||
const t = Math.min(1, (now - startTs) / segment);
|
||||
marker.setLatLng([startLat + dLat * t, startLng + dLng * t]);
|
||||
if (t < 1) frameRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
};
|
||||
}, [lat, lon, duration]);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
position={mountRef.current}
|
||||
icon={icon}
|
||||
zIndexOffset={zIndexOffset}
|
||||
eventHandlers={eventHandlers}
|
||||
>
|
||||
{children}
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fits the viewport to whatever is currently plotted. */
|
||||
function FitBounds({ points }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!points.length) return;
|
||||
/* A single point has no extent — `fitBounds` on it zooms to maximum, which
|
||||
looks like the map broke. Centre instead. */
|
||||
if (points.length === 1) {
|
||||
map.setView(points[0], 14);
|
||||
return;
|
||||
}
|
||||
map.fitBounds(L.latLngBounds(points), { padding: [40, 40], maxZoom: 15 });
|
||||
}, [points, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const coordsOf = (lat, lng) => {
|
||||
const latitude = Number(lat);
|
||||
const longitude = Number(lng);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (latitude === 0 && longitude === 0) return null;
|
||||
return [latitude, longitude];
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function DispatchMap({
|
||||
orders = [],
|
||||
riders = [],
|
||||
/** The rider whose planned stops and actual track are drawn, if any. */
|
||||
selectedRiderId = null,
|
||||
onRiderSelect,
|
||||
/** `[[lat, lng], …]` — the rider's actual GPS trail, drawn behind the plan. */
|
||||
actualTrack = [],
|
||||
height = 460,
|
||||
className,
|
||||
}) {
|
||||
const orderMarkers = useMemo(
|
||||
() =>
|
||||
orders
|
||||
.map((order) => {
|
||||
const position = coordsOf(order.deliverylatitude ?? order.droplat, order.deliverylongitude ?? order.droplon);
|
||||
if (!position) return null;
|
||||
const status = String(order.orderstatus || '').toLowerCase();
|
||||
return {
|
||||
id: order.orderheaderid ?? order.orderid,
|
||||
position,
|
||||
color: STATUS_COLOR[status] || '#94a3b8',
|
||||
riderId: order.userid != null ? String(order.userid) : null,
|
||||
order,
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
[orders]
|
||||
);
|
||||
|
||||
const riderMarkers = useMemo(
|
||||
() =>
|
||||
riders
|
||||
.map((rider) => {
|
||||
const position = coordsOf(rider.latitude, rider.longitude);
|
||||
if (!position) return null;
|
||||
const id = rider.userid != null ? String(rider.userid) : String(rider.milerprofileid);
|
||||
return {
|
||||
id,
|
||||
position,
|
||||
color: RIDER_COLOR[String(rider.status || '').toLowerCase()] || '#6366f1',
|
||||
rider,
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
[riders]
|
||||
);
|
||||
|
||||
/* The selected rider's stops, in planned order — the line the operator is
|
||||
comparing the actual track against. */
|
||||
const plannedRoute = useMemo(() => {
|
||||
if (!selectedRiderId) return [];
|
||||
return orderMarkers
|
||||
.filter((marker) => marker.riderId === String(selectedRiderId))
|
||||
.sort((a, b) => (Number(a.order.step) || 0) - (Number(b.order.step) || 0))
|
||||
.map((marker) => marker.position);
|
||||
}, [orderMarkers, selectedRiderId]);
|
||||
|
||||
const allPoints = useMemo(
|
||||
() => [...orderMarkers.map((m) => m.position), ...riderMarkers.map((m) => m.position)],
|
||||
[orderMarkers, riderMarkers]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} style={{ height }}>
|
||||
<MapContainer
|
||||
center={[11.0168, 76.9558]}
|
||||
zoom={12}
|
||||
scrollWheelZoom
|
||||
style={{ height: '100%', width: '100%', borderRadius: '1rem' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<FitBounds points={allPoints} />
|
||||
|
||||
{/* Actual first, plan on top — the plan is the reference, and drawing it
|
||||
underneath would hide it wherever the two agree. */}
|
||||
{actualTrack.length >= 2 && (
|
||||
<Polyline positions={actualTrack} pathOptions={{ color: '#64748b', weight: 3, opacity: 0.7 }} />
|
||||
)}
|
||||
{plannedRoute.length >= 2 && (
|
||||
<Polyline positions={plannedRoute} pathOptions={{ color: BRAND, weight: 3, dashArray: '6 6' }} />
|
||||
)}
|
||||
|
||||
{orderMarkers.map((marker) => (
|
||||
<Marker
|
||||
key={`order-${marker.id}`}
|
||||
position={marker.position}
|
||||
icon={pinIcon(marker.color, marker.order.step || '●')}
|
||||
>
|
||||
<Popup>
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>{orDash(marker.order.orderid)}</p>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12 }}>{orDash(marker.order.deliverycustomer)}</p>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, opacity: 0.75 }}>
|
||||
{orDash(marker.order.deliveryaddress)}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12 }}>
|
||||
{orDash(marker.order.ridername)} · {orDash(marker.order.orderstatus)}
|
||||
</p>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{riderMarkers.map((marker) => (
|
||||
<AnimatedRiderMarker
|
||||
key={`rider-${marker.id}`}
|
||||
target={marker.position}
|
||||
icon={pinIcon(
|
||||
marker.color,
|
||||
String(marker.rider.username || 'R').charAt(0).toUpperCase(),
|
||||
String(selectedRiderId) === marker.id
|
||||
)}
|
||||
zIndexOffset={2500}
|
||||
eventHandlers={{ click: () => onRiderSelect?.(marker.rider) }}
|
||||
>
|
||||
<Popup>
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>{orDash(marker.rider.username)}</p>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12 }}>{orDash(marker.rider.status)}</p>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, opacity: 0.75 }}>
|
||||
Last ping {formatDoormileTimestamp(marker.rider.logdate, 'hh:mm A', 'unknown')}
|
||||
</p>
|
||||
</Popup>
|
||||
</AnimatedRiderMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user