initial commit
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { LuArrowUp } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { ChatDictationButton, useChatDictation } from './shim';
|
||||
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 ||============================== //
|
||||
//
|
||||
@@ -136,7 +136,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
disabled={!canSend}
|
||||
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
<LuArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { Selector } from './shim';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
|
||||
// ==============================|| Doormile AI — one dropdown step ||============================== //
|
||||
//
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { memo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { AiOutlineCopy as CopyOutlined } from 'react-icons/ai';
|
||||
import { LuChevronDown } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { IconButton } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { ChatToolCalls } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { ChatToolCalls } from '@astryxdesign/core/Chat';
|
||||
|
||||
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
||||
import AIFlowStep from './AIFlowStep';
|
||||
@@ -51,7 +51,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
|
||||
variant="ghost"
|
||||
label="Copy answer"
|
||||
tooltip="Copy answer"
|
||||
icon={<Copy />}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => onCopy(message)}
|
||||
/>
|
||||
</HStack>
|
||||
@@ -96,7 +96,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
|
||||
<span>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
</span>
|
||||
<ChevronDown size={11} aria-hidden="true" />
|
||||
<LuChevronDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import '../DoormileAI.css';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { X as CloseOutlined, MoreHorizontal as MoreOutlined } from 'lucide-react';
|
||||
import { ChevronDown as LuChevronDown, PanelRightOpen as LuPanelRightOpen, PanelRightClose as LuPanelRightClose, History as LuHistory, Trash2 as LuTrash2, MessageSquare as LuMessageSquare } from 'lucide-react';
|
||||
import { AiOutlineClose as CloseOutlined, AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
||||
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { IconButton } from './shim';
|
||||
import { DropdownMenu } from './shim';
|
||||
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 { toast } from '@/components/ui/use-toast';
|
||||
const OpenToast = ({message, tone}) => toast({description: message, variant: tone === 'error' ? 'destructive' : 'default'});
|
||||
const STATUS = { error: 'error', success: 'success', warning: 'warning', info: 'info', muted: 'muted' };
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '@/lib/assistant/intents';
|
||||
import { executeCreateCustomer, buildCustomerPayload } from '@/lib/assistant/actions';
|
||||
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions';
|
||||
@@ -228,17 +226,8 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsMounted(true);
|
||||
// One frame between mounting and showing, so the slide-in has a
|
||||
// from-state to animate out of. rAF is the right clock for that, but a
|
||||
// hidden or heavily throttled document never runs the callback at all —
|
||||
// and a panel that refuses to open is worse than one that opens without
|
||||
// its animation, so a timer backs it up.
|
||||
const raf = requestAnimationFrame(() => setIsShown(true));
|
||||
const fallback = setTimeout(() => setIsShown(true), 50);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearTimeout(fallback);
|
||||
};
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
setIsShown(false);
|
||||
const timer = setTimeout(() => setIsMounted(false), ANIMATION_MS);
|
||||
@@ -275,14 +264,15 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
}, [isWide]);
|
||||
|
||||
// ---- anchor the dock to the real header height ----
|
||||
// The dock starts where the top nav ends. Measured rather than assumed, and
|
||||
// re-measured on resize, so the seam stays closed if the header ever changes
|
||||
// height — a hard-coded number leaves a sliver of page visible above the
|
||||
// panel the moment the bar grows.
|
||||
// The dock starts where the top nav ends. `--appshell-header-height` looks
|
||||
// like the right token for that and Dispatch.css already reads it, but
|
||||
// Astryx does not actually publish it — verified in the browser, where it
|
||||
// resolves to nothing and the fallback wins. The header is 57px, not the 64
|
||||
// a fallback would guess, so trusting it left a 7px sliver of page visible
|
||||
// above the panel.
|
||||
//
|
||||
// AdminLayout marks its bar `data-app-header`; that attribute exists purely
|
||||
// as this hook. The selector used to be `.astryx-layout-header`, a class this
|
||||
// app does not render at all, so the measurement silently never ran.
|
||||
// Measured instead, and re-measured on resize, so the seam stays closed if
|
||||
// the header ever changes height.
|
||||
useEffect(() => {
|
||||
if (!isMounted) return undefined;
|
||||
const header = document.querySelector('[data-app-header]');
|
||||
@@ -1394,25 +1384,22 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
|
||||
const isCompactStrip = hasThread;
|
||||
|
||||
if (!isMounted || !isShown) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="dai-scrim"
|
||||
data-open={isShown}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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}
|
||||
data-state={isShown ? 'open' : 'closed'}
|
||||
gap={0}
|
||||
padding={0}
|
||||
/* `complementary`, not `dialog`. A dialog promises modality — focus is
|
||||
trapped, the rest of the page is inert, and screen readers announce
|
||||
it as blocking. None of that is true any more: the page beside it
|
||||
stays live and operable, so announcing it as a dialog would be a
|
||||
lie to exactly the users who cannot see that it is docked. */
|
||||
role="complementary"
|
||||
aria-label="Doormile AI — Operations Copilot"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { AlertTriangle, Info, Sparkles } from 'lucide-react';
|
||||
import { AiOutlineWarning as WarningOutlined, AiOutlineInfoCircle as InfoCircleOutlined } from 'react-icons/ai';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
|
||||
// ==============================|| 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.
|
||||
// The Doormile D. One component behind the assistant's identity, so the
|
||||
// header, every reply, the welcome screen and the thinking state can't drift
|
||||
// apart — they all render this. The mark is RGBA with a transparent ground, so
|
||||
// it sits on the panel surface rather than needing a coloured chip behind it.
|
||||
export const Spark = ({ size = 'sm' }) => (
|
||||
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
|
||||
<Sparkles className="text-brand w-4 h-4" />
|
||||
<img src={doormileMark} alt="" />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
@@ -87,8 +99,8 @@ StatGrid.propTypes = {
|
||||
// silently dropped), and the rehydrated "element" would then crash the render
|
||||
// on the next panel open.
|
||||
const STATE_ICONS = {
|
||||
warning: <AlertTriangle />,
|
||||
info: <Info />
|
||||
warning: <WarningOutlined />,
|
||||
info: <InfoCircleOutlined />
|
||||
};
|
||||
|
||||
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { TextArea } from './shim';
|
||||
import { FileInput } from './shim';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
|
||||
import { parseBulkRows, BULK_MAX } from '@/lib/assistant/bulkOrderActions';
|
||||
import { parseBulkFile, templateCsv, downloadCsv } from '@/lib/assistant/bulkFile';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import { CHIP_LABELS } from './pageContext';
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
import AIPanel from './AIPanel';
|
||||
import '../DoormileAI.css';
|
||||
|
||||
@@ -20,17 +23,19 @@ const DoormileAITrigger = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger inline-flex items-center justify-center rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
data-active={isOpen}
|
||||
aria-label="Open Doormile AI"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<Bot className="dai-trigger-mark text-slate-700" aria-hidden="true" />
|
||||
</button>
|
||||
<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))}
|
||||
>
|
||||
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AIPanel isOpen={isOpen} onClose={close} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
Clock3 as LuClock3,
|
||||
Package as LuPackage,
|
||||
Bike as LuBike,
|
||||
Truck as LuTruck,
|
||||
Building2 as LuBuilding2,
|
||||
CircleDot as LuCircleDot,
|
||||
Banknote as LuBanknote,
|
||||
Users as LuUsers,
|
||||
Layers as LuLayers,
|
||||
TimerOff as LuTimerOff,
|
||||
UserPlus as LuUserPlus,
|
||||
PackagePlus as LuPackagePlus,
|
||||
ListPlus as LuListPlus,
|
||||
Repeat as LuRepeat
|
||||
} from 'lucide-react';
|
||||
LuClock3,
|
||||
LuPackage,
|
||||
LuBike,
|
||||
LuTruck,
|
||||
LuBuilding2,
|
||||
LuCircleDot,
|
||||
LuBanknote,
|
||||
LuUsers,
|
||||
LuLayers,
|
||||
LuTimerOff,
|
||||
LuUserPlus,
|
||||
LuPackagePlus,
|
||||
LuListPlus,
|
||||
LuRepeat
|
||||
} from 'react-icons/lu';
|
||||
|
||||
// ==============================|| Doormile AI — page context ||============================== //
|
||||
//
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
import { DropdownMenu as KrowDropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
|
||||
export const HStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, vAlign, justify, wrap, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-row ${
|
||||
vAlign === 'start' ? 'items-start' : vAlign === 'end' ? 'items-end' : vAlign === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${wrap === 'wrap' ? 'flex-wrap' : ''} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const VStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, align, justify, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-col ${
|
||||
align === 'start' ? 'items-start' : align === 'end' ? 'items-end' : align === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const Text = ({ as: Component = 'span', children, className = '', style, ...props }) => {
|
||||
return (
|
||||
<Component className={`text-sm ${className}`} style={style} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconButton = ({ children, className = '', icon, onClick, tooltip, label, size, variant, isIconOnly, ...props }) => {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors ${
|
||||
size === 'sm' ? 'p-1.5' : 'p-2'
|
||||
} ${isIconOnly ? 'aspect-square' : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
title={tooltip || label}
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
{icon || children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({ button, items }) => (
|
||||
<KrowDropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton {...button} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{items?.map((it, i) => (
|
||||
<DropdownMenuItem key={i} onClick={it.onClick}>
|
||||
{it.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</KrowDropdownMenu>
|
||||
);
|
||||
|
||||
export const Button = ({ children, onClick, className = '' }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const Selector = ({ value, onChange, options = [], placeholder }) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder || 'Select...'}
|
||||
</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.value || o} value={o.value || o}>
|
||||
{o.label || o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
export const TextArea = ({ value, onChange, placeholder, rows = 3, className = '' }) => (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FileInput = ({ onChange, accept, className = '' }) => (
|
||||
<input
|
||||
type="file"
|
||||
onChange={onChange}
|
||||
accept={accept}
|
||||
className={`block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChatToolCalls = ({ calls }) => (
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
{calls?.map((c, i) => (
|
||||
<div key={i} className="mb-1">
|
||||
<strong>{c.tool || 'Tool'}</strong>
|
||||
<pre className="mt-1 overflow-x-auto text-[10px]">{JSON.stringify(c.args, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const useChatDictation = () => {
|
||||
return { isDictating: false, startDictation: () => {}, stopDictation: () => {}, toggleDictation: () => {} };
|
||||
};
|
||||
|
||||
export const ChatDictationButton = ({ isDictating, onToggle }) => (
|
||||
<button type="button" onClick={onToggle} className="p-2 text-gray-500 hover:text-blue-500" title="Dictation unavailable">
|
||||
🎤
|
||||
</button>
|
||||
);
|
||||
Reference in New Issue
Block a user