Doormile AI: replace the bot popover with a right-side slide-over
Renames the operator assistant to "Doormile AI / Operations Copilot" and
moves it from a header Popover to a portalled slide-over panel.
- DoormileAI/ — trigger, panel, welcome, message, composer and shared
primitives. Assistant turns are deliberately NOT bubbles; that is what
keeps this reading as part of the dashboard rather than a bolted-on
chatbot.
- pageContext.js — route-aware suggested questions. Every suggestion is a
phrasing the matcher actually resolves; a chip that returns "I can't
answer that" is worse than no chip.
- DoormileAI.css — panel styling, animation, responsive and reduced-motion.
Selectors that style an Astryx Stack are written `.dai-root .x` because
`padding={0}` emits a StyleX atomic at the same specificity as a bare
class, so a single class can lose on stylesheet order.
- Messages are JSON round-tripped through localStorage, so icons are
referenced by key rather than stored as React elements — an element does
not survive the trip and the rehydrated value crashes the next render.
- Errors now render a polished state and log the real error to the console,
instead of surfacing raw API messages in a toast.
The route, sidebar entry and i18n key for the old standalone Assistant page
are removed with it — this is a header surface, not a page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import { clearFcmToken } from 'store/reducers/fcmSlice';
|
|||||||
import { logoutUser } from 'store/reducers/loginUserSlice';
|
import { logoutUser } from 'store/reducers/loginUserSlice';
|
||||||
import { performSessionLogout } from 'utils/session';
|
import { performSessionLogout } from 'utils/session';
|
||||||
import { DT } from 'themes/dt/tokens';
|
import { DT } from 'themes/dt/tokens';
|
||||||
|
import DoormileAITrigger from 'pages/nearle/assistant/DoormileAI';
|
||||||
|
|
||||||
// doormile-logo.png is a white asset; recolour to brand red for this
|
// doormile-logo.png is a white asset; recolour to brand red for this
|
||||||
// light TopNav surface (same trick login.js uses for its white-card logo).
|
// light TopNav surface (same trick login.js uses for its white-card logo).
|
||||||
@@ -130,6 +131,8 @@ const AppTopNav = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DoormileAITrigger />
|
||||||
|
|
||||||
<Popover placement="below" alignment="end" content={<NotificationPanel />} label="Notifications">
|
<Popover placement="below" alignment="end" content={<NotificationPanel />} label="Notifications">
|
||||||
<IconButton label="Notifications" tooltip="Notifications" variant="ghost" icon={<BellOutlined />} />
|
<IconButton label="Notifications" tooltip="Notifications" variant="ghost" icon={<BellOutlined />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ import {
|
|||||||
TruckOutlined,
|
TruckOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
RadarChartOutlined,
|
RadarChartOutlined,
|
||||||
RiseOutlined,
|
RiseOutlined
|
||||||
RobotOutlined
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
// icons
|
// icons
|
||||||
@@ -67,8 +66,7 @@ const icons = {
|
|||||||
TruckOutlined,
|
TruckOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
RadarChartOutlined,
|
RadarChartOutlined,
|
||||||
RiseOutlined,
|
RiseOutlined
|
||||||
RobotOutlined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
|
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
|
||||||
@@ -128,13 +126,6 @@ const nearle = {
|
|||||||
url: '/doormile/customers',
|
url: '/doormile/customers',
|
||||||
icon: icons.ContactsOutlined
|
icon: icons.ContactsOutlined
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'assistant',
|
|
||||||
title: <FormattedMessage id="assistant" />,
|
|
||||||
type: 'item',
|
|
||||||
url: '/doormile/assistant',
|
|
||||||
icon: icons.RobotOutlined
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'fleetops',
|
id: 'fleetops',
|
||||||
title: <FormattedMessage id="fleetops" />,
|
title: <FormattedMessage id="fleetops" />,
|
||||||
|
|||||||
776
src/pages/nearle/assistant/DoormileAI.css
Normal file
776
src/pages/nearle/assistant/DoormileAI.css
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
Doormile AI — Operations Copilot
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Styling for the right-side slide-over assistant. Follows the same
|
||||||
|
convention as OrdersRedesign.css / Dispatch.css (a page-scoped stylesheet
|
||||||
|
imported by its own component) rather than inline styles, so the panel's
|
||||||
|
hover / focus / media-query / reduced-motion states are all expressible.
|
||||||
|
|
||||||
|
Every value below resolves to a design token (`--color-*`, `--spacing-*`,
|
||||||
|
`--radius-*`) or to a local `--dai-*` token defined once here. The local
|
||||||
|
tokens exist because the AI surface needs a few values the shared DT set
|
||||||
|
doesn't carry (the AI accent, the scrim, the panel elevation).
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.dai-root {
|
||||||
|
/* Brand accent — resolves to the app's accent token, which is black
|
||||||
|
(root CLAUDE.md §6.2: "the brand is black", one accent only). Send
|
||||||
|
button, active states and the trigger all read from this single
|
||||||
|
variable, so switching the assistant to Doormile red is a one-line
|
||||||
|
change here rather than a hunt through the components. */
|
||||||
|
--dai-accent: var(--color-accent, #0f172a);
|
||||||
|
--dai-accent-contrast: #ffffff;
|
||||||
|
|
||||||
|
/* AI identity accent — used ONLY on the spark/orb marks so the assistant
|
||||||
|
reads as an AI surface without turning the panel into a purple product. */
|
||||||
|
--dai-ai-from: #6366f1;
|
||||||
|
--dai-ai-to: #8b5cf6;
|
||||||
|
--dai-ai-glow: rgba(99, 102, 241, 0.18);
|
||||||
|
|
||||||
|
--dai-text: #0f172a;
|
||||||
|
--dai-text-secondary: #64748b;
|
||||||
|
--dai-text-muted: #94a3b8;
|
||||||
|
--dai-surface: #ffffff;
|
||||||
|
--dai-surface-alt: #f8fafc;
|
||||||
|
--dai-surface-hover: #f1f5f9;
|
||||||
|
--dai-border: rgba(15, 23, 42, 0.08);
|
||||||
|
--dai-border-strong: rgba(15, 23, 42, 0.14);
|
||||||
|
--dai-scrim: rgba(15, 23, 42, 0.08);
|
||||||
|
--dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
|
||||||
|
--dai-live: #10b981;
|
||||||
|
|
||||||
|
--dai-panel-width: 428px;
|
||||||
|
--dai-inset: 12px;
|
||||||
|
--dai-duration: 240ms;
|
||||||
|
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Scrim — deliberately light. The dashboard underneath must stay readable;
|
||||||
|
this is a layering cue, not a modal blackout.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1300;
|
||||||
|
background: var(--dai-scrim);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dai-duration) var(--dai-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-scrim[data-open='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Panel
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--dai-inset);
|
||||||
|
right: var(--dai-inset);
|
||||||
|
bottom: var(--dai-inset);
|
||||||
|
z-index: 1301;
|
||||||
|
width: var(--dai-panel-width);
|
||||||
|
max-width: calc(100vw - (var(--dai-inset) * 2));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--dai-surface);
|
||||||
|
border: 1px solid var(--dai-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--dai-shadow);
|
||||||
|
transform: translateX(calc(100% + var(--dai-inset) * 2));
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
transform var(--dai-duration) var(--dai-ease),
|
||||||
|
opacity var(--dai-duration) var(--dai-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-panel[data-open='true'] {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-panel:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Header
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 14px 12px 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--dai-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--dai-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The AI mark. A soft gradient orb — not a robot face. */
|
||||||
|
.dai-root .dai-spark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
|
||||||
|
box-shadow: 0 0 0 3px var(--dai-ai-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-spark[data-size='sm'] {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-spark[data-size='md'] {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-spark[data-size='lg'] {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
box-shadow: 0 0 0 6px var(--dai-ai-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live indicator — subtle, not a large pill. */
|
||||||
|
.dai-root .dai-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 550;
|
||||||
|
color: #047857;
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-live-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--dai-live);
|
||||||
|
animation: dai-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dai-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page-context strip — "Orders · Today · All locations" */
|
||||||
|
.dai-root .dai-context {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
background: var(--dai-surface-alt);
|
||||||
|
border-bottom: 1px solid var(--dai-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Scroll region
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--dai-border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--dai-border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Welcome state
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-welcome {
|
||||||
|
padding: 22px 14px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-welcome-greeting {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--dai-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-welcome-lead {
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--dai-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-welcome-note {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Suggestion cards
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 11px;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--dai-text);
|
||||||
|
background: var(--dai-surface);
|
||||||
|
border: 1px solid var(--dai-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 140ms ease,
|
||||||
|
border-color 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-suggestion:hover {
|
||||||
|
background: var(--dai-surface-alt);
|
||||||
|
border-color: var(--dai-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-suggestion:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-suggestion:focus-visible {
|
||||||
|
outline: 2px solid var(--dai-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-suggestion-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--dai-surface-hover);
|
||||||
|
color: var(--dai-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-suggestion-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-suggestion-arrow {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-3px);
|
||||||
|
transition:
|
||||||
|
opacity 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-suggestion:hover .dai-suggestion-arrow,
|
||||||
|
.dai-suggestion:focus-visible .dai-suggestion-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plain text link-button ("View more", "Sources") */
|
||||||
|
.dai-link {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dai-text-secondary);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-link:hover {
|
||||||
|
color: var(--dai-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-link:focus-visible {
|
||||||
|
outline: 2px solid var(--dai-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Conversation
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-thread {
|
||||||
|
padding: 16px 14px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-msg {
|
||||||
|
animation: dai-enter 220ms var(--dai-ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dai-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User — compact, right aligned, brand surface. */
|
||||||
|
.dai-root .dai-msg-user {
|
||||||
|
max-width: 82%;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--dai-accent-contrast);
|
||||||
|
background: var(--dai-accent);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assistant — no bubble. Text sits on the panel surface. */
|
||||||
|
.dai-root .dai-msg-ai {
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--dai-text);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-msg-ai-detail {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--dai-text-secondary);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-msg-name {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dai-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-msg-footer {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy button — only revealed on hover of the message row. */
|
||||||
|
.dai-root .dai-msg-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-msg-row:hover .dai-msg-actions,
|
||||||
|
.dai-root .dai-msg-row:focus-within .dai-msg-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Structured metrics
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-stat {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--dai-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--dai-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-stat-value {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headline metric — the "primary number is large" rule. */
|
||||||
|
.dai-root .dai-metric-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 680;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--dai-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dai-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Typing / loading
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-typing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-typing span {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--dai-text-muted);
|
||||||
|
animation: dai-bounce 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-typing span:nth-child(2) {
|
||||||
|
animation-delay: 0.16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-typing span:nth-child(3) {
|
||||||
|
animation-delay: 0.32s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dai-bounce {
|
||||||
|
0%,
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-shimmer {
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--dai-surface-hover) 25%, #e9eef5 37%, var(--dai-surface-hover) 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: dai-shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dai-shimmer {
|
||||||
|
from {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 0 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
States (error / empty)
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-state {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--dai-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--dai-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-state-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dai-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-state-body {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--dai-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-state-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Jump-to-latest
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-jump {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dai-text);
|
||||||
|
background: var(--dai-surface);
|
||||||
|
border: 1px solid var(--dai-border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-jump:hover {
|
||||||
|
background: var(--dai-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-jump:focus-visible {
|
||||||
|
outline: 2px solid var(--dai-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-scroll-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Composer
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-composer-wrap {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
|
border-top: 1px solid var(--dai-border);
|
||||||
|
background: var(--dai-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-composer {
|
||||||
|
border: 1px solid var(--dai-border-strong);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--dai-surface);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
padding: 8px 8px 6px 12px;
|
||||||
|
transition:
|
||||||
|
border-color 140ms ease,
|
||||||
|
box-shadow 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-composer[data-focused='true'] {
|
||||||
|
border-color: var(--dai-accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-composer textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--dai-text);
|
||||||
|
background: transparent;
|
||||||
|
max-height: 108px; /* ~5 lines */
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-composer textarea::placeholder {
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-send {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--dai-accent-contrast);
|
||||||
|
background: var(--dai-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
opacity 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-send:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-send:active:not(:disabled) {
|
||||||
|
transform: scale(0.93);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-send:disabled {
|
||||||
|
opacity: 0.28;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-send:focus-visible {
|
||||||
|
outline: 2px solid var(--dai-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-root .dai-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Trigger (lives in the app TopNav)
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #4f46e5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 140ms ease,
|
||||||
|
border-color 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-trigger:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.09);
|
||||||
|
border-color: rgba(99, 102, 241, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-trigger:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--dai-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-trigger[data-active='true'] {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Responsive
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.dai-root {
|
||||||
|
--dai-panel-width: 396px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dai-root {
|
||||||
|
--dai-inset: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-panel {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-msg-user {
|
||||||
|
max-width: 88%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Reduced motion — no slide, no pulse, no shimmer. Opacity only.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dai-panel,
|
||||||
|
.dai-scrim {
|
||||||
|
transition: opacity 1ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-panel {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dai-msg,
|
||||||
|
.dai-live-dot,
|
||||||
|
.dai-typing span,
|
||||||
|
.dai-shimmer,
|
||||||
|
.dai-suggestion,
|
||||||
|
.dai-send,
|
||||||
|
.dai-trigger {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/pages/nearle/assistant/DoormileAI/AIComposer.js
Normal file
90
src/pages/nearle/assistant/DoormileAI/AIComposer.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { LuArrowUp } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
|
||||||
|
|
||||||
|
// ==============================|| Doormile AI — composer ||============================== //
|
||||||
|
//
|
||||||
|
// Auto-growing textarea (capped at ~5 lines by the stylesheet's max-height),
|
||||||
|
// Enter to send, Shift+Enter for a newline. Hand-rolled rather than Astryx's
|
||||||
|
// <ChatComposer> because the panel needs the compact anchored treatment and
|
||||||
|
// a circular send button, neither of which that component exposes — the
|
||||||
|
// dictation button it ships IS reused here rather than reinvented.
|
||||||
|
|
||||||
|
const MAX_ROWS_PX = 108;
|
||||||
|
|
||||||
|
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
const dictation = useChatDictation({ onResult: (transcript) => onChange(transcript) });
|
||||||
|
|
||||||
|
// Grow with content, then let the stylesheet's max-height take over and
|
||||||
|
// scroll. Runs before paint so there's no visible jump on the first line
|
||||||
|
// break.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const canSend = value.trim().length > 0 && !isBusy;
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!canSend) return;
|
||||||
|
onSubmit(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack className="dai-composer-wrap" gap={1} padding={0}>
|
||||||
|
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
rows={1}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label="Ask Doormile AI about operations"
|
||||||
|
/>
|
||||||
|
<HStack gap={1} padding={0} vAlign="center" justify="between">
|
||||||
|
<ChatDictationButton dictation={dictation} size="sm" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dai-send"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!canSend}
|
||||||
|
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
|
||||||
|
>
|
||||||
|
<LuArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
<Text className="dai-hint">Enter to send · Shift + Enter for a new line</Text>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AIComposer.propTypes = {
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
onSubmit: PropTypes.func.isRequired,
|
||||||
|
isBusy: PropTypes.bool,
|
||||||
|
placeholder: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIComposer;
|
||||||
122
src/pages/nearle/assistant/DoormileAI/AIMessage.js
Normal file
122
src/pages/nearle/assistant/DoormileAI/AIMessage.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { CopyOutlined } from '@ant-design/icons';
|
||||||
|
import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
import { ChatToolCalls } from '@astryxdesign/core/Chat';
|
||||||
|
|
||||||
|
import { Spark, Metric, StatGrid, StateBlock } from './AIParts';
|
||||||
|
|
||||||
|
// ==============================|| Doormile AI — a single turn ||============================== //
|
||||||
|
//
|
||||||
|
// User turns are compact right-aligned bubbles. Assistant turns are NOT
|
||||||
|
// bubbles — text sits directly on the panel surface with a small spark mark,
|
||||||
|
// which is what keeps this from reading as a consumer chat app.
|
||||||
|
//
|
||||||
|
// The `sources` disclosure is the one thing that must not be dropped in the
|
||||||
|
// name of visual quiet: it's the existing verifiability contract (every
|
||||||
|
// number traceable to a real call). It's collapsed by default and expands to
|
||||||
|
// the same <ChatToolCalls> the previous panel showed inline.
|
||||||
|
|
||||||
|
const UserMessage = ({ text }) => (
|
||||||
|
<VStack className="dai-msg" gap={0} padding={0}>
|
||||||
|
<Text className="dai-msg-user">{text}</Text>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
UserMessage.propTypes = { text: PropTypes.string.isRequired };
|
||||||
|
|
||||||
|
const AssistantMessage = ({ message, onCopy, onAsk }) => {
|
||||||
|
const [showSources, setShowSources] = useState(false);
|
||||||
|
const sourceCount = message.sourceCalls?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack className="dai-msg dai-msg-row" gap={1.5} padding={0}>
|
||||||
|
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
||||||
|
<HStack gap={1} padding={0} vAlign="center">
|
||||||
|
<Spark size="sm" />
|
||||||
|
<Text className="dai-msg-name">Doormile AI</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack className="dai-msg-actions" gap={0} padding={0}>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
label="Copy answer"
|
||||||
|
tooltip="Copy answer"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(message)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{message.state ? (
|
||||||
|
<StateBlock iconKey={message.state.iconKey} tone={message.state.tone} title={message.state.title} body={message.state.body} />
|
||||||
|
) : (
|
||||||
|
<VStack gap={1.5} padding={0}>
|
||||||
|
{message.metric ? (
|
||||||
|
<VStack gap={1.5} padding={0}>
|
||||||
|
<Metric value={message.metric.value} label={message.metric.label} />
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Text className="dai-msg-ai">{message.text}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StatGrid stats={message.stats} />
|
||||||
|
|
||||||
|
{message.detail && <Text className="dai-msg-ai-detail">{message.detail}</Text>}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live-data footer + source disclosure. Small and muted — the point is
|
||||||
|
that it's available, not that it's announced on every turn. */}
|
||||||
|
{sourceCount > 0 && (
|
||||||
|
<VStack gap={1} padding={0}>
|
||||||
|
<HStack gap={1} padding={0} vAlign="center">
|
||||||
|
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
|
||||||
|
<button type="button" className="dai-link" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||||
|
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||||
|
<LuChevronDown
|
||||||
|
size={11}
|
||||||
|
style={{ marginLeft: 3, verticalAlign: '-1px', transform: showSources ? 'rotate(180deg)' : 'none' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</HStack>
|
||||||
|
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.followUps?.length > 0 && (
|
||||||
|
<VStack gap={1} padding={0}>
|
||||||
|
{message.followUps.map((q) => (
|
||||||
|
<button key={q} type="button" className="dai-suggestion" onClick={() => onAsk(q)}>
|
||||||
|
<Text className="dai-suggestion-text">{q}</Text>
|
||||||
|
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AssistantMessage.propTypes = {
|
||||||
|
message: PropTypes.object.isRequired,
|
||||||
|
onCopy: PropTypes.func.isRequired,
|
||||||
|
onAsk: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIMessage = ({ message, onCopy, onAsk }) =>
|
||||||
|
message.sender === 'user' ? <UserMessage text={message.text} /> : <AssistantMessage message={message} onCopy={onCopy} onAsk={onAsk} />;
|
||||||
|
|
||||||
|
AIMessage.propTypes = {
|
||||||
|
message: PropTypes.object.isRequired,
|
||||||
|
onCopy: PropTypes.func.isRequired,
|
||||||
|
onAsk: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIMessage;
|
||||||
304
src/pages/nearle/assistant/DoormileAI/AIPanel.js
Normal file
304
src/pages/nearle/assistant/DoormileAI/AIPanel.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||||
|
import { LuChevronDown } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||||
|
|
||||||
|
import { OpenToast } from 'components/third-party/OpenToast';
|
||||||
|
import { STATUS } from 'themes/dt/tokens';
|
||||||
|
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '../intents';
|
||||||
|
import { getPageContext } from './pageContext';
|
||||||
|
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
|
||||||
|
import AIWelcome from './AIWelcome';
|
||||||
|
import AIMessage from './AIMessage';
|
||||||
|
import AIComposer from './AIComposer';
|
||||||
|
import '../DoormileAI.css';
|
||||||
|
|
||||||
|
// ==============================|| Doormile AI — slide-over panel ||============================== //
|
||||||
|
//
|
||||||
|
// Right-side slide-over rendered into a portal so it layers over the page
|
||||||
|
// without the dashboard reflowing underneath it. Data logic is unchanged —
|
||||||
|
// every answer still comes from intents.js's answerQuestion(), which only
|
||||||
|
// calls the same API functions the pages themselves use.
|
||||||
|
//
|
||||||
|
// The panel stays mounted through its closing transition (`isMounted` vs
|
||||||
|
// `isShown`) so the exit animation actually plays instead of the element
|
||||||
|
// disappearing on the first frame.
|
||||||
|
|
||||||
|
const ANIMATION_MS = 260;
|
||||||
|
const HISTORY_KEY = 'doormileBotHistory';
|
||||||
|
const HISTORY_LIMIT = 50;
|
||||||
|
const PIN_THRESHOLD_PX = 48;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
const makeId = () => nextId++;
|
||||||
|
const now = () => dayjs().format('hh:mm A');
|
||||||
|
|
||||||
|
const loadHistory = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_KEY);
|
||||||
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveHistory = (messages) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(messages.slice(-HISTORY_LIMIT)));
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode, quota) — history just won't persist
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIPanel = ({ isOpen, onClose }) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const pageContext = getPageContext(pathname);
|
||||||
|
|
||||||
|
const [isMounted, setIsMounted] = useState(isOpen);
|
||||||
|
const [isShown, setIsShown] = useState(false);
|
||||||
|
const [messages, setMessages] = useState(loadHistory);
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [context, setContext] = useState({ lastIntentId: null, lastParams: null });
|
||||||
|
const [isPinned, setIsPinned] = useState(true);
|
||||||
|
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const lastQuestionRef = useRef(null);
|
||||||
|
|
||||||
|
// ---- open / close transition ----
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsMounted(true);
|
||||||
|
const raf = requestAnimationFrame(() => setIsShown(true));
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
setIsShown(false);
|
||||||
|
const timer = setTimeout(() => setIsMounted(false), ANIMATION_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// ---- Escape closes ----
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// ---- move focus into the panel on open ----
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isShown) return;
|
||||||
|
const textarea = panelRef.current?.querySelector('textarea');
|
||||||
|
(textarea || panelRef.current)?.focus();
|
||||||
|
}, [isShown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveHistory(messages);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// ---- auto-scroll, but never yank the view away from someone reading ----
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPinned) return;
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [messages, isSending, isPinned]);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setIsPinned(el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToLatest = () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
setIsPinned(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const push = (message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]);
|
||||||
|
|
||||||
|
const ask = useCallback(
|
||||||
|
async (text) => {
|
||||||
|
const question = String(text || '').trim();
|
||||||
|
if (!question || isSending) return;
|
||||||
|
|
||||||
|
lastQuestionRef.current = question;
|
||||||
|
setValue('');
|
||||||
|
setIsPinned(true);
|
||||||
|
setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
|
||||||
|
setIsSending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await answerQuestion(question, context);
|
||||||
|
if (result) {
|
||||||
|
setContext({ lastIntentId: result.intentId, lastParams: result.params });
|
||||||
|
push({
|
||||||
|
sender: 'assistant',
|
||||||
|
text: result.headline,
|
||||||
|
detail: result.detail,
|
||||||
|
metric: result.metric,
|
||||||
|
stats: result.stats,
|
||||||
|
sourceCalls: result.sourceCalls,
|
||||||
|
followUps: FOLLOW_UP_SUGGESTIONS[result.intentId] || null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Matched nothing. This is a coverage state, not a failure — say so
|
||||||
|
// plainly rather than inventing an answer.
|
||||||
|
push({
|
||||||
|
sender: 'assistant',
|
||||||
|
state: {
|
||||||
|
tone: STATUS.muted,
|
||||||
|
iconKey: 'info',
|
||||||
|
title: "I can't answer that one yet",
|
||||||
|
body: 'Try rephrasing around orders, riders, hubs, vehicles, batches, status, tenants or revenue — or pick one of the suggestions.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Developers get the real error; the operator gets a clean state.
|
||||||
|
console.error('[Doormile AI] answerQuestion failed', err);
|
||||||
|
push({
|
||||||
|
sender: 'assistant',
|
||||||
|
state: {
|
||||||
|
tone: STATUS.error,
|
||||||
|
iconKey: 'warning',
|
||||||
|
title: 'Something went wrong',
|
||||||
|
body: "I couldn't retrieve the latest operational data. Try that question again in a moment."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[context, isSending]
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyMessage = (m) => {
|
||||||
|
const text = [m.text, m.detail].filter(Boolean).join('\n');
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => OpenToast('Copied to clipboard', 'success', 1500))
|
||||||
|
.catch(() => OpenToast('Could not copy', 'error', 1500));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearConversation = () => {
|
||||||
|
setMessages([]);
|
||||||
|
setContext({ lastIntentId: null, lastParams: null });
|
||||||
|
setIsPinned(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isMounted) return null;
|
||||||
|
|
||||||
|
const hasThread = messages.length > 0;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<HStack className="dai-root dai-scrim" data-open={isShown} padding={0} gap={0} onClick={onClose} aria-hidden="true" />
|
||||||
|
|
||||||
|
<VStack
|
||||||
|
as="aside"
|
||||||
|
ref={panelRef}
|
||||||
|
className="dai-root dai-panel"
|
||||||
|
data-open={isShown}
|
||||||
|
gap={0}
|
||||||
|
padding={0}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Doormile AI — Operations Copilot"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{/* ---- header ---- */}
|
||||||
|
<VStack as="header" className="dai-header" gap={0} padding={0}>
|
||||||
|
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
||||||
|
<HStack gap={1.5} padding={0} vAlign="center">
|
||||||
|
<Spark size="md" />
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text className="dai-title">Doormile AI</Text>
|
||||||
|
<Text className="dai-subtitle">Operations Copilot</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={1} padding={0} vAlign="center">
|
||||||
|
<LiveIndicator />
|
||||||
|
<DropdownMenu
|
||||||
|
hasChevron={false}
|
||||||
|
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
|
||||||
|
items={[
|
||||||
|
{ label: 'New conversation', onClick: clearConversation },
|
||||||
|
{ label: 'Clear conversation', onClick: clearConversation }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
label="Close assistant"
|
||||||
|
tooltip="Close assistant"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* ---- page context ---- */}
|
||||||
|
<Text className="dai-context">{pageContext.label} · Today · All locations</Text>
|
||||||
|
|
||||||
|
{/* ---- conversation / welcome ---- */}
|
||||||
|
<VStack className="dai-scroll-wrap" gap={0} padding={0}>
|
||||||
|
<VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}>
|
||||||
|
{hasThread ? (
|
||||||
|
<VStack className="dai-thread" gap={4} padding={0}>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<AIMessage key={m.id} message={m} onCopy={copyMessage} onAsk={ask} />
|
||||||
|
))}
|
||||||
|
{isSending && (
|
||||||
|
<VStack className="dai-msg" gap={1.5} padding={0}>
|
||||||
|
<HStack gap={1} padding={0} vAlign="center">
|
||||||
|
<Spark size="sm" />
|
||||||
|
<Text className="dai-msg-name">Doormile AI</Text>
|
||||||
|
</HStack>
|
||||||
|
<TypingIndicator />
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<AIWelcome context={pageContext} onAsk={ask} />
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{hasThread && !isPinned && (
|
||||||
|
<button type="button" className="dai-jump" onClick={scrollToLatest}>
|
||||||
|
<LuChevronDown size={13} aria-hidden="true" />
|
||||||
|
Jump to latest
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* ---- composer ---- */}
|
||||||
|
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
|
||||||
|
</VStack>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AIPanel.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIPanel;
|
||||||
124
src/pages/nearle/assistant/DoormileAI/AIParts.js
Normal file
124
src/pages/nearle/assistant/DoormileAI/AIParts.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { LuSparkles } from 'react-icons/lu';
|
||||||
|
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
|
||||||
|
// ==============================|| Doormile AI — shared primitives ||============================== //
|
||||||
|
//
|
||||||
|
// Small presentational pieces shared by the panel's states. Kept in one file
|
||||||
|
// because each is a handful of lines and they are only ever used together.
|
||||||
|
|
||||||
|
// The AI mark — a soft gradient orb, deliberately not a robot face.
|
||||||
|
export const Spark = ({ size = 'sm' }) => (
|
||||||
|
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
|
||||||
|
<LuSparkles size={size === 'lg' ? 20 : size === 'md' ? 15 : 12} strokeWidth={2.1} />
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
Spark.propTypes = { size: PropTypes.oneOf(['sm', 'md', 'lg']) };
|
||||||
|
|
||||||
|
// "● Live" — subtle, not a large pill.
|
||||||
|
export const LiveIndicator = ({ label = 'Live' }) => (
|
||||||
|
<HStack className="dai-live" padding={0} gap={0} vAlign="center" aria-label="Answers are read from live operational data">
|
||||||
|
<HStack className="dai-live-dot" padding={0} gap={0} aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
LiveIndicator.propTypes = { label: PropTypes.string };
|
||||||
|
|
||||||
|
// Thinking state. Three dots plus two shimmer bars — enough to read as
|
||||||
|
// "working", not enough to be a light show.
|
||||||
|
export const TypingIndicator = () => (
|
||||||
|
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="Doormile AI is thinking">
|
||||||
|
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</HStack>
|
||||||
|
<VStack gap={1} padding={0} style={{ width: '100%' }} aria-hidden="true">
|
||||||
|
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '78%' }} />
|
||||||
|
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '52%' }} />
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The headline number for a counting answer — "the primary number is large".
|
||||||
|
export const Metric = ({ value, label }) => (
|
||||||
|
<VStack gap={0.25} padding={0}>
|
||||||
|
<Text className="dai-metric-value">{Number.isFinite(value) ? value.toLocaleString('en-IN') : value}</Text>
|
||||||
|
<Text className="dai-metric-label">{label}</Text>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
Metric.propTypes = {
|
||||||
|
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||||
|
label: PropTypes.string.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact breakdown grid. `color` is a status hex supplied by the caller —
|
||||||
|
// the one case the token system genuinely can't express (see CLAUDE.md §6.5),
|
||||||
|
// so it stays an inline style rather than a class.
|
||||||
|
export const StatGrid = ({ stats }) => {
|
||||||
|
if (!stats?.length) return null;
|
||||||
|
return (
|
||||||
|
<HStack className="dai-stat-grid" padding={0} gap={0} role="list">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<VStack key={s.label} className="dai-stat" gap={0.25} padding={0} role="listitem">
|
||||||
|
<Text className="dai-stat-value" style={s.color ? { color: s.color } : undefined}>
|
||||||
|
{s.value}
|
||||||
|
</Text>
|
||||||
|
<Text className="dai-stat-label">{s.label}</Text>
|
||||||
|
</VStack>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
StatGrid.propTypes = {
|
||||||
|
stats: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||||
|
color: PropTypes.string
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Icons are looked up by KEY, never passed in as a node. A message carrying a
|
||||||
|
// React element would be destroyed by the JSON round-trip through
|
||||||
|
// localStorage (elements don't serialise — $$typeof is a Symbol and is
|
||||||
|
// silently dropped), and the rehydrated "element" would then crash the render
|
||||||
|
// on the next panel open.
|
||||||
|
const STATE_ICONS = {
|
||||||
|
warning: <WarningOutlined />,
|
||||||
|
info: <InfoCircleOutlined />
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shared shell for the error / no-data states, so both read as the same kind
|
||||||
|
// of thing rather than two separately-invented blocks.
|
||||||
|
export const StateBlock = ({ iconKey, tone, title, body, action }) => (
|
||||||
|
<VStack className="dai-state" gap={1.5} padding={0}>
|
||||||
|
<HStack gap={1.5} padding={0} vAlign="start">
|
||||||
|
<HStack className="dai-state-icon" padding={0} gap={0} aria-hidden="true" style={{ background: `${tone}14`, color: tone }}>
|
||||||
|
{STATE_ICONS[iconKey] || STATE_ICONS.info}
|
||||||
|
</HStack>
|
||||||
|
<VStack gap={0.5} padding={0}>
|
||||||
|
<Text className="dai-state-title">{title}</Text>
|
||||||
|
<Text className="dai-state-body">{body}</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
{action}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
StateBlock.propTypes = {
|
||||||
|
iconKey: PropTypes.oneOf(['warning', 'info']),
|
||||||
|
tone: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
body: PropTypes.string.isRequired,
|
||||||
|
action: PropTypes.node
|
||||||
|
};
|
||||||
86
src/pages/nearle/assistant/DoormileAI/AIWelcome.js
Normal file
86
src/pages/nearle/assistant/DoormileAI/AIWelcome.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LuArrowRight } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
|
||||||
|
import { Spark } from './AIParts';
|
||||||
|
|
||||||
|
// ==============================|| Doormile AI — welcome state ||============================== //
|
||||||
|
//
|
||||||
|
// Shown only when the thread is empty. Deliberately top-weighted and compact
|
||||||
|
// so the panel never opens on a large blank region: greeting + one line of
|
||||||
|
// what it can do, then straight into four suggestions.
|
||||||
|
|
||||||
|
const greeting = () => {
|
||||||
|
const h = dayjs().hour();
|
||||||
|
if (h < 12) return 'Good morning';
|
||||||
|
if (h < 17) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
};
|
||||||
|
|
||||||
|
const SuggestionCard = ({ item, onAsk }) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)}>
|
||||||
|
<HStack className="dai-suggestion-icon" padding={0} gap={0} aria-hidden="true">
|
||||||
|
<Icon size={14} strokeWidth={2} />
|
||||||
|
</HStack>
|
||||||
|
<Text className="dai-suggestion-text">{item.text}</Text>
|
||||||
|
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SuggestionCard.propTypes = {
|
||||||
|
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
|
||||||
|
onAsk: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIWelcome = ({ context, onAsk }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const more = context.more || [];
|
||||||
|
const shown = expanded ? [...context.suggestions, ...more] : context.suggestions;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack className="dai-welcome" gap={3} padding={0}>
|
||||||
|
<VStack gap={1.5} padding={0}>
|
||||||
|
<Spark size="lg" />
|
||||||
|
<VStack gap={0.5} padding={0}>
|
||||||
|
<Text className="dai-welcome-greeting">{greeting()}</Text>
|
||||||
|
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||||
|
</VStack>
|
||||||
|
<Text className="dai-welcome-note">
|
||||||
|
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack gap={1.5} padding={0}>
|
||||||
|
<Text className="dai-section-label">Suggested questions</Text>
|
||||||
|
<VStack gap={1} padding={0}>
|
||||||
|
{shown.map((item) => (
|
||||||
|
<SuggestionCard key={item.text} item={item} onAsk={onAsk} />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
{more.length > 0 && (
|
||||||
|
<button type="button" className="dai-link" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded}>
|
||||||
|
{expanded ? 'Show fewer' : `View ${more.length} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AIWelcome.propTypes = {
|
||||||
|
context: PropTypes.shape({
|
||||||
|
suggestions: PropTypes.array.isRequired,
|
||||||
|
more: PropTypes.array
|
||||||
|
}).isRequired,
|
||||||
|
onAsk: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIWelcome;
|
||||||
44
src/pages/nearle/assistant/DoormileAI/index.js
Normal file
44
src/pages/nearle/assistant/DoormileAI/index.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { LuSparkles } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||||
|
|
||||||
|
import AIPanel from './AIPanel';
|
||||||
|
import '../DoormileAI.css';
|
||||||
|
|
||||||
|
// ==============================|| Doormile AI — trigger ||============================== //
|
||||||
|
//
|
||||||
|
// Mounted in AppTopNav. Owns only the open/closed state; everything else
|
||||||
|
// lives in AIPanel. Focus returns here when the panel closes, so keyboard
|
||||||
|
// users land back where they started.
|
||||||
|
|
||||||
|
const DoormileAITrigger = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const buttonRef = useRef(null);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip content="Doormile AI">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
className="dai-root dai-trigger"
|
||||||
|
data-active={isOpen}
|
||||||
|
aria-label="Open Doormile AI"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||||
|
>
|
||||||
|
<LuSparkles size={16} strokeWidth={2.1} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<AIPanel isOpen={isOpen} onClose={close} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DoormileAITrigger;
|
||||||
138
src/pages/nearle/assistant/DoormileAI/pageContext.js
Normal file
138
src/pages/nearle/assistant/DoormileAI/pageContext.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { LuClock3, LuPackage, LuBike, LuTruck, LuBuilding2, LuCircleDot, LuBanknote, LuUsers, LuLayers, LuTimerOff } from 'react-icons/lu';
|
||||||
|
|
||||||
|
// ==============================|| Doormile AI — page context ||============================== //
|
||||||
|
//
|
||||||
|
// Maps the current route to (a) a short context label shown under the panel
|
||||||
|
// header and (b) the suggested questions offered in the welcome state, so the
|
||||||
|
// assistant opens on questions relevant to what the operator is looking at
|
||||||
|
// rather than a fixed Orders-shaped list.
|
||||||
|
//
|
||||||
|
// Every suggestion here MUST be a phrasing the deterministic matcher in
|
||||||
|
// intents.js actually resolves — a suggestion chip that returns "I can't
|
||||||
|
// answer that yet" is worse than no chip. When adding one, check it against
|
||||||
|
// the INTENTS catalog first.
|
||||||
|
|
||||||
|
const ORDERS = {
|
||||||
|
label: 'Orders',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||||
|
{ icon: LuPackage, text: 'How many orders today?' },
|
||||||
|
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||||
|
{ icon: LuBanknote, text: 'Total revenue today' }
|
||||||
|
],
|
||||||
|
more: [
|
||||||
|
{ icon: LuCircleDot, text: 'How many pending orders today?' },
|
||||||
|
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
||||||
|
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||||
|
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||||
|
{ icon: LuClock3, text: 'Orders today vs yesterday' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const RIDERS = {
|
||||||
|
label: 'Riders',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuBike, text: 'How many riders are active?' },
|
||||||
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||||
|
{ icon: LuPackage, text: 'How many orders today?' },
|
||||||
|
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
||||||
|
],
|
||||||
|
more: [
|
||||||
|
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||||
|
{ icon: LuBanknote, text: 'Total revenue today' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const VEHICLES = {
|
||||||
|
label: 'Vehicles',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||||
|
{ icon: LuBike, text: 'How many riders are active?' },
|
||||||
|
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||||
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||||
|
],
|
||||||
|
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const HUBS = {
|
||||||
|
label: 'Hubs',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||||
|
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
|
||||||
|
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||||
|
{ icon: LuBike, text: 'How many riders are active?' }
|
||||||
|
],
|
||||||
|
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISPATCH = {
|
||||||
|
label: 'Dispatch',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||||
|
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||||
|
{ icon: LuBike, text: 'How many riders are active?' },
|
||||||
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||||
|
],
|
||||||
|
more: [
|
||||||
|
{ icon: LuPackage, text: 'How many orders today?' },
|
||||||
|
{ icon: LuCircleDot, text: 'How many pending orders today?' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const TENANTS = {
|
||||||
|
label: 'Tenants',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuUsers, text: 'How many tenants do we have?' },
|
||||||
|
{ icon: LuPackage, text: 'How many orders today?' },
|
||||||
|
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||||
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||||
|
],
|
||||||
|
more: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPORTS = {
|
||||||
|
label: 'Reports',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||||
|
{ icon: LuClock3, text: 'Orders today vs yesterday' },
|
||||||
|
{ icon: LuPackage, text: 'How many orders this week?' },
|
||||||
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||||
|
],
|
||||||
|
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONTEXT = {
|
||||||
|
label: 'Operations',
|
||||||
|
suggestions: [
|
||||||
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||||
|
{ icon: LuPackage, text: 'How many orders today?' },
|
||||||
|
{ icon: LuBike, text: 'How many riders are active?' },
|
||||||
|
{ icon: LuBuilding2, text: 'Current hub status' }
|
||||||
|
],
|
||||||
|
more: [
|
||||||
|
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||||
|
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
|
||||||
|
// with the wrong label.
|
||||||
|
const ROUTES = [
|
||||||
|
['/doormile/dispatch', DISPATCH],
|
||||||
|
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
|
||||||
|
['/doormile/orders', ORDERS],
|
||||||
|
['/doormile/riders', RIDERS],
|
||||||
|
['/doormile/vehicles', VEHICLES],
|
||||||
|
['/doormile/hubs', HUBS],
|
||||||
|
['/doormile/tenants', TENANTS],
|
||||||
|
['/doormile/reports', REPORTS],
|
||||||
|
['/doormile/customers', { ...DEFAULT_CONTEXT, label: 'Customers' }],
|
||||||
|
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
|
||||||
|
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
|
||||||
|
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getPageContext = (pathname = '') => {
|
||||||
|
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
||||||
|
return match ? match[1] : DEFAULT_CONTEXT;
|
||||||
|
};
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { MdSmartToy } from 'react-icons/md';
|
|
||||||
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { Center } from '@astryxdesign/core/Center';
|
|
||||||
import {
|
|
||||||
ChatLayout,
|
|
||||||
ChatMessageList,
|
|
||||||
ChatMessage,
|
|
||||||
ChatMessageBubble,
|
|
||||||
ChatMessageMetadata,
|
|
||||||
ChatComposer,
|
|
||||||
ChatToolCalls
|
|
||||||
} from '@astryxdesign/core/Chat';
|
|
||||||
|
|
||||||
import PageHeader from 'components/nearle_components/PageHeader';
|
|
||||||
import { PageShell } from 'components/nearle_components/PageLayout';
|
|
||||||
import { OpenToast } from 'components/third-party/OpenToast';
|
|
||||||
import { DT } from 'themes/dt/tokens';
|
|
||||||
import { answerQuestion, SUPPORTED_QUESTIONS } from './intents';
|
|
||||||
|
|
||||||
// ==============================|| Doormile Bot ||============================== //
|
|
||||||
//
|
|
||||||
// Deterministic Q&A over live order/rider data — see intents.js for the
|
|
||||||
// intent catalog and the plan this was built from (no LLM: every answer
|
|
||||||
// comes from a real API call, never a generated guess). This file owns only
|
|
||||||
// the chat UI + message state; all data logic lives in intents.js.
|
|
||||||
|
|
||||||
let nextId = 1;
|
|
||||||
const makeId = () => nextId++;
|
|
||||||
|
|
||||||
const now = () => dayjs().format('hh:mm A');
|
|
||||||
|
|
||||||
export default function Assistant() {
|
|
||||||
const [messages, setMessages] = useState([]);
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
const [isSending, setIsSending] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (text) => {
|
|
||||||
const question = text.trim();
|
|
||||||
if (!question || isSending) return;
|
|
||||||
|
|
||||||
setValue('');
|
|
||||||
setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
|
|
||||||
setIsSending(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await answerQuestion(question);
|
|
||||||
if (result) {
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: makeId(),
|
|
||||||
sender: 'assistant',
|
|
||||||
text: result.headline,
|
|
||||||
detail: result.detail,
|
|
||||||
sourceCalls: result.sourceCalls,
|
|
||||||
timestamp: now()
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: makeId(),
|
|
||||||
sender: 'assistant',
|
|
||||||
text: "I can't answer that yet.",
|
|
||||||
detail: `Right now I can answer:\n${SUPPORTED_QUESTIONS.map((q) => `• ${q}`).join('\n')}`,
|
|
||||||
timestamp: now()
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
OpenToast(err.response?.data?.message || err.message || 'Failed to fetch an answer', 'error', 2500);
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: makeId(),
|
|
||||||
sender: 'assistant',
|
|
||||||
text: 'Something went wrong fetching that — try again in a moment.',
|
|
||||||
timestamp: now()
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
setIsSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell fill>
|
|
||||||
<PageHeader title="Assistant" subtitle="Live · Ask Doormile" live />
|
|
||||||
|
|
||||||
<VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<ChatLayout
|
|
||||||
density="balanced"
|
|
||||||
composer={
|
|
||||||
<ChatComposer
|
|
||||||
value={value}
|
|
||||||
onChange={setValue}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
isDisabled={isSending}
|
|
||||||
placeholder="Ask about today's orders, batches, riders…"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
emptyState={
|
|
||||||
<Center style={{ height: '100%' }}>
|
|
||||||
<VStack gap={2} padding={4} vAlign="center" style={{ textAlign: 'center', maxWidth: 420 }}>
|
|
||||||
<MdSmartToy size={32} color={DT.textSecondary} />
|
|
||||||
<Text weight="bold">Ask Doormile</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
I answer from live data only — no guessing. Try:
|
|
||||||
</Text>
|
|
||||||
<VStack gap={0.5} padding={0}>
|
|
||||||
{SUPPORTED_QUESTIONS.map((q) => (
|
|
||||||
<Text key={q} type="supporting" color="secondary">
|
|
||||||
• {q}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChatMessageList isStreaming={isSending}>
|
|
||||||
{messages.map((m) => (
|
|
||||||
<ChatMessage key={m.id} sender={m.sender}>
|
|
||||||
<ChatMessageBubble metadata={<ChatMessageMetadata timestamp={m.timestamp} />}>
|
|
||||||
<VStack gap={1} padding={0}>
|
|
||||||
<Text>{m.text}</Text>
|
|
||||||
{m.detail && (
|
|
||||||
<Text type="supporting" color="secondary" style={{ whiteSpace: 'pre-line' }}>
|
|
||||||
{m.detail}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</ChatMessageBubble>
|
|
||||||
{/* Audit trail — shows exactly what was queried and how many
|
|
||||||
rows came back, so the answer is verifiable, not a claim
|
|
||||||
the operator has to take on faith. */}
|
|
||||||
{m.sourceCalls && <ChatToolCalls calls={m.sourceCalls} />}
|
|
||||||
</ChatMessage>
|
|
||||||
))}
|
|
||||||
</ChatMessageList>
|
|
||||||
</ChatLayout>
|
|
||||||
</VStack>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,6 @@ const AppUsers = Loadable(lazy(() => import('pages/nearle/appUsers/appUsers')));
|
|||||||
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
|
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
|
||||||
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
|
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
|
||||||
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
|
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
|
||||||
const Assistant = Loadable(lazy(() => import('pages/nearle/assistant/assistant')));
|
|
||||||
|
|
||||||
// ==============================|| MAIN ROUTING ||============================== //
|
// ==============================|| MAIN ROUTING ||============================== //
|
||||||
|
|
||||||
@@ -169,10 +168,6 @@ const MainRoutes = {
|
|||||||
{
|
{
|
||||||
path: 'competitive-intel',
|
path: 'competitive-intel',
|
||||||
element: <CompetitiveIntel />
|
element: <CompetitiveIntel />
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'assistant',
|
|
||||||
element: <Assistant />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"profitability": "Profitability",
|
"profitability": "Profitability",
|
||||||
"dispatch": "Dispatch",
|
"dispatch": "Dispatch",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
"assistant": "Assistant",
|
|
||||||
"fleetops": "Fleet & Ops",
|
"fleetops": "Fleet & Ops",
|
||||||
"hubs": "Hubs",
|
"hubs": "Hubs",
|
||||||
"vehicles": "Vehicles",
|
"vehicles": "Vehicles",
|
||||||
|
|||||||
Reference in New Issue
Block a user