Files
doormilxpress_astryx/src/components/assistant/DoormileAI/AIMessage.jsx

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);