import { Show, createSignal, onMount, onCleanup } from 'solid-js'; import { defineWebComponent } from './define'; import { readSlots, MESSAGE_SLOTS } from './slots'; import { ChatConfig, useChatConfig, type ProseSize } from '../primitives/chat-config'; import { Message, MessageAvatar, MessageBody } from '../components/message'; import { createMessageFeedback } from '../primitives/message-feedback'; import type { ChatMessage } from './chat-types'; interface Props extends Record { /** The full message object. Set as a JS property. */ message?: ChatMessage; /** Convenience for simple cases when not passing a `message` object. */ role?: 'user' | 'assistant'; /** Convenience content (used when `message` is not set). */ content?: string; /** Force markdown on/off. Defaults to on for assistant, off for user. */ markdown?: boolean; /** Text/markdown sizing for the message body. */ proseSize?: ProseSize; /** Shiki theme name used for fenced code blocks in the content. */ codeTheme?: string; /** Disable syntax highlighting for code blocks (no Shiki loads). */ codeHighlight?: boolean; /** Whether the action bar is always visible (`'always'`, default) or only * revealed on hover of the message row (`'hover'`). */ actionsReveal?: 'always' | 'hover'; /** Convenience avatar image URL (used when `message.avatar` is not set). */ avatarSrc?: string; /** Convenience avatar fallback text (used when `message.avatar` is not set). */ avatarFallback?: string; /** Avatar rail mode. `'none'` omits the avatar rail entirely so the body spans * the full row (predictable layout when you never show avatars). Any other * value keeps the default behaviour: the built-in avatar when one resolves, or * your `slot="avatar"` content when projected (which REPLACES the built-in). */ avatar?: 'none' | string; } /** Events fired by ``. */ interface Events { /** An action button was clicked. `action` is the built-in name or custom id. * `state` is present only for the toggleable feedback votes: `'on'` when a * like/dislike is set, `'off'` when re-tapped to clear. */ 'kai-message-action': { messageId: string; action: string; state?: 'on' | 'off' }; } /** * `` — a single message row: markdown/plain content, reasoning, * tool calls, attachments, and action buttons, rendered from one `message` * object (the same shape `` uses per message). The keystone of the * "compose your own message list" pattern. Emits `kai-message-action`. */ defineWebComponent('kai-message', { message: undefined, role: 'assistant', content: undefined, markdown: undefined, proseSize: 'sm', codeTheme: 'github-dark-dimmed', codeHighlight: true, actionsReveal: 'always', avatarSrc: undefined, avatarFallback: undefined, avatar: undefined, }, (props, { dispatch, flag, element, expose }) => { const outer = useChatConfig(); const msg = (): ChatMessage => props.message ?? { id: 'message', role: props.role ?? 'assistant', content: props.content ?? '' }; // Copy + vote state lives here (above the rendered body) so it survives a // re-render when the host swaps in a fresh `message` object during streaming. const feedback = createMessageFeedback({ emit: (detail) => dispatch('kai-message-action', detail), // No `target`: a STANDALONE must not anchor its copy/feedback // toast to the individual message (that lands mid-thread in a compose-your-own // list). Let them route to the global region (top of the viewport), which is // where a confirmation belongs across a hand-built thread. (Inside // the chat owns feedback and targets ITSELF, so in-chat toasts still stay in-chat.) }); // Imperative method API — `copy()` runs the exact path of the built-in copy // action (`feedback.handleAction(msg, 'copy')`): writes the content to the // clipboard, shows the transient copied-check on the bar, and emits // `kai-message-action{action:'copy'}`. expose({ /** Copy the message content to the clipboard and show the copied check. */ copy: () => feedback.handleAction(msg(), 'copy'), }); // Read declarative children from light DOM. // Shadow DOM with no suppresses them visually — they're invisible data carriers. const [slottedActions, setSlottedActions] = createSignal([]); // Which composition slots (before-body / after-body / avatar) the consumer filled. const [slots, setSlots] = createSignal>({}); onMount(() => { const read = () => { const nodes = [...element.querySelectorAll('kai-action')]; setSlottedActions(nodes.map(n => ({ id: n.id || n.getAttribute('action') || '', label: n.textContent?.trim() || n.getAttribute('label') || n.id || '', icon: n.getAttribute('icon') ?? undefined, tooltip: n.getAttribute('tooltip') ?? undefined, }))); setSlots(readSlots(element, MESSAGE_SLOTS)); }; read(); const observer = new MutationObserver(read); observer.observe(element, { childList: true, attributes: true, subtree: true }); onCleanup(() => observer.disconnect()); }); const isUser = () => msg().role === 'user'; const avatar = () => msg().avatar ?? (props.avatarSrc || props.avatarFallback ? { src: props.avatarSrc as string | undefined, fallback: props.avatarFallback as string | undefined } : undefined); // Avatar rail state. `avatar="none"` omits the rail; a filled `slot="avatar"` // REPLACES the built-in; otherwise the built-in renders when one resolves. const noAvatar = () => props.avatar === 'none'; const hasAvatarSlot = () => !!slots()['avatar']; // The rail shows when not suppressed and there's either a slot or a resolved avatar. const showRail = () => !noAvatar() && (hasAvatarSlot() || !!avatar()); const reveal = () => (props.actionsReveal === 'hover' ? 'hover' : 'always'); // markdown: explicit prop/attribute wins; otherwise default by role. const markdownExplicit = () => element.hasAttribute('markdown') || props.markdown === true || props.markdown === false; const useMarkdown = () => (markdownExplicit() ? flag('markdown') : !isUser()); const mergedActions = () => [...(msg().actions ?? []), ...slottedActions()]; const body = () => ( feedback.handleAction(msg(), action)} beforeBody={slots()['before-body'] ? : undefined} afterBody={slots()['after-body'] ? : undefined} /> ); // Row carries `group` so a hover-revealed action bar fades in on row hover. const rowGroup = () => (reveal() === 'hover' ? 'group ' : ''); // The avatar rail: a filled `slot="avatar"` REPLACES the built-in MessageAvatar // (the consumer owns that node, wrapped in `part="avatar"` so it's still // styleable + consistent with the built-in part name). const avatarRail = () => ( {(av) => } } >
); return ( {body()} } > {avatarRail()}
{body()}
); });