278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
import { memo, useState } from 'react';
|
|
import PropTypes from 'prop-types';
|
|
import { AiOutlineCopy as CopyOutlined } from 'react-icons/ai';
|
|
import { LuChevronDown, LuDownload } from 'react-icons/lu';
|
|
|
|
import { HStack } from '@astryxdesign/core/HStack';
|
|
import { VStack } from '@astryxdesign/core/VStack';
|
|
import { Text } from '@astryxdesign/core/Text';
|
|
import { ChatToolCalls } from '@astryxdesign/core/Chat';
|
|
import { Button } from '@/components/ui/button';
|
|
import { IconButton } from '@/components/ds';
|
|
|
|
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
|
import AIFlowStep from './AIFlowStep';
|
|
import AIAddressStep from './AIAddressStep';
|
|
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">MileTruth</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.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>
|
|
<LuChevronDown 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 variant="secondary" size="sm" onClick={() => onStopLive(message)}>
|
|
Stop
|
|
</Button>
|
|
</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)}
|
|
/>
|
|
)}
|
|
|
|
{/* The address turn. Without this branch the step passed the gate in
|
|
AIPanel and nothing drew it — the question appeared with an empty
|
|
space beneath it, which is worse than the free-text version it
|
|
replaced. */}
|
|
{message.flowStep?.type === 'address' && !message.flowStep.__answered && (
|
|
<AIAddressStep
|
|
step={message.flowStep}
|
|
isBusy={message.flowStep.__answered}
|
|
onChoose={(value, option) => onChooseStep(message, value, option)}
|
|
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' ? (
|
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="bg-slate-900 text-white hover:bg-black font-semibold text-xs px-3.5 h-8 rounded-lg shadow-xs cursor-pointer"
|
|
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
|
|
>
|
|
{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'}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border border-slate-200 bg-white hover:bg-slate-50 text-slate-700 font-medium text-xs px-3 h-8 rounded-lg shadow-2xs cursor-pointer"
|
|
onClick={() => onCancelAction(message)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
|
|
{/* A second, wider commit — used when rows were excluded by
|
|
default but including them is a legitimate choice. */}
|
|
{message.form.altDraft && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-slate-600 hover:bg-slate-100 font-medium text-xs px-3 h-8 rounded-lg cursor-pointer"
|
|
onClick={() => onSubmitForm(message, message.form.altDraft)}
|
|
>
|
|
{message.form.altLabel || 'Include everything'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<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
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex items-center justify-center gap-2 text-xs font-medium text-slate-700 bg-white border border-slate-200 hover:bg-slate-50 hover:text-slate-900 rounded-lg px-3.5 py-2 w-full shadow-2xs transition-colors cursor-pointer"
|
|
onClick={() => downloadCsv('doormile-bulk-orders-unsendable.csv', failedRowsCsv(message.downloadRows))}
|
|
>
|
|
<LuDownload className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
<span>
|
|
Download {message.downloadRows.length} unsendable row{message.downloadRows.length === 1 ? '' : 's'} (CSV)
|
|
</span>
|
|
</Button>
|
|
</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);
|