Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"react-dom": "^19.0.0",
"react-markdown": "^9.0.1",
"react-router": "^7.0.0",
"remark-gfm": "^4.0.0"
"remark-gfm": "^4.0.0",
"zxing-wasm": "^3.1.2"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/aros-shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@
}
.aros-chip:hover { border-color: var(--accent); color: var(--ink); }
.aros-chip__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.aros-attach-error { font-size: 12.5px; color: #b45309; margin-bottom: 8px; line-height: 1.45; }
.aros-attach-busy { font-size: 12.5px; color: var(--ink-2); margin-bottom: 8px; }
@keyframes spin { to { transform: rotate(360deg); } }
.aros-inputrow {
display: flex; align-items: center; gap: 10px;
border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface);
Expand Down
147 changes: 122 additions & 25 deletions apps/web/src/aros-ai/ArosChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { useCanvas } from './CanvasContext';
import { itemsFromMessages } from './canvas';
import { chatReplyText } from '../lib/chatReply';
import { useVoice, cancelSpeech, type VoiceApi } from './voice';
import { AttachSheet } from '../redesign/attach/AttachSheet';
import { AttachmentThumbs } from '../redesign/attach/AttachmentThumbs';
import { CatalogNotice } from '../redesign/attach/CatalogNotice';
import { type Attachment, type AttachError, type CatalogState, attachError, toWire, barcodeLookupQuery, barcodeOutcome } from '../redesign/attach/attachments';
import { useConnectionSummary } from '../redesign/data';

// ---------------------------------------------------------------------------
// Persistence
Expand All @@ -14,7 +19,7 @@ import { useVoice, cancelSpeech, type VoiceApi } from './voice';
const STORAGE_KEY = 'aros-chat-messages';
const MAX_STORED = 50;

interface Message { role: 'user' | 'agent'; content: string; timestamp: number; }
interface Message { role: 'user' | 'agent'; content: string; timestamp: number; attachments?: Attachment[]; catalog?: CatalogState; upc?: string; }

function loadMessages(greeting: string): Message[] {
try {
Expand All @@ -25,7 +30,11 @@ function loadMessages(greeting: string): Message[] {
}

function persistMessages(msgs: Message[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(msgs.slice(-MAX_STORED))); } catch {}
// Drop heavy base64 dataUrls before persisting — they blow the localStorage
// quota. Durable attachment history is Shared S's job (shre-files + message
// ref); until then a reloaded transcript shows a lightweight file chip.
const light = msgs.slice(-MAX_STORED).map((m) => (m.attachments ? { ...m, attachments: m.attachments.map((a) => ({ ...a, dataUrl: '' })) } : m));
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(light)); } catch {}
}

