Standalone chat UI built with vanilla Web Components (Custom Elements + Shadow DOM) that connects to the MLX inference server's SSE streaming endpoint. Zero framework dependencies, single JS bundle output. Components: - <lem-chat>: Container with SSE client, config via attributes - <lem-messages>: Scrollable message list with auto-scroll - <lem-message>: Single message bubble with streaming + <think> tag support - <lem-input>: Textarea with Enter to send, Shift+Enter for newline Build: esbuild src/lem-chat.ts → dist/lem-chat.js (15KB ESM) Replaces the monolithic chat.js in core/go-ml. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
import { inputStyles } from './styles';
|
|
import type { LemSendDetail } from './types';
|
|
|
|
export class LemInput extends HTMLElement {
|
|
private shadow!: ShadowRoot;
|
|
private textarea!: HTMLTextAreaElement;
|
|
private sendBtn!: HTMLButtonElement;
|
|
private _disabled = false;
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback(): void {
|
|
const style = document.createElement('style');
|
|
style.textContent = inputStyles;
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'input-wrapper';
|
|
|
|
this.textarea = document.createElement('textarea');
|
|
this.textarea.rows = 1;
|
|
this.textarea.placeholder = 'Message LEM...';
|
|
|
|
this.sendBtn = document.createElement('button');
|
|
this.sendBtn.className = 'send-btn';
|
|
this.sendBtn.type = 'button';
|
|
this.sendBtn.disabled = true;
|
|
this.sendBtn.appendChild(this.createSendIcon());
|
|
|
|
wrapper.appendChild(this.textarea);
|
|
wrapper.appendChild(this.sendBtn);
|
|
this.shadow.appendChild(style);
|
|
this.shadow.appendChild(wrapper);
|
|
|
|
this.textarea.addEventListener('input', () => {
|
|
this.textarea.style.height = 'auto';
|
|
this.textarea.style.height =
|
|
Math.min(this.textarea.scrollHeight, 120) + 'px';
|
|
this.sendBtn.disabled =
|
|
this._disabled || this.textarea.value.trim() === '';
|
|
});
|
|
|
|
this.textarea.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
this.submit();
|
|
}
|
|
});
|
|
|
|
this.sendBtn.addEventListener('click', () => this.submit());
|
|
}
|
|
|
|
private createSendIcon(): SVGSVGElement {
|
|
const ns = 'http://www.w3.org/2000/svg';
|
|
const svg = document.createElementNS(ns, 'svg');
|
|
svg.setAttribute('viewBox', '0 0 24 24');
|
|
svg.setAttribute('fill', 'none');
|
|
svg.setAttribute('stroke', 'currentColor');
|
|
svg.setAttribute('stroke-width', '2');
|
|
svg.setAttribute('stroke-linecap', 'round');
|
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
svg.setAttribute('width', '16');
|
|
svg.setAttribute('height', '16');
|
|
const line = document.createElementNS(ns, 'line');
|
|
line.setAttribute('x1', '22');
|
|
line.setAttribute('y1', '2');
|
|
line.setAttribute('x2', '11');
|
|
line.setAttribute('y2', '13');
|
|
const polygon = document.createElementNS(ns, 'polygon');
|
|
polygon.setAttribute('points', '22 2 15 22 11 13 2 9 22 2');
|
|
svg.appendChild(line);
|
|
svg.appendChild(polygon);
|
|
return svg;
|
|
}
|
|
|
|
private submit(): void {
|
|
const text = this.textarea.value.trim();
|
|
if (!text || this._disabled) return;
|
|
this.dispatchEvent(
|
|
new CustomEvent<LemSendDetail>('lem-send', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: { text },
|
|
})
|
|
);
|
|
this.textarea.value = '';
|
|
this.textarea.style.height = 'auto';
|
|
this.sendBtn.disabled = true;
|
|
this.textarea.focus();
|
|
}
|
|
|
|
get disabled(): boolean {
|
|
return this._disabled;
|
|
}
|
|
|
|
set disabled(value: boolean) {
|
|
this._disabled = value;
|
|
this.textarea.disabled = value;
|
|
this.sendBtn.disabled = value || this.textarea.value.trim() === '';
|
|
this.textarea.placeholder = value ? 'LEM is thinking...' : 'Message LEM...';
|
|
}
|
|
|
|
override focus(): void {
|
|
this.textarea?.focus();
|
|
}
|
|
}
|
|
|
|
customElements.define('lem-input', LemInput);
|