update on the agents and registry side

This commit is contained in:
2026-09-30 12:03:13 +05:30
parent 92e8da2842
commit 53199127ba
73 changed files with 7268 additions and 1134 deletions

View File

@@ -28,6 +28,7 @@ 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 ClientOnboarding = lazy(() => import('@/pages/doormile/clients/ClientOnboarding'));
const CreateCustomer = lazy(() => import('@/pages/doormile/clients/CreateCustomer'));
const Pricing = lazy(() => import('@/pages/doormile/pricing/Pricing'));
@@ -124,6 +125,8 @@ const AuthenticatedApp = () => {
<Route path="tenants" element={<Tenants />} />
<Route path="clients/create" element={<CreateClient />} />
{/* Owner-only (admin@doormile.com); the page and the server both check. */}
<Route path="clients/onboard" element={<ClientOnboarding />} />
<Route path="customer/create" element={<CreateCustomer />} />
<Route path="pricing" element={<Pricing />} />

View File

@@ -166,6 +166,31 @@ export const createAdminTenant = async (data) => {
return response.data;
};
/**
* Onboard a client: the tenant, its console login (doormile_auth) and its
* appusers row, in one server transaction. Only the onboarding owner login
* (admin@doormile.com) may call it; anyone else gets 403.
* @param {{ companyname: string, contactname: string, email: string, phone: string,
* password: string, applocationid: number, requiredeliveryotp?: boolean }} data
* @returns {Promise<{ success: boolean, data: { tenant: object, login: object } }>}
*/
export const onboardClient = async (data) => {
const response = await doormileAxios.post('/admin/clients/onboard', data);
return response.data;
};
/** Operating cities a new client can be placed in (applocations; owner login only). */
export const getOnboardingCities = async () => {
const response = await doormileAxios.get('/admin/clients/cities');
return response.data.data;
};
/** Clients that have a console login, newest first (owner login only). */
export const getOnboardedClients = async () => {
const response = await doormileAxios.get('/admin/clients/onboarded');
return response.data.data;
};
export const getAdminTenant = async (id) => {
const response = await doormileAxios.get(`/admin/tenants/${id}`);
return response.data.data;
@@ -728,3 +753,80 @@ export const deleteCarrierPricing = async (id) => {
const response = await doormileAxios.delete(`/admin/carrier-pricing/${id}`);
return response.data;
};
// ==============================|| AI agent registry ||============================== //
// /admin/ai/* — Doormile staff only (a partner-tenant login gets 403). Reads
// are open to admin/manager/executive; every write is admin-only and audited
// server-side. See docs/agent-platform-plan.md.
export const getAiAgents = async () => {
const response = await doormileAxios.get('/admin/ai/agents');
return response.data.data;
};
export const getAiSkills = async () => {
const response = await doormileAxios.get('/admin/ai/skills');
return response.data.data;
};
export const getAiTools = async () => {
const response = await doormileAxios.get('/admin/ai/tools');
return response.data.data;
};
export const getAiRegistryAudit = async (limit) => {
const response = await doormileAxios.get(`/admin/ai/audit${buildQuery({ limit })}`);
return response.data.data;
};
/** @param {string} id @param {{ enabled?: boolean, thresholds?: Record<string, number> }} patch */
export const updateAiSkill = async (id, patch) => {
const response = await doormileAxios.patch(`/admin/ai/skills/${encodeURIComponent(id)}`, patch);
return response.data;
};
/** @param {{ agentid: string, title: string, category?: string, description?: string, sampleprompt?: string, tools: string[] }} skill */
export const createAiSkill = async (skill) => {
const response = await doormileAxios.post('/admin/ai/skills', skill);
return response.data;
};
/**
* Autonomy ON needs `confirm` set to the agent id — the server refuses it
* otherwise, so a stray click cannot let an agent act without a human.
* @param {string} id @param {{ autonomous?: boolean, model?: string, confirm?: string }} patch
*/
export const updateAiAgent = async (id, patch) => {
const response = await doormileAxios.patch(`/admin/ai/agents/${encodeURIComponent(id)}`, patch);
return response.data;
};
/**
* What the agents did over the last `days` (1–30): runs and failures per
* AI_engine agent, decisions by type/outcome, live heartbeat. `receiving` is
* false when the backend is not subscribed to AI_engine telemetry.
* @param {number} days
*/
export const getAiInsights = async (days = 7) => {
const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`);
return response.data.data;
};
/**
* Agent Studio's Test tab: one prompt through Claude with a skill's tools.
* Read tools run with personal data redacted; write tools come back as
* proposals and are never executed. Admin only. 503 (code
* PLAYGROUND_NOT_CONFIGURED) when the server has no Claude client; 429 when
* the per-user limit is reached.
* @param {{ agentid: string, skillid?: string, prompt: string }} body
*/
export const runAiPlayground = async (body) => {
const response = await doormileAxios.post('/admin/ai/playground/run', body);
return response.data.data;
};
/** Recent agent decisions, newest first. `before` is the last id of the previous page. */
export const getAiDecisions = async ({ type, before, limit } = {}) => {
const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`);
return response.data.data;
};

View File

@@ -12,7 +12,8 @@ import {
LuUserPlus,
LuPackagePlus,
LuListPlus,
LuRepeat
LuRepeat,
LuShieldAlert
} from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
@@ -45,6 +46,7 @@ import {
// suggestion with no entry here simply shows its full text.
// ---------------------------------------------------------------------------
export const CHIP_LABELS = {
'What needs attention right now?': 'Needs attention',
"Give me today's operations summary": "Today's summary",
'How many orders today?': 'Orders today',
'Which orders are delayed?': 'Delayed orders',
@@ -171,6 +173,9 @@ const TRIPSHEETS = {
const EXCEPTIONS = {
label: 'Exceptions',
suggestions: [
// The ops briefing (intents.js opsBriefing) — the same rules as the early
// warnings banner on this page, asked for in words.
{ icon: LuShieldAlert, text: 'What needs attention right now?' },
{ icon: LuCircleDot, text: 'How many open exceptions?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' }
],

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Sparkles, SlidersHorizontal, RefreshCw } from 'lucide-react';
import SlaRemediationCard from './SlaRemediationCard';
import { scanBookings } from '@/lib/assistant/scan';
import { toAgentRows } from '@/lib/assistant/agent/normalise';
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
import { wallClockNow } from '@/lib/assistant/agent/signals';
import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions';
import { Button } from '@/components/ui/button';
/**
* AgentOperationsBanner
*
* Early warnings from the console's monitoring skills. It evaluates the live
* booking scan against every skill ENABLED IN THE AGENT REGISTRY, with the
* registry's thresholds (Settings → Skills & Tools). Nothing here acts on its
* own: a finding's action runs only when an operator clicks it, and actions
* with no working endpoint render "Review only".
*
* Ported from feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
* The branch opened a localStorage-only settings modal from here; that modal
* was not ported — "Configure skills" goes to the registry page instead.
*
* Props:
* - category: string | string[] (e.g. 'sla_management', 'rider_safety', 'fleet_optimization', 'loss_prevention')
* - skillIds: string[] (e.g. ['skill_sla_guardian', 'skill_fleet_balancer'])
* - title: string (Custom banner title)
* - maxItems: number (Default 4)
* - hideWhenClean: boolean (If true, renders nothing when all clear)
*/
export default function AgentOperationsBanner({
category,
skillIds,
title = 'Early warnings',
maxItems = 4,
hideWhenClean = false,
className = ''
}) {
const navigate = useNavigate();
// Which settings the rules are running on — the registry's, or the code
// defaults while it is unreachable. Said on screen, so a tuned threshold is
// never assumed when it is not in force.
const [settingsSource, setSettingsSource] = useState(() => SkillRegistry.getSource());
const {
data: allFindings = [],
isLoading,
refetch,
isFetching
} = useQuery({
queryKey: ['doormile', 'agent-operations-briefing'],
queryFn: async () => {
const scan = await scanBookings();
const agentRows = toAgentRows(scan?.rows);
const agent = AgentFactory.synthesizeDefaultAgent();
return agent.evaluateTelemetry(agentRows, wallClockNow());
},
staleTime: 30_000,
refetchInterval: 60_000,
});
// Carry out a proposal, then re-evaluate.
//
// The refetch is the point, not politeness: an agent that fires an action and
// never looks again is a button. Re-running the scan is what turns this into
// a loop — the finding either disappears, proving the action landed, or it
// survives and stays on the board.
//
// Errors are re-thrown so SlaRemediationCard shows its failure state; a
// partial success (six riders reached out of eight) resolves ok and the
// message names the ones that were missed.
const handleRemediate = useCallback(
async (finding) => {
const result = await executeProposal(finding);
if (!result?.ok) {
throw new Error(result?.message || 'The action did not complete.');
}
await refetch();
return result;
},
[refetch]
);
// Re-evaluate when the registry's settings arrive or change (a save in
// Settings → Skills & Tools refetches the registry, which lands here).
useEffect(() => {
const unsubscribe = SkillRegistry.subscribe(() => {
setSettingsSource(SkillRegistry.getSource());
refetch();
});
return unsubscribe;
}, [refetch]);
// Contextual page-level filtering
const findings = useMemo(() => {
if (!category && !skillIds) {
return allFindings;
}
const targetCategories = category
? Array.isArray(category)
? category
: [category]
: null;
const targetSkills = skillIds ? new Set(skillIds) : null;
return allFindings.filter((finding) => {
const skill = SkillRegistry.getSkill(finding.skillId);
if (targetSkills && finding.skillId && !targetSkills.has(finding.skillId)) {
return false;
}
if (targetCategories && skill && !targetCategories.includes(skill.category)) {
return false;
}
return true;
});
}, [allFindings, category, skillIds]);
if (isLoading) {
return null;
}
const hasFindings = findings.length > 0;
if (hideWhenClean && !hasFindings) {
return null;
}
return (
<>
<div className={`space-y-3 p-4 rounded-2xl bg-surface-2 border border-line ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-mono font-bold uppercase tracking-wider text-accent-ink">
<Sparkles className="h-4 w-4" />
<span>{title}</span>
<span className="text-ink-4">({findings.length} detected)</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate('/doormile/settings?tab=skills')}
className="h-7 px-2.5 text-xs text-ink-2 hover:text-ink flex items-center gap-1.5 border-line shadow-none"
title="Settings → Skills & Tools: enable skills and tune thresholds"
>
<SlidersHorizontal className="h-3.5 w-3.5 text-accent-ink" />
<span>Configure skills</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="h-7 px-2 text-xs text-ink-3 hover:text-ink flex items-center gap-1.5"
title="Re-run telemetry diagnosis"
>
<RefreshCw className={`h-3.5 w-3.5 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</Button>
</div>
</div>
{settingsSource !== 'registry' && (
<p role="note" className="text-[11px] text-ink-3">
Running on default thresholds — the skill settings from Settings → Skills &amp; Tools could not be loaded.
</p>
)}
{hasFindings ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{findings.slice(0, maxItems).map((finding, idx) => (
<SlaRemediationCard
key={finding.id || idx}
finding={finding}
// Only proposals with a real executor become clickable. The rest
// render disabled as "Review only" rather than implying an action.
onRemediate={canExecuteProposal(finding) ? handleRemediate : undefined}
/>
))}
</div>
) : (
<div className="text-xs text-ink-3 py-1 flex items-center gap-1.5 font-mono">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Nothing flagged by the {SkillRegistry.getActiveSkills().length} enabled skills.
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import {
Sparkles,
ShieldAlert,
CheckCircle2,
RefreshCw,
Zap
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { OpenToast } from '@/api/doormile/notify';
/**
* Proactive SLA Risk & Self-Healing Remediation Card.
* Detects aging, stalled, or at-risk parcels and presents a 1-click mitigation plan.
*/
export default function SlaRemediationCard({ finding, onRemediate }) {
const [remediated, setRemediated] = useState(false);
const [loading, setLoading] = useState(false);
if (!finding) return null;
const { title, why, severity = 'warning', count = 1, proposal } = finding;
// A proposal is only actionable when a caller supplies an executor. Without
// one there is no write path — the agent's mutating tools deliberately return
// proposals rather than performing the write themselves.
const canExecute = typeof onRemediate === 'function';
const handleApply = async () => {
// Guarded rather than merely disabled: a click that cannot act must never
// reach the success path.
//
// This previously fell through to `setTimeout(600)` and then reported
// "Remediation executed" with a green card — and no caller has ever passed
// onRemediate, so EVERY click took that branch. An operator was told a
// stalled rider had been messaged when nothing had been sent. A button that
// lies about acting is worse than no button, exactly as a wrong number is
// worse than no answer (assistant/CLAUDE.md §3).
if (!canExecute) return;
setLoading(true);
try {
const result = await onRemediate(finding);
setRemediated(true);
// OpenToast is (message, type). The branch called it ('success', msg), so
// every toast would have read just "success". The executor's own message
// is shown because it counts partial success ("Notified 6 … could not
// reach 2: …"), which a fixed string would hide.
OpenToast(result?.message || `Done: ${proposal.label}`, 'success');
} catch (err) {
OpenToast(err?.message || 'The action did not complete', 'error');
} finally {
setLoading(false);
}
};
const isCritical = severity === 'critical';
return (
<div
className={`rounded-xl border p-4 transition-all duration-200 ${
remediated
? 'bg-emerald-50/50 border-emerald-200'
: isCritical
? 'bg-rose-50/40 border-rose-200 hover:border-rose-300'
: 'bg-amber-50/40 border-amber-200 hover:border-amber-300'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div
className={`p-2 rounded-lg ${
remediated
? 'bg-emerald-100 text-emerald-700'
: isCritical
? 'bg-rose-100 text-rose-700'
: 'bg-amber-100 text-amber-700'
}`}
>
{remediated ? <CheckCircle2 className="h-4 w-4" /> : <ShieldAlert className="h-4 w-4" />}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider font-mono text-ink">
{/* Not "Critical SLA Risk" / "Autonomous Finding": findings come
from cash and fleet rules too, and nothing here acts on its own. */}
{remediated ? 'Action sent' : isCritical ? 'Needs attention now' : 'Worth a look'}
</span>
<Badge variant="outline" className="text-[10px] h-4 py-0 font-mono">
{count} {count === 1 ? 'Order' : 'Orders'}
</Badge>
</div>
<h4 className="text-sm font-semibold text-ink mt-0.5">{title}</h4>
</div>
</div>
{!remediated && proposal && (
<Button
size="sm"
className={`text-xs gap-1.5 font-medium shadow-sm ${
isCritical
? 'bg-rose-600 hover:bg-rose-700 text-white'
: 'bg-amber-600 hover:bg-amber-700 text-white'
}`}
onClick={handleApply}
disabled={loading || !canExecute}
title={
canExecute
? proposal.blastRadius
: 'Review only — no execution path is wired for this action yet'
}
>
{loading ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Zap className="h-3.5 w-3.5 fill-current" />
)}
{canExecute ? proposal.label : 'Review only'}
</Button>
)}
</div>
<p className="text-xs text-ink-2 mt-2 leading-relaxed">{why}</p>
{proposal && !remediated && (
<div className="mt-3 pt-2.5 border-t border-line/60 flex items-center justify-between text-xs text-ink-3">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5 text-accent-ink" />
Proposed Action: <strong className="text-ink">{proposal.label}</strong>
</span>
<span className="text-[11px] text-ink-4">{proposal.blastRadius}</span>
</div>
)}
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import {
Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, User, UserCheck, Warehouse,
ShieldAlert, User, UserCheck, UserPlus, Warehouse,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { DOORMILE_MARK_URL } from '@/assets/brand';
@@ -16,9 +16,11 @@ import {
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding';
import { useExceptions } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
import { useSkillRegistrySync } from '@/lib/assistant/skills/useSkillRegistrySync';
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
/**
@@ -221,6 +223,10 @@ export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, isClient, logout } = useAuth();
// One sync for the whole console, so the Exceptions banner and the chat's
// "what needs attention" briefing run on the same registry settings. Partner
// logins cannot read the registry (403) and run on code defaults.
useSkillRegistrySync(!isClient);
const [menuOpen, setMenuOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(() => {
try {
@@ -251,12 +257,21 @@ export default function AdminLayout() {
return NAV;
}, [isClient]);
const canOnboard = canOnboardClients(user, isClient);
const navGroups = useMemo(() => {
if (isClient) {
return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops');
}
return NAV_GROUPS;
}, [isClient]);
if (!canOnboard) return NAV_GROUPS;
// Only the onboarding owner login sees this entry; the server refuses
// everyone else regardless.
return NAV_GROUPS.map((group) =>
group.label === 'Fleet Ops'
? { ...group, items: [...group.items, { label: 'Client Onboarding', path: '/doormile/clients/onboard', icon: UserPlus }] }
: group
);
}, [isClient, canOnboard]);
const allDestinations = useMemo(() => {
return [...navItems, ...navGroups.flatMap((group) => group.items)];

View File

@@ -0,0 +1,92 @@
// ==============================|| Doormile AI — Autonomous Agent Factory ||============================== //
import { SkillRegistry } from '../skills/SkillRegistry';
import { wallClockNow } from './signals';
const SEVERITY_RANK = { critical: 0, warning: 1, watch: 2 };
export class AgentFactory {
/**
* Compiles an Agent configuration from a collection of skills.
*/
static createAgent(skills = SkillRegistry.getActiveSkills()) {
const activeSkills = Array.isArray(skills) ? skills : [];
// 1. Synthesize System Prompt
const instructions = activeSkills
.map((s) => `### ${s.name} (${s.category})\n${(s.instructions || '').trim()}`)
.join('\n\n');
const systemPrompt = `
You are the Doormile Autonomous Logistics Fleet Orchestrator.
You operate with the following active operational skill modules:
${instructions || 'Standard logistics routing and telemetry diagnostics.'}
Guiding Invariant:
Read-only queries execute instantly. Mutating actions (reassignments, pings, OTP enforcement) MUST return human-in-the-loop proposals with clear blast radius.
`.trim();
// 2. Aggregate Tools from active skills
const tools = activeSkills.flatMap((s) => s.tools || []);
const toolMap = new Map();
tools.forEach((t) => toolMap.set(t.name, t));
// 3. Telemetry Evaluation Engine
const evaluateTelemetry = (rows = [], now = wallClockNow()) => {
const allFindings = [];
for (const skill of activeSkills) {
if (typeof skill.evaluate === 'function') {
// Extract current threshold values
const rawThresholds = {};
if (skill.effectiveThresholds) {
Object.entries(skill.effectiveThresholds).forEach(([k, v]) => {
rawThresholds[k] = v.currentValue;
});
} else if (skill.thresholds) {
Object.entries(skill.thresholds).forEach(([k, v]) => {
rawThresholds[k] = v.value;
});
}
const findings = skill.evaluate(rows, now, rawThresholds);
if (Array.isArray(findings)) {
allFindings.push(...findings);
}
}
}
// Sort by severity (critical > warning > watch), then by affected row count descending
return allFindings.sort(
(a, b) =>
(SEVERITY_RANK[a.severity] ?? 99) - (SEVERITY_RANK[b.severity] ?? 99) ||
(b.count || 0) - (a.count || 0)
);
};
return {
name: 'Doormile Fleet Orchestrator',
version: '2.0.0',
activeSkillCount: activeSkills.length,
skills: activeSkills,
tools,
toolMap,
systemPrompt,
evaluateTelemetry,
executeSkillTool: async (toolName, args) => {
const tool = toolMap.get(toolName);
if (!tool || typeof tool.handler !== 'function') {
return { success: false, error: `Skill tool "${toolName}" not found or has no handler.` };
}
return tool.handler(args);
}
};
}
/**
* Helper to build the default active agent runtime based on current SkillRegistry state.
*/
static synthesizeDefaultAgent() {
return AgentFactory.createAgent(SkillRegistry.getActiveSkills());
}
}

View File

@@ -0,0 +1,153 @@
// ==============================|| Doormile AI — proposal executors ||============================== //
//
// The layer that turns a finding's proposal into a real API call.
//
// It exists as its own module because the skills deliberately have no API
// access at all — not one of them imports an endpoint, and their tool handlers
// only build proposal objects. That separation is worth keeping: a skill stays
// a pure function of rows and thresholds, which is why all eight are testable
// without a network. Execution is the part with consequences, so it lives in
// one place where the failure modes can be handled once.
//
// Two rules, both learned the hard way in assignActions.js:
//
// • Never report an action that did not happen. A card that says "rider
// notified" when the push returned 400 is worse than one that says nothing:
// a dispatcher who believes a rider was pinged does not follow up.
// • A partial success is a partial success. Notifying six riders out of eight
// is reported as six out of eight, with the two failures named.
import { getMilers, buildMilerLookup, notifyRider } from '@/api/doormile';
/** A stable, human-facing reference for a row. */
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
/**
* Rider pushes key off milerprofileid; findings carry mileruserid.
*
* These are two different small integers on the same record, and using the
* wrong one fails silently in both directions — the notify 404s, or a different
* rider is paged. buildMilerLookup is the same bridge the Orders page uses;
* this must never grow a second one.
*/
const profileIdFor = (row, lookup) => lookup.byUserId.get(String(row?.userid))?.milerprofileid;
// ---- notifyRider ------------------------------------------------------------
const executeNotifyRider = async (scope, message) => {
const rows = (scope || []).filter((r) => r?.userid);
if (!rows.length) {
return { ok: false, message: 'None of these parcels has an assigned rider to notify.', sourceCalls: [] };
}
// One fetch for the whole batch. Per row would be an identical request each
// time, and the lookup is the same for all of them.
const milers = await getMilers();
const lookup = buildMilerLookup(milers);
const sourceCalls = [];
const notified = [];
const unreachable = [];
for (const row of rows) {
const profileId = profileIdFor(row, lookup);
// No profile id means no push is possible. Recorded rather than skipped,
// so the operator is not left assuming a phone buzzed.
if (!profileId) {
unreachable.push(`${ref(row)} (${row.ridername || 'rider'}: no device registered)`);
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(profileId, 'Doormile Ops', message);
notified.push(ref(row));
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'complete',
stats: `${ref(row)} → ${row.ridername || 'rider'}`
});
} catch (err) {
unreachable.push(`${ref(row)} (${err?.message || 'push failed'})`);
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'error',
errorMessage: err?.message || 'notification failed'
});
}
}
const parts = [];
if (notified.length) parts.push(`Notified ${notified.length} rider${notified.length === 1 ? '' : 's'}.`);
if (unreachable.length) parts.push(`Could not reach ${unreachable.length}: ${unreachable.join('; ')}.`);
return {
ok: notified.length > 0,
message: parts.join(' ') || 'Nothing was sent.',
notified,
unreachable,
sourceCalls
};
};
// ---- assignMiler: deliberately NOT an executor --------------------------------
//
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. Shipping it would have
// put a button on the Exceptions banner that could never succeed.
//
// Findings that propose `assignMiler` therefore render "Review only". The
// admin route that could back it, POST /admin/bookings/:id/assign-miler, needs
// a chosen rider per booking, which a finding does not pick. Wiring this is a
// backend decision (an admin batch-assign route), not a console one.
// ---- registry ---------------------------------------------------------------
/**
* Keyed on the `tool` a finding's proposal actually carries.
*
* Deliberately NOT the skills' declared tool names (ping_stalled_rider,
* reassign_sla_critical_order, …). Those belong to a separate, currently unused
* layer whose handlers only build proposals; the UI renders `proposal.tool`,
* and that is what an operator's click has to resolve.
*
* Verbs with no entry here have no endpoint behind them yet. That is not an
* error — the card renders them disabled and says "Review only" rather than
* pretending.
*/
const EXECUTORS = {
notifyRider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
finding?.severity === 'critical'
? 'Urgent: this delivery needs attention now. Please check the app.'
: 'Please check this delivery in the app when you can.'
)
// assignMiler: none — see the note above. Its findings render "Review only".
};
/** Whether a finding's proposal can actually be carried out. */
export const canExecuteProposal = (finding) => Boolean(EXECUTORS[finding?.proposal?.tool]);
/** Every verb that currently has a real executor behind it. */
export const executableTools = () => Object.keys(EXECUTORS);
/**
* Runs a finding's proposal.
*
* Throws when there is no executor rather than resolving quietly, so a caller
* cannot mistake "nothing happened" for success. The UI is expected to have
* checked canExecuteProposal first and disabled the control.
*/
export const executeProposal = async (finding) => {
const run = EXECUTORS[finding?.proposal?.tool];
if (!run) {
throw new Error(`No executor for "${finding?.proposal?.tool}" — this proposal is review-only.`);
}
return run(finding);
};

View File

@@ -0,0 +1,112 @@
// ==============================|| Doormile AI — the ops briefing ||============================== //
//
// Turns the findings from signals.js into the shape the assistant panel already
// renders: a headline, a detail body, and a metric. Pure — it takes rows and an
// instant and returns an object, so the whole briefing can be tested without a
// network, a clock, or React.
//
// This is the agent's "explain" step. Every line it produces is traceable to a
// rule in signals.js and to the rows that rule matched, because an operator who
// cannot see why the agent believes something will not act on it — and an
// unexplained instruction from a bot is exactly the thing CLAUDE.md §3 refuses
// to ship.
import { AgentFactory } from './AgentFactory';
import { ALL_CLEAR, wallClockNow } from './signals';
import { toAgentRows } from './normalise';
const SEVERITY_LABEL = {
critical: 'Needs attention now',
warning: 'Worth looking at',
watch: 'Keep an eye on'
};
/**
* A truncated scan is a floor, not a total.
*
* `/admin/bookings` caps pagesize, so a scan drains pages up to a budget and
* reports whether it ran out. Every count built on a truncated scan says "at
* least", because the alternative — stating a total that is quietly a subset —
* is the exact failure the assistant's verifiability contract exists to
* prevent. A missed breach is worse than an approximate one, so the briefing
* still runs; it just stops claiming completeness.
*/
export const countPrefix = (truncated) => (truncated ? 'at least ' : '');
/** One finding rendered as a block of lines. */
const renderFinding = (finding, truncated) => {
const lines = [];
lines.push(`${SEVERITY_LABEL[finding.severity]} — ${finding.title}`);
lines.push(finding.why);
if (finding.detail?.length) {
lines.push(...finding.detail.map((d) => ` • ${d}`));
const shown = finding.detail.length;
if (finding.count > shown) {
lines.push(` • …and ${countPrefix(truncated)}${finding.count - shown} more`);
}
}
if (finding.proposal) {
lines.push(` → ${finding.proposal.label} (${finding.proposal.blastRadius})`);
}
return lines.join('\n');
};
/**
* Builds the full briefing.
*
* `scan` is the result of the assistant's own booking scan — `{ rows,
* truncated }` — so the briefing inherits the same data the rest of the
* catalog answers from and cannot disagree with it.
*/
export const buildBriefing = (scan, now = wallClockNow()) => {
// Raw `/admin/bookings` rows in, agent rows out. Without this the rules read
// undefined on every field and the briefing reports an all-clear board.
const rows = toAgentRows(scan?.rows);
const truncated = Boolean(scan?.truncated);
// The SAME engine the Exceptions banner runs, deliberately.
//
// This used to call a private set of rules in signals.js that duplicated five
// of the skills and hardcoded their thresholds. The two drifted immediately —
// signals.js flagged a stalled rider at 25 minutes while DoorstepStallSkill
// used 20 — so the chat panel and the banner disagreed about the same parcel,
// and a threshold moved in Agent Studio changed one surface but not the other.
//
// Going through the registry means there is one definition of every rule, one
// place thresholds live, and the chat panel sees every enabled skill rather
// than the five somebody happened to reimplement here.
const findings = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(rows, now);
if (!findings.length) {
return {
headline: ALL_CLEAR,
detail: truncated
? 'Note: the booking scan hit its page budget, so this covers the most recent orders rather than every one.'
: `Checked ${rows.length} open and recent bookings.`,
findings,
metric: { value: 0, label: 'issues found' }
};
}
const critical = findings.filter((f) => f.severity === 'critical');
const affected = new Set(findings.flatMap((f) => f.rows.map((r) => r.bookingid))).size;
const headline = critical.length
? `${critical.length} thing${critical.length === 1 ? '' : 's'} need${critical.length === 1 ? 's' : ''} attention now, across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`
: `Nothing critical, but ${findings.length} thing${findings.length === 1 ? '' : 's'} worth looking at across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`;
const body = findings.map((f) => renderFinding(f, truncated)).join('\n\n');
const footer = truncated
? '\n\nThe booking scan hit its page budget, so these counts are a floor — there may be more.'
: '';
return {
headline,
detail: `${body}${footer}`,
findings,
metric: { value: findings.length, label: findings.length === 1 ? 'issue found' : 'issues found' }
};
};

View File

@@ -0,0 +1,53 @@
// ==============================|| Doormile AI — scan row adapter ||============================== //
//
// The assistant's booking scan returns RAW rows straight off `/admin/bookings`
// — `status`, `createdat`, `assignedmileruserid`, `serviceoptions[0]` — not the
// normalised delivery rows the Deliveries page renders. The two shapes share
// almost no field names, so the agent's rules cannot read a scan row directly.
//
// This adapter is the seam. It exists as its own file because getting it wrong
// fails silently in the worst possible way: every rule reads `undefined`, every
// rule matches nothing, and the agent cheerfully reports "nothing needs
// attention" on a board that is on fire. That is precisely the failure mode
// CLAUDE.md §3 exists to prevent, and it is invisible without a test.
//
// The status derivation deliberately goes through the console's own
// `mapBookingStatusToDeliveryStatus` rather than a local copy — the same
// function the Deliveries page's table calls. That is the house rule (never
// hand-roll a second data path) and it is also what guarantees the agent and
// the screen can never disagree about what state a parcel is in.
import { mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries';
/** The rider's arrival stamp, which the API has spelled several ways. */
const reachedAtOf = (b) => b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
/** Rider display name, from whichever join carried it. */
const riderNameOf = (b) =>
b.milername || b.ridername || b.assignedmilername || b.miler?.name || undefined;
/**
* One raw booking row → the shape signals.js reads.
*
* Every field is derived, never invented: a raw row that carries no service
* option yields `expecteddeliverytime: undefined`, and the SLA rules skip it
* rather than treating a missing promise as a kept or broken one.
*/
export const toAgentRow = (b) => ({
bookingid: b.bookingid ?? b.id,
orderid: b.bookingno || (b.bookingid ? `#${b.bookingid}` : '—'),
// Booking status, consignment status and reachedat resolved by the console's
// own mapper — including the Converted_To_Consignment handoff, where the
// lifecycle moves onto the consignment and the booking's status freezes.
orderstatus: mapBookingStatusToDeliveryStatus(b),
orderdate: b.createdat || b.orderdate || b.updatedat,
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
reachedat: reachedAtOf(b),
userid: b.assignedmileruserid ?? b.mileruserid,
ridername: riderNameOf(b)
});
/** A whole scan's rows, adapted. Non-array input yields an empty list. */
export const toAgentRows = (rows) => (Array.isArray(rows) ? rows.map(toAgentRow) : []);

View File

@@ -0,0 +1,44 @@
// ==============================|| Doormile AI — shared agent primitives ||============================== //
//
// What is left after the rules moved out.
//
// This file used to hold its own copy of five detection rules — breached SLA,
// at-risk SLA, aging unassigned work, doorstep stalls, rider saturation — each
// with a hardcoded threshold in a local THRESHOLDS object. Every one of those
// rules already existed as a skill in skills/definitions/, with the same
// finding id and a threshold an operator can tune in Agent Studio.
//
// Two implementations of one rule is a bug with a delay fuse, and this one had
// already fired: signals.js flagged a doorstep stall at 25 minutes while
// DoorstepStallSkill used 20, so the chat panel and the Exceptions banner
// disagreed about the same rider, out of the box, before anyone touched a
// slider. Worse, the hardcoded copy could not see the registry at all — tuning
// a threshold moved the banner and left the chat panel where it was.
//
// The rules now live in exactly one place: skills/definitions/. Everything that
// needs findings goes through AgentFactory, which reads the registry, so there
// is one definition per rule and one place a threshold can be changed.
//
// What remains here is the small shared vocabulary that is genuinely common to
// every consumer and belongs to none of them.
import dayjs from 'dayjs';
/**
* "Now", in the same form every Doormile timestamp is stored in.
*
* NOT `new Date().toISOString()`. That returns UTC, and parseDoormileTimestamp
* deliberately strips any zone marker and reads the remaining digits as IST
* wall-clock — so an ISO string would hand the rules a clock running 5h30m
* slow, and every parcel less than five and a half hours overdue would look
* like it was still in the future. The agent would report an all-clear board
* through most of a working day.
*
* This was a live bug. It passed every unit test, because the tests all passed
* `now` explicitly and only the default was wrong.
*/
export const wallClockNow = () => dayjs().format('YYYY-MM-DD HH:mm:ss');
/** Shown when every enabled skill returns no findings. */
export const ALL_CLEAR =
'Nothing needs attention right now — no breached promises, no stalled riders, and no aging unassigned work.';

View File

@@ -22,6 +22,7 @@ import {
getTenantLocations
} from 'pages/api/doormileApi';
import { getalltenants, getallridersummary } from 'pages/api/api';
import { buildBriefing } from '@/lib/assistant/agent/briefing';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
import { STATUS } from 'themes/dt/tokens';
@@ -745,6 +746,48 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
const MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [
{
// The ops briefing: every monitoring skill enabled in the agent registry,
// run over the same booking scan the rest of the catalog answers from —
// the SAME engine as the Exceptions banner (AgentFactory), so the chat and
// the banner can never disagree about a parcel. Ported from
// feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
//
// FIRST on purpose: later intents match bare "orders"/"riders", which
// appear in most phrasings of a sweep.
//
// Narrower than the branch on purpose. The branch also claimed
// "late/overdue/delayed orders", which would have stolen the existing
// delayed-orders answer (a count on the Orders taxonomy) and replaced it
// with a briefing. Only an explicit request for a sweep lands here.
id: 'opsBriefing',
label: 'What needs attention right now — e.g. "what needs attention", "anything going wrong", "ops check"',
match: (text) => {
const t = String(text).toLowerCase();
const asksForSweep =
/\b(what|anything|any)\b.{0,24}\b(needs?\s+(my\s+)?attention|going\s+wrong|at\s+risk|urgent|on\s+fire)\b/.test(t) ||
/\b(ops|operations|status|health|daily)\s+(check|sweep|briefing|brief)\b/.test(t) ||
/\bwhat\s+should\s+i\s+(do|look\s+at)\b/.test(t);
return asksForSweep ? {} : null;
},
run: async () => {
const scan = await scanBookings();
const briefing = buildBriefing(scan);
return {
headline: briefing.headline,
detail: briefing.detail,
metric: briefing.metric,
sourceCalls: [
scanCall(
scan,
briefing.findings.length
? `${briefing.findings.length} finding${briefing.findings.length === 1 ? '' : 's'} over ${scan.rows.length} rows`
: `no issues over ${scan.rows.length} rows`
)
]
};
}
},
{
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
// contains "orders", so createBulkOrders and createOrder would otherwise

View File

@@ -0,0 +1,123 @@
// ==============================|| Doormile AI — Skill Registry ||============================== //
//
// The console's eight monitoring skills: their RULES live in code
// (./definitions), their SETTINGS — enabled, thresholds — live in the backend
// agent registry (/admin/ai/skills, edited in Settings → Skills & Tools). This
// class joins the two.
//
// On the feat/agentic-ops-layer branch these settings were kept in the
// browser's localStorage, so a threshold tuned on one machine changed nothing
// on any other, and two operators could look at the same board through
// different rules. Ported onto main in Phase 3 of docs/agent-platform-plan.md,
// the settings come from the registry instead, and nothing is stored locally.
//
// Until the registry answers (first load, a network error, a partner login
// that is not allowed to read it) every skill runs on its code defaults, and
// `getSource()` says so, so the banner can tell the operator which settings it
// is running on rather than implying they are the tuned ones.
import { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
import { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
import { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
import { HighValueCodSkill } from './definitions/HighValueCodSkill';
import { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
import { HubCongestionSkill } from './definitions/HubCongestionSkill';
import { LateDispatchSkill } from './definitions/LateDispatchSkill';
import { CashExposureSkill } from './definitions/CashExposureSkill';
export const DEFAULT_SKILLS = [
SlaGuardianSkill,
DoorstepStallSkill,
FleetBalancerSkill,
HighValueCodSkill,
RiderBatterySafetySkill,
HubCongestionSkill,
LateDispatchSkill,
CashExposureSkill
];
/** A stored value outside the definition's range falls back to its default. */
const inRange = (config, v) =>
typeof v === 'number' && Number.isFinite(v) && v >= config.min && v <= config.max;
class SkillRegistryClass {
constructor() {
this.skills = new Map();
this.listeners = new Set();
this.config = {}; // { [skillId]: { enabled: boolean, thresholds: { [key]: number } } }
this.source = 'defaults';
DEFAULT_SKILLS.forEach((skill) => this.skills.set(skill.id, skill));
}
/**
* Adopt the registry's settings. `rows` is the /admin/ai/skills response;
* rows for skills this console does not implement are ignored.
*/
applyRegistryConfig(rows) {
const next = {};
(Array.isArray(rows) ? rows : []).forEach((row) => {
if (!row || !this.skills.has(row.skillid)) return;
next[row.skillid] = {
enabled: Boolean(row.enabled),
thresholds: row.thresholds && typeof row.thresholds === 'object' ? row.thresholds : {}
};
});
this.config = next;
this.source = 'registry';
this.notify();
}
/** Forget the registry's settings and run on code defaults. */
clearRegistryConfig() {
if (this.source === 'defaults' && !Object.keys(this.config).length) return;
this.config = {};
this.source = 'defaults';
this.notify();
}
/** 'registry' when running on the tuned settings, 'defaults' otherwise. */
getSource() {
return this.source;
}
getAllSkills() {
return Array.from(this.skills.values()).map((skill) => {
const cfg = this.config[skill.id];
const enabled = cfg ? cfg.enabled : (skill.defaultEnabled ?? true);
const effectiveThresholds = {};
Object.entries(skill.thresholds || {}).forEach(([key, config]) => {
const stored = cfg?.thresholds?.[key];
effectiveThresholds[key] = { ...config, currentValue: inRange(config, stored) ? stored : config.value };
});
return { ...skill, enabled, effectiveThresholds };
});
}
getActiveSkills() {
return this.getAllSkills().filter((s) => s.enabled);
}
getSkill(skillId) {
return this.getAllSkills().find((s) => s.id === skillId);
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
const all = this.getAllSkills();
this.listeners.forEach((fn) => {
try {
fn(all);
} catch {
// One listener failing must not stop the others hearing the change.
}
});
}
}
export const SkillRegistry = new SkillRegistryClass();

View File

@@ -0,0 +1,161 @@
// ==============================|| Skill: Cash Exposure Agent ||============================== //
//
// Monitors the total live COD (Cash on Delivery) cash a single rider is carrying
// across ALL their active orders. When total exposure exceeds the safe limit,
// proposes a mandatory cash handoff stop at the nearest hub.
//
// This is distinct from HighValueCodSkill, which monitors per-order value.
// This skill monitors per-rider TOTAL cash accumulation across their full run.
const ACTIVE_COD_STATUSES = new Set(['accepted', 'picked', 'active', 'arrived']);
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const CashExposureSkill = {
id: 'skill_cash_exposure',
name: 'Cash Exposure Agent',
description: 'Monitors total live COD cash per rider across all active orders. Flags when a rider carries unsafe cash levels and proposes hub handoff.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no COD amount per order: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no COD amount per order: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
maxCashPerRider: {
label: 'Max Safe Cash Per Rider',
description: 'Maximum total COD cash (₹) a single rider should carry simultaneously across all active orders.',
value: 10000,
min: 2000,
max: 50000,
step: 1000,
unit: '₹'
},
warningCashPercent: {
label: 'Warning Threshold (%)',
description: 'Percentage of max cash limit at which to issue a warning (before it becomes critical).',
value: 75,
min: 50,
max: 95,
step: 5,
unit: '%'
}
},
instructions: `
- A rider carrying excessive COD cash is a major financial risk if the phone dies, the rider is delayed, or there is a dispute.
- Flag riders above the cash threshold for a mandatory hub cash handoff before continuing deliveries.
- Critical: stop new COD orders being assigned to flagged riders until handoff is confirmed.
`,
tools: [
{
name: 'enforce_cash_handoff',
description: 'Proposes a mandatory cash handoff stop at the nearest hub for an overexposed rider.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Rider user ID carrying excessive cash' },
totalCash: { type: 'number', description: 'Total COD cash the rider currently holds (₹)' },
hubId: { type: 'string', description: 'Nearest hub for handoff' }
},
required: ['milerId', 'totalCash']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'cash_handoff',
milerId: args.milerId,
totalCash: args.totalCash,
hubId: args.hubId || 'nearest',
blastRadius: `Reroutes rider to nearest hub for cash handoff of ₹${args.totalCash?.toLocaleString('en-IN')}. Pauses new COD order assignments to this rider until confirmed.`
}
})
}
],
evaluate: (rows = [], _now, currentThresholds = {}) => {
const maxCash = currentThresholds.maxCashPerRider ?? 10000;
const warningPct = (currentThresholds.warningCashPercent ?? 75) / 100;
const warningLimit = maxCash * warningPct;
const findings = [];
// Group active COD orders by rider
const byRider = {};
rows
.filter((r) =>
ACTIVE_COD_STATUSES.has(String(r?.orderstatus || '').toLowerCase()) &&
r?.paymenttype &&
String(r.paymenttype).toLowerCase().includes('cod') &&
r?.userid
)
.forEach((r) => {
const uid = r.userid;
if (!byRider[uid]) {
byRider[uid] = { riderId: uid, riderName: r.ridername || r.miler_name || `Rider ${uid}`, orders: [] };
}
byRider[uid].orders.push(r);
});
const criticalRiders = [];
const warningRiders = [];
Object.values(byRider).forEach((riderData) => {
const total = riderData.orders.reduce((sum, r) => {
const amt = parseFloat(r.cod_amount || r.orderamount || r.totalamount || 0);
return sum + (isNaN(amt) ? 0 : amt);
}, 0);
riderData.totalCash = total;
if (total >= maxCash) {
criticalRiders.push(riderData);
} else if (total >= warningLimit) {
warningRiders.push(riderData);
}
});
criticalRiders.sort((a, b) => b.totalCash - a.totalCash);
warningRiders.sort((a, b) => b.totalCash - a.totalCash);
if (criticalRiders.length) {
findings.push({
id: 'cash_exposure_critical',
skillId: 'skill_cash_exposure',
severity: 'critical',
title: `${criticalRiders.length} rider${criticalRiders.length === 1 ? '' : 's'} carrying ≥₹${maxCash.toLocaleString('en-IN')} in COD cash`,
why: `Total COD cash per rider exceeds the safe limit of ₹${maxCash.toLocaleString('en-IN')}. Immediate hub handoff required.`,
count: criticalRiders.length,
rows: criticalRiders.flatMap((rd) => rd.orders),
detail: criticalRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} active COD order${rd.orders.length === 1 ? '' : 's'}`
),
proposal: {
label: 'Enforce cash handoff at hub',
tool: 'enforce_cash_handoff',
scope: criticalRiders.flatMap((rd) => rd.orders),
blastRadius: 'Reroutes affected riders to nearest hub for mandatory cash handoff. New COD assignments paused.'
}
});
}
if (warningRiders.length) {
findings.push({
id: 'cash_exposure_warning',
skillId: 'skill_cash_exposure',
severity: 'warning',
title: `${warningRiders.length} rider${warningRiders.length === 1 ? '' : 's'} approaching cash limit (>${Math.round(warningPct * 100)}% of ₹${maxCash.toLocaleString('en-IN')})`,
why: `Total COD cash is approaching the safe threshold. Plan a hub handoff stop for these riders.`,
count: warningRiders.length,
rows: warningRiders.flatMap((rd) => rd.orders),
detail: warningRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} orders`
)
});
}
return findings;
}
};

View File

@@ -0,0 +1,90 @@
// ==============================|| Skill: Doorstep Stall Rescuer ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const DoorstepStallSkill = {
id: 'skill_doorstep_stall',
name: 'Doorstep Stall Rescuer',
description: 'Flags riders who stamped arrival at consignee doorstep but have been stalled with no progress.',
category: 'rider_operations',
icon: 'MapPinOff',
defaultEnabled: true,
thresholds: {
arrivedStalledMin: {
label: 'Doorstep Stall Timeout',
description: 'Minutes after arriving at customer location before flagging as stalled.',
value: 20,
min: 10,
max: 60,
step: 5,
unit: 'min'
}
},
instructions: `
- When a rider is stalled at a delivery location, suggest verifying customer contact or initiating automated phone verification.
`,
tools: [
{
name: 'ping_stalled_rider',
description: 'Sends an in-app ping to check if the rider needs help with gate access or customer contact.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Miler ID to reach' },
message: { type: 'string', description: 'Optional prompt text' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'notify_miler',
milerId: args.milerId,
blastRadius: `Pings rider #${args.milerId} with doorstep assistance prompt.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const stallLimit = currentThresholds.arrivedStalledMin ?? 20;
const stalled = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'arrived' && r.reachedat)
.map((r) => ({ row: r, waitingMin: minutesBetween(r.reachedat, now) }))
.filter((m) => m.waitingMin !== null && m.waitingMin >= stallLimit)
.sort((a, b) => b.waitingMin - a.waitingMin);
if (!stalled.length) return [];
return [
{
id: 'stalled_at_door',
skillId: 'skill_doorstep_stall',
severity: 'critical',
title: `${stalled.length} rider${stalled.length === 1 ? '' : 's'} stalled at door for > ${stallLimit}m`,
why: 'Rider app reported arrival but delivery confirmation has not completed. Possible gate, address, or absent consignee issue.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — ${m.row.ridername || 'Rider'} waiting ${m.waitingMin} min`),
proposal: {
label: 'Message waiting riders & support',
tool: 'notifyRider',
scope: stalled.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an assistance check-in to stalled delivery riders.'
}
}
];
}
};

View File

@@ -0,0 +1,87 @@
// ==============================|| Skill: Fleet Load Balancer ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
export const FleetBalancerSkill = {
id: 'skill_fleet_balancer',
name: 'Fleet Load Balancer',
description: 'Prevents rider overload by flagging riders at maximum active parcel capacity while queue work waits.',
category: 'fleet_optimization',
icon: 'Scale',
defaultEnabled: true,
thresholds: {
riderActiveCap: {
label: 'Rider Active Capacity Cap',
description: 'Maximum concurrent active orders a single rider should carry simultaneously.',
value: 3,
min: 1,
max: 6,
step: 1,
unit: 'orders'
}
},
instructions: `
- Keep rider concurrent workload at or below the specified active cap to prevent cumulative delivery delays.
- Proactively redistribute un-picked orders from overloaded riders to available nearby fleet.
`,
tools: [
{
name: 'balance_rider_load',
description: 'Reallocates queued unpicked orders from a saturated rider to an available rider.',
parameters: {
type: 'object',
properties: {
fromMilerId: { type: 'number', description: 'Overloaded miler ID' },
toMilerId: { type: 'number', description: 'Available miler ID' }
},
required: ['fromMilerId', 'toMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'rebalance_workload',
fromMilerId: args.fromMilerId,
toMilerId: args.toMilerId,
blastRadius: `Transfers queued parcels from Rider #${args.fromMilerId} to Rider #${args.toMilerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const activeCap = currentThresholds.riderActiveCap ?? 3;
const safeRows = Array.isArray(rows) ? rows : [];
const unassignedCount = safeRows.filter((r) => String(r?.orderstatus || '').toLowerCase() === 'pending').length;
const byRider = new Map();
safeRows.filter((r) => isOpen(r) && r.userid).forEach((r) => {
const key = String(r.userid);
if (!byRider.has(key)) byRider.set(key, { riderId: r.userid, name: r.ridername || `Rider ${r.userid}`, rows: [] });
byRider.get(key).rows.push(r);
});
const saturated = [...byRider.values()]
.filter((entry) => entry.rows.length >= activeCap)
.sort((a, b) => b.rows.length - a.rows.length);
if (!saturated.length || unassignedCount === 0) return [];
return [
{
id: 'rider_saturation',
skillId: 'skill_fleet_balancer',
severity: 'watch',
title: `${saturated.length} rider${saturated.length === 1 ? ' is' : 's are'} at capacity (≥${activeCap} jobs) while orders queue`,
why: `Riders carrying ${activeCap}+ in-flight jobs are capped and will not receive newly queued orders.`,
count: saturated.length,
rows: saturated.flatMap((entry) => entry.rows),
detail: saturated.slice(0, 8).map((entry) => `${entry.name} — ${entry.rows.length} concurrent orders`)
}
];
}
};

View File

@@ -0,0 +1,93 @@
// ==============================|| Skill: High-Value COD Escort ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HighValueCodSkill = {
id: 'skill_high_value_cod',
name: 'High-Value Cash Guardian',
description: 'Audits large Cash-on-Delivery consignments to prevent loss, fraud, or unverified deliveries.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no payment amount or payment mode: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no payment amount or payment mode: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
codRiskThresholdAmount: {
label: 'High-Value COD Threshold (₹)',
description: 'Cash-on-Delivery amount above which an order is classified as high-risk cash asset.',
value: 3000,
min: 1000,
max: 20000,
step: 500,
unit: '₹'
}
},
instructions: `
- High-value COD consignments must require OTP verification at the doorstep and verified senior rider assignment.
`,
tools: [
{
name: 'enforce_otp_verification',
description: 'Flags an order as strictly requiring consignee OTP delivery confirmation before handover.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Target booking ID' }
},
required: ['bookingId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'require_otp',
bookingId: args.bookingId,
blastRadius: `Enables mandatory OTP confirmation on mobile app for parcel #${args.bookingId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minAmount = currentThresholds.codRiskThresholdAmount ?? 3000;
const safeRows = Array.isArray(rows) ? rows : [];
const highValueCod = safeRows.filter((r) => {
if (!isOpen(r)) return false;
const isCod = String(r.paymenttype || r.payment_mode || '').toLowerCase().includes('cod') ||
String(r.paymentstatus || '').toLowerCase() === 'pending';
const amount = Number(r.orderamount || r.totalamount || r.price || 0);
return isCod && amount >= minAmount;
});
if (!highValueCod.length) return [];
return [
{
id: 'high_value_cod_alert',
skillId: 'skill_high_value_cod',
severity: 'watch',
title: `${highValueCod.length} high-value COD consignment${highValueCod.length === 1 ? '' : 's'} (≥ ₹${minAmount.toLocaleString()}) active`,
why: `Large cash collection in transit requires mandatory OTP handover verification.`,
count: highValueCod.length,
rows: highValueCod,
detail: highValueCod.slice(0, 8).map((r) => {
const amt = Number(r.orderamount || r.totalamount || r.price || 0);
return `${ref(r)} — ₹${amt.toLocaleString()} (${r.orderstatus || 'active'})`;
}),
proposal: {
label: 'Verify OTP requirements',
tool: 'enforce_otp_verification',
scope: highValueCod,
blastRadius: 'Sets strict OTP delivery guard on high-value cash deliveries.'
}
}
];
}
};

View File

@@ -0,0 +1,105 @@
// ==============================|| Skill: Hub Congestion Agent ||============================== //
//
// Detects parcels that have been sitting at a hub for too long without
// being picked up by a rider. These represent blocked capacity at the hub
// and direct SLA risk for the customer. This is critical for hub operations
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const HUB_STATUSES = new Set(['pending', 'accepted']); // at hub, not yet picked
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HubCongestionSkill = {
id: 'skill_hub_congestion',
name: 'Hub Congestion Agent',
description: 'Detects parcels sitting at a hub without rider pickup beyond the dwell threshold. Dispatches nearest idle rider.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
hubDwellMinutes: {
label: 'Hub Dwell Threshold',
description: 'Minutes a parcel can sit at hub without a rider pickup before raising a congestion alert.',
value: 45,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- Parcels sitting at a hub beyond the dwell threshold block capacity for the next batch.
- Identify the nearest idle rider in the same zone and dispatch immediately.
- Prefer riders with the fewest active orders to avoid overloading.
`,
tools: [
{
name: 'dispatch_hub_idle_parcels',
description: 'Proposes dispatching a nearby idle rider to collect stalled hub parcels.',
parameters: {
type: 'object',
properties: {
hubId: { type: 'string', description: 'Hub identifier where parcels are stalled' },
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs to dispatch' },
milerId: { type: 'number', description: 'Target idle rider user ID' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'dispatch_hub_parcels',
hubId: args.hubId || 'unknown',
bookingIds: args.bookingIds,
milerId: args.milerId,
blastRadius: `Dispatches rider to collect ${args.bookingIds?.length || 'stalled'} parcel(s) from hub. Updates parcel status to In-Transit.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const dwellLimit = currentThresholds.hubDwellMinutes ?? 45;
const findings = [];
const stalled = rows
.filter((r) => HUB_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, dwellMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.dwellMin !== null && m.dwellMin >= dwellLimit)
.sort((a, b) => b.dwellMin - a.dwellMin);
if (stalled.length) {
findings.push({
id: 'hub_parcel_stalled',
skillId: 'skill_hub_congestion',
severity: 'critical',
title: `${stalled.length} parcel${stalled.length === 1 ? '' : 's'} stalled at hub for >${dwellLimit}m`,
why: 'Parcel has been at the hub beyond the acceptable dwell time with no rider assigned for pickup.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — at hub for ${m.dwellMin} min`),
proposal: {
label: 'Dispatch idle rider to hub',
tool: 'dispatch_hub_idle_parcels',
scope: stalled.map((m) => m.row),
blastRadius: 'Assigns nearest idle zone rider to collect and dispatch all stalled parcels.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,133 @@
// ==============================|| Skill: Late Dispatch Agent ||============================== //
//
// Flags orders that have been accepted/created but not yet dispatched to a rider
// within the acceptable dispatch window during business hours.
// Late dispatch at creation cascades into late delivery at the door.
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const PENDING_DISPATCH_STATUSES = new Set(['pending']);
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const LateDispatchSkill = {
id: 'skill_late_dispatch',
name: 'Late Dispatch Agent',
description: 'Flags orders accepted but sitting in queue without dispatch for too long. Triggers auto-assignment to prevent cascading delays.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
lateDispatchMinutes: {
label: 'Dispatch Deadline',
description: 'Minutes after order creation before flagging as late dispatch.',
value: 30,
min: 10,
max: 90,
step: 5,
unit: 'min'
},
criticalDispatchMinutes: {
label: 'Critical Dispatch Deadline',
description: 'Minutes after order creation before escalating to critical alert.',
value: 60,
min: 30,
max: 180,
step: 10,
unit: 'min'
}
},
instructions: `
- Orders waiting for dispatch beyond the threshold accumulate delay that can't be recovered at the doorstep.
- Auto-assign to the nearest available rider immediately.
- Prioritize orders with the earliest SLA deadlines when multiple are pending.
`,
tools: [
{
name: 'trigger_auto_dispatch',
description: 'Proposes auto-assigning late-pending orders to available riders in zone.',
parameters: {
type: 'object',
properties: {
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs requiring immediate dispatch' },
reason: { type: 'string', description: 'Dispatch urgency reason' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'auto_dispatch',
bookingIds: args.bookingIds,
reason: args.reason || 'Late dispatch threshold exceeded',
blastRadius: `Auto-assigns ${args.bookingIds?.length || 'late'} order(s) to nearest available zone riders. Rider notifications will be sent.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const lateThreshold = currentThresholds.lateDispatchMinutes ?? 30;
const criticalThreshold = currentThresholds.criticalDispatchMinutes ?? 60;
const findings = [];
const allPending = rows
.filter((r) => PENDING_DISPATCH_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, waitMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.waitMin !== null && m.waitMin >= lateThreshold)
.sort((a, b) => b.waitMin - a.waitMin);
const critical = allPending.filter((m) => m.waitMin >= criticalThreshold);
const warning = allPending.filter((m) => m.waitMin >= lateThreshold && m.waitMin < criticalThreshold);
if (critical.length) {
findings.push({
id: 'late_dispatch_critical',
skillId: 'skill_late_dispatch',
severity: 'critical',
title: `${critical.length} order${critical.length === 1 ? '' : 's'} critically late for dispatch (>${criticalThreshold}m)`,
why: `Order has been sitting in queue for over ${criticalThreshold} minutes with no rider assigned. Delivery SLA is at severe risk.`,
count: critical.length,
rows: critical.map((m) => m.row),
detail: critical.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min for dispatch`),
proposal: {
label: 'Auto-dispatch to available riders',
tool: 'trigger_auto_dispatch',
scope: critical.map((m) => m.row),
blastRadius: 'Immediately assigns nearest available riders. Overrides manual queue priority.'
}
});
}
if (warning.length) {
findings.push({
id: 'late_dispatch_warning',
skillId: 'skill_late_dispatch',
severity: 'warning',
title: `${warning.length} order${warning.length === 1 ? '' : 's'} approaching dispatch deadline (>${lateThreshold}m)`,
why: `Order has been waiting ${lateThreshold}+ minutes for rider assignment. Act now to prevent SLA breach.`,
count: warning.length,
rows: warning.map((m) => m.row),
detail: warning.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min`),
proposal: {
label: 'Dispatch now',
tool: 'trigger_auto_dispatch',
scope: warning.map((m) => m.row),
blastRadius: 'Assigns available riders to pending orders based on zone proximity.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,91 @@
// ==============================|| Skill: Rider Battery & Device Safety ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const RiderBatterySafetySkill = {
id: 'skill_rider_battery_safety',
name: 'Rider Device & SOS Safety',
description: 'Monitors rider device telemetry to prevent unreachability due to low battery during active deliveries.',
category: 'rider_safety',
icon: 'BatteryWarning',
// OFF by default (2026-09-29): the rows this rule reads carry no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows',
defaultEnabled: false,
thresholds: {
criticalBatteryPercent: {
label: 'Critical Battery Level (%)',
description: 'Threshold below which rider device is considered at risk of shutdown.',
value: 15,
min: 5,
max: 30,
step: 5,
unit: '%'
}
},
instructions: `
- When a rider has critical battery while carrying in-transit orders, advise charging or offloading remaining pending pickups.
`,
tools: [
{
name: 'alert_low_battery_rider',
description: 'Notifies rider to connect portable charger or report to nearest hub.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Target miler user ID' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'battery_alert',
milerId: args.milerId,
blastRadius: `Sends device power warning notification to Rider #${args.milerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minBattery = currentThresholds.criticalBatteryPercent ?? 15;
const safeRows = Array.isArray(rows) ? rows : [];
const atRisk = safeRows.filter((r) => {
if (!isOpen(r) || !r.userid) return false;
const battery = Number(r.battery_percentage || r.battery_level || r.battery || 100);
return battery > 0 && battery <= minBattery;
});
if (!atRisk.length) return [];
return [
{
id: 'rider_critical_battery',
skillId: 'skill_rider_battery_safety',
severity: 'warning',
title: `${atRisk.length} active delivery under low rider device battery (≤ ${minBattery}%)`,
why: `Rider phone is nearing shutdown, risking lost GPS telemetry and customer communication failure.`,
count: atRisk.length,
rows: atRisk,
detail: atRisk.slice(0, 8).map((r) => {
const bat = r.battery_percentage || r.battery_level || r.battery || 'Low';
return `${ref(r)} — ${r.ridername || 'Rider'} (${bat}% battery)`;
}),
proposal: {
label: 'Send power alert',
tool: 'alert_low_battery_rider',
scope: atRisk,
blastRadius: 'Sends charging prompt to rider.'
}
}
];
}
};

View File

@@ -0,0 +1,157 @@
// ==============================|| Skill: SLA Breach Guardian ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const IN_FLIGHT_STATUSES = new Set(['picked', 'active']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const SlaGuardianSkill = {
id: 'skill_sla_guardian',
name: 'SLA Breach Guardian',
description: 'Monitors promised customer delivery ETAs and flags both breached and imminent SLA violations.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
slaRiskWindowMin: {
label: 'At-Risk Warning Window',
description: 'Minutes before promised delivery window to flag an un-picked parcel as at-risk.',
value: 45,
min: 15,
max: 90,
step: 5,
unit: 'min'
},
unassignedAgingMin: {
label: 'Unassigned Aging Threshold',
description: 'Minutes an order can remain pending with no assigned rider before raising an alarm.',
value: 60,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- When orders exceed promised delivery ETA or are within the risk window without being in flight, prioritize immediate reassignment.
- Select nearby available riders with high historical on-time fulfillment rates.
`,
tools: [
{
name: 'reassign_sla_critical_order',
description: 'Proposes transferring an SLA-at-risk order to an optimal nearby rider.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Booking ID to reassign' },
targetMilerId: { type: 'number', description: 'Target miler user ID' },
reason: { type: 'string', description: 'Justification' }
},
required: ['bookingId', 'targetMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'reassign_miler',
bookingId: args.bookingId,
milerId: args.targetMilerId,
reason: args.reason || 'SLA risk mitigation',
blastRadius: `Reassigns parcel #${args.bookingId} and updates customer ETA tracking.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const riskWindow = currentThresholds.slaRiskWindowMin ?? 45;
const agingWindow = currentThresholds.unassignedAgingMin ?? 60;
const findings = [];
// 1. Breached SLA
const breached = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime)
.map((r) => ({ row: r, overdueMin: minutesBetween(r.expecteddeliverytime, now) }))
.filter((m) => m.overdueMin !== null && m.overdueMin > 0)
.sort((a, b) => b.overdueMin - a.overdueMin);
if (breached.length) {
findings.push({
id: 'sla_breached',
skillId: 'skill_sla_guardian',
severity: 'critical',
title: `${breached.length} ${breached.length === 1 ? 'parcel is' : 'parcels are'} past promised delivery time`,
why: 'Estimated delivery time on service option has elapsed and parcel is not yet delivered.',
count: breached.length,
rows: breached.map((m) => m.row),
detail: breached.slice(0, 8).map((m) => `${ref(m.row)} — ${m.overdueMin} min overdue, status: ${m.row.orderstatus}`),
proposal: {
label: 'Notify assigned riders',
tool: 'notifyRider',
scope: breached.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an urgent priority notification to the assigned rider.'
}
});
}
// 2. At-Risk SLA
const atRisk = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime && !IN_FLIGHT_STATUSES.has(String(r.orderstatus).toLowerCase()))
.map((r) => ({ row: r, dueInMin: minutesBetween(now, r.expecteddeliverytime) }))
.filter((m) => m.dueInMin !== null && m.dueInMin > 0 && m.dueInMin <= riskWindow)
.sort((a, b) => a.dueInMin - b.dueInMin);
if (atRisk.length) {
findings.push({
id: 'sla_at_risk',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${atRisk.length} parcel${atRisk.length === 1 ? '' : 's'} due within ${riskWindow}m and not yet moving`,
why: 'Inside the delivery promise window but parcel pickup has not begun.',
count: atRisk.length,
rows: atRisk.map((m) => m.row),
detail: atRisk.slice(0, 8).map((m) => `${ref(m.row)} — due in ${m.dueInMin} min, status: ${m.row.orderstatus}`)
});
}
// 3. Unassigned Aging
const aging = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'pending')
.map((r) => ({ row: r, ageMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.ageMin !== null && m.ageMin >= agingWindow)
.sort((a, b) => b.ageMin - a.ageMin);
if (aging.length) {
findings.push({
id: 'unassigned_aging',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${aging.length} booking${aging.length === 1 ? '' : 's'} unassigned for > ${agingWindow}m`,
why: 'Order has been sitting in queue with no assigned miler.',
count: aging.length,
rows: aging.map((m) => m.row),
detail: aging.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.ageMin} min`),
proposal: {
label: 'Auto-assign available rider',
tool: 'assignMiler',
scope: aging.map((m) => m.row),
blastRadius: 'Initiates dispatch matching across idle zone riders.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,10 @@
export { SkillRegistry, DEFAULT_SKILLS } from './SkillRegistry';
export { useSkillRegistrySync } from './useSkillRegistrySync';
export { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
export { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
export { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
export { HighValueCodSkill } from './definitions/HighValueCodSkill';
export { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
export { HubCongestionSkill } from './definitions/HubCongestionSkill';
export { LateDispatchSkill } from './definitions/LateDispatchSkill';
export { CashExposureSkill } from './definitions/CashExposureSkill';

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useAiSkills } from '@/lib/doormileHooks';
import { SkillRegistry } from './SkillRegistry';
/**
* Keeps the in-memory SkillRegistry in step with the backend agent registry.
*
* Mounted once, in the console shell, so the Exceptions banner and the chat's
* "what needs attention" briefing both run on the same, tuned settings. A
* change saved in Settings → Skills & Tools invalidates the registry query,
* this refetches, and every subscriber re-evaluates.
*
* `enabled` is false for partner-tenant logins: the registry is Doormile-staff
* only (the backend answers 403), so they run on code defaults, which the
* banner states.
*/
export function useSkillRegistrySync(enabled = true) {
const { data, isError } = useAiSkills({ enabled, retry: false });
useEffect(() => {
if (data) SkillRegistry.applyRegistryConfig(data);
}, [data]);
useEffect(() => {
if (isError || !enabled) SkillRegistry.clearRegistryConfig();
}, [isError, enabled]);
}

View File

@@ -0,0 +1,75 @@
/**
* Who may onboard a new client (create a tenant and its console login).
*
* The server is the real gate — POST /admin/clients/onboard answers 403 to
* anyone outside CLIENT_ONBOARDING_OWNERS (default admin@doormile.com), and
* only for Doormile staff with roleid 1. This list only decides whether the
* console SHOWS the page and its links, so keep it matching the server's.
*/
const DEFAULT_OWNERS = 'admin@doormile.com';
export const CLIENT_ONBOARDING_OWNERS = String(import.meta.env?.VITE_CLIENT_ONBOARDING_OWNERS || DEFAULT_OWNERS)
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
/**
* @param {any} user the signed-in console user (AuthContext)
* @param {boolean} isClient true for a partner-tenant login
*/
export function canOnboardClients(user, isClient) {
if (!user || isClient) return false;
const email = String(user.email || '').trim().toLowerCase();
const role = String(user.role || '').toLowerCase();
return CLIENT_ONBOARDING_OWNERS.includes(email) && (role === 'admin' || String(user.roleid) === '1');
}
/** A random password an operator can hand over: 14 chars, no look-alikes. */
export function generateClientPassword(length = 14) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#%&*';
const bytes = new Uint32Array(length);
globalThis.crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < length; i += 1) out += chars[bytes[i] % chars.length];
// Guarantee a digit and a letter, so a generated password is never all one class.
if (!/\d/.test(out)) out = `${out.slice(0, -1)}7`;
if (!/[a-z]/i.test(out)) out = `k${out.slice(1)}`;
return out;
}
/** Client-side mirror of the server's validation, for inline errors. */
export function validateOnboarding(form) {
const errors = {};
const phone = String(form.phone || '').replace(/\D/g, '');
if (String(form.companyname || '').trim().length < 2) errors.companyname = 'Enter the company name';
if (String(form.contactname || '').trim().length < 2) errors.contactname = "Enter the contact person's name";
if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(String(form.email || '').trim())) errors.email = 'Enter a valid email address';
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = 'Enter a 10-digit mobile number';
if (!form.applocationid) errors.applocationid = "Choose the client's operating city";
const pw = String(form.password || '');
if (pw.length < 8) errors.password = 'At least 8 characters';
else if (pw.length > 72) errors.password = 'At most 72 characters';
else if (pw.toLowerCase() === String(form.email || '').trim().toLowerCase() || pw === phone) {
errors.password = 'Must not be the email or the phone number';
}
if (form.confirm !== pw) errors.confirm = 'The passwords do not match';
return errors;
}
/**
* Why onboarding cannot run, from the failed GET /admin/clients/cities. The
* page shows this instead of an empty city list: the likeliest cause is a
* console pointed at an API that does not have the onboarding endpoints yet.
*/
export function onboardingUnavailableMessage(err) {
const status = err?.response?.status;
if (status === 404) {
return 'This API server does not have client onboarding yet. The backend changes that add it need to be deployed first.';
}
if (status === 403) {
return 'The server refused this login. Client onboarding is only for the admin@doormile.com account, signed in as Doormile staff.';
}
if (status === 401) return 'Your session has expired. Sign in again.';
if (!err?.response) return 'Could not reach the API server. Check your connection and try again.';
return err.response.data?.message || `The server answered ${status}. Try again shortly.`;
}

View File

@@ -49,6 +49,11 @@ export const calculateDrivingRoute = async (origin, destination) => {
const data = await response.json();
if (data.routes && data.routes.length > 0) {
const route = data.routes[0];
// `distance` stays whole km: it is what pricing and bulk upload bill on,
// and changing its precision would change invoices. `meters` is the
// unrounded road length, for display only — a 120 m drop rounds to
// 0 km here and must not be shown as "0 km" or floored to "1 km".
const meters = Math.max(0, Math.round(route.distance));
const distanceKm = Math.round(route.distance / 1000);
const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null;
// OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng]
@@ -56,6 +61,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = durationMin;
return {
distance: Math.max(0, distanceKm),
meters,
minutes: durationMin,
polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -81,6 +87,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = null;
return {
distance: aerialDistance,
meters: Math.round(R * c * 1.3 * 1000),
minutes: null,
polyline: [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -95,6 +102,30 @@ export const calculateDrivingDistance = async (origin, destination) => {
return res.distance;
};
/**
* Formats a road length for display: metres under 1 km (to the nearest 10 m),
* km with one decimal above it. Returns '—' when the length is unknown.
* @param {number} meters
*/
export const formatRouteDistance = (meters) => {
if (meters === null || meters === undefined) return '—';
const m = Number(meters);
if (!Number.isFinite(m) || m < 0) return '—';
const tens = Math.round(m / 10) * 10;
if (tens < 1000) return `${tens} m`;
return `${Number((m / 1000).toFixed(1))} km`;
};
/**
* Formats a drive time for display. OSRM reports seconds; a sub-minute leg
* rounds to 0 and must read "< 1 min", not fall through to a default.
* @param {number|null} minutes
*/
export const formatRouteDuration = (minutes) => {
if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return '—';
return Number(minutes) < 1 ? '< 1 min' : `${minutes} mins`;
};
/**
* Calculates total charge based on distance and pricing tier
*/

View File

@@ -67,6 +67,14 @@ export const KEYS = {
carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit],
appLocations: ['doormile', 'app-locations'],
// AI agent registry. One prefix, so a write invalidates agents, skills,
// tools and the audit together — a skill toggle changes an agent's counts.
aiRegistry: ['doormile', 'ai'],
aiAgents: ['doormile', 'ai', 'agents'],
aiSkills: ['doormile', 'ai', 'skills'],
aiTools: ['doormile', 'ai', 'tools'],
aiAudit: ['doormile', 'ai', 'audit'],
};
/* ── Shared helpers ───────────────────────────────────────────────────────── */
@@ -179,6 +187,23 @@ export const useTenant = (id, options) =>
export const useCreateTenant = () =>
useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' });
export const useOnboardedClients = (options) =>
useQuery({ queryKey: [...KEYS.tenants, 'onboarded'], queryFn: api.getOnboardedClients, staleTime: 30_000, ...options });
export const useOnboardingCities = (options) =>
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
// No toasts: the page shows its own success panel and inline errors.
export const useOnboardClient = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.onboardClient,
onSuccess: (result) => {
if (result?.success !== false) queryClient.invalidateQueries({ queryKey: KEYS.tenants });
},
});
};
export const useUpdateTenant = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateAdminTenant(id, data),
@@ -792,3 +817,57 @@ export const useOrderPercentages = (startdate, enddate, options) =>
staleTime: 30_000,
...options,
});
/* ── AI agent registry (Settings → Skills & Tools) ────────────────────────── */
export const useAiAgents = (options) =>
useQuery({ queryKey: KEYS.aiAgents, queryFn: api.getAiAgents, staleTime: 30_000, ...options });
export const useAiSkills = (options) =>
useQuery({ queryKey: KEYS.aiSkills, queryFn: api.getAiSkills, staleTime: 30_000, ...options });
export const useAiTools = (options) =>
useQuery({ queryKey: KEYS.aiTools, queryFn: api.getAiTools, staleTime: 5 * 60_000, ...options });
export const useUpdateAiSkill = () =>
useDoormileMutation({
mutationFn: ({ id, patch }) => api.updateAiSkill(id, patch),
invalidates: [KEYS.aiRegistry],
successMessage: 'Skill updated',
});
export const useCreateAiSkill = () =>
useDoormileMutation({
mutationFn: (skill) => api.createAiSkill(skill),
invalidates: [KEYS.aiRegistry],
successMessage: 'Skill registered',
});
export const useUpdateAiAgent = () =>
useDoormileMutation({
mutationFn: ({ id, patch }) => api.updateAiAgent(id, patch),
invalidates: [KEYS.aiRegistry],
successMessage: 'Agent updated',
});
// Read-only, and it changes as agents work: refetched every minute while open.
export const useAiInsights = (days = 7, options) =>
useQuery({
queryKey: [...KEYS.aiRegistry, 'insights', days],
queryFn: () => api.getAiInsights(days),
staleTime: 30_000,
refetchInterval: 60_000,
...options,
});
// A playground run. No toasts and no invalidation: it changes nothing, and the
// Test tab shows its own result or error inline.
export const useRunAiPlayground = () => useMutation({ mutationFn: api.runAiPlayground });
export const useAiDecisions = (params = {}, options) =>
useQuery({
queryKey: [...KEYS.aiRegistry, 'decisions', params],
queryFn: () => api.getAiDecisions(params),
staleTime: 30_000,
...options,
});

View File

@@ -62,7 +62,7 @@ export default function Login() {
if (!validate()) return;
setStatus('loading');
const result = await login(form.email.trim(), form.password);
const result = await login(form.email.trim().toLowerCase(), form.password);
if (!result.success) {
setStatus('idle');

View File

@@ -2,12 +2,9 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { AnimatePresence, motion } from 'framer-motion';
import {
Activity,
CheckCircle2,
ChevronDown,
ChevronUp,
Copy,
ExternalLink,
Radio,
RefreshCw,
Server,
@@ -19,7 +16,7 @@ import {
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { TRIGGER, agentById } from '@/lib/agentNetwork';
import { agentById } from '@/lib/agentNetwork';
const UI_ENDPOINT = 'https://app.doormile.com/doormile/agents';
const WS_ENDPOINT = 'wss://app.doormile.com/doormile/agents/stream';

View File

@@ -5,20 +5,14 @@ import gsap from 'gsap';
import {
Activity,
AlertTriangle,
ArrowRight,
Box,
CheckCircle2,
Cpu,
Layers,
Package,
Play,
Radio,
RotateCcw,
Server,
ShieldAlert,
ShieldCheck,
Sparkles,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';

View File

@@ -4,16 +4,11 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
ChevronRight,
ExternalLink,
Layers,
Pause,
Play,
RotateCcw,
ShieldCheck,
Sparkles,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';

View File

@@ -1,34 +1,13 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { AnimatePresence, motion } from 'framer-motion';
import {
Activity,
AlertTriangle,
ArrowUpRight,
Bot,
CheckCircle2,
ChevronRight,
ExternalLink,
Layers,
Radio,
RefreshCw,
Server,
ShieldAlert,
ShieldCheck,
Terminal,
Zap,
} from 'lucide-react';
import { PageHeader } from '@/components/ds';
import { cn } from '@/lib/utils';
import {
AGENTS,
LOG_ERROR_LINES,
LOG_TOTAL_LINES,
RUN_LOG,
agentById,
networkStats,
} from '@/lib/agentNetwork';
import { SNAPSHOT_AT, SNAPSHOT_LABEL, networkStats } from '@/lib/agentNetwork';
import AgentFlowVisualizer from './AgentFlowVisualizer';
import AgentInspectorDrawer from './AgentInspectorDrawer';
import './Agents.css';
@@ -38,7 +17,16 @@ export default function Agents() {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [activeLogModal, setActiveLogModal] = useState(null);
// Live Activity Stream (3–5 recent events, Requirement: LIVE ACTIVITY)
// Counts come from the snapshot in lib/agentNetwork.js, not from a feed.
// The status bar used to hard-code them; now they at least match the record
// the rest of the page is drawn from, and the bar says how old that record is.
const stats = networkStats();
const idle = stats.total - stats.working - stats.degraded;
// SAMPLE activity. The seed rows and everything AgentFlowVisualizer appends
// come from its playback loop, not from AI_engine. `AI_engine` exposes no
// endpoint this console can read (see lib/agentNetwork.js), so the panel is
// labelled as a simulation rather than as live.
const [liveActivities, setLiveActivities] = useState([
{
id: 1,
@@ -94,14 +82,19 @@ export default function Agents() {
═══════════════════════════════════════════════════════════════════ */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-surface px-4 py-2.5 shadow-2xs">
{/* Left: Identity */}
<div className="flex items-center gap-2.5">
<span className="flex h-2.5 w-2.5 rounded-full bg-emerald-500 animate-pulse" />
<div className="flex flex-wrap items-center gap-2.5">
{/* Static, not pulsing: nothing on this page is a live feed. */}
<span className="flex h-2.5 w-2.5 rounded-full bg-slate-400" />
<h1 className="font-heading text-sm sm:text-base font-bold text-ink-1">
Doormile AI Agent Network
</h1>
<span className="text-caption text-ink-4 hidden md:inline">
· Real-Time Workflow Visualizer
</span>
<time
dateTime={SNAPSHOT_AT}
className="rounded-full border border-border bg-surface-subtle px-2 py-0.5 text-[11px] font-semibold text-ink-3"
title="Figures are read from the AI_engine run log, not streamed. They do not update."
>
Snapshot · {SNAPSHOT_LABEL}
</time>
</div>
{/* Right: Clean Status Indicators */}
@@ -109,19 +102,19 @@ export default function Agents() {
{/* 1 Processing */}
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/40 px-2.5 py-0.5 text-emerald-800 dark:text-emerald-300 font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
<span>1 Processing</span>
<span>{stats.working} Processing</span>
</div>
{/* 1 Warning */}
<div className="flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40 px-2.5 py-0.5 text-amber-800 dark:text-amber-300 font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
<span>1 Warning</span>
<span>{stats.degraded} Warning</span>
</div>
{/* 7 Idle */}
<div className="flex items-center gap-1.5 rounded-full border border-border bg-surface-subtle px-2.5 py-0.5 text-ink-3 font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-slate-400" />
<span>7 Idle</span>
<span>{idle} Idle</span>
</div>
{/* 🔒 Autonomy Locked */}
@@ -130,7 +123,9 @@ export default function Agents() {
title="Autonomy locked: AI observes and recommends, but cannot independently alter production systems."
>
<ShieldCheck className="h-3 w-3 text-indigo-600 dark:text-indigo-400" />
<span>Autonomy: 0 / 8</span>
{/* Denominator is the agents that HAVE an autonomy gate (Dispatch,
Exception, Express). It read "0 / 8", which implied eight gates. */}
<span>Autonomy gates on: {stats.autonomyOn} / {stats.gated}</span>
</div>
</div>
</div>
@@ -153,11 +148,11 @@ export default function Agents() {
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-ink-2" />
<h2 className="font-heading text-xs font-bold uppercase tracking-wider text-ink-1">
Live Activity
Sample Activity
</h2>
</div>
<span className="text-[10.5px] font-mono text-ink-4">
Latest events
<span className="text-[10.5px] font-mono text-amber-700">
Simulation · not live data
</span>
</div>

View File

@@ -0,0 +1,381 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, ShieldCheck, UserPlus } from 'lucide-react';
import {
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
StatusBadge, Surface, Switch,
} from '@/components/ds';
import { inputVariants } from '@/components/ui/input';
import { useAuth } from '@/lib/AuthContext';
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
import {
canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding,
} from '@/lib/clientOnboarding';
/**
* Client onboarding — register a new client and give them a console login.
*
* One submit creates, in a single server transaction, the tenant (the client
* company), its console login (doormile_auth, scoped to that tenant) and its
* appusers row. The client then signs in at the normal console login and sees
* only their own data.
*
* Restricted to the onboarding owner login (admin@doormile.com). The server
* enforces that; this page only hides itself from everyone else.
*/
const EMPTY = {
companyname: '',
contactname: '',
email: '',
phone: '',
applocationid: '',
password: '',
confirm: '',
requiredeliveryotp: false,
};
const SERVER_FIELD = [
['company name', 'companyname'],
['contact person', 'contactname'],
['email', 'email'],
['mobile', 'phone'],
['password', 'password'],
['operating city', 'applocationid'],
];
/** Put a server message under the field it is about, when it names one. */
function serverFieldOf(message) {
const m = String(message || '').toLowerCase();
const hit = SERVER_FIELD.find(([needle]) => m.includes(needle));
return hit ? hit[1] : null;
}
function OnboardedList() {
const { data = [], isLoading } = useOnboardedClients();
const columns = [
{ key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
{ key: 'primarycontact', header: 'Contact', accessor: (r) => r.primarycontact || '—' },
{ key: 'status', header: 'Status', cell: (r) => <StatusBadge status={r.status} size="sm" /> },
{
key: 'logincreatedat',
header: 'Login created',
accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'),
},
];
return (
<Surface radius="xl" padding="lg">
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
<DataTable
columns={columns}
rows={data}
getRowId={(row, i) => `${row.tenantid}-${row.loginemail}-${i}`}
loading={isLoading}
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
/>
</Surface>
);
}
export default function ClientOnboarding() {
const navigate = useNavigate();
const { user, isClient } = useAuth();
const allowed = canOnboardClients(user, isClient);
const onboard = useOnboardClient();
const citiesQ = useOnboardingCities({ enabled: allowed, retry: false });
const locations = citiesQ.data || [];
// When the city list cannot be read, onboarding cannot work on this server
// either (same backend, same gate) — say why instead of an empty dropdown.
const unavailable = citiesQ.isError ? onboardingUnavailableMessage(citiesQ.error) : '';
const cities = useMemo(() => locations.filter((l) => Number(l.applocationid) > 0), [locations]);
const [form, setForm] = useState(EMPTY);
const [errors, setErrors] = useState({});
const [formError, setFormError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [created, setCreated] = useState(null); // { tenant, login, password }
const [copied, setCopied] = useState(false);
const set = (key) => (value) => {
setForm((f) => ({ ...f, [key]: value }));
setErrors((e) => ({ ...e, [key]: undefined }));
};
if (!allowed) {
return (
<Stack space="lg">
<EmptyState
icon={ShieldCheck}
title="Client onboarding is restricted"
description="Only the designated onboarding account can register new clients."
action={
isClient
? { label: 'Go to home', onClick: () => navigate('/doormile/home') }
: { label: 'Back to clients', onClick: () => navigate('/doormile/tenants') }
}
/>
</Stack>
);
}
const submit = (event) => {
event.preventDefault();
setFormError('');
const nextErrors = validateOnboarding(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
const password = form.password;
onboard.mutate(
{
companyname: form.companyname.trim(),
contactname: form.contactname.trim(),
email: form.email.trim().toLowerCase(),
phone: form.phone.replace(/\D/g, ''),
password,
applocationid: Number(form.applocationid),
requiredeliveryotp: form.requiredeliveryotp,
},
{
onSuccess: (res) => {
if (res?.success === false) {
setFormError(res.message || 'The client was not created.');
return;
}
setCreated({ ...res.data, password });
setForm(EMPTY);
setCopied(false);
},
onError: (err) => {
const status = err?.response?.status;
const message = err?.response?.data?.message;
if (status === 403) {
setFormError('Your login is not allowed to onboard clients.');
return;
}
const field = serverFieldOf(message);
if (field && message) setErrors((e) => ({ ...e, [field]: message }));
else setFormError(message || 'The client could not be created. Nothing was saved.');
},
}
);
};
const copyLogin = async () => {
if (!created) return;
const text = [
`Doormile console: ${window.location.origin}/login`,
`Email: ${created.login.email}`,
`Password: ${created.password}`,
].join('\n');
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
setCopied(false);
}
};
return (
<Stack space="lg">
<PageHeader
breadcrumb={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/doormile/tenants">Clients</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Onboard client</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
}
actions={
<Button variant="outline" onClick={() => navigate('/doormile/tenants')}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
}
/>
{created && (
<Alert tone="success" title={`${created.tenant.tenantname} is onboarded`} role="status">
<p>
Console login <span className="font-semibold">{created.login.email}</span> is ready. They sign in at the
usual console login and see only their own orders.
</p>
<p className="mt-1">
This is the only time the password is shown — share it with the client securely.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={copyLogin}>
<Copy className="mr-1.5 h-4 w-4" /> {copied ? 'Copied' : 'Copy login details'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCreated(null)}>
Onboard another
</Button>
</div>
</Alert>
)}
<form onSubmit={submit} noValidate>
<Surface radius="xl" padding="lg">
<div className="mb-5 flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-tint text-brand">
<UserPlus className="h-5 w-5" />
</div>
<div>
<h1 className="text-h4 font-semibold text-ink-1">Onboard a new client</h1>
<p className="text-body-sm text-ink-3">
Creates the client and their console login together. If any part fails, nothing is saved.
</p>
</div>
</div>
{unavailable && (
<Alert
tone="warning"
className="mb-4"
role="alert"
title="Client onboarding is not available right now"
action={
<Button size="sm" variant="outline" onClick={() => citiesQ.refetch()}>
Try again
</Button>
}
>
{unavailable}
</Alert>
)}
{formError && (
<Alert tone="destructive" className="mb-4" role="alert">
{formError}
</Alert>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Company name" required error={errors.companyname} className="sm:col-span-2">
<Input
value={form.companyname}
onChange={(e) => set('companyname')(e.target.value)}
placeholder="Peelamedu Provisions Pvt Ltd"
maxLength={120}
/>
</Field>
<Field label="Contact person" required error={errors.contactname}>
<Input
value={form.contactname}
onChange={(e) => set('contactname')(e.target.value)}
placeholder="Priya Raman"
maxLength={80}
/>
</Field>
<Field label="Mobile number" required error={errors.phone}>
<Input
value={form.phone}
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
placeholder="9876543210"
inputMode="numeric"
/>
</Field>
<Field label="Login email" required error={errors.email} hint="The client signs in to the console with this">
<Input
type="email"
value={form.email}
onChange={(e) => set('email')(e.target.value)}
placeholder="ops@client.com"
autoComplete="off"
/>
</Field>
<Field label="Operating city" required error={errors.applocationid}>
<select
value={form.applocationid}
onChange={(e) => set('applocationid')(e.target.value)}
className={inputVariants()}
>
<option value="">
{citiesQ.isLoading ? 'Loading cities…' : unavailable ? 'Cities could not be loaded' : cities.length ? 'Choose a city' : 'No operating cities found'}
</option>
{cities.map((c) => (
<option key={c.applocationid} value={c.applocationid}>
{c.applocationname}
</option>
))}
</select>
</Field>
<Field label="Password" required error={errors.password} hint="At least 8 characters" htmlFor="onboard-password">
<Input
id="onboard-password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => set('password')(e.target.value)}
autoComplete="new-password"
maxLength={72}
/>
</Field>
<Field label="Confirm password" required error={errors.confirm}>
<Input
type={showPassword ? 'text' : 'password'}
value={form.confirm}
onChange={(e) => set('confirm')(e.target.value)}
autoComplete="new-password"
maxLength={72}
/>
</Field>
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const pw = generateClientPassword();
setForm((f) => ({ ...f, password: pw, confirm: pw }));
setErrors((e) => ({ ...e, password: undefined, confirm: undefined }));
setShowPassword(true);
}}
>
<KeyRound className="mr-1.5 h-4 w-4" /> Generate password
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPassword((s) => !s)}>
{showPassword ? <EyeOff className="mr-1.5 h-4 w-4" /> : <Eye className="mr-1.5 h-4 w-4" />}
{showPassword ? 'Hide' : 'Show'} password
</Button>
</div>
<Field
label="Require delivery OTP"
inline
hint="The rider must collect a one-time code from the receiver"
className="sm:col-span-2"
>
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
</Field>
</div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/tenants')}>
Cancel
</Button>
<Button type="submit" loading={onboard.isPending} disabled={Boolean(unavailable)}>
Onboard client
</Button>
</div>
</Surface>
</form>
<OnboardedList />
</Stack>
);
}

View File

@@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Building2, CheckCircle2, CircleSlash, IndianRupee, Mail, MapPin, Pencil, Phone,
Plus, Timer, Users,
Plus, Timer, UserPlus, Users,
} from 'lucide-react';
import {
Button, DataTable, Drawer, EmptyState, Field, Grid, IconButton, Input, KpiCard,
@@ -14,6 +14,8 @@ import {
useTenants, useUpdateTenant, useUpdateTenantLocation,
} from '@/lib/doormileHooks';
import { currency, matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding';
/**
* Clients — the tenant companies whose deliveries this network carries.
@@ -281,6 +283,8 @@ function PricingDrawer({ tenant, open, onClose }) {
export default function Tenants() {
const navigate = useNavigate();
const { user, isClient } = useAuth();
const canOnboard = canOnboardClients(user, isClient);
const { data: tenants = [], isLoading, isFetching } = useTenants();
const updateTenant = useUpdateTenant();
@@ -456,6 +460,11 @@ export default function Tenants() {
placeholder="Search by client name, email or phone"
className="w-full sm:w-72"
/>
{canOnboard && (
<Button variant="outline" onClick={() => navigate('/doormile/clients/onboard')}>
<UserPlus className="mr-1.5 h-4 w-4" /> Onboard client
</Button>
)}
<Button onClick={() => navigate('/doormile/clients/create')}>
<Plus className="mr-1.5 h-4 w-4" /> New client
</Button>

View File

@@ -49,9 +49,16 @@ const STATUS_TABS = [
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
// `short` is what the list shows: the trigger's clock icon already says
// "batch", so "Morning Batch" repeated the word on every row.
const BATCH_OPTIONS = [
{ id: 'all', label: 'All batches', range: 'Across the day' },
...BATCHES.map((batch) => ({ id: batch.id, label: batch.label, range: batchRangeLabel(batch) })),
{ id: 'all', label: 'All batches', short: 'All batches', range: 'All day' },
...BATCHES.map((batch) => ({
id: batch.id,
label: batch.label,
short: batch.label.replace(/\s*batch$/i, ''),
range: batchRangeLabel(batch),
})),
];
const ALL = 'all';
@@ -431,27 +438,34 @@ export default function Deliveries() {
/>
<ZoneSelector className="w-full sm:w-auto shrink-0" />
{/* Batch. Sized like the Clients and Milers selects beside it.
The trigger names the batch only: SelectItem puts an option's
whole content in ItemText, so a bare <SelectValue/> copied the
time-range badge into the trigger too and needed 210px for it.
The list is a compact label + time, no badges. */}
<Select value={batch} onValueChange={setBatch}>
<SelectTrigger className="w-full sm:w-auto min-w-[210px] h-10">
<SelectTrigger className="w-full sm:w-44" aria-label="Batch">
<div className="flex items-center gap-2 min-w-0">
<Clock className="h-3.5 w-3.5 text-ink-3 shrink-0" />
<SelectValue />
<SelectValue>
<span className="truncate">{BATCH_OPTIONS.find((o) => o.id === batch)?.label || 'All batches'}</span>
</SelectValue>
</div>
</SelectTrigger>
<SelectContent className="w-[300px] p-1.5 shadow-lg rounded-xl">
<SelectContent className="min-w-[var(--radix-select-trigger-width)] w-52">
{BATCH_OPTIONS.map((option) => (
<SelectItem
key={option.id}
value={option.id}
textValue={option.id === 'all' ? 'All batches' : `${option.label} · ${option.range}`}
className="py-2 pl-2.5 pr-8 rounded-lg cursor-pointer my-0.5"
textValue={option.label}
// ItemText is SelectItem's last child and sizes to its text;
// stretched, the times line up in one right-hand column.
className="cursor-pointer [&>span:last-child]:w-full"
>
<div className="flex items-center justify-between gap-3 w-full">
<span className="font-medium text-ink-1 text-sm">{option.label}</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-md bg-surface-sunken text-ink-3 border border-border/50 shrink-0">
{option.range}
</span>
</div>
<span className="flex w-full items-center justify-between gap-3">
<span className="text-ink-1">{option.short}</span>
<span className="text-xs text-ink-4 tabular-nums">{option.range}</span>
</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -6,6 +6,7 @@ import {
Textarea,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
@@ -209,6 +210,9 @@ export default function Exceptions() {
return (
<Stack space="lg">
{/* Early warnings from the console's monitoring skills (agent registry). */}
<AgentOperationsBanner />
<Grid cols={4}>
<KpiCard
icon={ClipboardList}

View File

@@ -41,21 +41,14 @@ import {
getAdminTenants,
getTenantLocations
} from '@/api/doormile/endpoints';
// calculateDrivingDistance was used by the deadhead-leg effect below without
// ever being imported — a ReferenceError on every customer-pickup order that
// had a pinned collection address, thrown synchronously inside the effect so
// the `.catch()` on that promise chain could never see it. Neither the build
// nor the lint config catches an unbound identifier in a .jsx file, which is
// why it survived. Found while auditing the coordinate changes; unrelated to
// them.
//
// getLastRouteDurationMin is gone from this list: it was imported and never
// called, which the project's own lint reports as an error. Dropped while
// rewriting the statement rather than left behind in it.
// The deadhead-leg effect uses calculateDrivingRoute, not
// calculateDrivingDistance: the latter returns whole km only, so a collection
// point 300 m from the hub read "0 km". Both legs now display `meters`.
import {
calculateDrivingDistance,
calculateDrivingRoute,
calculateTotalCharge
calculateTotalCharge,
formatRouteDistance,
formatRouteDuration
} from '@/lib/distance';
import { buildCityScope, geocodeAddress, saveRecentAddress } from '@/lib/geocodingService';
import { useHubs } from '@/lib/doormileHooks';
@@ -549,9 +542,14 @@ export default function CreateOrder() {
calculateDrivingRoute({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
.then((res) => {
if (!active) return;
// No floor and no default. `Math.max(1, d || 1)` turned every leg under
// 500 m (OSRM rounds to 0 km) into "1 km", and `minutes || 15` turned a
// sub-minute drive into "15 mins" — a same-address order showed both.
// `distance` (whole km) is still what pricing bills on; `meters` is shown.
setRoute({
distance: Math.max(1, res.distance || 1),
minutes: res.minutes || 15,
distance: Math.max(0, res.distance || 0),
meters: Number.isFinite(res.meters) ? res.meters : null,
minutes: Number.isFinite(res.minutes) ? res.minutes : null,
polyline: res.polyline || [[pLat, pLng], [dLat, dLng]],
resolved: true
});
@@ -591,9 +589,15 @@ export default function CreateOrder() {
}
let active = true;
calculateDrivingDistance({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng })
.then((distance) => {
if (active) setDeadhead({ distance: Math.max(0, distance || 0), resolved: true });
calculateDrivingRoute({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng })
.then((res) => {
if (active) {
setDeadhead({
distance: Math.max(0, res.distance || 0),
meters: Number.isFinite(res.meters) ? res.meters : null,
resolved: true
});
}
})
.catch(() => {
if (active) setDeadhead({ distance: 0, resolved: false });
@@ -1704,14 +1708,14 @@ export default function CreateOrder() {
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg text-center">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Driving Dist.</div>
<div className="text-base font-black text-slate-900 mt-0.5">
{route.resolved ? `${route.distance} km` : '—'}
{route.resolved ? formatRouteDistance(route.meters ?? route.distance * 1000) : '—'}
</div>
</div>
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg text-center">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Est. Duration</div>
<div className="text-base font-black text-slate-900 mt-0.5">
{route.minutes ? `${route.minutes} mins` : '—'}
{route.resolved ? formatRouteDuration(route.minutes) : '—'}
</div>
</div>
</div>
@@ -1757,7 +1761,7 @@ export default function CreateOrder() {
<span className="w-2 h-2 rounded-full bg-emerald-500" />
Pickup ➔ <span className="w-2 h-2 rounded-full bg-orange-500" /> Delivery
</span>
<span className="font-semibold text-slate-700">{route.distance} km road path</span>
<span className="font-semibold text-slate-700">{formatRouteDistance(route.meters ?? route.distance * 1000)} road path</span>
</div>
</div>
)}
@@ -1774,7 +1778,7 @@ export default function CreateOrder() {
Hub → collection point · not billed
</div>
</div>
<div className="text-sm font-black text-violet-900 shrink-0 ml-2">{deadhead.distance} km</div>
<div className="text-sm font-black text-violet-900 shrink-0 ml-2">{formatRouteDistance(deadhead.meters ?? deadhead.distance * 1000)}</div>
</div>
)}

View File

@@ -9,19 +9,12 @@ import Customers from '@/pages/doormile/customers/Customers';
import Profitability from '@/pages/doormile/reports/Profitability';
import AgentStudio from './agentStudio/AgentStudio';
// ⚠️ Skills & Tools (Agent Studio) is a PREVIEW. It does not configure
// anything yet.
//
// It reads and writes browser localStorage and nothing else: no endpoint, no
// table, and no importer outside its own folder. A skill added here exists on
// one machine, in one browser, and is invisible to colleagues and to the
// assistant. The Home assistant runs on the regex catalogue in
// lib/assistant/intents.js and has never heard of a "skill".
//
// It is kept deliberately — the shape is right and it is what Phase 6 of
// docs/agent-platform-plan.md wires to the real tool registry in
// doormile_backend/internal/ai/tools. Until then, do not read a change made
// here as a change to the agent's behaviour.
// Skills & Tools (Agent Studio) reads and writes the backend agent registry
// (/admin/ai/*, docs/agent-platform-plan.md Phase 2). Changes are shared and
// audited. The console ops skills (Exceptions banner, chat briefing) read it
// since Phase 3; AI_engine starts reading it in Phase 5. The page says so on
// screen. Until then, a change to an AI_engine agent here does not change
// what that agent does.
//
// It is NOT the default tab. Landing on a section that does nothing is how an
// operator concludes the AI is broken; Profitability leads instead.

View File

@@ -1,225 +1,406 @@
import React, { useState } from 'react';
import {
Sliders,
BarChart3,
ShieldCheck,
TrendingUp,
Cpu,
Save,
} from 'lucide-react';
import { Sliders, BarChart3, ShieldCheck, ShieldAlert, Cpu, Save, Info, Radio } from 'lucide-react';
import { useAiDecisions, useAiInsights } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { cn } from '@/lib/utils';
import { OpenToast } from '@/components/third-party/OpenToast';
import { messageOf } from '@/api/doormile/notify';
import { MODEL_OPTIONS } from './registryAdapters';
export function AgentConfigView({ activeAgent }) {
const [model, setModel] = useState('claude-3-5-sonnet');
const [temperature, setTemperature] = useState(0.2);
const [systemPrompt, setSystemPrompt] = useState(
`You are ${activeAgent?.name || 'an autonomous agent'} operating in the Doormile Express logistics ecosystem. Prioritize safety, deterministic tool calling, and high-accuracy dispatch operations.`
function Fact({ label, children }) {
return (
<div className="space-y-0.5">
<dt className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{label}</dt>
<dd className="text-xs text-slate-800 break-words">{children || '—'}</dd>
</div>
);
const [requireConfirmOnWrite, setRequireConfirmOnWrite] = useState(true);
}
const handleSave = () => {
OpenToast('Agent configuration saved successfully', 'success', 2500);
/**
* The agent's registry record, and the two settings an admin may change:
* the model (AI_engine agents only) and autonomy (only the three agents whose
* writes AI_engine gates). It replaced a form with a temperature slider and a
* model list (GPT-4o, DeepSeek) that nothing read, and a Save that saved nothing.
*/
export function AgentConfigView({ activeAgent, canEdit = false, onSave }) {
const [model, setModel] = useState(activeAgent?.model || '');
const [autonomous, setAutonomous] = useState(Boolean(activeAgent?.autonomous));
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
if (!activeAgent) return null;
const isEngine = activeAgent.runtime === 'engine';
const modelChanged = isEngine && model !== (activeAgent.model || '');
const autonomyChanged = activeAgent.hasAutonomyGate && autonomous !== activeAgent.autonomous;
const turningOn = autonomyChanged && autonomous;
const confirmOk = !turningOn || confirm === activeAgent.id;
const dirty = modelChanged || autonomyChanged;
// A model id the registry holds but this list does not name stays selectable.
const options = MODEL_OPTIONS.some((o) => o.value === model) ? MODEL_OPTIONS : [...MODEL_OPTIONS, { value: model, label: model }];
const handleSave = async () => {
setError('');
const patch = {};
if (modelChanged) patch.model = model;
if (autonomyChanged) patch.autonomous = autonomous;
if (turningOn) patch.confirm = confirm;
setSaving(true);
try {
await onSave(activeAgent.id, patch);
setConfirm('');
} catch (err) {
setError(messageOf(err, 'The agent was not updated'));
} finally {
setSaving(false);
}
};
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-6">
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
<div className="flex items-start justify-between gap-4 pb-4 border-b border-slate-100">
<div>
<h3 className="text-base font-bold text-slate-900 font-heading flex items-center gap-2">
<Sliders className="w-4 h-4 text-brand" />
<span>Agent Governance & Model Configuration</span>
<span>{activeAgent.name}</span>
</h3>
<p className="text-xs text-slate-500 mt-0.5">
Configure LLM parameters, system identity, and write safety boundaries for {activeAgent?.name}.
</p>
<p className="text-xs text-slate-500 mt-0.5">{activeAgent.description}</p>
</div>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-1.5 cursor-pointer"
>
<Save className="w-3.5 h-3.5" />
<span>Save Configuration</span>
</button>
{canEdit && (isEngine || activeAgent.hasAutonomyGate) && (
<button
type="button"
onClick={handleSave}
disabled={!dirty || !confirmOk || saving}
className="px-4 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-1.5 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
>
<Save className="w-3.5 h-3.5" />
<span>{saving ? 'Saving…' : 'Save changes'}</span>
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-xs">
{/* Foundation Model Choice */}
<div className="space-y-2">
<label className="font-bold text-slate-800 flex items-center gap-1.5">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Fact label="Status">
{activeAgent.statusMeta.label} — {activeAgent.statusMeta.hint}
</Fact>
<Fact label="Runs in">{isEngine ? 'AI_engine' : 'The console'}</Fact>
<Fact label="Woken by">{activeAgent.wakeon}</Fact>
<Fact label="Implemented at">
<span className="font-mono text-[11px]">{activeAgent.classref}</span>
</Fact>
{activeAgent.llmdecision && (
<Fact label="Model decision">
<span className="font-mono text-[11px]">{activeAgent.llmdecision}</span>
</Fact>
)}
</dl>
{isEngine && (
<div className="space-y-2 text-xs">
<label htmlFor="agent-model" className="font-bold text-slate-800 flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5 text-brand" />
<span>Primary Model Engine</span>
<span>Model</span>
</label>
<select
id="agent-model"
value={model}
disabled={!canEdit || saving}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 focus:outline-none focus:border-brand font-medium"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 focus:outline-none focus:border-brand font-medium disabled:text-slate-500"
>
<option value="claude-3-5-sonnet">Claude 3.5 Sonnet (Recommended - Precision Tool Calling)</option>
<option value="gpt-4o">GPT-4o (High Speed Reasoning)</option>
<option value="deepseek-v3">DeepSeek V3 (Cost Efficient)</option>
{options.map((o) => (
<option key={o.value || 'default'} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{/* Temperature Slider */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-bold text-slate-800">Determinism (Temperature)</label>
<span className="font-mono font-bold text-brand">{temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full accent-[#C8102E] cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-slate-400">
<span>0.0 (Strictly Deterministic)</span>
<span>1.0 (Creative)</span>
</div>
</div>
</div>
{/* System Prompt Instruction */}
<div className="space-y-2 text-xs">
<label className="font-bold text-slate-800">
Agent System Instruction & Operational Persona
</label>
<textarea
rows={4}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 text-xs font-mono text-slate-800 focus:outline-none focus:border-brand focus:bg-white resize-none"
/>
</div>
{/* Safety Gate Checkbox */}
<div className="p-4 rounded-2xl bg-amber-50/70 border border-amber-200/80 flex items-center justify-between">
<div className="space-y-0.5">
<div className="font-bold text-amber-900 text-xs flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-amber-700" />
<span>Enforce Human-in-the-Loop Confirmation on Write Intents</span>
</div>
<p className="text-[11px] text-amber-800/80">
Requires explicit operator approval before executing order creations or fleet reallocations.
</p>
</div>
<button
type="button"
onClick={() => setRequireConfirmOnWrite((v) => !v)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
requireConfirmOnWrite ? 'bg-amber-600' : 'bg-slate-300'
{!activeAgent.llmdecision && (
<p className="text-[11px] text-slate-500">This agent makes no model call today, so the setting has no effect on it.</p>
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
requireConfirmOnWrite ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
</div>
</div>
)}
{activeAgent.hasAutonomyGate ? (
<div className={cn('p-4 rounded-2xl border space-y-3', autonomous ? 'bg-rose-50/70 border-rose-200' : 'bg-amber-50/70 border-amber-200/80')}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="font-bold text-slate-900 text-xs flex items-center gap-1.5">
{autonomous ? <ShieldAlert className="w-4 h-4 text-rose-700" /> : <ShieldCheck className="w-4 h-4 text-amber-700" />}
<span>Autonomous</span>
</div>
<p className="text-[11px] text-slate-600">
{autonomous
? 'This agent may act without a human — reassign riders or message customers.'
: 'Off: this agent raises alerts and a human decides.'}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={autonomous}
aria-label="Autonomous"
disabled={!canEdit || saving}
onClick={() => {
setAutonomous((v) => !v);
setError('');
}}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out disabled:cursor-not-allowed disabled:opacity-50',
autonomous ? 'bg-rose-600' : 'bg-slate-300'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
autonomous ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
</div>
{turningOn && (
<label className="block space-y-1 text-[11px]">
<span className="font-semibold text-rose-900">
Type <span className="font-mono">{activeAgent.id}</span> to confirm
</span>
<input
type="text"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="off"
spellCheck={false}
className="w-full rounded-lg border border-rose-300 bg-white px-2.5 py-1.5 font-mono text-xs focus:outline-none focus:border-rose-500"
/>
</label>
)}
</div>
) : (
<p className="flex items-start gap-2 text-[11px] text-slate-500">
<Info className="w-3.5 h-3.5 mt-px shrink-0" />
<span>This agent has no autonomy switch: it never writes to orders, riders or customers on its own.</span>
</p>
)}
{error && (
<div role="alert" className="p-2.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 font-semibold text-[11px]">
{error}
</div>
)}
</div>
);
}
export function AgentInsightsView() {
const WINDOWS = [
{ days: 1, label: '24 h' },
{ days: 7, label: '7 days' },
{ days: 30, label: '30 days' },
];
const pct = (part, whole) => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : '—');
const ms = (v) => (v > 0 ? (v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${Math.round(v)} ms`) : '—');
const when = (raw) => formatDoormileTimestamp(raw, 'DD MMM, hh:mm A');
function Tile({ label, value, hint, tone = 'slate' }) {
const tones = { slate: 'text-slate-900', rose: 'text-rose-700', emerald: 'text-emerald-700' };
return (
<div className="space-y-6">
{/* Metrics Row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Total Invocations
</div>
<div className="text-2xl font-black text-slate-900 font-heading">1,482</div>
<div className="text-[10px] text-emerald-600 font-semibold flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
+18.4% this week
</div>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">{label}</div>
<div className={cn('text-2xl font-black font-heading', tones[tone])}>{value}</div>
{hint && <div className="text-[10px] text-slate-500">{hint}</div>}
</div>
);
}
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Tool Success Rate
</div>
<div className="text-2xl font-black text-emerald-600 font-heading">99.4%</div>
<div className="text-[10px] text-slate-500 font-medium">0 validation rejections</div>
</div>
/**
* What the agents actually did (plan Phase 4): runs and failures per AI_engine
* agent from its task telemetry, decisions from the decision log, and each
* agent's latest heartbeat. Every figure comes from /admin/ai/insights and
* /admin/ai/decisions; nothing is estimated. It replaced a panel of invented
* numbers ("99.4% success", "1,482 invocations").
*/
export function AgentInsightsView({ agents = [], activeAgent }) {
const [days, setDays] = useState(7);
const insightsQ = useAiInsights(days);
const decisionsQ = useAiDecisions({ limit: 5 });
const data = insightsQ.data;
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Average Latency
</div>
<div className="text-2xl font-black text-brand font-heading">380ms</div>
<div className="text-[10px] text-emerald-600 font-semibold">Sub-second execution</div>
</div>
const engineAgents = agents.filter((a) => a.runtime === 'engine');
const statsById = Object.fromEntries((data?.runs?.peragent || []).map((s) => [s.agentid, s]));
const liveById = Object.fromEntries((data?.live || []).map((s) => [s.agentid, s]));
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Gated Actions Confirmed
</div>
<div className="text-2xl font-black text-amber-600 font-heading">328</div>
<div className="text-[10px] text-slate-500 font-medium">100% human-verified</div>
</div>
</div>
{/* Tool Distribution Breakdown */}
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-4 text-xs">
return (
<div className="space-y-5 text-xs">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-bold text-slate-900 font-heading flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-brand" />
<span>Capability Invocation Volume by Category</span>
<span>What the agents did</span>
</h3>
<div className="space-y-3 pt-2">
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Authoring & Skill Management (MileTruth AI)</span>
<span>540 calls (36%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-brand rounded-full w-[36%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Workforce Analytics</span>
<span>420 calls (28%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-slate-700 rounded-full w-[28%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Autonomous Order Scheduling</span>
<span>310 calls (21%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-emerald-600 rounded-full w-[21%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Dispatch Rebalance</span>
<span>212 calls (15%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-rose-600 rounded-full w-[15%]" />
</div>
</div>
<div role="group" aria-label="Time window" className="flex rounded-lg border border-slate-200 bg-white p-0.5">
{WINDOWS.map((w) => (
<button
key={w.days}
type="button"
aria-pressed={days === w.days}
onClick={() => setDays(w.days)}
className={cn(
'px-3 py-1 rounded-md font-semibold cursor-pointer',
days === w.days ? 'bg-brand text-white' : 'text-slate-600 hover:bg-slate-50'
)}
>
{w.label}
</button>
))}
</div>
</div>
{activeAgent?.runtime === 'console' && (
<p className="flex items-start gap-2 text-[11px] text-slate-500">
<Info className="w-3.5 h-3.5 mt-px shrink-0" />
<span>
{activeAgent.name} runs in the operator&apos;s browser, so its runs are not recorded. The figures below are
AI_engine&apos;s agents.
</span>
</p>
)}
{insightsQ.isLoading ? (
<div className="rounded-2xl border border-slate-200 bg-white p-8 text-center text-slate-500">Loading agent activity…</div>
) : insightsQ.error ? (
<div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-800">
<div className="font-bold">Could not load agent activity</div>
<div className="mt-0.5">{messageOf(insightsQ.error)}</div>
</div>
) : (
<>
{!data.receiving && (
<div role="note" className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2.5 text-amber-900">
<Radio className="w-4 h-4 mt-px shrink-0 text-amber-600" />
<span>
<span className="font-bold">Not receiving agent telemetry.</span> This backend is not connected to the NATS
server AI_engine publishes to, so agent runs are not being recorded. Decisions still come from the decision log.
</span>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Tile label="Agent runs" value={data.runs.total.toLocaleString('en-IN')} hint={`last ${WINDOWS.find((w) => w.days === days)?.label || `${days} days`}`} />
<Tile
label="Failed"
value={data.runs.failed.toLocaleString('en-IN')}
hint={`${pct(data.runs.failed, data.runs.total)} of runs`}
tone={data.runs.failed > 0 ? 'rose' : 'slate'}
/>
<Tile label="Decisions" value={data.decisions.total.toLocaleString('en-IN')} hint="from the decision log" />
<Tile
label="Agents reporting"
value={`${data.live.length} / ${engineAgents.length || '—'}`}
hint="heartbeat in the last 5 min"
tone={data.live.length > 0 ? 'emerald' : 'slate'}
/>
</div>
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-2xs overflow-x-auto">
<table className="w-full text-left">
<caption className="sr-only">Runs per AI_engine agent</caption>
<thead className="bg-slate-50/80 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
<tr>
<th className="py-2.5 px-3">Agent</th>
<th className="py-2.5 px-3">Now</th>
<th className="py-2.5 px-3 text-right">Runs</th>
<th className="py-2.5 px-3 text-right">Failed</th>
<th className="py-2.5 px-3 text-right">Avg time</th>
<th className="py-2.5 px-3">Last run</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{engineAgents.map((a) => {
const s = statsById[a.id];
const live = liveById[a.id];
return (
<tr key={a.id} className={cn(a.id === activeAgent?.id && 'bg-brand-tint/30')}>
<td className="py-2 px-3 font-semibold text-slate-800">{a.name}</td>
<td className="py-2 px-3">
{live ? (
<span title={`Last heartbeat ${when(live.lastseenat)}`} className="text-emerald-700 font-semibold">
{live.status || 'up'}
</span>
) : (
<span className="text-slate-400">silent</span>
)}
</td>
<td className="py-2 px-3 text-right tabular-nums">{s ? s.runs.toLocaleString('en-IN') : 0}</td>
<td className={cn('py-2 px-3 text-right tabular-nums', s?.failed > 0 && 'text-rose-700 font-semibold')}>
{s ? s.failed.toLocaleString('en-IN') : 0}
</td>
<td className="py-2 px-3 text-right tabular-nums">{ms(s?.avgdurationms)}</td>
<td className="py-2 px-3 text-slate-500">{s?.lastrunat ? when(s.lastrunat) : '—'}</td>
</tr>
);
})}
</tbody>
</table>
{data.runs.total === 0 && (
<p className="px-3 py-2.5 text-[11px] text-slate-500 border-t border-slate-100">
{data.receiving ? 'No agent runs were recorded in this window.' : 'No runs can be recorded until telemetry is received.'}
</p>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-2xs p-4 space-y-2">
<h4 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">Decisions by type</h4>
{data.decisions.bytype.length === 0 ? (
<p className="text-slate-500">No decisions were logged in this window.</p>
) : (
<ul className="space-y-2">
{data.decisions.bytype.map((d) => (
<li key={d.decisiontype}>
<div className="flex justify-between font-semibold text-slate-800">
<span className="font-mono">{d.decisiontype}</span>
<span className="tabular-nums">{d.total.toLocaleString('en-IN')}</span>
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{Object.entries(d.outcomes).map(([outcome, n]) => (
<span key={outcome} className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-600">
{outcome} · {n}
</span>
))}
</div>
</li>
))}
</ul>
)}
</div>
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-2xs p-4 space-y-2">
<h4 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">Latest decisions</h4>
{decisionsQ.isLoading ? (
<p className="text-slate-500">Loading…</p>
) : decisionsQ.error ? (
<p className="text-rose-700">{messageOf(decisionsQ.error)}</p>
) : (decisionsQ.data || []).length === 0 ? (
<p className="text-slate-500">The decision log is empty.</p>
) : (
<ul className="divide-y divide-slate-100">
{decisionsQ.data.map((d) => (
<li key={d.id} className="py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
<span className="font-mono font-semibold text-slate-800">{d.decisiontype}</span>
<span className="text-[10px] text-slate-400">{when(d.createdat)}</span>
</div>
<div className="text-[11px] text-slate-500">
{d.bookingid ? `Booking #${d.bookingid} · ` : ''}
{d.outcome ? `outcome: ${d.outcome}` : 'outcome not recorded yet'}
</div>
{d.reasoning && <p className="text-[11px] text-slate-600 line-clamp-2">{d.reasoning}</p>}
</li>
))}
</ul>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -4,64 +4,119 @@ import {
Wrench,
Bot,
Send,
CheckCircle2,
Terminal,
RotateCcw,
ShieldCheck,
AlertTriangle,
} from 'lucide-react';
import { useRunAiPlayground } from '../../../../lib/doormileHooks';
/**
* Agent Studio's Test tab. Each prompt is one real run on the backend
* (POST /admin/ai/playground/run): Claude with the chosen skill's tools, where
* read tools execute with personal data redacted and write/notify tools come
* back as proposals that are never executed. The trace shown is the server's,
* step for step — nothing here is simulated.
*/
const OUTCOME = {
executed: { label: 'executed', cls: 'text-emerald-400' },
proposed: { label: 'proposal only — not executed', cls: 'text-amber-300' },
unavailable: { label: 'not available in the playground', cls: 'text-slate-400' },
rejected: { label: 'refused — not in this skill', cls: 'text-red-400' },
error: { label: 'failed', cls: 'text-red-400' },
};
const MAX_PROMPT = 2000;
/** What to tell the operator when a run fails, from the server's code/status. */
export function playgroundErrorMessage(err) {
const status = err?.response?.status;
const data = err?.response?.data || {};
if (data.code === 'PLAYGROUND_NOT_CONFIGURED') {
return 'The Test playground is not switched on for this server yet (no Claude client is configured). Nothing was run.';
}
if (status === 429) return data.message || 'Playground limit reached. Try again in a few minutes.';
if (status === 403) return 'Only an admin can run the Test playground.';
return data.message || 'The run failed. Nothing was changed.';
}
function pretty(raw) {
if (raw == null) return '';
try {
return JSON.stringify(typeof raw === 'string' ? JSON.parse(raw) : raw, null, 1);
} catch {
return String(raw);
}
}
function TraceStep({ step }) {
if (step.kind === 'text') {
return <div className="text-xs text-slate-200 font-sans whitespace-pre-wrap">{step.text}</div>;
}
const o = OUTCOME[step.outcome] || { label: step.outcome, cls: 'text-slate-300' };
return (
<div className="bg-slate-950 p-2.5 rounded-lg border border-slate-800 text-[11px] space-y-1" data-testid="trace-tool">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-amber-400 font-bold">
<Wrench className="w-3 h-3" />
<span>{step.tool}()</span>
{step.toolkind && <span className="text-slate-500 font-normal">[{step.toolkind}]</span>}
</span>
<span className={`${o.cls} font-sans`}>
{o.label} · {step.ms} ms
</span>
</div>
<div className="text-slate-400 text-[10px] break-all">Input: {pretty(step.input)}</div>
{step.result && (
<details className="text-slate-400 text-[10px]">
<summary className="cursor-pointer text-slate-500">Result</summary>
<pre className="whitespace-pre-wrap break-all max-h-48 overflow-y-auto">{pretty(step.result)}</pre>
</details>
)}
</div>
);
}
export default function AgentPlayground({
activeAgent,
skills = [],
initialPrompt = '',
initialSkillId = '',
canRun = false,
}) {
const [query, setQuery] = useState(initialPrompt || '');
const [isRunning, setIsRunning] = useState(false);
const [skillId, setSkillId] = useState(initialSkillId || '');
const [messages, setMessages] = useState([]);
const run = useRunAiPlayground();
useEffect(() => {
if (initialPrompt) {
setQuery(initialPrompt);
}
if (initialPrompt) setQuery(initialPrompt);
}, [initialPrompt]);
const runSimulation = (promptToRun) => {
useEffect(() => {
if (initialSkillId && skills.some((s) => s.id === initialSkillId)) setSkillId(initialSkillId);
}, [initialSkillId, skills]);
const submit = (promptToRun) => {
const text = (promptToRun || query).trim();
if (!text) return;
if (!text || run.isPending || !canRun || !activeAgent) return;
const userMsg = { role: 'user', content: text, id: Date.now() };
setMessages((prev) => [...prev, userMsg]);
setIsRunning(true);
const id = Date.now();
setMessages((prev) => [...prev, { role: 'user', content: text, id }]);
setQuery('');
// Simulate Agent Thought, Tool Selection, and Deterministic execution
setTimeout(() => {
// Find matching skill or tool
const matchedSkill = skills.find(
(s) =>
text.toLowerCase().includes((s.title || '').toLowerCase()) ||
(s.prompt && text.toLowerCase().includes(s.prompt.toLowerCase())) ||
(s.category && text.toLowerCase().includes(s.category.toLowerCase()))
) || skills[0];
const agentResponse = {
role: 'assistant',
id: Date.now() + 1,
skill: matchedSkill?.title || 'System Core',
thought: `Matched query intent to skill "${matchedSkill?.title}". Selecting optimal deterministic tool...`,
toolCall: matchedSkill?.tools?.[0]
? {
name: matchedSkill.tools[0],
args: { query: text, timestamp: new Date().toISOString() },
}
: null,
output: `Agent processed capability [${matchedSkill?.title}]. Autonomous verification succeeded with 100% confidence.`,
};
setMessages((prev) => [...prev, agentResponse]);
setIsRunning(false);
}, 900);
run.mutate(
{ agentid: activeAgent.id, skillid: skillId || undefined, prompt: text },
{
onSuccess: (trace) => setMessages((prev) => [...prev, { role: 'assistant', id: id + 1, trace }]),
onError: (err) =>
setMessages((prev) => [...prev, { role: 'error', id: id + 1, content: playgroundErrorMessage(err) }]),
}
);
};
const selectedSkill = skills.find((s) => s.id === skillId);
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-5">
{/* Header */}
@@ -71,11 +126,9 @@ export default function AgentPlayground({
<Bot className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900 font-heading">
Agent Test Playground
</h3>
<h3 className="text-base font-bold text-slate-900 font-heading">Agent Test Playground</h3>
<p className="text-xs text-slate-500">
Run test prompts against {activeAgent?.name || 'this agent'} and inspect tool calling execution traces.
Run a prompt through Claude as {activeAgent?.name || 'this agent'} and see every tool call it makes.
</p>
</div>
</div>
@@ -92,102 +145,134 @@ export default function AgentPlayground({
)}
</div>
{/* Suggested Quick Triggers */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs">
<span className="text-slate-400 font-semibold shrink-0">Try trigger:</span>
{/* What a run can and cannot do */}
<div
role="note"
aria-label="Playground safety"
className="flex items-start gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-xs text-emerald-900"
>
<ShieldCheck className="w-4 h-4 mt-0.5 shrink-0 text-emerald-600" />
<p>
Runs are real Claude calls on live data. Read tools execute with names, phones, addresses and notes removed
before Claude sees them. Write and notify tools are <span className="font-bold">never executed</span> — they
come back as proposals. Admin only, 10 runs per 10 minutes.
</p>
</div>
{/* Scope + triggers */}
<div className="flex flex-wrap items-center gap-2 text-xs">
<label htmlFor="playground-skill" className="text-slate-400 font-semibold">
Skill:
</label>
<select
id="playground-skill"
value={skillId}
onChange={(e) => setSkillId(e.target.value)}
className="bg-slate-50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs text-slate-800"
>
<option value="">All enabled skills</option>
{skills.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
{s.enabled ? '' : ' (disabled)'}
</option>
))}
</select>
{skills
.filter((s) => s.prompt)
.filter((s) => s.prompt && (!skillId || s.id === skillId))
.map((s) => (
<button
key={s.id}
type="button"
onClick={() => runSimulation(s.prompt)}
className="px-3 py-1 rounded-full bg-brand-tint/70 border border-brand/20 text-brand-dark hover:bg-brand-tint transition-colors font-medium shrink-0 flex items-center gap-1.5 cursor-pointer"
disabled={!canRun || run.isPending}
onClick={() => {
setSkillId(s.id);
submit(s.prompt);
}}
className="px-3 py-1 rounded-full bg-brand-tint/70 border border-brand/20 text-brand-dark hover:bg-brand-tint transition-colors font-medium shrink-0 flex items-center gap-1.5 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles className="w-3 h-3 text-brand" />
<span>"{s.prompt}"</span>
<span>&quot;{s.prompt}&quot;</span>
</button>
))}
</div>
{/* Interactive Trace Output Log */}
<div className="min-h-[260px] max-h-[420px] overflow-y-auto rounded-2xl bg-slate-900 text-slate-100 p-4 font-mono text-xs space-y-4">
{messages.length === 0 && !isRunning && (
{/* Trace */}
<div className="min-h-[260px] max-h-[520px] overflow-y-auto rounded-2xl bg-slate-900 text-slate-100 p-4 font-mono text-xs space-y-4">
{messages.length === 0 && !run.isPending && (
<div className="text-slate-500 text-center py-12 flex flex-col items-center gap-2 font-sans">
<Terminal className="w-8 h-8 opacity-40" />
<div className="font-semibold text-sm">Playground session ready</div>
<p className="text-xs text-slate-400 max-w-sm">
Type a prompt or select a sample trigger above to test tool calling and deterministic intent execution.
{canRun
? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools Claude calls and what they return.`
: 'Only an admin can run the Test playground.'}
</p>
</div>
)}
{messages.map((m) => (
<div key={m.id} className="space-y-2">
{m.role === 'user' ? (
<div className="flex items-start gap-2">
{messages.map((m) => {
if (m.role === 'user') {
return (
<div key={m.id} className="flex items-start gap-2">
<span className="text-red-400 font-bold shrink-0">&gt; USER:</span>
<span className="text-white font-sans text-xs">{m.content}</span>
</div>
) : (
<div className="space-y-2 bg-slate-800/70 p-3.5 rounded-xl border border-slate-700/80">
<div className="flex items-center justify-between text-[11px] text-red-300 font-sans font-bold">
<span>AGENT INTENT RESOLUTION: {m.skill}</span>
<span className="text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5" />
Confidence: 99.4%
</span>
</div>
<div className="text-[11px] text-slate-400 font-sans">
<span className="text-slate-500">Reasoning: </span>
{m.thought}
</div>
{m.toolCall && (
<div className="bg-slate-950 p-2.5 rounded-lg border border-slate-800 text-[11px] space-y-1">
<div className="flex items-center gap-1.5 text-amber-400 font-bold">
<Wrench className="w-3 h-3" />
<span>Tool Execution: {m.toolCall.name}()</span>
</div>
<div className="text-slate-400 text-[10px]">
Args: {JSON.stringify(m.toolCall.args)}
</div>
</div>
)}
<div className="text-xs text-emerald-300 font-sans pt-1">
{m.output}
</div>
);
}
if (m.role === 'error') {
return (
<div key={m.id} role="alert" className="flex items-start gap-2 text-amber-300 font-sans text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{m.content}</span>
</div>
)}
</div>
))}
);
}
const t = m.trace || {};
return (
<div key={m.id} className="space-y-2 bg-slate-800/70 p-3.5 rounded-xl border border-slate-700/80">
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-slate-400 font-sans">
<span className="text-red-300 font-bold">{t.model}</span>
<span data-testid="trace-meta">
{t.turns} turn{t.turns === 1 ? '' : 's'} · {t.inputtokens} in / {t.outputtokens} out tokens ·{' '}
{(t.ms / 1000).toFixed(1)} s{t.stopreason === 'max_turns' ? ' · stopped at the turn limit' : ''}
</span>
</div>
{(t.steps || []).map((s, i) => (
<TraceStep key={i} step={s} />
))}
{!(t.steps || []).length && <div className="text-slate-500 font-sans">No output.</div>}
</div>
);
})}
{isRunning && (
{run.isPending && (
<div className="flex items-center gap-2 text-red-400 animate-pulse font-sans text-xs py-2">
<div className="w-3 h-3 border-2 border-red-400 border-t-transparent rounded-full animate-spin" />
<span>Agent evaluating prompt against capability registry...</span>
<span>Running on Claude…</span>
</div>
)}
</div>
{/* Prompt Input Box */}
{/* Prompt */}
<div className="relative flex items-center gap-2">
<input
type="text"
value={query}
maxLength={MAX_PROMPT}
disabled={!canRun}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') runSimulation();
if (e.key === 'Enter') submit();
}}
placeholder="Ask or command the agent to test capability invocation..."
className="flex-1 bg-slate-50 border border-slate-200 rounded-2xl px-4 py-3 text-xs sm:text-sm text-slate-900 focus:outline-none focus:border-brand focus:bg-white shadow-2xs transition-all"
placeholder={canRun ? 'Ask the agent something, e.g. “Which bookings are still unassigned?”' : 'Admin only'}
aria-label="Playground prompt"
className="flex-1 bg-slate-50 border border-slate-200 rounded-2xl px-4 py-3 text-xs sm:text-sm text-slate-900 focus:outline-none focus:border-brand focus:bg-white shadow-2xs transition-all disabled:opacity-60"
/>
<button
type="button"
onClick={() => runSimulation()}
disabled={!query.trim() || isRunning}
onClick={() => submit()}
disabled={!canRun || !query.trim() || run.isPending}
className="px-5 py-3 rounded-2xl bg-brand hover:bg-brand-dark disabled:opacity-50 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-2 cursor-pointer disabled:cursor-not-allowed"
>
<span>Run</span>

View File

@@ -14,9 +14,32 @@ import {
Check,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { AGENTS } from './agentRegistryData';
const STATUS_TONE = {
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-200',
amber: 'bg-amber-50 text-amber-800 border-amber-200',
slate: 'bg-slate-100 text-slate-600 border-slate-200',
rose: 'bg-rose-50 text-rose-700 border-rose-200',
indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200',
};
// The registry's word for what an agent actually does today. Shown beside
// every agent, because a simulated agent that looks live is the failure the
// registry exists to end.
function StatusBadge({ agent }) {
const meta = agent.statusMeta;
return (
<span
title={meta.hint}
className={cn('shrink-0 rounded-full border px-1.5 py-px text-[9.5px] font-bold', STATUS_TONE[meta.tone] || STATUS_TONE.slate)}
>
{meta.label}
</span>
);
}
export default function AgentScopeNavigator({
agents = [],
activeAgentId,
onSelectAgent,
skills = [],
@@ -32,7 +55,8 @@ export default function AgentScopeNavigator({
const [toolsOpen, setToolsOpen] = useState(true);
const [agentMenuOpen, setAgentMenuOpen] = useState(false);
const currentAgent = AGENTS.find((a) => a.id === activeAgentId) || AGENTS[0];
const currentAgent = agents.find((a) => a.id === activeAgentId) || agents[0];
if (!currentAgent) return null;
const agentSkills = skills.filter((s) => s.scope !== 'board');
const boardSkills = skills.filter((s) => s.scope === 'board');
@@ -53,8 +77,9 @@ export default function AgentScopeNavigator({
<div className="text-xs sm:text-sm font-bold text-slate-900 truncate font-heading group-hover:text-brand transition-colors">
{currentAgent.name}
</div>
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{currentAgent.badge || 'AGENT'}
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
<span>{currentAgent.badge || 'AGENT'}</span>
<StatusBadge agent={currentAgent} />
</div>
</div>
</div>
@@ -72,7 +97,7 @@ export default function AgentScopeNavigator({
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-3 py-1.5">
Switch Agent Scope
</div>
{AGENTS.map((agent) => (
{agents.map((agent) => (
<button
key={agent.id}
type="button"
@@ -87,8 +112,11 @@ export default function AgentScopeNavigator({
: 'text-slate-700 hover:bg-slate-50'
)}
>
<span>{agent.name}</span>
{agent.id === activeAgentId && <Check className="w-3.5 h-3.5" />}
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{agent.name}</span>
<StatusBadge agent={agent} />
</span>
{agent.id === activeAgentId && <Check className="w-3.5 h-3.5 shrink-0" />}
</button>
))}
</div>

View File

@@ -1,21 +1,27 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import {
Sliders,
Zap,
Wrench,
Bot,
BarChart3,
AlertTriangle,
Info,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/AuthContext';
import {
AGENTS,
SURFACES,
loadStoredSkills,
saveStoredSkills,
loadStoredTools,
loadActiveAgentId,
saveActiveAgentId,
} from './agentRegistryData';
useAiAgents,
useAiSkills,
useAiTools,
useCreateAiSkill,
useUpdateAiAgent,
useUpdateAiSkill,
} from '@/lib/doormileHooks';
import { messageOf } from '@/api/doormile/notify';
import { SURFACES, loadActiveAgentId, saveActiveAgentId } from './agentRegistryData';
import { buildStudioModel, canEditRegistry } from './registryAdapters';
import AgentScopeNavigator from './AgentScopeNavigator';
import SkillCardsGrid from './SkillCardsGrid';
import SkillDetailsDrawer from './SkillDetailsDrawer';
@@ -23,7 +29,6 @@ import CreateSkillModal from './CreateSkillModal';
import ToolRegistryView from './ToolRegistryView';
import AgentPlayground from './AgentPlayground';
import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights';
import { OpenToast } from '@/components/third-party/OpenToast';
const TOP_NAV_TABS = [
{ id: 'configure', label: 'Configure', icon: Sliders },
@@ -33,175 +38,251 @@ const TOP_NAV_TABS = [
{ id: 'insights', label: 'Insights', icon: BarChart3 },
];
/**
* Settings → Skills & Tools.
*
* Reads and writes the backend agent registry (/admin/ai/*). Nothing here is
* stored in the browser except which agent was last selected. Writes are
* admin-only; the server enforces that, and the page disables the controls
* rather than letting a manager click into a 403.
*/
export default function AgentStudio() {
const { user } = useAuth();
const canEdit = canEditRegistry(user);
const agentsQ = useAiAgents();
const skillsQ = useAiSkills();
const toolsQ = useAiTools();
const updateSkill = useUpdateAiSkill();
const createSkill = useCreateAiSkill();
const updateAgent = useUpdateAiAgent();
const [activeTab, setActiveTab] = useState('skills');
const [activeAgentId, setActiveAgentId] = useState(loadActiveAgentId());
const [skills, setSkills] = useState(loadStoredSkills());
const [tools, setTools] = useState(loadStoredTools());
const [selectedDrawerSkill, setSelectedDrawerSkill] = useState(null);
const [selectedAgentId, setSelectedAgentId] = useState(loadActiveAgentId());
const [drawerSkillId, setDrawerSkillId] = useState(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [testPrompt, setTestPrompt] = useState('');
const [testSkillId, setTestSkillId] = useState('');
const model = useMemo(
() => buildStudioModel(agentsQ.data || [], skillsQ.data || [], toolsQ.data || []),
[agentsQ.data, skillsQ.data, toolsQ.data]
);
// With no saved choice, open on the first agent that has skills: JARVIS sorts
// first and has none, and landing on an empty grid reads as a broken page.
const activeAgent =
model.agentsById[selectedAgentId] || model.agents.find((a) => a.skillCount > 0) || model.agents[0] || null;
const agentSkills = useMemo(
() => model.skills.filter((s) => s.agentId === activeAgent?.id),
[model.skills, activeAgent?.id]
);
const agentTools = useMemo(() => {
const used = new Set(agentSkills.flatMap((s) => s.tools));
return model.tools.filter((t) => used.has(t.name));
}, [agentSkills, model.tools]);
// Read from the query data every render, so the drawer shows the server's
// state after a save rather than a stale copy taken when it opened.
const drawerSkill = model.skills.find((s) => s.id === drawerSkillId) || null;
// Persist agent choice
const handleSelectAgent = (id) => {
setActiveAgentId(id);
setSelectedAgentId(id);
saveActiveAgentId(id);
const agent = AGENTS.find((a) => a.id === id);
OpenToast(`Switched active scope to ${agent?.name || id}`, 'info', 2000);
};
// Toggle Skill Status
const handleToggleStatus = (skillId) => {
setSkills((prev) => {
const next = prev.map((s) => {
if (s.id === skillId) {
const updated = { ...s, enabled: !s.enabled };
OpenToast(
`Skill "${s.title}" is now ${updated.enabled ? 'Enabled' : 'Disabled'}`,
updated.enabled ? 'success' : 'info',
2000
);
return updated;
}
return s;
});
saveStoredSkills(next);
return next;
});
// Sync drawer skill state if open
setSelectedDrawerSkill((prev) => {
if (prev && prev.id === skillId) {
return { ...prev, enabled: !prev.enabled };
}
return prev;
});
const skill = model.skills.find((s) => s.id === skillId);
if (!skill || !canEdit) return;
updateSkill.mutate({ id: skillId, patch: { enabled: !skill.enabled } });
};
// Save new skill
const handleSaveNewSkill = (newSkill) => {
setSkills((prev) => {
const next = [newSkill, ...prev];
saveStoredSkills(next);
return next;
});
OpenToast(`Skill "${newSkill.title}" registered successfully`, 'success', 2500);
};
const handleSaveThresholds = (skillId, thresholds) =>
updateSkill.mutateAsync({ id: skillId, patch: { thresholds } });
const handleCreateSkill = (payload) => createSkill.mutateAsync(payload);
const handleSaveAgent = (agentId, patch) => updateAgent.mutateAsync({ id: agentId, patch });
// Trigger test for skill
const handleTestSkill = (skill) => {
setTestPrompt(skill.prompt || `Test capability: ${skill.title}`);
setTestSkillId(skill.id);
setTestSkillId(skill.id);
setActiveTab('test');
OpenToast(`Loaded "${skill.title}" into test playground`, 'info', 1800);
};
const currentAgent = AGENTS.find((a) => a.id === activeAgentId) || AGENTS[0];
const loading = agentsQ.isLoading || skillsQ.isLoading || toolsQ.isLoading;
const loadError = agentsQ.error || skillsQ.error || toolsQ.error;
const canCreate = canEdit && activeAgent?.runtime === 'console';
const createDisabledReason = !canEdit
? 'Only an admin can add skills.'
: activeAgent?.runtime !== 'console'
? 'AI_engine only runs the skills written in its code, so custom skills can be added to console agents only.'
: '';
return (
<div className="space-y-5 pb-10">
{/* 1. Top Level Mode Tabs (Matching Reference Screenshot: Configure | Skills 2 | Tools | Test | Insights) */}
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-semibold">
{TOP_NAV_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const badgeCount =
tab.id === 'skills'
? skills.length
: tab.id === 'tools'
? tools.length
: null;
{/* Said on screen: what this page changes today, and what it does not. */}
<div
role="note"
className="flex items-start gap-2.5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
<p>
<span className="font-bold">Half wired.</span> Changes here are saved to the Doormile agent registry and
every change is audited. The Console Ops Agent&apos;s skills — the Exceptions early warnings and the
chat&apos;s &ldquo;what needs attention&rdquo; — run on these settings now. AI_engine&apos;s Dispatch,
Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its
registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on
their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real Claude
calls (reads redacted, writes only proposed) once the server has a Claude client; until then it says so.
</p>
</div>
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2',
isActive
? 'border-brand text-brand font-bold'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
)}
>
<span>{tab.label}</span>
{badgeCount !== null && (
<span
{!canEdit && (
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600">
<Info className="w-4 h-4 shrink-0 text-slate-400" />
<span>You can view the registry. Only an admin can change it.</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-white p-10 text-sm text-slate-500">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading the agent registry…</span>
</div>
) : loadError ? (
<div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-800">
<div className="font-bold">Could not load the agent registry</div>
<div className="mt-1 text-xs">{messageOf(loadError)}</div>
<button
type="button"
onClick={() => {
agentsQ.refetch();
skillsQ.refetch();
toolsQ.refetch();
}}
className="mt-3 rounded-lg border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-800 hover:bg-rose-100 cursor-pointer"
>
Try again
</button>
</div>
) : !activeAgent ? (
<div className="rounded-2xl border border-slate-200 bg-white p-10 text-center text-sm text-slate-500">
The registry has no agents yet. It is seeded when the backend starts.
</div>
) : (
<>
{/* 1. Top Level Mode Tabs */}
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-semibold">
{TOP_NAV_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const badgeCount =
tab.id === 'skills' ? agentSkills.length : tab.id === 'tools' ? model.tools.length : null;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2',
isActive
? 'bg-brand text-white'
: 'bg-slate-100 text-slate-500'
? 'border-brand text-brand font-bold'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
)}
>
{badgeCount}
</span>
<span>{tab.label}</span>
{badgeCount !== null && (
<span
className={cn(
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
isActive ? 'bg-brand text-white' : 'bg-slate-100 text-slate-500'
)}
>
{badgeCount}
</span>
)}
</button>
);
})}
</div>
{/* 2. Main Studio 2-Column Responsive Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
<div className="lg:col-span-4 xl:col-span-3">
<AgentScopeNavigator
agents={model.agents}
activeAgentId={activeAgent.id}
onSelectAgent={handleSelectAgent}
skills={agentSkills}
tools={agentTools}
surfaces={SURFACES}
onSelectSkill={(skill) => setDrawerSkillId(skill.id)}
onSelectTool={() => setActiveTab('tools')}
/>
</div>
<div className="lg:col-span-8 xl:col-span-9">
{activeTab === 'skills' && (
<SkillCardsGrid
skills={agentSkills}
activeAgent={activeAgent}
onTestSkill={handleTestSkill}
onOpenDetails={(skill) => setDrawerSkillId(skill.id)}
onNewSkill={() => setCreateModalOpen(true)}
canCreate={canCreate}
createDisabledReason={createDisabledReason}
/>
)}
</button>
);
})}
</div>
{/* 2. Main Studio 2-Column Responsive Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Scope & Agent Hierarchy Navigator */}
<div className="lg:col-span-4 xl:col-span-3">
<AgentScopeNavigator
activeAgentId={activeAgentId}
onSelectAgent={handleSelectAgent}
skills={skills}
tools={tools}
surfaces={SURFACES}
onSelectSkill={(skill) => setSelectedDrawerSkill(skill)}
onSelectTool={() => setActiveTab('tools')}
/>
</div>
{activeTab === 'tools' && <ToolRegistryView tools={model.tools} />}
{/* Right Column: Main Content Area */}
<div className="lg:col-span-8 xl:col-span-9">
{activeTab === 'skills' && (
<SkillCardsGrid
skills={skills}
activeAgent={currentAgent}
onTestSkill={handleTestSkill}
onOpenDetails={(skill) => setSelectedDrawerSkill(skill)}
onNewSkill={() => setCreateModalOpen(true)}
/>
)}
{activeTab === 'configure' && (
<AgentConfigView
key={activeAgent.id}
activeAgent={activeAgent}
canEdit={canEdit}
onSave={handleSaveAgent}
/>
)}
{activeTab === 'tools' && <ToolRegistryView tools={tools} />}
{activeTab === 'test' && (
<AgentPlayground
key={activeAgent.id}
activeAgent={activeAgent}
skills={agentSkills}
initialPrompt={testPrompt}
initialSkillId={testSkillId}
canRun={canEdit}
/>
)}
{activeTab === 'configure' && (
<AgentConfigView activeAgent={currentAgent} />
)}
{activeTab === 'insights' && <AgentInsightsView agents={model.agents} activeAgent={activeAgent} />}
</div>
</div>
</>
)}
{activeTab === 'test' && (
<AgentPlayground
activeAgent={currentAgent}
skills={skills}
initialPrompt={testPrompt}
/>
)}
{activeTab === 'insights' && <AgentInsightsView />}
</div>
</div>
{/* Slide-over Skill Details Drawer */}
<SkillDetailsDrawer
skill={selectedDrawerSkill}
tools={tools}
isOpen={Boolean(selectedDrawerSkill)}
onClose={() => setSelectedDrawerSkill(null)}
skill={drawerSkill}
tools={model.tools}
isOpen={Boolean(drawerSkill)}
onClose={() => setDrawerSkillId(null)}
onToggleStatus={handleToggleStatus}
onSaveThresholds={handleSaveThresholds}
onTestSkill={handleTestSkill}
canEdit={canEdit}
isSaving={updateSkill.isPending}
/>
{/* New Skill Creation Modal */}
<CreateSkillModal
isOpen={createModalOpen}
isOpen={createModalOpen && canCreate}
onClose={() => setCreateModalOpen(false)}
onSaveSkill={handleSaveNewSkill}
availableTools={tools}
activeAgentId={activeAgentId}
onSaveSkill={handleCreateSkill}
availableTools={model.tools}
activeAgent={activeAgent}
isSaving={createSkill.isPending}
/>
</div>
);

View File

@@ -1,223 +1,222 @@
import React, { useState } from 'react';
import { X, Plus, Sparkles, Wrench, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { messageOf } from '@/api/doormile/notify';
const CATEGORIES = ['Authoring', 'Workforce', 'Logistics', 'Dispatch', 'Custom'];
// Registry categories in use. Stored lowercase; the cards upper-case them.
const CATEGORIES = [
{ value: 'logistics', label: 'Logistics' },
{ value: 'dispatch', label: 'Dispatch' },
{ value: 'operations', label: 'Operations' },
{ value: 'sla_management', label: 'SLA management' },
{ value: 'custom', label: 'Custom' },
];
const EMPTY = { title: '', category: 'logistics', description: '', prompt: '', tools: [] };
/**
* Registers a skill in the backend registry (POST /admin/ai/skills). A skill
* may only use tools that already exist, and only on a console agent — the
* server enforces both. The modal stays open on a refusal and shows why.
*/
export default function CreateSkillModal({
isOpen,
onClose,
onSaveSkill,
availableTools = [],
activeAgentId,
activeAgent,
isSaving = false,
}) {
const [title, setTitle] = useState('');
const [category, setCategory] = useState('Workforce');
const [description, setDescription] = useState('');
const [prompt, setPrompt] = useState('');
const [integration, setIntegration] = useState('MileTruth AI');
const [selectedTools, setSelectedTools] = useState([]);
const [form, setForm] = useState(EMPTY);
const [error, setError] = useState('');
if (!isOpen) return null;
const toggleTool = (toolName) => {
setSelectedTools((prev) =>
prev.includes(toolName)
? prev.filter((t) => t !== toolName)
: [...prev, toolName]
);
const set = (field) => (e) => {
setForm((f) => ({ ...f, [field]: e.target.value }));
setError('');
};
const handleSubmit = (e) => {
e.preventDefault();
if (!title.trim()) {
setError('Please provide a skill title');
return;
}
if (!description.trim()) {
setError('Please provide a skill description');
return;
}
const toggleTool = (toolName) => {
setForm((f) => ({
...f,
tools: f.tools.includes(toolName) ? f.tools.filter((t) => t !== toolName) : [...f.tools, toolName],
}));
setError('');
};
const newSkill = {
id: `skill-${Date.now()}`,
title: title.trim(),
category: category.toUpperCase(),
scope: 'agent',
integration: integration.trim() || 'Custom',
enabled: true,
statusBadge: 'Added',
description: description.trim(),
prompt: prompt.trim() || null,
tools: selectedTools,
agentId: activeAgentId || 'miletruth-agent',
createdAt: new Date().toISOString().split('T')[0],
};
onSaveSkill(newSkill);
const close = () => {
setForm(EMPTY);
setError('');
onClose();
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.title.trim()) return setError('Please provide a skill title');
if (!form.description.trim()) return setError('Please provide a skill description');
if (form.tools.length === 0) return setError('Attach at least one tool');
try {
await onSaveSkill({
agentid: activeAgent?.id,
title: form.title.trim(),
category: form.category,
description: form.description.trim(),
sampleprompt: form.prompt.trim(),
tools: form.tools,
});
close();
} catch (err) {
setError(messageOf(err, 'The skill was not registered'));
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-xs transition-opacity animate-in fade-in"
onClick={onClose}
/>
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-xs transition-opacity animate-in fade-in" onClick={close} />
{/* Modal Dialog */}
<div className="relative bg-white rounded-3xl max-w-lg w-full shadow-2xl border border-slate-200 p-6 z-50 animate-in zoom-in-95 duration-150">
{/* Header */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-skill-title"
className="relative bg-white rounded-3xl max-w-lg w-full shadow-2xl border border-slate-200 p-6 z-50 animate-in zoom-in-95 duration-150"
>
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-brand-tint text-brand flex items-center justify-center">
<Plus className="w-4 h-4 stroke-[2.5]" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900 font-heading">
<h3 id="create-skill-title" className="text-base font-bold text-slate-900 font-heading">
Register New Agent Skill
</h3>
<p className="text-[11px] text-slate-500">
Define capabilities and connect tools for this agent scope.
</p>
<p className="text-[11px] text-slate-500">For {activeAgent?.name}. Built only from existing tools.</p>
</div>
</div>
<button
type="button"
onClick={onClose}
onClick={close}
aria-label="Close"
className="p-1 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 pt-4 text-xs">
{error && (
<div className="p-2.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 font-semibold text-[11px]">
<div role="alert" className="p-2.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 font-semibold text-[11px]">
{error}
</div>
)}
{/* Skill Title */}
<div className="space-y-1">
<label className="font-bold text-slate-700">Skill Title *</label>
<label htmlFor="skill-title" className="font-bold text-slate-700">
Skill Title *
</label>
<input
id="skill-title"
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
setError('');
}}
placeholder="e.g. Dynamic Fleet Rebalance"
value={form.title}
maxLength={120}
onChange={set('title')}
placeholder="e.g. Night shift watch"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
{/* Category & Scope Integration */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="font-bold text-slate-700">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="font-bold text-slate-700">Integration Tag</label>
<input
type="text"
value={integration}
onChange={(e) => setIntegration(e.target.value)}
placeholder="e.g. MileTruth AI, Doormile API"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
<div className="space-y-1">
<label htmlFor="skill-category" className="font-bold text-slate-700">
Category
</label>
<select
id="skill-category"
value={form.category}
onChange={set('category')}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
{/* Description */}
<div className="space-y-1">
<label className="font-bold text-slate-700">Description *</label>
<label htmlFor="skill-description" className="font-bold text-slate-700">
Description *
</label>
<textarea
id="skill-description"
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
setError('');
}}
placeholder="Detailed description of what this capability accomplishes for the agent..."
value={form.description}
onChange={set('description')}
placeholder="What this skill does for the agent…"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white resize-none"
/>
</div>
{/* Sample Trigger Prompt */}
<div className="space-y-1">
<label className="font-bold text-slate-700 flex items-center gap-1.5">
<label htmlFor="skill-prompt" className="font-bold text-slate-700 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-brand" />
<span>Sample Trigger Prompt</span>
</label>
<input
id="skill-prompt"
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder='e.g. "How is workforce training progressing?"'
value={form.prompt}
onChange={set('prompt')}
placeholder='e.g. "Which orders are about to breach SLA?"'
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
{/* Connect Tools Checklist */}
<div className="space-y-1.5">
<label className="font-bold text-slate-700 flex items-center gap-1.5">
<div className="font-bold text-slate-700 flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-slate-500" />
<span>Attach Tool Registry Endpoints</span>
</label>
<div className="max-h-32 overflow-y-auto border border-slate-200 rounded-xl p-2 bg-slate-50/50 space-y-1">
<span>Attach Tools *</span>
</div>
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-xl p-2 bg-slate-50/50 space-y-1">
{availableTools.map((tool) => {
const isSelected = selectedTools.includes(tool.name);
const isSelected = form.tools.includes(tool.name);
return (
<button
key={tool.name}
type="button"
aria-pressed={isSelected}
onClick={() => toggleTool(tool.name)}
className={cn(
'w-full flex items-center justify-between p-2 rounded-lg text-left text-[11px] transition-colors cursor-pointer',
isSelected
? 'bg-brand-tint text-brand font-semibold border border-brand/30'
: 'hover:bg-slate-100 text-slate-700'
'w-full flex items-center justify-between gap-2 p-2 rounded-lg text-left text-[11px] transition-colors cursor-pointer',
isSelected ? 'bg-brand-tint text-brand font-semibold border border-brand/30' : 'hover:bg-slate-100 text-slate-700'
)}
>
<span className="font-mono">{tool.name}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-brand" />}
<span className="font-mono truncate">{tool.name}</span>
<span className="flex items-center gap-1.5 shrink-0">
<span className="text-[9.5px] font-sans text-slate-400">{tool.category}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-brand" />}
</span>
</button>
);
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-100">
<button
type="button"
onClick={onClose}
onClick={close}
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold text-xs cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs cursor-pointer"
disabled={isSaving}
className="px-5 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs cursor-pointer disabled:opacity-50"
>
Add Skill
{isSaving ? 'Saving…' : 'Add Skill'}
</button>
</div>
</form>

View File

@@ -73,7 +73,7 @@ export default function SkillCard({ skill, onTest, onDetails }) {
)}
>
<CategoryIcon className="w-3 h-3" />
<span>{(skill.category || 'SKILL').toUpperCase()}</span>
<span>{(skill.category || 'SKILL').toUpperCase().replace(/_/g, ' ')}</span>
</div>
<div

View File

@@ -9,6 +9,8 @@ export default function SkillCardsGrid({
onTestSkill,
onOpenDetails,
onNewSkill,
canCreate = true,
createDisabledReason = '',
}) {
const [scopeTab, setScopeTab] = useState('agent'); // 'agent' or 'board'
const [searchQuery, setSearchQuery] = useState('');
@@ -140,7 +142,7 @@ export default function SkillCardsGrid({
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 no-scrollbar">
{Object.entries(categories).map(([cat, count]) => {
const isSelected = categoryFilter === cat;
const displayLabel = cat === 'ALL' ? 'All' : cat.charAt(0) + cat.slice(1).toLowerCase();
const displayLabel = cat === 'ALL' ? 'All' : (cat.charAt(0) + cat.slice(1).toLowerCase()).replace(/_/g, ' ');
return (
<button
@@ -181,7 +183,9 @@ export default function SkillCardsGrid({
<button
type="button"
onClick={onNewSkill}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-white border border-brand text-brand hover:bg-brand-tint font-semibold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer"
disabled={!canCreate}
title={canCreate ? undefined : createDisabledReason}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-white border border-brand text-brand hover:bg-brand-tint font-semibold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100 disabled:hover:bg-white"
>
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
<span>New skill</span>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
X,
Sparkles,
@@ -8,8 +8,124 @@ import {
Code,
ChevronDown,
ChevronRight,
SlidersHorizontal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { messageOf } from '@/api/doormile/notify';
const SOURCE_LABEL = { engine: 'AI_engine', console: 'Console', custom: 'Custom (made here)' };
/**
* Numeric thresholds, edited against the ranges the registry allows. The
* browser checks the range and step so a typo is caught before the round
* trip; the server checks them again and its word is final.
*/
function ThresholdsEditor({ skill, canEdit, isSaving, onSave }) {
const schema = skill.thresholdsSchema || [];
const [draft, setDraft] = useState(() => ({ ...skill.thresholds }));
const [error, setError] = useState('');
// A save (or another admin's change) arrives as a new version: resync.
useEffect(() => {
setDraft({ ...skill.thresholds });
setError('');
}, [skill.id, skill.version]); // eslint-disable-line react-hooks/exhaustive-deps
if (schema.length === 0) return null;
const dirty = schema.some((s) => Number(draft[s.key]) !== Number(skill.thresholds[s.key]));
const problemFor = (spec) => {
const v = Number(draft[spec.key]);
if (draft[spec.key] === '' || !Number.isFinite(v)) return 'Enter a number';
if (v < spec.min || v > spec.max) return `Between ${spec.min} and ${spec.max}`;
const steps = (v - spec.min) / spec.step;
if (spec.step > 0 && Math.abs(steps - Math.round(steps)) > 1e-6) return `In steps of ${spec.step}`;
return '';
};
const invalid = schema.some((s) => problemFor(s));
const save = async () => {
setError('');
const changed = {};
schema.forEach((s) => {
if (Number(draft[s.key]) !== Number(skill.thresholds[s.key])) changed[s.key] = Number(draft[s.key]);
});
try {
await onSave(skill.id, changed);
} catch (err) {
setError(messageOf(err, 'The thresholds were not saved'));
}
};
return (
<div className="space-y-2.5">
<h3 className="font-bold text-slate-800 uppercase tracking-wider text-[11px] flex items-center gap-1.5">
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-500" />
<span>Thresholds</span>
</h3>
<div className="space-y-2">
{schema.map((spec) => {
const problem = problemFor(spec);
return (
<label key={spec.key} className="flex items-center justify-between gap-3 p-2.5 rounded-xl border border-slate-200 bg-white">
<span className="min-w-0">
<span className="block font-semibold text-slate-800">{spec.label}</span>
<span className="block text-[10px] text-slate-400">
{spec.min}–{spec.max}
{spec.unit ? ` ${spec.unit}` : ''} · step {spec.step} · default {spec.default}
</span>
{problem && <span className="block text-[10px] font-semibold text-rose-600">{problem}</span>}
</span>
<input
type="number"
aria-label={spec.label}
value={draft[spec.key] ?? ''}
min={spec.min}
max={spec.max}
step={spec.step}
disabled={!canEdit || isSaving}
onChange={(e) => setDraft((d) => ({ ...d, [spec.key]: e.target.value === '' ? '' : Number(e.target.value) }))}
className={cn(
'w-24 shrink-0 rounded-lg border px-2 py-1 text-right font-mono text-xs focus:outline-none disabled:bg-slate-50 disabled:text-slate-500',
problem ? 'border-rose-300 focus:border-rose-500' : 'border-slate-200 focus:border-brand'
)}
/>
</label>
);
})}
</div>
{error && (
<div role="alert" className="p-2 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-[11px] font-semibold">
{error}
</div>
)}
{canEdit && (
<div className="flex justify-end gap-2">
<button
type="button"
disabled={!dirty || isSaving}
onClick={() => {
setDraft({ ...skill.thresholds });
setError('');
}}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 font-semibold disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
>
Reset
</button>
<button
type="button"
disabled={!dirty || invalid || isSaving}
onClick={save}
className="px-3 py-1.5 rounded-lg bg-brand text-white font-bold disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
>
{isSaving ? 'Saving…' : 'Save thresholds'}
</button>
</div>
)}
</div>
);
}
export default function SkillDetailsDrawer({
skill,
@@ -17,7 +133,10 @@ export default function SkillDetailsDrawer({
isOpen,
onClose,
onToggleStatus,
onSaveThresholds,
onTestSkill,
canEdit = false,
isSaving = false,
}) {
const [schemaOpen, setSchemaOpen] = useState(false);
@@ -63,15 +182,20 @@ export default function SkillDetailsDrawer({
<div>
<div className="font-bold text-slate-800 text-sm">Capability Status</div>
<div className="text-slate-500 text-[11px] mt-0.5">
{isEnabled ? 'Available to this agent on active surfaces' : 'Disabled for this agent'}
{isEnabled ? 'Enabled in the registry' : 'Disabled in the registry'}
{!canEdit && ' · only an admin can change this'}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={isEnabled}
aria-label={`${skill.title} enabled`}
disabled={!canEdit || isSaving}
onClick={() => onToggleStatus(skill.id)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
isEnabled ? 'bg-brand' : 'bg-slate-300'
)}
>
@@ -129,12 +253,19 @@ export default function SkillDetailsDrawer({
<Layers className="w-3.5 h-3.5 text-slate-500" />
<span>{skill.integration || 'System Core'}</span>
</div>
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-semibold text-slate-700 capitalize">
Scope: {skill.scope === 'board' ? 'Board' : 'Agent Core'}
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-semibold text-slate-700">
Defined in: {SOURCE_LABEL[skill.source] || skill.source}
</div>
{skill.version != null && (
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-mono text-slate-600">
v{skill.version}
</div>
)}
</div>
</div>
<ThresholdsEditor skill={skill} canEdit={canEdit} isSaving={isSaving} onSave={onSaveThresholds} />
{/* 5. Connected Tools from Registry */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">

View File

@@ -35,7 +35,8 @@ export default function ToolRegistryView({ tools = [] }) {
<span>Agent Tool Registry (Schema Engine)</span>
</h3>
<p className="text-xs text-slate-500 mt-1">
Central registry providing JSONSchema parameter validation and human-in-the-loop confirmation gates.
Every capability a skill may call, from the Doormile agent registry. Write and messaging tools need an
operator's confirmation unless their agent is autonomous.
</p>
</div>
@@ -72,8 +73,8 @@ export default function ToolRegistryView({ tools = [] }) {
<thead className="bg-slate-50/80 border-b border-slate-200/80 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
<tr>
<th className="py-3 px-4">Tool Function</th>
<th className="py-3 px-4">Category</th>
<th className="py-3 px-4">Parent Skill</th>
<th className="py-3 px-4">Kind</th>
<th className="py-3 px-4">Used by</th>
<th className="py-3 px-4">Safety Gate</th>
<th className="py-3 px-4 text-right">Arguments</th>
</tr>
@@ -120,6 +121,9 @@ export default function ToolRegistryView({ tools = [] }) {
</td>
<td className="py-3 px-4">
{/* It read "Autonomous" for every ungated tool, which
described a read-only lookup as an agent acting on
its own. Ungated tools are reads or internal events. */}
{tool.requiresConfirmation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-50 text-amber-800 border border-amber-200">
<ShieldAlert className="w-3 h-3" />
@@ -128,7 +132,7 @@ export default function ToolRegistryView({ tools = [] }) {
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
<ShieldCheck className="w-3 h-3" />
Autonomous
No confirmation needed
</span>
)}
</td>
@@ -144,6 +148,19 @@ export default function ToolRegistryView({ tools = [] }) {
{isExpanded && (
<tr className="bg-slate-50/90">
<td colSpan={5} className="p-4 pl-9 space-y-3">
{(tool.target || tool.implementedAt) && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
<dt className="font-semibold text-slate-500">Touches</dt>
<dd className="font-mono text-slate-800 break-all">{tool.target || '—'}</dd>
<dt className="font-semibold text-slate-500">Implemented at</dt>
<dd className="font-mono text-slate-800 break-all">{tool.implementedAt || '—'}</dd>
</dl>
)}
{(!tool.parameters || tool.parameters.length === 0) && (
<p className="text-[11px] text-slate-500">
No parameters are pinned down for this tool yet — its input is open.
</p>
)}
<div className="text-xs font-bold text-slate-800 flex items-center gap-2">
<Code className="w-3.5 h-3.5 text-brand" />
<span>Tool Parameters Schema (OpenAI / Claude JSONSchema Specification)</span>

View File

@@ -1,241 +1,43 @@
// ==============================|| Agent Studio Data & Registry Layer ||============================== //
// Modeled directly after AI_engine/core/tool_registry.py and the reference screenshot.
export const AGENTS = [
{
id: 'miletruth-agent',
name: 'MileTruth AI Agent',
badge: 'PRIMARY AGENT',
iconType: 'sparkles',
description: 'Autonomous logistics intelligence, workforce analytics, order scheduling, and dynamic fleet dispatch.',
activeSurfacesCount: 3,
},
{
id: 'miletruth-dispatch-agent',
name: 'MileTruth AI Dispatcher',
badge: 'AGENT',
iconType: 'bot',
description: 'Real-time courier routing, SLA breach mitigation, and autonomous fleet allocation.',
activeSurfacesCount: 2,
},
];
// ==============================|| Agent Studio — local-only data ||============================== //
//
// Agents, skills and tools come from the backend registry (/admin/ai/*) since
// Phase 2 of docs/agent-platform-plan.md. What is left here is not registry
// data: the console surfaces an agent appears on, and which agent this browser
// last had selected (a per-viewer convenience, not shared state).
export const SURFACES = [
{
id: 'surf-home',
name: 'Home AI Assistant',
path: '/doormile/home',
description: 'Conversational natural language interface for fleet queries and order creation.',
status: 'active',
},
{
id: 'surf-dispatch',
name: 'Live Dispatch Board',
path: '/doormile/dispatch',
description: 'Real-time kanban grid and map board with autonomous allocation controls.',
description: 'Conversational interface for fleet queries and order creation.',
status: 'active',
},
{
id: 'surf-control-x',
name: 'Control X Fast Ops',
name: 'Control X',
path: '/doormile/control-x',
description: 'High-speed keyboard-driven workspace for rapid order actions and exceptions.',
description: 'Dispatch board with map, optimiser and assignment controls.',
status: 'active',
},
];
export const DEFAULT_TOOLS = [
{
name: 'open_add_skill_training',
parentSkill: 'MileTruth Skill Management',
description: 'Launch modal to configure workforce training modules and curriculum tracks.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'skill_id', type: 'string', description: 'Unique identifier of the target skill', required: true },
{ name: 'curriculum_track', type: 'string', description: 'Assigned curriculum difficulty tier', required: false, default: 'standard' },
],
},
{
name: 'open_add_training',
parentSkill: 'MileTruth Skill Management',
description: 'Add specific interactive training exercises, quizzes, or step-by-step checklists.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'training_title', type: 'string', description: 'Title of the training unit', required: true },
{ name: 'estimated_minutes', type: 'number', description: 'Duration in minutes', required: false, default: 15 },
],
},
{
name: 'open_miletruth_ai',
parentSkill: 'MileTruth Skill Management',
description: 'Direct deep-link launch into the MileTruth AI authoring and verification studio.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'workspace_view', type: 'string', description: 'Initial view mode in MileTruth AI', required: false, default: 'editor' },
],
},
{
name: 'query_learning_analytics',
parentSkill: 'Learning Analysis',
description: 'Query completion rates, assessment scores, and training progress across teams.',
category: 'Workforce',
requiresConfirmation: false,
parameters: [
{ name: 'team_id', type: 'string', description: 'Specific workforce team or all', required: false, default: 'all' },
{ name: 'timeframe', type: 'string', description: '7d, 30d, or 90d window', required: false, default: '30d' },
],
},
{
name: 'create_single_order',
parentSkill: 'Order Intake & Auto-Schedule',
description: 'Create an individual delivery booking in Doormile with geocoded coordinates.',
category: 'Logistics',
requiresConfirmation: true,
parameters: [
{ name: 'pickup_address', type: 'string', description: 'Full verified pickup address', required: true },
{ name: 'drop_address', type: 'string', description: 'Full verified destination address', required: true },
{ name: 'client_id', type: 'string', description: 'Billing client identifier', required: true },
{ name: 'weight_kg', type: 'number', description: 'Estimated package weight', required: false, default: 1.0 },
],
},
{
name: 'rebalance_riders',
parentSkill: 'Dispatch Rebalance & Allocation',
description: 'Autonomous algorithmic reallocation of idle or nearby riders to high-demand zones.',
category: 'Dispatch',
requiresConfirmation: true,
parameters: [
{ name: 'source_zone', type: 'string', description: 'Zone with surplus rider capacity', required: true },
{ name: 'target_zone', type: 'string', description: 'Zone experiencing demand spike', required: true },
{ name: 'rider_count', type: 'integer', description: 'Number of riders to request reassignment', required: true },
],
},
];
export const DEFAULT_SKILLS = [
{
id: 'miletruth-skill-mgmt',
title: 'MileTruth Skill Management',
category: 'AUTHORING',
categoryTheme: 'red',
scope: 'agent',
integration: 'MileTruth AI',
enabled: true,
statusBadge: 'Added',
description: 'Create workforce skills, training and verification in MileTruth AI.',
prompt: null,
tools: ['open_add_skill_training', 'open_add_training', 'open_miletruth_ai'],
agentId: 'miletruth-agent',
createdAt: '2026-03-10',
},
{
id: 'learning-analysis',
title: 'Learning Analysis',
category: 'WORKFORCE',
categoryTheme: 'slate',
scope: 'agent',
integration: 'MileTruth AI',
enabled: true,
statusBadge: 'Added',
description: 'Report what the training library holds and how far the workforce has progressed through it.',
prompt: 'How is training progressing?',
tools: ['query_learning_analytics'],
agentId: 'miletruth-agent',
createdAt: '2026-03-12',
},
{
id: 'order-intake-schedule',
title: 'Order Intake & Auto-Schedule',
category: 'LOGISTICS',
categoryTheme: 'emerald',
scope: 'agent',
integration: 'Doormile API',
enabled: true,
statusBadge: 'Added',
description: 'Parse multi-item orders from batch text or CSV and schedule with live dispatch.',
prompt: "Repeat yesterday's orders for client and schedule run",
tools: ['create_single_order'],
agentId: 'miletruth-agent',
createdAt: '2026-03-15',
},
{
id: 'dispatch-rebalance-alloc',
title: 'Dispatch Rebalance & Allocation',
category: 'DISPATCH',
categoryTheme: 'rose',
scope: 'agent',
integration: 'Doormile Fleet',
enabled: true,
statusBadge: 'Added',
description: 'Analyze live SLA queues and automatically reassign unassigned orders to active nearby couriers.',
prompt: 'Rebalance available riders into Zone 1 to prevent SLA delays',
tools: ['rebalance_riders'],
agentId: 'miletruth-agent',
createdAt: '2026-03-18',
},
];
const STORAGE_KEY_SKILLS = 'doormile_agent_skills_v2';
const STORAGE_KEY_TOOLS = 'doormile_agent_tools_v2';
const STORAGE_KEY_AGENT = 'doormile_active_agent_v2';
export function loadStoredSkills() {
try {
const raw = localStorage.getItem(STORAGE_KEY_SKILLS);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch (e) {
console.warn('Failed to parse stored skills, using defaults:', e);
}
return DEFAULT_SKILLS;
}
export function saveStoredSkills(skills) {
try {
localStorage.setItem(STORAGE_KEY_SKILLS, JSON.stringify(skills));
} catch (e) {
console.error('Failed to save skills to localStorage:', e);
}
}
export function loadStoredTools() {
try {
const raw = localStorage.getItem(STORAGE_KEY_TOOLS);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch (e) {
console.warn('Failed to parse stored tools, using defaults:', e);
}
return DEFAULT_TOOLS;
}
export function saveStoredTools(tools) {
try {
localStorage.setItem(STORAGE_KEY_TOOLS, JSON.stringify(tools));
} catch (e) {
console.error('Failed to save tools to localStorage:', e);
}
}
const STORAGE_KEY_AGENT = 'doormile_active_agent_v3';
// Storage can throw (private window, blocked site data); the page must still
// render, so every access is guarded and falls back to "no preference".
export function loadActiveAgentId() {
try {
return localStorage.getItem(STORAGE_KEY_AGENT) || 'miletruth-agent';
return localStorage.getItem(STORAGE_KEY_AGENT) || '';
} catch {
return 'miletruth-agent';
return '';
}
}
export function saveActiveAgentId(id) {
try {
localStorage.setItem(STORAGE_KEY_AGENT, id);
} catch (e) {
console.warn('Failed to store active agent:', e);
} catch {
// A preference that cannot be saved is not an error worth surfacing.
}
}

View File

@@ -0,0 +1,133 @@
// ==============================|| Agent Studio — registry adapters ||============================== //
//
// The backend registry (/admin/ai/*, doormile_backend internal/ai/registry)
// speaks in its own column names. The Agent Studio components were written
// against the old local mock's shape. These pure functions translate one into
// the other, so the components only change where they were showing something
// untrue — and the translation is unit-tested on its own.
/** What each agent status means, for the badge beside its name. */
export const AGENT_STATUS = {
live: { label: 'Live', tone: 'emerald', hint: 'Running in production.' },
partial: { label: 'Partial', tone: 'amber', hint: 'Runs, but parts of it are unfinished or rarely reached.' },
simulation: { label: 'Simulation', tone: 'slate', hint: 'Works on made-up data. Does not touch real orders or riders.' },
broken: { label: 'Broken', tone: 'rose', hint: 'Has known defects that stop it working.' },
unmerged: { label: 'Not merged', tone: 'indigo', hint: 'Built on a branch; not in the running console yet.' },
retired: { label: 'Retired', tone: 'slate', hint: 'No longer in use.' },
};
/** What each tool kind means, for the safety column. */
export const TOOL_KIND = {
read: { label: 'Read-only', gated: false },
write: { label: 'Writes', gated: true },
notify: { label: 'Messages people', gated: true },
event: { label: 'Internal event', gated: false },
};
const title = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
/** @param {any} a — an AgentView from /admin/ai/agents */
export function toUiAgent(a) {
return {
id: a.agentid,
name: a.name,
runtime: a.runtime,
status: a.status,
statusMeta: AGENT_STATUS[a.status] || { label: title(a.status), tone: 'slate', hint: '' },
badge: a.runtime === 'engine' ? 'AI_ENGINE AGENT' : 'CONSOLE AGENT',
description: a.purpose,
wakeon: a.wakeon,
classref: a.classref,
llmdecision: a.llmdecision,
hasAutonomyGate: Boolean(a.hasautonomygate),
autonomous: Boolean(a.autonomous),
model: a.model || '',
skillCount: a.skillcount ?? 0,
toolCount: a.toolcount ?? 0,
};
}
/**
* @param {any} s — a SkillView from /admin/ai/skills
* @param {Record<string, { name: string }>} agentsById — for the integration tag
*/
export function toUiSkill(s, agentsById = {}) {
return {
id: s.skillid,
agentId: s.agentid,
title: s.title,
category: (s.category || 'general').toUpperCase(),
description: s.description,
prompt: s.sampleprompt || null,
integration: agentsById[s.agentid]?.name || s.agentid,
source: s.source,
enabled: Boolean(s.enabled),
scope: 'agent',
tools: s.tools || [],
thresholds: s.thresholds || {},
thresholdsSchema: s.thresholdsschema || [],
version: s.version,
};
}
/**
* JSON-schema `properties` → the parameter rows the tool table renders.
* @param {any} schema
*/
export function schemaToParameters(schema) {
const props = schema?.properties || {};
const required = new Set(schema?.required || []);
return Object.entries(props).map(([name, p]) => ({
name,
type: p?.type || 'any',
description: p?.description || '',
required: required.has(name),
...(p && 'default' in p ? { default: p.default } : {}),
}));
}
/**
* @param {any} t — a ToolView from /admin/ai/tools
* @param {any[]} uiSkills — to name the skills that use the tool
*/
export function toUiTool(t, uiSkills = []) {
const users = uiSkills.filter((s) => s.tools.includes(t.toolname)).map((s) => s.title);
const kind = TOOL_KIND[t.kind] || { label: title(t.kind), gated: Boolean(t.requiresconfirmation) };
return {
name: t.toolname,
description: t.description,
kind: t.kind,
category: kind.label,
parentSkill: users.join(', '),
requiresConfirmation: Boolean(t.requiresconfirmation),
target: t.target,
implementedAt: t.implementedat,
parameters: schemaToParameters(t.inputschema),
};
}
/** Everything the page renders, from the three raw lists. */
export function buildStudioModel(rawAgents = [], rawSkills = [], rawTools = []) {
const agents = rawAgents.map(toUiAgent);
const agentsById = Object.fromEntries(agents.map((a) => [a.id, a]));
const skills = rawSkills.map((s) => toUiSkill(s, agentsById));
const tools = rawTools.map((t) => toUiTool(t, skills));
return { agents, agentsById, skills, tools };
}
/** Only the admin role may change the registry; the server enforces the same. */
export const canEditRegistry = (user) => String(user?.role || '').toLowerCase() === 'admin';
/**
* Model ids an engine agent may be pinned to. Empty means "the engine's own
* default" (LLM_MODEL in AI_engine). The server accepts any `claude-*` id.
* Haiku is left out on purpose: it takes no adaptive thinking or effort, so
* the engine runs it without them (core/llm.py request_params) — too weak
* for the stall and assignment decisions. A pin set elsewhere still shows.
*/
export const MODEL_OPTIONS = [
{ value: '', label: 'Engine default (LLM_MODEL)' },
{ value: 'claude-opus-5-5', label: 'Claude Opus 5.5' },
{ value: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5' },
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
];