updates on the ui changes and changed into doormile
This commit is contained in:
158
src/components/assistant/DoormileAI/AIComposer.jsx
Normal file
158
src/components/assistant/DoormileAI/AIComposer.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { ChatDictationButton, useChatDictation } from './shim';
|
||||
|
||||
// ==============================|| Doormile AI — composer ||============================== //
|
||||
//
|
||||
// Auto-growing textarea (capped at ~5 lines by the stylesheet's max-height),
|
||||
// Enter to send, Shift+Enter for a newline. Hand-rolled rather than Astryx's
|
||||
// <ChatComposer> because the panel needs the compact anchored treatment and
|
||||
// a circular send button, neither of which that component exposes — the
|
||||
// dictation button it ships IS reused here rather than reinvented.
|
||||
|
||||
const MAX_ROWS_PX = 108;
|
||||
|
||||
// How long a dictating operator has to stay quiet before the message sends
|
||||
// itself. Long enough to think mid-sentence, short enough that you are not left
|
||||
// wondering whether it heard you.
|
||||
const SILENCE_MS = 3000;
|
||||
|
||||
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
const textareaRef = useRef(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// ---- dictation: send on silence ----
|
||||
// Speech arrives as a stream of partial transcripts, so "finished speaking"
|
||||
// is not an event the API gives us — it has to be inferred from the stream
|
||||
// going quiet. Every transcript update re-arms a timer; whichever one is not
|
||||
// followed by another within SILENCE_MS is the end of the sentence.
|
||||
//
|
||||
// Everything the timer touches is read through a ref. The callback is created
|
||||
// once and fires up to three seconds later, so a closed-over `value` or
|
||||
// `isBusy` would be whatever they were when dictation started, not when the
|
||||
// operator stopped talking — it would send a stale half-sentence, or send
|
||||
// while a previous answer was still streaming.
|
||||
const valueRef = useRef(value);
|
||||
const isBusyRef = useRef(isBusy);
|
||||
const dictationRef = useRef(null);
|
||||
const silenceTimer = useRef(0);
|
||||
|
||||
valueRef.current = value;
|
||||
isBusyRef.current = isBusy;
|
||||
|
||||
const clearSilence = useCallback(() => {
|
||||
if (silenceTimer.current) {
|
||||
clearTimeout(silenceTimer.current);
|
||||
silenceTimer.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const armSilence = useCallback(() => {
|
||||
clearSilence();
|
||||
silenceTimer.current = setTimeout(() => {
|
||||
silenceTimer.current = 0;
|
||||
// Stop the mic first. Sending while it is still listening would let the
|
||||
// next words land in an input the operator has already "sent".
|
||||
dictationRef.current?.stop();
|
||||
const text = valueRef.current.trim();
|
||||
if (text && !isBusyRef.current) onSubmit(text);
|
||||
}, SILENCE_MS);
|
||||
}, [clearSilence, onSubmit]);
|
||||
|
||||
const dictation = useChatDictation({
|
||||
// Fires on every partial AND final result, which is what makes this a
|
||||
// silence detector rather than an end-of-recognition one.
|
||||
onTranscript: (transcript) => {
|
||||
onChange(transcript);
|
||||
armSilence();
|
||||
},
|
||||
onResult: (transcript) => {
|
||||
onChange(transcript);
|
||||
armSilence();
|
||||
},
|
||||
// Stopped by hand, or the engine gave up: no pending auto-send.
|
||||
onEnd: clearSilence,
|
||||
onError: clearSilence
|
||||
});
|
||||
|
||||
dictationRef.current = dictation;
|
||||
|
||||
// A panel closed mid-sentence must not fire a message afterwards.
|
||||
useEffect(() => clearSilence, [clearSilence]);
|
||||
|
||||
// Grow with content, then let the stylesheet's max-height take over and
|
||||
// scroll. Runs before paint so there's no visible jump on the first line
|
||||
// break.
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
|
||||
}, [value]);
|
||||
|
||||
const canSend = value.trim().length > 0 && !isBusy;
|
||||
|
||||
const submit = () => {
|
||||
if (!canSend) return;
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack className="dai-composer-wrap" gap={1} padding={0}>
|
||||
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
aria-label="Ask Doormile AI about operations"
|
||||
/>
|
||||
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
|
||||
to the far left and the send button to the far right, leaving a
|
||||
wide dead gap between two things that do the same job — put a
|
||||
message in. Together they read as one action cluster. */}
|
||||
<HStack gap={1} padding={0} vAlign="center" justify="end">
|
||||
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
|
||||
<button
|
||||
type="button"
|
||||
className="dai-send"
|
||||
onClick={submit}
|
||||
disabled={!canSend}
|
||||
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text className="dai-hint">
|
||||
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIComposer.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
isBusy: PropTypes.bool,
|
||||
placeholder: PropTypes.string
|
||||
};
|
||||
|
||||
export default AIComposer;
|
||||
118
src/components/assistant/DoormileAI/AIFlowStep.jsx
Normal file
118
src/components/assistant/DoormileAI/AIFlowStep.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { Selector } from './shim';
|
||||
|
||||
// ==============================|| Doormile AI — one dropdown step ||============================== //
|
||||
//
|
||||
// Rendered when a conversational step is a `select`. The Create Order page uses
|
||||
// dropdowns for business location, category, weight and service tier, so the
|
||||
// bot does too — asking an operator to TYPE a location name they'd otherwise
|
||||
// pick from a list is slower and invites a name the resolver can't match.
|
||||
//
|
||||
// Options load asynchronously (locations, customers and tenants are fetched
|
||||
// live) so the list is never stale or invented. Two states matter and are
|
||||
// distinguished rather than merged into one spinner:
|
||||
//
|
||||
// • loading — still fetching
|
||||
// • empty — fetched successfully and there genuinely is nothing to pick,
|
||||
// which is a dead end the operator has to be told about (a
|
||||
// tenant with no saved locations cannot have an order created)
|
||||
const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
|
||||
const [options, setOptions] = useState(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setOptions(null);
|
||||
setFailed(false);
|
||||
setValue('');
|
||||
Promise.resolve(step.options?.(draft) ?? [])
|
||||
.then((list) => alive && setOptions(list))
|
||||
.catch(() => alive && setFailed(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step.id]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-field-err">I couldn’t load the options for that. Say “cancel” and try again.</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (options === null) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-action-note">Loading options…</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
{/* An empty list must still leave a way forward. The generic message
|
||||
plus a lone Cancel is a dead end, and it is reached exactly when
|
||||
something upstream failed — which is the worst moment to strip the
|
||||
operator of options. A step that can also accept a typed answer
|
||||
says so here via `emptyHint`. */}
|
||||
<Text className="dai-field-err">
|
||||
{step.id === 'pickuplocationid'
|
||||
? 'This tenant has no saved business locations — add one on the Tenants page before creating an order.'
|
||||
: step.emptyHint || 'There’s nothing to choose from here.'}
|
||||
</Text>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const chosen = options.find((o) => String(o.value) === String(value));
|
||||
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<Selector
|
||||
label={step.ask}
|
||||
isLabelHidden
|
||||
placeholder="Choose one"
|
||||
options={options.map((o) => ({ value: String(o.value), label: o.label }))}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
// A long customer list is unusable without search.
|
||||
hasSearch={options.length > 8}
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label="Continue"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!value || isBusy}
|
||||
// The whole option is passed back, not just its value — steps that
|
||||
// pick a record (a location, a customer) need the record itself to
|
||||
// fill several fields at once.
|
||||
onClick={() => onChoose(value, chosen)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIFlowStep.propTypes = {
|
||||
step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string, options: PropTypes.func }).isRequired,
|
||||
onChoose: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
draft: PropTypes.object,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIFlowStep;
|
||||
245
src/components/assistant/DoormileAI/AIMessage.jsx
Normal file
245
src/components/assistant/DoormileAI/AIMessage.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { memo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { IconButton } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { ChatToolCalls } from './shim';
|
||||
|
||||
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
||||
import AIFlowStep from './AIFlowStep';
|
||||
import AIRowsStep from './AIRowsStep';
|
||||
import { failedRowsCsv, downloadCsv } from '@/lib/assistant/bulkFile';
|
||||
|
||||
// ==============================|| Doormile AI — a single turn ||============================== //
|
||||
//
|
||||
// User turns are compact right-aligned bubbles. Assistant turns are NOT
|
||||
// bubbles — text sits directly on the panel surface with a small spark mark,
|
||||
// which is what keeps this from reading as a consumer chat app.
|
||||
//
|
||||
// The `sources` disclosure is the one thing that must not be dropped in the
|
||||
// name of visual quiet: it's the existing verifiability contract (every
|
||||
// number traceable to a real call). It's collapsed by default and expands to
|
||||
// the same <ChatToolCalls> the previous panel showed inline.
|
||||
|
||||
const UserMessage = ({ text }) => (
|
||||
<VStack className="dai-msg" gap={0} padding={0}>
|
||||
<Text className="dai-msg-user">{text}</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
UserMessage.propTypes = { text: PropTypes.string.isRequired };
|
||||
|
||||
const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
|
||||
const [showSources, setShowSources] = useState(false);
|
||||
const sourceCount = message.sourceCalls?.length || 0;
|
||||
|
||||
return (
|
||||
<VStack className="dai-msg dai-msg-row" gap={1.5} padding={0}>
|
||||
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
||||
<HStack gap={1} padding={0} vAlign="center">
|
||||
<Spark size="sm" />
|
||||
<Text className="dai-msg-name">Doormile AI</Text>
|
||||
</HStack>
|
||||
<HStack className="dai-msg-actions" gap={0} padding={0}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Copy answer"
|
||||
tooltip="Copy answer"
|
||||
icon={<Copy />}
|
||||
onClick={() => onCopy(message)}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{message.state ? (
|
||||
<StateBlock iconKey={message.state.iconKey} tone={message.state.tone} title={message.state.title} body={message.state.body} />
|
||||
) : (
|
||||
<VStack gap={1.5} padding={0}>
|
||||
{message.metric ? (
|
||||
<VStack gap={1.5} padding={0}>
|
||||
<Metric value={message.metric.value} label={message.metric.label} />
|
||||
</VStack>
|
||||
) : (
|
||||
<Text className="dai-msg-ai">{message.text}</Text>
|
||||
)}
|
||||
|
||||
<StatGrid stats={message.stats} />
|
||||
|
||||
{message.list && <AnswerList title={message.list.title} items={message.list.items} numbered={message.list.numbered !== false} />}
|
||||
|
||||
{message.detail &&
|
||||
(message.isDoc ? (
|
||||
<Text className="dai-doc">{message.detail}</Text>
|
||||
) : (
|
||||
<Text className="dai-msg-ai-detail">{message.detail}</Text>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Live-data footer + source disclosure. Small and muted — the point is
|
||||
that it's available, not that it's announced on every turn. */}
|
||||
{sourceCount > 0 && (
|
||||
<VStack gap={1} padding={0}>
|
||||
<HStack className="dai-msg-foot" gap={0} padding={0}>
|
||||
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
|
||||
{/* The chevron's offset and rotation live in the stylesheet — as inline
|
||||
styles they couldn't be transitioned, and `verticalAlign` only
|
||||
works while the button's content is inline, which is exactly the
|
||||
thing that made it wrap. */}
|
||||
<button type="button" className="dai-link dai-sources" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||
<span>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
</span>
|
||||
<ChevronDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* ---- Write gate ---------------------------------------------------
|
||||
Real inputs, not a parsed sentence. The form shows every value that
|
||||
will be sent and submits only on Create. A form rehydrated from
|
||||
localStorage in a later session is marked 'stale' by the panel and
|
||||
renders read-only, so a write can never fire from a resurrected
|
||||
message. */}
|
||||
{/* A conversational step that needs a dropdown rather than typing. */}
|
||||
{message.liveStop && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button label="Stop" variant="secondary" size="sm" onClick={() => onStopLive(message)} />
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* The bulk create's rows turn — a whole file in one answer. */}
|
||||
{message.flowStep?.type === 'rows' && !message.flowStep.__answered && (
|
||||
<AIRowsStep
|
||||
isBusy={message.flowStep.__answered}
|
||||
onSubmit={(parsed) => onChooseStep(message, parsed)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.flowStep?.type === 'select' && (
|
||||
<AIFlowStep
|
||||
step={message.flowStep}
|
||||
draft={message.flowDraft}
|
||||
isBusy={message.flowStep.__answered}
|
||||
onChoose={(value, option) => onChooseStep(message, value, option)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign'].includes(message.form?.kind) && (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
{message.form.status === 'open' ? (
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={
|
||||
message.form.confirmLabel
|
||||
? message.form.confirmLabel
|
||||
: message.form.kind === 'confirmBulk'
|
||||
? `Create ${message.form.draft?.rows?.length || 0} orders`
|
||||
: message.form.kind === 'confirmOrder'
|
||||
? 'Create order'
|
||||
: message.form.kind === 'confirmAssign'
|
||||
? 'Assign rider'
|
||||
: 'Submit'
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
|
||||
{/* A second, wider commit — used when rows were excluded by
|
||||
default but including them is a legitimate choice. */}
|
||||
{message.form.altDraft && (
|
||||
<Button
|
||||
label={message.form.altLabel || 'Include everything'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.altDraft)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}>
|
||||
{message.form.status === 'done'
|
||||
? '✓ Created'
|
||||
: message.form.status === 'cancelled'
|
||||
? 'Cancelled — nothing was sent.'
|
||||
: message.form.status === 'failed'
|
||||
? message.form.error
|
||||
: 'Expired — ask again to recreate this.'}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{message.downloadRows?.length > 0 && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button
|
||||
label={`Download the ${message.downloadRows.length} row${message.downloadRows.length === 1 ? '' : 's'} that didn’t go through`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-unsendable.csv', failedRowsCsv(message.downloadRows))}
|
||||
/>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Follow-ups are NOT rendered here.
|
||||
|
||||
They used to appear twice: once under each answer and again in the
|
||||
strip above the composer. Two sets of chips offering overlapping
|
||||
questions in one panel is the assistant asking the same thing twice,
|
||||
and the in-message copy scrolled away the moment the next answer
|
||||
arrived — so the one that was always reachable is the one that was
|
||||
kept. `message.followUps` still carries the intent's suggestions;
|
||||
AIPanel feeds them into that single strip. */}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AssistantMessage.propTypes = {
|
||||
message: PropTypes.object.isRequired,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onSubmitForm: PropTypes.func.isRequired,
|
||||
onCancelAction: PropTypes.func.isRequired,
|
||||
onChooseStep: PropTypes.func.isRequired,
|
||||
onStopLive: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
|
||||
message.sender === 'user' ? (
|
||||
<UserMessage text={message.text} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
onCopy={onCopy}
|
||||
onSubmitForm={onSubmitForm}
|
||||
onCancelAction={onCancelAction}
|
||||
onChooseStep={onChooseStep}
|
||||
onStopLive={onStopLive}
|
||||
/>
|
||||
);
|
||||
|
||||
AIMessage.propTypes = {
|
||||
message: PropTypes.object.isRequired,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onSubmitForm: PropTypes.func.isRequired,
|
||||
onCancelAction: PropTypes.func.isRequired,
|
||||
onChooseStep: PropTypes.func.isRequired,
|
||||
onStopLive: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// Memoised, and this is not an optimisation garnish — it's what makes the
|
||||
// thread scrollable. Every scroll event and every composer keystroke re-renders
|
||||
// the panel, and without this each one re-rendered all 50 restored turns,
|
||||
// tool-call disclosures included. The panel keeps its handler identities stable
|
||||
// so this comparison actually holds.
|
||||
export default memo(AIMessage);
|
||||
1591
src/components/assistant/DoormileAI/AIPanel.jsx
Normal file
1591
src/components/assistant/DoormileAI/AIPanel.jsx
Normal file
File diff suppressed because it is too large
Load Diff
149
src/components/assistant/DoormileAI/AIParts.jsx
Normal file
149
src/components/assistant/DoormileAI/AIParts.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { AlertTriangle, Info, Sparkles } from 'lucide-react';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
|
||||
export const Spark = ({ size = 'sm' }) => (
|
||||
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
|
||||
<Sparkles className="text-brand w-4 h-4" />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
Spark.propTypes = { size: PropTypes.oneOf(['sm', 'md', 'lg']) };
|
||||
|
||||
// "● Live" — subtle, not a large pill.
|
||||
export const LiveIndicator = ({ label = 'Live' }) => (
|
||||
<HStack className="dai-live" padding={0} gap={0} vAlign="center" aria-label="Answers are read from live operational data">
|
||||
<HStack className="dai-live-dot" padding={0} gap={0} aria-hidden="true" />
|
||||
{label}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
LiveIndicator.propTypes = { label: PropTypes.string };
|
||||
|
||||
// Thinking state. Three dots plus two shimmer bars — enough to read as
|
||||
// "working", not enough to be a light show.
|
||||
export const TypingIndicator = () => (
|
||||
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="Doormile AI is thinking">
|
||||
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</HStack>
|
||||
<VStack gap={1} padding={0} style={{ width: '100%' }} aria-hidden="true">
|
||||
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '78%' }} />
|
||||
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '52%' }} />
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
// The headline number for a counting answer — "the primary number is large".
|
||||
export const Metric = ({ value, label }) => (
|
||||
<VStack gap={0.25} padding={0}>
|
||||
<Text className="dai-metric-value">{Number.isFinite(value) ? value.toLocaleString('en-IN') : value}</Text>
|
||||
<Text className="dai-metric-label">{label}</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
Metric.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
// Compact breakdown grid. `color` is a status hex supplied by the caller —
|
||||
// the one case the token system genuinely can't express (see CLAUDE.md §6.5),
|
||||
// so it stays an inline style rather than a class.
|
||||
export const StatGrid = ({ stats }) => {
|
||||
if (!stats?.length) return null;
|
||||
return (
|
||||
<HStack className="dai-stat-grid" padding={0} gap={0} role="list">
|
||||
{stats.map((s) => (
|
||||
<VStack key={s.label} className="dai-stat" gap={0.25} padding={0} role="listitem">
|
||||
<Text className="dai-stat-value" style={s.color ? { color: s.color } : undefined}>
|
||||
{s.value}
|
||||
</Text>
|
||||
<Text className="dai-stat-label">{s.label}</Text>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
StatGrid.propTypes = {
|
||||
stats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
color: PropTypes.string
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// Icons are looked up by KEY, never passed in as a node. A message carrying a
|
||||
// React element would be destroyed by the JSON round-trip through
|
||||
// localStorage (elements don't serialise — $$typeof is a Symbol and is
|
||||
// silently dropped), and the rehydrated "element" would then crash the render
|
||||
// on the next panel open.
|
||||
const STATE_ICONS = {
|
||||
warning: <AlertTriangle />,
|
||||
info: <Info />
|
||||
};
|
||||
|
||||
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
|
||||
// left an operator unable to see the rest of an answer at all — the body
|
||||
// scrolls past ~12 rows so a long list can't stretch the panel.
|
||||
export const AnswerList = ({ title, items, numbered = true }) => {
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<VStack className="dai-list" gap={0} padding={0}>
|
||||
<HStack className="dai-list-head" padding={0} gap={0}>
|
||||
<span>{title}</span>
|
||||
<span>{items.length}</span>
|
||||
</HStack>
|
||||
<VStack className="dai-list-body" gap={0} padding={0} role="list">
|
||||
{items.map((item, i) => (
|
||||
<HStack className="dai-list-item" key={`${item.label}-${i}`} padding={0} gap={0} role="listitem">
|
||||
{/* Numbering makes sense for "the 14 tenants"; it is noise on a
|
||||
field/value dump like a submitted payload. */}
|
||||
{numbered ? <span className="dai-list-index">{i + 1}</span> : null}
|
||||
<span className="dai-list-label">{item.label}</span>
|
||||
{item.meta ? <span className="dai-list-meta">{item.meta}</span> : null}
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AnswerList.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string.isRequired, meta: PropTypes.string })),
|
||||
numbered: PropTypes.bool
|
||||
};
|
||||
|
||||
// Shared shell for the error / no-data states, so both read as the same kind
|
||||
// of thing rather than two separately-invented blocks.
|
||||
export const StateBlock = ({ iconKey, tone, title, body, action }) => (
|
||||
<VStack className="dai-state" gap={1.5} padding={0}>
|
||||
<HStack gap={1.5} padding={0} vAlign="start">
|
||||
<HStack className="dai-state-icon" padding={0} gap={0} aria-hidden="true" style={{ background: `${tone}14`, color: tone }}>
|
||||
{STATE_ICONS[iconKey] || STATE_ICONS.info}
|
||||
</HStack>
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-state-title">{title}</Text>
|
||||
<Text className="dai-state-body">{body}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
{action}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
StateBlock.propTypes = {
|
||||
iconKey: PropTypes.oneOf(['warning', 'info']),
|
||||
tone: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
body: PropTypes.string.isRequired,
|
||||
action: PropTypes.node
|
||||
};
|
||||
154
src/components/assistant/DoormileAI/AIRowsStep.jsx
Normal file
154
src/components/assistant/DoormileAI/AIRowsStep.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from './shim';
|
||||
import { HStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { TextArea } from './shim';
|
||||
import { FileInput } from './shim';
|
||||
|
||||
import { parseBulkRows, BULK_MAX } from '@/lib/assistant/bulkOrderActions';
|
||||
import { parseBulkFile, templateCsv, downloadCsv } from '@/lib/assistant/bulkFile';
|
||||
|
||||
// ==============================|| Doormile AI — the rows turn ||============================== //
|
||||
//
|
||||
// One conversational turn that accepts a whole file. A sheet and a paste both
|
||||
// produce the same row array (bulkFile.js / parseBulkRows), so this hands back
|
||||
// one parse result either way and the flow doesn't care which was used.
|
||||
//
|
||||
// File and paste are offered together rather than behind a "file or paste?"
|
||||
// question, because that question costs a turn and answers nothing the operator
|
||||
// hasn't already decided by having a file or not.
|
||||
//
|
||||
// What is reported before the operator can continue, and why each matters:
|
||||
// • rows read — so a truncated or half-empty sheet is visible immediately
|
||||
// • columns NOT recognised, by name — an unread price column would otherwise
|
||||
// silently re-quote every row
|
||||
// • rows repeated inside the file — the bulk endpoint has no idempotency key,
|
||||
// so a duplicate that gets through is a second real rider at one door
|
||||
|
||||
const PLACEHOLDER = `Ramesh Kumar, 9876543210, 12 Cross Cut Rd Gandhipuram, 641012, Coimbatore, 120, 2 meal boxes
|
||||
Priya N, 9876500011, 45 RS Puram, 641002, Coimbatore, 95, documents`;
|
||||
|
||||
const AIRowsStep = ({ onSubmit, onCancel, isBusy }) => {
|
||||
const [file, setFile] = useState(null);
|
||||
const [fileParse, setFileParse] = useState(null);
|
||||
const [fileError, setFileError] = useState(null);
|
||||
const [raw, setRaw] = useState('');
|
||||
const [isReading, setIsReading] = useState(false);
|
||||
|
||||
const pasted = raw.trim() ? parseBulkRows(raw) : null;
|
||||
// A loaded file wins; clearing it falls back to whatever is in the textarea.
|
||||
const parsed = fileParse || pasted;
|
||||
|
||||
const handleFile = async (picked) => {
|
||||
const chosen = Array.isArray(picked) ? picked[0] : picked;
|
||||
setFile(chosen || null);
|
||||
setFileError(null);
|
||||
if (!chosen) {
|
||||
setFileParse(null);
|
||||
return;
|
||||
}
|
||||
setIsReading(true);
|
||||
try {
|
||||
const result = await parseBulkFile(chosen);
|
||||
setFileParse(result);
|
||||
setRaw('');
|
||||
} catch (err) {
|
||||
setFileParse(null);
|
||||
setFileError(err.message || 'Couldn’t read that file.');
|
||||
} finally {
|
||||
setIsReading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<FileInput
|
||||
label="Order sheet"
|
||||
isLabelHidden
|
||||
accept=".csv,.xls,.xlsx"
|
||||
value={file}
|
||||
onChange={handleFile}
|
||||
isDisabled={isBusy || isReading}
|
||||
/>
|
||||
|
||||
{!fileParse && (
|
||||
<TextArea label="Or paste rows" rows={4} value={raw} onChange={setRaw} placeholder={PLACEHOLDER} isDisabled={isBusy} />
|
||||
)}
|
||||
|
||||
{isReading && <Text className="dai-action-note">Reading the file…</Text>}
|
||||
{fileError && <Text className="dai-field-err">{fileError}</Text>}
|
||||
|
||||
{parsed && (
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-action-note">
|
||||
{fileParse ? `${fileParse.sheetName} — ` : ''}
|
||||
{parsed.rows.length} complete row{parsed.rows.length === 1 ? '' : 's'}
|
||||
{parsed.errors.length ? ` · ${parsed.errors.length} unusable` : ''}
|
||||
</Text>
|
||||
|
||||
{parsed.ignoredColumns?.length > 0 && (
|
||||
<Text className="dai-action-note">Columns I didn’t recognise, so didn’t use: {parsed.ignoredColumns.join(', ')}</Text>
|
||||
)}
|
||||
|
||||
{parsed.duplicates?.length > 0 && (
|
||||
<Text className="dai-field-err">
|
||||
{parsed.duplicates.length} row{parsed.duplicates.length === 1 ? '' : 's'} repeat an earlier phone and address (
|
||||
{parsed.duplicates.map((d) => `line ${d.line}`).join(', ')}) — they’d become separate orders.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Keyed by line AND index: one row can fail several validations, so
|
||||
`line` alone is not unique and React logged "two children with the
|
||||
same key" for every repeat — seen live as a 1/2/3 cycle. Same
|
||||
shape the list renderer in AIParts already uses. */}
|
||||
{parsed.errors.slice(0, 5).map((e, i) => (
|
||||
<Text key={`${e.line}-${i}`} className="dai-field-err">
|
||||
Line {e.line}: {e.reason}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <Text className="dai-action-note">…and {parsed.errors.length - 5} more unusable rows.</Text>}
|
||||
|
||||
{parsed.rows.length > BULK_MAX && (
|
||||
<Text className="dai-action-note">
|
||||
Over {BULK_MAX} — I’ll send them in {Math.ceil(parsed.rows.length / BULK_MAX)} batches.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={parsed?.rows.length ? `Use these ${parsed.rows.length} rows` : 'Continue'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!parsed?.rows.length || isBusy || isReading}
|
||||
onClick={() => onSubmit(parsed)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
<Button
|
||||
label="Template"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tooltip="Download a blank sheet with the exact column titles I read"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-template.csv', templateCsv())}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Text className="dai-action-note">
|
||||
Pasted rows: name, phone, address, pincode, city, price, description — comma, tab or pipe separated. Leave the price blank and I’ll
|
||||
quote it.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIRowsStep.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIRowsStep;
|
||||
90
src/components/assistant/DoormileAI/AIWelcome.jsx
Normal file
90
src/components/assistant/DoormileAI/AIWelcome.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
|
||||
import { CHIP_LABELS } from './pageContext';
|
||||
|
||||
// ==============================|| Doormile AI — welcome state ||============================== //
|
||||
//
|
||||
// Shown only when the thread is empty. Deliberately top-weighted and compact
|
||||
// so the panel never opens on a large blank region: greeting + one line of
|
||||
// what it can do, then straight into four suggestions.
|
||||
|
||||
const greeting = () => {
|
||||
const h = dayjs().hour();
|
||||
if (h < 12) return 'Good morning';
|
||||
if (h < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
// A chip, not a row.
|
||||
//
|
||||
// These used to be full-width rows: a 26px icon tile, the question, and a
|
||||
// reveal-on-hover arrow, stacked one per line. That reads fine for four
|
||||
// suggestions and falls apart at twenty — the panel opened on a wall of
|
||||
// identical boxes that had to be read top to bottom, and the whole welcome
|
||||
// state scrolled.
|
||||
//
|
||||
// As chips they flow into a block that is scanned rather than read: each one
|
||||
// is only as wide as its question, so the varying widths give the eye
|
||||
// something to land on, and twenty of them occupy a few rows instead of
|
||||
// twenty. The icon stays as a small inline glyph for recognition; the arrow
|
||||
// is gone, because on a chip it was decoration competing with the label.
|
||||
const SuggestionChip = ({ item, onAsk }) => {
|
||||
const Icon = item.icon;
|
||||
// Display short, ask long. `item.text` is what intents.js matches on, so it
|
||||
// is what gets asked and what assistive tech announces; the chip only shows
|
||||
// a trimmed version of it so twenty of them fit in a few rows.
|
||||
const label = CHIP_LABELS[item.text] || item.text;
|
||||
return (
|
||||
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)} aria-label={item.text} title={item.text}>
|
||||
<Icon className="dai-suggestion-icon" size={13} strokeWidth={2} aria-hidden="true" />
|
||||
<span className="dai-suggestion-text">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
SuggestionChip.propTypes = {
|
||||
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// Exported so AIPanel can render it directly above the composer rather than
|
||||
// inside the scrolling body. The chips are a way to START a message, so they
|
||||
// belong with the input, not buried in the welcome copy where they scrolled
|
||||
// out of reach the moment a conversation began.
|
||||
export const SuggestionChips = ({ items, onAsk }) => (
|
||||
<HStack className="dai-suggestions" wrap="wrap" gap={1.5} padding={0} vAlign="start">
|
||||
{items.map((item) => (
|
||||
<SuggestionChip key={item.text} item={item} onAsk={onAsk} />
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
SuggestionChips.propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIWelcome = () => {
|
||||
return (
|
||||
<VStack className="dai-welcome" gap={1.5} padding={0}>
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-welcome-greeting">{greeting()}</Text>
|
||||
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||
</VStack>
|
||||
<Text className="dai-welcome-note">
|
||||
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIWelcome.propTypes = {
|
||||
onAsk: PropTypes.func
|
||||
};
|
||||
|
||||
export default AIWelcome;
|
||||
39
src/components/assistant/DoormileAI/index.jsx
Normal file
39
src/components/assistant/DoormileAI/index.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import AIPanel from './AIPanel';
|
||||
import '../DoormileAI.css';
|
||||
|
||||
// ==============================|| Doormile AI — trigger ||============================== //
|
||||
//
|
||||
// Mounted in AppTopNav. Owns only the open/closed state; everything else
|
||||
// lives in AIPanel. Focus returns here when the panel closes, so keyboard
|
||||
// users land back where they started.
|
||||
|
||||
const DoormileAITrigger = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger inline-flex items-center justify-center rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
data-active={isOpen}
|
||||
aria-label="Open Doormile AI"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<Bot className="dai-trigger-mark text-slate-700" aria-hidden="true" />
|
||||
</button>
|
||||
<AIPanel isOpen={isOpen} onClose={close} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoormileAITrigger;
|
||||
413
src/components/assistant/DoormileAI/pageContext.jsx
Normal file
413
src/components/assistant/DoormileAI/pageContext.jsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
Clock3 as LuClock3,
|
||||
Package as LuPackage,
|
||||
Bike as LuBike,
|
||||
Truck as LuTruck,
|
||||
Building2 as LuBuilding2,
|
||||
CircleDot as LuCircleDot,
|
||||
Banknote as LuBanknote,
|
||||
Users as LuUsers,
|
||||
Layers as LuLayers,
|
||||
TimerOff as LuTimerOff,
|
||||
UserPlus as LuUserPlus,
|
||||
PackagePlus as LuPackagePlus,
|
||||
ListPlus as LuListPlus,
|
||||
Repeat as LuRepeat
|
||||
} from 'lucide-react';
|
||||
|
||||
// ==============================|| Doormile AI — page context ||============================== //
|
||||
//
|
||||
// Maps the current route to (a) a short context label shown under the panel
|
||||
// header and (b) the suggested questions offered in the welcome state, so the
|
||||
// assistant opens on questions relevant to what the operator is looking at
|
||||
// rather than a fixed Orders-shaped list.
|
||||
//
|
||||
// Every suggestion here MUST be a phrasing the deterministic matcher in
|
||||
// intents.js actually resolves — a suggestion chip that returns "I can't
|
||||
// answer that yet" is worse than no chip. When adding one, check it against
|
||||
// the INTENTS catalog first.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Short chip labels.
|
||||
//
|
||||
// Every `text` below is the exact phrasing the deterministic matcher in
|
||||
// intents.js resolves, so it CANNOT be shortened in place — that is the string
|
||||
// the click actually asks. This map only changes what the chip DISPLAYS.
|
||||
//
|
||||
// It exists because the welcome state offers up to twenty suggestions, and as
|
||||
// full sentences they are 130-255px wide in a ~393px panel: barely two fit per
|
||||
// row, so the "chips" laid out as fourteen near-full-width rows and read as a
|
||||
// list again. Shortened, three or four fit per row and the block becomes
|
||||
// something you scan.
|
||||
//
|
||||
// The full question stays the accessible name and the tooltip, so nothing is
|
||||
// lost for screen readers or for anyone unsure what a chip will ask. A
|
||||
// suggestion with no entry here simply shows its full text.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHIP_LABELS = {
|
||||
"Give me today's operations summary": "Today's summary",
|
||||
'How many orders today?': 'Orders today',
|
||||
'Which orders are delayed?': 'Delayed orders',
|
||||
'Create an order': 'Create order',
|
||||
'Create multiple orders': 'Bulk orders',
|
||||
'Total revenue today': 'Revenue today',
|
||||
'How many pending orders today?': 'Pending today',
|
||||
'How many cancelled orders today?': 'Cancelled today',
|
||||
'How many delivered orders today?': 'Delivered today',
|
||||
'Morning Batch orders today': 'Morning batch',
|
||||
'Orders today vs yesterday': 'Today vs yesterday',
|
||||
'Create a customer': 'Create customer',
|
||||
'How many riders are active?': 'Active riders',
|
||||
'How many vehicles are available?': 'Vehicles available',
|
||||
'Current hub status': 'Hub status',
|
||||
'Which hubs are experiencing delays?': 'Hub delays',
|
||||
'How many tenants do we have?': 'Tenants',
|
||||
'How many orders this week?': 'Orders this week',
|
||||
'Total revenue this week': 'Revenue this week',
|
||||
'How many customers do we have?': 'Customers',
|
||||
'Assign a rider': 'Assign order',
|
||||
"Repeat yesterday's orders": 'Repeat yesterday'
|
||||
};
|
||||
|
||||
// The one follow-up phrasing that is not already a page suggestion. Verified
|
||||
// against ASSIGN_TRIGGER in assignActions.js — it opens the assign-rider flow.
|
||||
const ASSIGN_TEXT = 'Assign a rider';
|
||||
|
||||
// Recreates a previous day's run rather than building an order from scratch.
|
||||
// This is the phrasing intents.js documents for it ("repeat yesterday's
|
||||
// orders") and it is matched by REPEAT_TRIGGER in repeatRuns.js, which is
|
||||
// ordered AHEAD of createOrder/createBulkOrders precisely so a sentence
|
||||
// containing both "repeat" and "orders" recalls the run instead of opening a
|
||||
// blank create form.
|
||||
const REPEAT_TEXT = "Repeat yesterday's orders";
|
||||
|
||||
// Not a question — an OUTCOME. AIPanel sets this as the follow-up key when a
|
||||
// create or repeat run actually succeeds, so the next steps offered are the
|
||||
// ones that follow from an order existing, rather than the ones that follow
|
||||
// from having asked to make one. Assigning a rider only makes sense on this
|
||||
// side of the write.
|
||||
export const ORDER_CREATED = '__orderCreated';
|
||||
|
||||
// The same write, but with every new order already given a rider — a repeat run
|
||||
// that handed each copy back to whoever ran it yesterday.
|
||||
//
|
||||
// It needs its own key purely so ASSIGN_TEXT can be left out. Offering "Assign
|
||||
// order" after the bot has just assigned them all presents finished work as a
|
||||
// pending step, and an operator who takes the suggestion is walked into
|
||||
// reassigning orders that are already out.
|
||||
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';
|
||||
|
||||
const ORDERS = {
|
||||
label: 'Orders',
|
||||
suggestions: [
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuPackagePlus, text: 'Create an order' },
|
||||
{ icon: LuRepeat, text: REPEAT_TEXT }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuListPlus, text: 'Create multiple orders' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' },
|
||||
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
||||
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||
{ icon: LuClock3, text: 'Orders today vs yesterday' }
|
||||
]
|
||||
};
|
||||
|
||||
const RIDERS = {
|
||||
label: 'Riders',
|
||||
suggestions: [
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' }
|
||||
]
|
||||
};
|
||||
|
||||
const VEHICLES = {
|
||||
label: 'Vehicles',
|
||||
suggestions: [
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||
};
|
||||
|
||||
const HUBS = {
|
||||
label: 'Hubs',
|
||||
suggestions: [
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' }
|
||||
],
|
||||
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||
};
|
||||
|
||||
const DISPATCH = {
|
||||
label: 'Dispatch',
|
||||
suggestions: [
|
||||
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' }
|
||||
]
|
||||
};
|
||||
|
||||
const TENANTS = {
|
||||
label: 'Tenants',
|
||||
suggestions: [
|
||||
{ icon: LuUsers, text: 'How many tenants do we have?' },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
|
||||
const REPORTS = {
|
||||
label: 'Reports',
|
||||
suggestions: [
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuClock3, text: 'Orders today vs yesterday' },
|
||||
{ icon: LuPackage, text: 'How many orders this week?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
|
||||
};
|
||||
|
||||
const DEFAULT_CONTEXT = {
|
||||
label: 'Operations',
|
||||
suggestions: [
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuBuilding2, text: 'Current hub status' }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
||||
]
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Follow-ups.
|
||||
//
|
||||
// What the suggestion strip offers AFTER a question has been asked. Showing
|
||||
// all twenty again is noise — the operator has already told you what they are
|
||||
// doing, so the next set should be the natural next steps for it. Creating an
|
||||
// order leads to assigning a rider and to what is still pending; asking about
|
||||
// revenue leads to the week and to the day-over-day comparison.
|
||||
//
|
||||
// Every string here is either one of the CHIP_LABELS questions above (all of
|
||||
// which the matcher already resolves) or ASSIGN_TEXT. That constraint is the
|
||||
// whole point: pageContext's rule is that a suggestion which returns "I can't
|
||||
// answer that yet" is worse than no suggestion, and a follow-up is MORE likely
|
||||
// to be clicked than a cold one, because it arrives exactly when it is
|
||||
// relevant. Do not add a phrasing here without checking it against INTENTS.
|
||||
// ---------------------------------------------------------------------------
|
||||
const FOLLOW_UPS = {
|
||||
// --- write actions ---
|
||||
// Note what is NOT here: assigning a rider.
|
||||
//
|
||||
// "Create an order" only STARTS the create conversation — no order exists at
|
||||
// that moment, so offering to assign one is offering to act on nothing. And
|
||||
// once the order really is created, AIPanel already drops the operator
|
||||
// straight into the assign flow itself (see the confirmOrder branch), because
|
||||
// that hand-off is mandatory rather than optional. A chip there would either
|
||||
// duplicate a step the product performs on its own or fire it too early.
|
||||
//
|
||||
// ASSIGN_TEXT belongs on the questions below that surface orders which
|
||||
// already EXIST and are waiting for a rider — and on ORDER_CREATED, which is
|
||||
// keyed off the write succeeding rather than off anything that was asked.
|
||||
'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'],
|
||||
'Create multiple orders': ['How many pending orders today?', 'How many orders today?'],
|
||||
[REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'],
|
||||
'Create a customer': ['Create an order', 'How many customers do we have?'],
|
||||
[ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'],
|
||||
[ORDER_CREATED]: [ASSIGN_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
||||
// No ASSIGN_TEXT: there is nothing left to assign.
|
||||
[ORDER_CREATED_ASSIGNED]: ['How many riders are active?', 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
||||
|
||||
// --- order volume ---
|
||||
"Give me today's operations summary": [
|
||||
'How many pending orders today?',
|
||||
'Which orders are delayed?',
|
||||
'Total revenue today',
|
||||
'How many riders are active?'
|
||||
],
|
||||
'How many orders today?': [
|
||||
'How many pending orders today?',
|
||||
'How many delivered orders today?',
|
||||
'Orders today vs yesterday',
|
||||
'Total revenue today'
|
||||
],
|
||||
'How many pending orders today?': [ASSIGN_TEXT, 'Which orders are delayed?', 'How many delivered orders today?'],
|
||||
'How many delivered orders today?': ['How many pending orders today?', 'Total revenue today', 'Orders today vs yesterday'],
|
||||
'How many cancelled orders today?': ['How many delivered orders today?', 'How many orders today?', 'Which orders are delayed?'],
|
||||
'Which orders are delayed?': [ASSIGN_TEXT, 'Which hubs are experiencing delays?', 'How many riders are active?'],
|
||||
'Morning Batch orders today': ['Which orders are delayed?', 'How many riders are active?', 'How many orders today?'],
|
||||
|
||||
// --- money and trend ---
|
||||
'Total revenue today': ['Total revenue this week', 'Orders today vs yesterday', 'How many orders today?'],
|
||||
'Total revenue this week': ['How many orders this week?', 'Total revenue today'],
|
||||
'Orders today vs yesterday': [REPEAT_TEXT, 'Total revenue today', 'How many orders this week?'],
|
||||
'How many orders this week?': ['Total revenue this week', 'How many orders today?'],
|
||||
|
||||
// --- fleet and network ---
|
||||
'How many riders are active?': ['How many vehicles are available?', 'Current hub status', ASSIGN_TEXT],
|
||||
'How many vehicles are available?': ['How many riders are active?', 'Current hub status'],
|
||||
'Current hub status': ['Which hubs are experiencing delays?', 'How many vehicles are available?', 'How many riders are active?'],
|
||||
'Which hubs are experiencing delays?': ['Current hub status', 'Which orders are delayed?', 'How many riders are active?'],
|
||||
|
||||
// --- accounts ---
|
||||
'How many tenants do we have?': ['How many customers do we have?', 'How many orders today?'],
|
||||
'How many customers do we have?': ['Create a customer', 'How many tenants do we have?']
|
||||
};
|
||||
|
||||
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
|
||||
// with the wrong label.
|
||||
const ROUTES = [
|
||||
['/doormile/dispatch', DISPATCH],
|
||||
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
|
||||
['/doormile/orders', ORDERS],
|
||||
['/doormile/riders', RIDERS],
|
||||
['/doormile/vehicles', VEHICLES],
|
||||
['/doormile/hubs', HUBS],
|
||||
['/doormile/tenants', TENANTS],
|
||||
['/doormile/reports', REPORTS],
|
||||
[
|
||||
'/doormile/customers',
|
||||
{
|
||||
label: 'Customers',
|
||||
suggestions: [
|
||||
{ icon: LuUserPlus, text: 'Create a customer' },
|
||||
{ icon: LuUsers, text: 'How many customers do we have?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' }
|
||||
],
|
||||
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
|
||||
}
|
||||
],
|
||||
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
|
||||
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
|
||||
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
|
||||
];
|
||||
|
||||
// ---- every question, from every page ---------------------------------------
|
||||
//
|
||||
// The catalog is the same wherever you are: the assistant can answer about
|
||||
// orders, riders, hubs and the rest regardless of which screen is open, so
|
||||
// hiding a question because you happen to be on Dispatch made it look narrower
|
||||
// than it is. What the page still decides is ORDER — its own questions lead,
|
||||
// then everything else follows, deduplicated by text.
|
||||
//
|
||||
// `more` is retired: AIWelcome renders one flat list now, so a second array
|
||||
// would only be a second place for a question to hide.
|
||||
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
|
||||
|
||||
// Every question declared anywhere. Two jobs: it is the icon index behind
|
||||
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
|
||||
// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
|
||||
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
|
||||
const EVERY_QUESTION = (() => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
ALL_CONTEXTS.forEach((c) => {
|
||||
[...(c.suggestions || []), ...(c.more || [])].forEach((q) => {
|
||||
if (seen.has(q.text)) return;
|
||||
seen.add(q.text);
|
||||
out.push(q);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The questions that ride along on EVERY page.
|
||||
//
|
||||
// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
|
||||
// Orders page offered "How many tenants do we have?" and "Current hub status"
|
||||
// above the composer, questions an operator working orders is not asking. The
|
||||
// strip is a shortcut, and a shortcut that lists everything is a menu.
|
||||
//
|
||||
// What is left is the daily operating picture: what came in, what is stuck,
|
||||
// what got out, what it earned, and the three ways to put work into the system.
|
||||
//
|
||||
// Dropping a question from THIS list does not remove it from the assistant —
|
||||
// it stays typeable, it stays a follow-up, and it still leads on its own page,
|
||||
// because withGlobalQuestions puts each page's own suggestions first. Hubs
|
||||
// still opens on hub questions; they just no longer follow you to Orders.
|
||||
// ---------------------------------------------------------------------------
|
||||
const GLOBAL_TEXTS = new Set([
|
||||
"Give me today's operations summary",
|
||||
'How many orders today?',
|
||||
'Which orders are delayed?',
|
||||
'How many pending orders today?',
|
||||
'How many delivered orders today?',
|
||||
'How many cancelled orders today?',
|
||||
'Morning Batch orders today',
|
||||
'Orders today vs yesterday',
|
||||
'Total revenue today',
|
||||
'How many riders are active?',
|
||||
'Create an order',
|
||||
REPEAT_TEXT,
|
||||
'Create multiple orders'
|
||||
]);
|
||||
|
||||
const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
|
||||
|
||||
// The page's own suggestions lead; the global set follows, deduplicated.
|
||||
const withGlobalQuestions = (context) => {
|
||||
const seen = new Set();
|
||||
const suggestions = [];
|
||||
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
|
||||
if (seen.has(q.text)) return;
|
||||
seen.add(q.text);
|
||||
suggestions.push(q);
|
||||
});
|
||||
return { ...context, suggestions, more: [] };
|
||||
};
|
||||
|
||||
export const getPageContext = (pathname = '') => {
|
||||
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
||||
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Follow-ups are stored as bare strings (they have to be — they are the exact
|
||||
// text that gets asked), so this turns one back into a renderable chip by
|
||||
// looking its icon up among the questions already declared above. Built from
|
||||
// EVERY_QUESTION rather than a second hand-written list, so a suggestion can
|
||||
// never end up with an icon here that disagrees with its icon on a page.
|
||||
// ---------------------------------------------------------------------------
|
||||
const QUESTION_BY_TEXT = new Map(EVERY_QUESTION.map((q) => [q.text, q]));
|
||||
QUESTION_BY_TEXT.set(ASSIGN_TEXT, { icon: LuBike, text: ASSIGN_TEXT });
|
||||
|
||||
// Returns [] for anything unrecognised — a typed question, or one with no
|
||||
// natural next step. The caller falls back to the page's own suggestions, so
|
||||
// the strip is never empty.
|
||||
export const getFollowUps = (askedText) => {
|
||||
const next = FOLLOW_UPS[askedText];
|
||||
if (!next) return [];
|
||||
return next.map((text) => QUESTION_BY_TEXT.get(text)).filter(Boolean);
|
||||
};
|
||||
|
||||
// Turns arbitrary follow-up strings into chips. Used for the intent-keyed
|
||||
// FOLLOW_UP_SUGGESTIONS in intents.js, whose phrasings ("What about
|
||||
// yesterday?", "This week") are conversational rather than one of the declared
|
||||
// questions, so they have no icon of their own.
|
||||
export const toChips = (texts = []) => texts.map((text) => QUESTION_BY_TEXT.get(text) || { icon: LuCircleDot, text });
|
||||
139
src/components/assistant/DoormileAI/shim.jsx
Normal file
139
src/components/assistant/DoormileAI/shim.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { DropdownMenu as KrowDropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
|
||||
export const HStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, vAlign, justify, wrap, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-row ${
|
||||
vAlign === 'start' ? 'items-start' : vAlign === 'end' ? 'items-end' : vAlign === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${wrap === 'wrap' ? 'flex-wrap' : ''} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const VStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, align, justify, style, ...props }) => {
|
||||
return (
|
||||
<Component
|
||||
className={`flex flex-col ${
|
||||
align === 'start' ? 'items-start' : align === 'end' ? 'items-end' : align === 'center' ? 'items-center' : 'items-stretch'
|
||||
} ${
|
||||
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
|
||||
} ${className}`}
|
||||
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const Text = ({ as: Component = 'span', children, className = '', style, ...props }) => {
|
||||
return (
|
||||
<Component className={`text-sm ${className}`} style={style} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconButton = ({ children, className = '', icon, onClick, tooltip, label, size, variant, isIconOnly, ...props }) => {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors ${
|
||||
size === 'sm' ? 'p-1.5' : 'p-2'
|
||||
} ${isIconOnly ? 'aspect-square' : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
title={tooltip || label}
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
{icon || children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({ button, items }) => (
|
||||
<KrowDropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton {...button} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{items?.map((it, i) => (
|
||||
<DropdownMenuItem key={i} onClick={it.onClick}>
|
||||
{it.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</KrowDropdownMenu>
|
||||
);
|
||||
|
||||
export const Button = ({ children, onClick, className = '' }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const Selector = ({ value, onChange, options = [], placeholder }) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder || 'Select...'}
|
||||
</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.value || o} value={o.value || o}>
|
||||
{o.label || o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
export const TextArea = ({ value, onChange, placeholder, rows = 3, className = '' }) => (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FileInput = ({ onChange, accept, className = '' }) => (
|
||||
<input
|
||||
type="file"
|
||||
onChange={onChange}
|
||||
accept={accept}
|
||||
className={`block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChatToolCalls = ({ calls }) => (
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
{calls?.map((c, i) => (
|
||||
<div key={i} className="mb-1">
|
||||
<strong>{c.tool || 'Tool'}</strong>
|
||||
<pre className="mt-1 overflow-x-auto text-[10px]">{JSON.stringify(c.args, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const useChatDictation = () => {
|
||||
return { isDictating: false, startDictation: () => {}, stopDictation: () => {}, toggleDictation: () => {} };
|
||||
};
|
||||
|
||||
export const ChatDictationButton = ({ isDictating, onToggle }) => (
|
||||
<button type="button" onClick={onToggle} className="p-2 text-gray-500 hover:text-blue-500" title="Dictation unavailable">
|
||||
🎤
|
||||
</button>
|
||||
);
|
||||
Reference in New Issue
Block a user