// ========================================================================== // Chat stream atoms: user message, AI message, skill router pills, file drops // ========================================================================== function UserMsg({ children }) { return (
{children}
); } function AIMsg({ children, streaming, small }) { return (
{children} {streaming && }
); } // ==== Skill router pill ==== // stages: "pending" | "thinking" | "done" // // Two skills are visually distinct ("moat"): consulting-brand, consulting-data. // They are rendered with a filled background because they represent Inku's // defensibility — retrieval across the customer's brand corpus and their // performance data. Every other skill is a prompt-shaped generator. // See api.js chat() MOAT comment block for where this is wired on the backend. const SKILL_LABELS = { 'consulting-brand': 'Consulting brand', 'consulting-data': 'Consulting data', 'document-intelligence': 'Document intelligence', 'customer-research': 'Customer research', 'marketing-psychology': 'Marketing psychology', 'positioning': 'Positioning', 'messaging-architecture': 'Messaging architecture', 'ad-creative': 'Ad creative', 'launch-strategy': 'Launch strategy', 'social-content': 'Social content', 'variant-generation': 'Variant generation', 'performance-hypothesis': 'Performance hypothesis', 'fatigue-detection': 'Fatigue detection', 'brand-context': 'Brand context', }; const MOAT_SKILLS = new Set(['consulting-brand', 'consulting-data']); function SkillPill({ skill, stage }) { const id = skill.id || skill.label; const label = SKILL_LABELS[id] || skill.label || id; const isMoat = MOAT_SKILLS.has(id); // Moat pills lean warmer (accent-warm) when active, and always show filled. const bg = stage === 'thinking' ? (isMoat ? 'var(--accent-warm)' : 'var(--moss-glow)') : stage === 'done' ? (isMoat ? 'var(--moss-deep)' : 'var(--bg-2)') : (isMoat ? 'var(--card-solid)' : 'transparent'); const color = stage === 'thinking' ? (isMoat ? '#fff' : 'var(--moss-deep)') : stage === 'done' ? (isMoat ? 'var(--bg)' : 'var(--ink)') : (isMoat ? 'var(--accent-warm-2)' : 'var(--ink-mute)'); const border = isMoat ? `1px solid ${stage === 'done' ? 'var(--moss-deep)' : 'var(--accent-warm)'}` : '1px solid var(--line)'; return (
{isMoat ? : stage === 'thinking' ? : stage === 'done' ? : } {isMoat && (stage === 'thinking') && } {isMoat && (stage === 'done') && } {label}
); } function SkillRouter({ skills, activeIndex }) { // activeIndex = number of completed skills. skills[activeIndex] is "thinking". return (
routing {skills.map((s, i) => ( {i < skills.length - 1 && } ))}
); } // ==== File drop zone (used in composer + flow 1 empty state) ==== function DropZone({ onDrop, children }) { const [over, setOver] = React.useState(false); return (
{ e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={e => { e.preventDefault(); setOver(false); onDrop && onDrop(); }} onClick={() => onDrop && onDrop()} style={{ border: over ? '1.5px dashed var(--moss-deep)' : '1.5px dashed var(--line)', background: over ? 'var(--moss-glow)' : 'transparent', borderRadius: 14, padding: 28, textAlign: 'center', cursor: 'pointer', transition: 'all .2s', }}> {children}
); } // ==== Markdown rendering ==== // Assistant text comes back as markdown (bold, lists, tables, code). marked // + DOMPurify are loaded globally via CDN in index.html. We sanitize with // DOMPurify, then DOMParser turns the HTML string into inert DOM nodes // (script tags are not executed by 'text/html' parsing), which we move // into our ref. No innerHTML. function Markdown({ text }) { const ref = React.useRef(null); React.useEffect(() => { const node = ref.current; if (!node) return; while (node.firstChild) node.removeChild(node.firstChild); if (!text) return; if (typeof window.marked === 'undefined' || typeof window.DOMPurify === 'undefined') { node.textContent = text; return; } const html = window.marked.parse(text, { breaks: true, gfm: true, mangle: false, headerIds: false }); const safe = window.DOMPurify.sanitize(html); const parsed = new DOMParser().parseFromString(safe, 'text/html'); const frag = document.createDocumentFragment(); while (parsed.body.firstChild) frag.appendChild(parsed.body.firstChild); node.appendChild(frag); }, [text]); return
; } Object.assign(window, { UserMsg, AIMsg, SkillPill, SkillRouter, DropZone, Markdown });