feat: add lem-chat TypeScript web components

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>
This commit is contained in:
Claude 2026-02-24 18:23:59 +00:00
parent c8b32cc1a1
commit e6cd676278
No known key found for this signature in database
GPG key ID: AF404715446AEB41
12 changed files with 1545 additions and 0 deletions

1
lem-chat/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

34
lem-chat/index.html Normal file
View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LEM Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; background: #111; }
body {
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
}
lem-chat {
width: 720px;
height: 85vh;
max-height: 800px;
}
@media (max-width: 768px) {
lem-chat { width: 100%; height: 100%; max-height: none; border-radius: 0; }
}
</style>
</head>
<body>
<lem-chat
endpoint="http://localhost:8090"
model="local"
max-tokens="2048"
></lem-chat>
<script type="module" src="dist/lem-chat.js"></script>
</body>
</html>

515
lem-chat/package-lock.json generated Normal file
View file

@ -0,0 +1,515 @@
{
"name": "lem-chat",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lem-chat",
"version": "0.1.0",
"license": "EUPL-1.2",
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.7.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

16
lem-chat/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "lem-chat",
"version": "0.1.0",
"private": true,
"license": "EUPL-1.2",
"scripts": {
"build": "esbuild src/lem-chat.ts --bundle --format=esm --outfile=dist/lem-chat.js",
"watch": "esbuild src/lem-chat.ts --bundle --format=esm --outfile=dist/lem-chat.js --watch",
"dev": "esbuild src/lem-chat.ts --bundle --format=esm --outfile=dist/lem-chat.js --watch --servedir=.",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.7.0"
}
}

195
lem-chat/src/lem-chat.ts Normal file
View file

