update on the agents and registry side
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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?' }
|
||||
],
|
||||
|
||||
196
src/components/doormile/AgentOperationsBanner.jsx
Normal file
196
src/components/doormile/AgentOperationsBanner.jsx
Normal 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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
src/components/doormile/SlaRemediationCard.jsx
Normal file
137
src/components/doormile/SlaRemediationCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)];
|
||||
|
||||
92
src/lib/assistant/agent/AgentFactory.js
Normal file
92
src/lib/assistant/agent/AgentFactory.js
Normal 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());
|
||||
}
|
||||
}
|
||||
153
src/lib/assistant/agent/actions.js
Normal file
153
src/lib/assistant/agent/actions.js
Normal 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);
|
||||
};
|
||||
112
src/lib/assistant/agent/briefing.js
Normal file
112
src/lib/assistant/agent/briefing.js
Normal 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' }
|
||||
};
|
||||
};
|
||||
53
src/lib/assistant/agent/normalise.js
Normal file
53
src/lib/assistant/agent/normalise.js
Normal 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) : []);
|
||||
44
src/lib/assistant/agent/signals.js
Normal file
44
src/lib/assistant/agent/signals.js
Normal 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.';
|
||||
@@ -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
|
||||
|
||||
123
src/lib/assistant/skills/SkillRegistry.js
Normal file
123
src/lib/assistant/skills/SkillRegistry.js
Normal 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();
|
||||
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal file
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal file
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal 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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal file
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal 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`)
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal file
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal 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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal file
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal file
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal file
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
10
src/lib/assistant/skills/index.js
Normal file
10
src/lib/assistant/skills/index.js
Normal 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';
|
||||
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal file
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal 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]);
|
||||
}
|
||||
75
src/lib/clientOnboarding.js
Normal file
75
src/lib/clientOnboarding.js
Normal 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.`;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
381
src/pages/doormile/clients/ClientOnboarding.jsx
Normal file
381
src/pages/doormile/clients/ClientOnboarding.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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's browser, so its runs are not recorded. The figures below are
|
||||
AI_engine'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>"{s.prompt}"</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">> 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's skills — the Exceptions early warnings and the
|
||||
chat's “what needs attention” — run on these settings now. AI_engine'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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
133
src/pages/doormile/settings/agentStudio/registryAdapters.js
Normal file
133
src/pages/doormile/settings/agentStudio/registryAdapters.js
Normal 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' },
|
||||
];
|
||||
Reference in New Issue
Block a user