@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
160 lines (150 loc) • 6.33 kB
text/typescript
import type { EntityRef, ComposerDoc } from '../primitives/composer-model';
import { normalizeValue } from '../primitives/composer-model';
export const ZWSP = '';
export const ENTITY_ATTR = 'data-kai-entity';
export const entityStore = new WeakMap<HTMLElement, EntityRef>();
export function isEntityEl(node: Node | null): node is HTMLElement {
return !!node && node.nodeType === 1 && (node as HTMLElement).hasAttribute(ENTITY_ATTR);
}
/**
* A TreeWalker over the editable's text nodes that SKIPS text inside entity
* pills. Pills are atomic and contribute nothing to the text model — their inner
* label text must not pollute caret offsets, trigger detection, or highlight
* ranges. (Without this, the text seen at a caret right after a pill is the
* pill's label, so `/` reads as glued to the label instead of starting a token.)
*/
export function createTextWalker(root: HTMLElement): TreeWalker {
return root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
let p = node.parentElement;
while (p && p !== root) {
if (p.hasAttribute(ENTITY_ATTR)) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return NodeFilter.FILTER_ACCEPT;
},
});
}
/**
* The leading sigil shown on a "light" pill, by kind: skills `/`, agents `@`.
* Skills and agents render as decorated inline text led by their sigil; this is
* what makes them read like `/my-skill` / `@my-agent` rather than a chip.
* Returns '' for kinds rendered as a richer CHIP (plugins, and any other/unknown
* kind) — those carry an icon instead of a sigil. The sigil is visual only: it
* never enters the text model (the entity is read from `entityStore`, and the
* text walker skips pill-internal nodes).
*/
export function kindSigil(kind: string): string {
switch (kind) {
case 'skill':
return '/';
case 'agent':
return '@';
default:
return '';
}
}
/**
* Default monochrome (currentColor) glyph per entity kind, for the richer CHIP
* kinds shown in the trigger menu / on plugin pills: plugin = plug. (Skills and
* agents are light sigil-text pills — see `kindSigil` — so they take no glyph on
* the pill, though the menu may still show one.) Returns inline SVG markup
* (trusted — no user input), or '' for kinds without a default. An item's own
* `icon` always takes precedence over this.
*/
export function kindGlyph(kind: string): string {
const svg = (inner: string) =>
`<svg viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
switch (kind) {
case 'agent':
return svg('<rect x="4" y="8" width="16" height="12" rx="3"/><path d="M12 4.5V8M9.5 13.5v1.5M14.5 13.5v1.5"/><circle cx="12" cy="3" r="1.2"/>');
case 'plugin':
return svg('<path d="M9 2v4M15 2v4M6 6h12v5a6 6 0 0 1-12 0zM12 17v5"/>');
default:
return ''; // skills + unknown kinds: no default glyph
}
}
export function createEntityEl(
doc: Document,
entity: EntityRef,
kindIcons?: Record<string, string>,
): HTMLElement {
const el = doc.createElement('span');
el.setAttribute(ENTITY_ATTR, '');
el.setAttribute('contenteditable', 'false');
el.dataset.kind = entity.kind;
el.dataset.id = entity.id;
el.className = 'kai-composer-pill';
const sigil = kindSigil(entity.kind);
if (sigil) {
// Skills/agents: LIGHT pill — decorated inline text led by the sigil, no
// icon. (`/my-skill`, `@my-agent`.)
const s = doc.createElement('span');
s.className = 'kai-composer-pill-sigil';
s.setAttribute('aria-hidden', 'true');
s.textContent = sigil;
el.appendChild(s);
} else {
// Plugins (and other composite/unknown kinds): the richer CHIP, with an
// icon so a bundle reads differently from a skill/agent at a glance. Icon
// resolution: the item's own icon → per-kind default (kindIcons) → a
// built-in kind glyph (plugin) → nothing.
const iconSrc = entity.icon ?? kindIcons?.[entity.kind];
if (iconSrc) {
const img = doc.createElement('img');
img.src = iconSrc;
img.alt = '';
img.className = 'kai-composer-pill-icon';
el.appendChild(img);
} else {
const glyph = kindGlyph(entity.kind);
if (glyph) {
const span = doc.createElement('span');
span.className = 'kai-composer-pill-icon kai-composer-pill-glyph';
span.setAttribute('aria-hidden', 'true');
span.innerHTML = glyph; // trusted SVG markup (not user input)
el.appendChild(span);
}
}
}
el.appendChild(doc.createTextNode(entity.label));
entityStore.set(el, entity);
return el;
}
export function parseDom(root: HTMLElement): ComposerDoc {
const segs: ComposerDoc = [];
const nodes = root.childNodes;
nodes.forEach((node, i) => {
if (isEntityEl(node)) {
const stored = entityStore.get(node as HTMLElement);
const el = node as HTMLElement;
segs.push({ type: 'entity', entity: stored ?? { kind: el.dataset.kind ?? '', id: el.dataset.id ?? '', label: el.textContent ?? '' } });
} else if (node.nodeType === 1 && (node as HTMLElement).tagName === 'BR') {
// Drop the browser's trailing filler <br> — contenteditable inserts one when
// the field is cleared (or after a newline) to keep the line visible. Treating
// it as content would leave the field "non-empty" (placeholder stuck hidden)
// and serialize a phantom trailing "\n". A <br> BETWEEN content is a real
// newline and is preserved.
if (i === nodes.length - 1) return;
segs.push({ type: 'text', text: '\n' });
} else if (node.nodeType === 3) {
segs.push({ type: 'text', text: (node.textContent ?? '').split(ZWSP).join('') });
}
});
return normalizeValue(segs);
}
export function renderDoc(
root: HTMLElement,
doc: ComposerDoc,
ownerDoc: Document = document,
kindIcons?: Record<string, string>,
): void {
root.textContent = '';
for (const seg of doc) {
if (seg.type === 'text') root.appendChild(ownerDoc.createTextNode(seg.text));
else {
root.appendChild(createEntityEl(ownerDoc, seg.entity, kindIcons));
root.appendChild(ownerDoc.createTextNode(ZWSP));
}
}
}