// ---------------------------------------------------------------------------
Expand All @@ -41,10 +50,14 @@ export function ArosChat() {
const { config } = useWhitelabel();
const c = useChatTheme();
const canvas = useCanvas();
const connections = useConnectionSummary();

const greeting = config.agent.greeting ?? 'What do you need?';
const [messages, setMessages] = useState<Message[]>(() => loadMessages(greeting));
const [input, setInput] = useState('');
const [pending, setPending] = useState<Attachment[]>([]);
const [attachErrors, setAttachErrors] = useState<AttachError[]>([]);
const [attachBusy, setAttachBusy] = useState(false);
const [open, setOpen] = useState(false);
const [sending, setSending] = useState(false);
// Voice-conversation mode: hands-free (each spoken utterance auto-sends) + replies read aloud.
Expand Down Expand Up @@ -107,40 +120,90 @@ export function ArosChat() {
useEffect(() => { voiceConvoRef.current = voiceConvo; }, [voiceConvo]);
useEffect(() => { openRef.current = open; }, [open]);

const sendMessage = async (text: string): Promise<boolean> => {
if (!text.trim() || sendingRef.current) return false;
const sendMessage = async (text: string, atts: Attachment[] = pending, opts: { barcodeUpc?: string } = {}): Promise<boolean> => {
if ((!text.trim() && atts.length === 0) || sendingRef.current) return false;
// A send fired while a file is still encoding drops the attachment.
if (attachBusy) return false;
sendingRef.current = true;
const userMsg: Message = { role: 'user', content: text.trim(), timestamp: Date.now() };
const hasAttachments = atts.length > 0;
const userMsg: Message = { role: 'user', content: text.trim(), timestamp: Date.now(), ...(hasAttachments ? { attachments: atts } : {}) };
setSending(true);
setMessages((prev) => [...prev, userMsg]);
// Draft safety: cleared optimistically, restored in full on every failure
// path below. A dropped attachment means re-photographing the invoice.
setInput('');
setPending([]);
setAttachErrors([]);
const restoreDraft = () => {
setMessages((prev) => prev.filter((m) => m !== userMsg));
setInput((current) => current || text.trim());
setPending((current) => (current.length ? current : atts));
};

try {
const res = await fetch(`${ROUTER_URL}/v1/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'aros-agent',
messages: [{ role: 'user', content: text.trim() }],
messages: [{ role: 'user', content: text.trim() || 'Please review the attached file(s).' }],
...(hasAttachments ? { attachments: atts.map(toWire) } : {}),
stream: false,
}),
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const reply = chatReplyText(data);
setMessages((prev) => [...prev, { role: 'agent', content: reply, timestamp: Date.now() }]);
const catalog: CatalogState | undefined = opts.barcodeUpc
? barcodeOutcome({ connected: connections.total > 0, transportOk: true, replyText: reply })
: undefined;
setMessages((prev) => [...prev, { role: 'agent', content: reply, timestamp: Date.now(), ...(catalog ? { catalog, upc: opts.barcodeUpc } : {}) }]);
// speak only if voice-conversation is still on and the panel is still open (checked live)
if (voiceConvoRef.current && openRef.current) voiceRef.current?.speak(reply);
} catch {
setMessages((prev) => [...prev, { role: 'agent', content: 'Something went wrong. Please try again.', timestamp: Date.now() }]);
if (opts.barcodeUpc) {
setMessages((prev) => [...prev, { role: 'agent', content: 'I couldn’t reach your catalog to look that barcode up.', timestamp: Date.now(), catalog: 'catalog-unreachable', upc: opts.barcodeUpc }]);
sendingRef.current = false;
setSending(false);
return false;
}
restoreDraft();
// Honest failure: never describe an attachment we couldn't actually read.
setAttachErrors([attachError(hasAttachments
? 'I couldn’t read that attachment right now. I won’t guess what it says. Your message and files are back in the box — press Send to try again.'
: 'Something went wrong. Your message is back in the box — press Send to try again.')]);
return false;
} finally {
sendingRef.current = false;
setSending(false);
}
return true;
};

// Barcode → catalog. Without a connected store there is nothing to look the
// code up IN, so the honest not-connected state is shown instead of sending a
// query whose only possible answer would be guesswork.
const onBarcode = (upc: string) => {
if (connections.total === 0) {
setMessages((prev) => [...prev, { role: 'agent', content: '', timestamp: Date.now(), catalog: 'not-connected', upc }]);
return;
}
void sendMessage(barcodeLookupQuery(upc), [], { barcodeUpc: upc });
};

const onCatalogAction = (state: Exclude<CatalogState, 'found'>, upc?: string) => {
if (state === 'not-connected') { window.location.href = '/connectors'; return; }
if (state === 'catalog-unreachable') { if (upc) void sendMessage(barcodeLookupQuery(upc), [], { barcodeUpc: upc }); return; }
setInput(`Add UPC ${upc || ''} to my catalog.`.replace(/\s+/g, ' ').trim());
inputRef.current?.focus();
};

const removeAttachment = (id: string) => {
setPending((prev) => prev.filter((a) => a.id !== id));
setAttachErrors([]);
};

const voice = useVoice({
handsFree: voiceConvo,
getInput: () => input,
Expand Down Expand Up @@ -170,6 +233,9 @@ export function ArosChat() {

if (!config.features?.agentChat) return null;

// Blocked while encoding too — otherwise Send fires before the attachment is
// in state and the file is dropped from the turn.
const sendBlocked = (!input.trim() && pending.length === 0) || sending || attachBusy;
const agentName = config.agent.name;
const font = '-apple-system, "SF Pro Text", "SF Pro Display", BlinkMacSystemFont, "Helvetica Neue", "Inter", system-ui, sans-serif';

Expand Down Expand Up @@ -314,20 +380,28 @@ export function ArosChat() {
</svg>
</div>
)}
<div style={{
maxWidth: '80%', borderRadius: 16, padding: '10px 16px', fontSize: 13, lineHeight: 1.47,
background: isUser ? c.msgUser : c.msgAi, color: c.text1,
border: `1px solid ${isUser ? c.accentSoft : c.border2}`,
}}>
{isUser ? (
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{msg.content}</span>
) : (
<ChatMessageRenderer
content={msg.content}
palette={c}
onOpenWidget={(widgetIndex) => openWidgetOnCanvas(i, widgetIndex)}
/>
<div style={{ maxWidth: '80%' }}>
{(msg.content || (msg.attachments && msg.attachments.length > 0)) && (
<div style={{
borderRadius: 16, padding: '10px 16px', fontSize: 13, lineHeight: 1.47,
background: isUser ? c.msgUser : c.msgAi, color: c.text1,
border: `1px solid ${isUser ? c.accentSoft : c.border2}`,
}}>
{isUser ? (
<>
{msg.attachments && msg.attachments.length > 0 && <AttachmentThumbs attachments={msg.attachments} size={48} />}
{msg.content && <span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{msg.content}</span>}
</>
) : (
<ChatMessageRenderer
content={msg.content}
palette={c}
onOpenWidget={(widgetIndex) => openWidgetOnCanvas(i, widgetIndex)}
/>
)}
</div>
)}
{msg.catalog && <CatalogNotice state={msg.catalog} upc={msg.upc} onAction={(state) => onCatalogAction(state, msg.upc)} />}
</div>
{isUser && (
<div style={{ width: 24, height: 24, borderRadius: '50%', flexShrink: 0, marginTop: 2, background: c.bgInput, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
Expand Down Expand Up @@ -359,6 +433,17 @@ export function ArosChat() {
</div>

{/* Input */}
{(pending.length > 0 || attachErrors.length > 0 || attachBusy) && (
<div style={{ padding: '8px 16px 0', flexShrink: 0, background: c.bg2 }}>
{pending.length > 0 && <AttachmentThumbs attachments={pending} size={48} onRemove={removeAttachment} />}
{attachBusy && <div role="status" aria-live="polite" style={{ fontSize: 12, color: c.text3, padding: '4px 0' }}>Reading your file…</div>}
{attachErrors.length > 0 && (
<div role="alert">
{attachErrors.map((err) => <div key={err.id} style={{ fontSize: 12, color: '#b45309', padding: '4px 0', lineHeight: 1.45 }}>{err.text}</div>)}
</div>
)}
</div>
)}
<form
onSubmit={send}
style={{
Expand All @@ -367,6 +452,18 @@ export function ArosChat() {
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<AttachSheet
existing={pending}
onAttach={(a) => { setAttachErrors([]); setPending((prev) => [...prev, ...a]); }}
onBarcode={onBarcode}
onError={(msgs) => setAttachErrors(msgs.map(attachError))}
onBusyChange={setAttachBusy}
disabled={sending}
accent={c.accent}
// This composer ships a WORKING mic two buttons away; a "Voice ·
// coming soon" row beside it contradicts what the user can see.
voiceRow={voice.supported ? 'hidden' : 'coming-soon'}
/>
{voice.supported && (
<button
type="button"
Expand Down Expand Up @@ -417,13 +514,13 @@ export function ArosChat() {
</div>
<button
type="submit"
disabled={!input.trim() || sending}
disabled={sendBlocked}
style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: (!input.trim() || sending) ? c.bgInput : c.accent,
color: (!input.trim() || sending) ? c.text3 : '#fff',
border: 'none', cursor: (!input.trim() || sending) ? 'not-allowed' : 'pointer',
background: sendBlocked ? c.bgInput : c.accent,
color: sendBlocked ? c.text3 : '#fff',
border: 'none', cursor: sendBlocked ? 'not-allowed' : 'pointer',
transition: 'background 150ms',
}}
>
Expand Down
Loading