@ -0,0 +1,195 @@
import { chatStyles } from './styles';
import type { ChatMessage, ChatCompletionChunk, LemSendDetail } from './types';
import { LemMessages } from './lem-messages';
import { LemInput } from './lem-input';
import './lem-message';
export class LemChat extends HTMLElement {
private shadow!: ShadowRoot;
private messages!: LemMessages;
private input!: LemInput;
private statusEl!: HTMLDivElement;
private history: ChatMessage[] = [];
private abortController: AbortController | null = null;
static get observedAttributes(): string[] {
return ['endpoint', 'model', 'system-prompt', 'max-tokens', 'temperature'];
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback(): void {
const style = document.createElement('style');
style.textContent = chatStyles;
const header = document.createElement('div');
header.className = 'header';
this.statusEl = document.createElement('div');
this.statusEl.className = 'header-status';
const icon = document.createElement('div');
icon.className = 'header-icon';
icon.textContent = 'L';
const title = document.createElement('div');
title.className = 'header-title';
title.textContent = 'LEM';
const modelLabel = document.createElement('div');
modelLabel.className = 'header-model';
modelLabel.textContent = this.getAttribute('model') || 'local';
header.appendChild(this.statusEl);
header.appendChild(icon);
header.appendChild(title);
header.appendChild(modelLabel);
this.messages = document.createElement('lem-messages') as LemMessages;
this.input = document.createElement('lem-input') as LemInput;
this.shadow.appendChild(style);
this.shadow.appendChild(header);
this.shadow.appendChild(this.messages);
this.shadow.appendChild(this.input);
this.addEventListener('lem-send', ((e: Event) => {
const detail = (e as CustomEvent<LemSendDetail>).detail;
this.handleSend(detail.text);
}) as EventListener);
const systemPrompt = this.getAttribute('system-prompt');
if (systemPrompt) {
this.history.push({ role: 'system', content: systemPrompt });
}
this.checkConnection();
requestAnimationFrame(() => this.input.focus());
}
disconnectedCallback(): void {
this.abortController?.abort();
}
get endpoint(): string {
const attr = this.getAttribute('endpoint');
if (!attr) return window.location.origin;
return attr;
}
get model(): string {
return this.getAttribute('model') || '';
}
get maxTokens(): number {
const val = this.getAttribute('max-tokens');
return val ? parseInt(val, 10) : 2048;
}
get temperature(): number {
const val = this.getAttribute('temperature');
return val ? parseFloat(val) : 0.7;
}
private async checkConnection(): Promise<void> {
try {
const resp = await fetch(`${this.endpoint}/v1/models`, {
signal: AbortSignal.timeout(3000),
});
this.statusEl.classList.toggle('disconnected', !resp.ok);
} catch {
this.statusEl.classList.add('disconnected');
}
}
private async handleSend(text: string): Promise<void> {
this.messages.addMessage('user', text);
this.history.push({ role: 'user', content: text });
const assistantMsg = this.messages.addMessage('assistant');
assistantMsg.streaming = true;
this.input.disabled = true;
this.abortController?.abort();
this.abortController = new AbortController();
let fullResponse = '';
try {
const response = await fetch(`${this.endpoint}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: this.abortController.signal,
body: JSON.stringify({
model: this.model,
messages: this.history,
max_tokens: this.maxTokens,
temperature: this.temperature,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
if (!response.body) {
throw new Error('No response body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const chunk: ChatCompletionChunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta;
if (delta?.content) {
fullResponse += delta.content;
assistantMsg.appendToken(delta.content);
this.messages.scrollToBottom();
}
} catch {
// skip malformed chunks
}
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// user-initiated abort — ignore
} else {
const errorText =
err instanceof Error ? err.message : 'Connection failed';
if (!fullResponse) {
assistantMsg.text = `\u26A0\uFE0F ${errorText}`;
}
this.statusEl.classList.add('disconnected');
}
} finally {
assistantMsg.streaming = false;
this.input.disabled = false;
this.input.focus();
this.abortController = null;
if (fullResponse) {
this.history.push({ role: 'assistant', content: fullResponse });
}
}
}
}
customElements.define('lem-chat', LemChat);

110
lem-chat/src/lem-input.ts Normal file
View file

@ -0,0 +1,110 @@
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);

154
lem-chat/src/lem-message.ts Normal file
View file

@ -0,0 +1,154 @@
import { messageStyles } from './styles';
import { renderMarkdown } from './markdown';
interface ThinkSplit {
think: string | null;
response: string;
}
export class LemMessage extends HTMLElement {
private shadow!: ShadowRoot;
private thinkPanel!: HTMLDivElement;
private thinkContent!: HTMLDivElement;
private thinkLabel!: HTMLDivElement;
private contentEl!: HTMLDivElement;
private cursorEl: HTMLSpanElement | null = null;
private _text = '';
private _streaming = false;
private _thinkCollapsed = false;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback(): void {
const role = this.getAttribute('role') || 'user';
const style = document.createElement('style');
style.textContent = messageStyles;
const bubble = document.createElement('div');
bubble.className = 'bubble';
const roleEl = document.createElement('div');
roleEl.className = 'role';
roleEl.textContent = role === 'assistant' ? 'LEM' : 'You';
this.thinkPanel = document.createElement('div');
this.thinkPanel.className = 'think-panel';
this.thinkPanel.style.display = 'none';
this.thinkLabel = document.createElement('div');
this.thinkLabel.className = 'think-label';
this.thinkLabel.textContent = '\u25BC reasoning';
this.thinkLabel.addEventListener('click', () => {
this._thinkCollapsed = !this._thinkCollapsed;
this.thinkPanel.classList.toggle('collapsed', this._thinkCollapsed);
this.thinkLabel.textContent = this._thinkCollapsed
? '\u25B6 reasoning'
: '\u25BC reasoning';
});
this.thinkContent = document.createElement('div');
this.thinkContent.className = 'think-content';
this.thinkPanel.appendChild(this.thinkLabel);
this.thinkPanel.appendChild(this.thinkContent);
this.contentEl = document.createElement('div');
this.contentEl.className = 'content';
bubble.appendChild(roleEl);
if (role === 'assistant') {
bubble.appendChild(this.thinkPanel);
}
bubble.appendChild(this.contentEl);
this.shadow.appendChild(style);
this.shadow.appendChild(bubble);
if (this._text) {
this.updateContent();
}
}
get text(): string {
return this._text;
}
set text(value: string) {
this._text = value;
this.updateContent();
}
get streaming(): boolean {
return this._streaming;
}
set streaming(value: boolean) {
this._streaming = value;
this.updateContent();
}
appendToken(token: string): void {
this._text += token;
this.updateContent();
}
/**
* Splits text into think/response portions and renders each.
*
* Safety: renderMarkdown() escapes all HTML entities (& < > ") before any
* inline formatting is applied. The source is the local MLX model output,
* not arbitrary user HTML. Shadow DOM provides additional isolation.
*/
private updateContent(): void {
if (!this.contentEl) return;
const { think, response } = this.splitThink(this._text);
if (think !== null && this.thinkPanel) {
this.thinkPanel.style.display = '';
this.thinkContent.textContent = think;
}
// renderMarkdown() escapes all HTML before formatting — safe for innerHTML
// within Shadow DOM isolation, sourced from local MLX model only
const responseHtml = renderMarkdown(response);
this.contentEl.innerHTML = responseHtml;
if (this._streaming) {
if (!this.cursorEl) {
this.cursorEl = document.createElement('span');
this.cursorEl.className = 'cursor';
}
if (think !== null && !this._text.includes('</think>')) {
this.thinkContent.appendChild(this.cursorEl);
} else {
const lastChild = this.contentEl.lastElementChild || this.contentEl;
lastChild.appendChild(this.cursorEl);
}
}
}
private splitThink(text: string): ThinkSplit {
const thinkStart = text.indexOf('<think>');
if (thinkStart === -1) {
return { think: null, response: text };
}
const afterOpen = thinkStart + '<think>'.length;
const thinkEnd = text.indexOf('</think>', afterOpen);
if (thinkEnd === -1) {
return {
think: text.slice(afterOpen).trim(),
response: text.slice(0, thinkStart).trim(),
};
}
const thinkText = text.slice(afterOpen, thinkEnd).trim();
const beforeThink = text.slice(0, thinkStart).trim();
const afterThink = text.slice(thinkEnd + '</think>'.length).trim();
const response = [beforeThink, afterThink].filter(Boolean).join('\n');
return { think: thinkText, response };
}
}
customElements.define('lem-message', LemMessage);

View file

@ -0,0 +1,70 @@
import { messagesStyles } from './styles';
import type { LemMessage } from './lem-message';
export class LemMessages extends HTMLElement {
private shadow!: ShadowRoot;
private container!: HTMLDivElement;
private emptyEl!: HTMLDivElement;
private shouldAutoScroll = true;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback(): void {
const style = document.createElement('style');
style.textContent = messagesStyles;
this.container = document.createElement('div');
this.emptyEl = document.createElement('div');
this.emptyEl.className = 'empty';
const emptyIcon = document.createElement('div');
emptyIcon.className = 'empty-icon';
emptyIcon.textContent = '\u2728';
const emptyText = document.createElement('div');
emptyText.className = 'empty-text';
emptyText.textContent = 'Start a conversation';
this.emptyEl.appendChild(emptyIcon);
this.emptyEl.appendChild(emptyText);
this.shadow.appendChild(style);
this.shadow.appendChild(this.emptyEl);
this.shadow.appendChild(this.container);
this.addEventListener('scroll', () => {
const threshold = 60;
this.shouldAutoScroll =
this.scrollHeight - this.scrollTop - this.clientHeight < threshold;
});
}
addMessage(role: string, text?: string): LemMessage {
this.emptyEl.style.display = 'none';
const msg = document.createElement('lem-message') as LemMessage;
msg.setAttribute('role', role);
this.container.appendChild(msg);
if (text) {
msg.text = text;
}
this.scrollToBottom();
return msg;
}
scrollToBottom(): void {
if (this.shouldAutoScroll) {
requestAnimationFrame(() => {
this.scrollTop = this.scrollHeight;
});
}
}
clear(): void {
this.container.replaceChildren();
this.emptyEl.style.display = '';
this.shouldAutoScroll = true;
}
}
customElements.define('lem-messages', LemMessages);

80
lem-chat/src/markdown.ts Normal file
View file

@ -0,0 +1,80 @@
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function parseInline(text: string): string {
let result = escapeHtml(text);
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
result = result.replace(/__(.+?)__/g, '<strong>$1</strong>');
result = result.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, '<em>$1</em>');
result = result.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<em>$1</em>');
return result;
}
function wrapParagraph(lines: string[]): string {
const joined = lines.join('<br>');
if (joined.startsWith('<pre')) return joined;
return `<p>${joined}</p>`;
}
export function renderMarkdown(text: string): string {
const lines = text.split('\n');
const output: string[] = [];
let inCodeBlock = false;
let codeLines: string[] = [];
let codeLang = '';
for (const line of lines) {
if (line.trimStart().startsWith('```')) {
if (!inCodeBlock) {
inCodeBlock = true;
codeLang = line.trimStart().slice(3).trim();
codeLines = [];
} else {
const langAttr = codeLang ? ` data-lang="${escapeHtml(codeLang)}"` : '';
output.push(`<pre${langAttr}><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
inCodeBlock = false;
codeLines = [];
codeLang = '';
}
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
if (line.trim() === '') {
output.push('');
continue;
}
output.push(parseInline(line));
}
if (inCodeBlock) {
const langAttr = codeLang ? ` data-lang="${escapeHtml(codeLang)}"` : '';
output.push(`<pre${langAttr}><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
}
const paragraphs: string[] = [];
let current: string[] = [];
for (const line of output) {
if (line === '') {
if (current.length > 0) {
paragraphs.push(wrapParagraph(current));
current = [];
}
} else {
current.push(line);
}
}
if (current.length > 0) {
paragraphs.push(wrapParagraph(current));
}
return paragraphs.join('');
}

325
lem-chat/src/styles.ts Normal file
View file

@ -0,0 +1,325 @@
export const chatStyles = `
:host {
display: flex;
flex-direction: column;
background: var(--lem-bg, #1a1a1e);
color: var(--lem-text, #e0e0e0);
font-family: var(--lem-font, system-ui, -apple-system, sans-serif);
font-size: 14px;
line-height: 1.5;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.header-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--lem-accent, #5865f2);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #fff;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--lem-text, #e0e0e0);
}
.header-model {
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
margin-left: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.header-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #43b581;
flex-shrink: 0;
}
.header-status.disconnected {
background: #f04747;
}
`;
export const messagesStyles = `
:host {
display: block;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 0;
scroll-behavior: smooth;
}
:host::-webkit-scrollbar {
width: 6px;
}
:host::-webkit-scrollbar-track {
background: transparent;
}
:host::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 3px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: rgba(255, 255, 255, 0.25);
}
.empty-icon {
font-size: 36px;
opacity: 0.4;
}
.empty-text {
font-size: 14px;
}
`;
export const messageStyles = `
:host {
display: block;
padding: 6px 18px;
}
:host([role="user"]) .bubble {
background: var(--lem-msg-user, #2a2a3e);
margin-left: 40px;
border-radius: 12px 12px 4px 12px;
}
:host([role="assistant"]) .bubble {
background: var(--lem-msg-assistant, #1e1e2a);
margin-right: 40px;
border-radius: 12px 12px 12px 4px;
}
.bubble {
padding: 10px 14px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.role {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
color: rgba(255, 255, 255, 0.35);
}
:host([role="assistant"]) .role {
color: var(--lem-accent, #5865f2);
}
.content {
color: var(--lem-text, #e0e0e0);
line-height: 1.6;
}
.content p {
margin: 0 0 8px 0;
}
.content p:last-child {
margin-bottom: 0;
}
.content strong {
font-weight: 600;
color: #fff;
}
.content em {
font-style: italic;
color: rgba(255, 255, 255, 0.8);
}
.content code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: rgba(0, 0, 0, 0.3);
padding: 2px 5px;
border-radius: 4px;
color: #e8a0bf;
}
.content pre {
margin: 8px 0;
padding: 12px;
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.content pre code {
background: none;
padding: 0;
font-size: 12px;
color: #c9d1d9;
line-height: 1.5;
}
.think-panel {
margin: 6px 0 8px;
padding: 8px 12px;
background: rgba(88, 101, 242, 0.06);
border-left: 2px solid rgba(88, 101, 242, 0.3);
border-radius: 0 6px 6px 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
line-height: 1.5;
max-height: 200px;
overflow-y: auto;
}
.think-panel::-webkit-scrollbar {
width: 4px;
}
.think-panel::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.think-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(88, 101, 242, 0.5);
margin-bottom: 4px;
cursor: pointer;
user-select: none;
}
.think-label:hover {
color: rgba(88, 101, 242, 0.7);
}
.think-panel.collapsed .think-content {
display: none;
}
.cursor {
display: inline-block;
width: 7px;
height: 16px;
background: var(--lem-accent, #5865f2);
border-radius: 1px;
animation: blink 0.8s step-end infinite;
vertical-align: text-bottom;
margin-left: 2px;
}
@keyframes blink {
50% { opacity: 0; }
}
`;
export const inputStyles = `
:host {
display: block;
padding: 12px 16px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 8px 12px;
transition: border-color 0.15s;
}
.input-wrapper:focus-within {
border-color: var(--lem-accent, #5865f2);
}
textarea {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--lem-text, #e0e0e0);
font-family: inherit;
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 120px;
min-height: 22px;
padding: 0;
}
textarea::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.send-btn {
background: var(--lem-accent, #5865f2);
border: none;
border-radius: 8px;
color: #fff;
width: 32px;
height: 32px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s, transform 0.1s;
}
.send-btn:hover {
opacity: 0.85;
}
.send-btn:active {
transform: scale(0.95);
}
.send-btn:disabled {
opacity: 0.3;
cursor: default;
transform: none;
}
.send-btn svg {
width: 16px;
height: 16px;
}
`;

31
lem-chat/src/types.ts Normal file
View file

@ -0,0 +1,31 @@
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatCompletionRequest {
model: string;
messages: ChatMessage[];
max_tokens: number;
temperature: number;
stream: boolean;
}
export interface ChatCompletionChunk {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
delta: {
role?: string;
content?: string;
};
index: number;
finish_reason: string | null;
}>;
}
export interface LemSendDetail {
text: string;
}

14
lem-chat/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"declaration": false,
"isolatedModules": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}