From e6cd676278014644c772f0835c0d30f3ec501260 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 18:23:59 +0000 Subject: [PATCH] feat: add lem-chat TypeScript web components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: - : Container with SSE client, config via attributes - : Scrollable message list with auto-scroll - : Single message bubble with streaming + tag support - : 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 --- lem-chat/.gitignore | 1 + lem-chat/index.html | 34 +++ lem-chat/package-lock.json | 515 +++++++++++++++++++++++++++++++++++ lem-chat/package.json | 16 ++ lem-chat/src/lem-chat.ts | 195 +++++++++++++ lem-chat/src/lem-input.ts | 110 ++++++++ lem-chat/src/lem-message.ts | 154 +++++++++++ lem-chat/src/lem-messages.ts | 70 +++++ lem-chat/src/markdown.ts | 80 ++++++ lem-chat/src/styles.ts | 325 ++++++++++++++++++++++ lem-chat/src/types.ts | 31 +++ lem-chat/tsconfig.json | 14 + 12 files changed, 1545 insertions(+) create mode 100644 lem-chat/.gitignore create mode 100644 lem-chat/index.html create mode 100644 lem-chat/package-lock.json create mode 100644 lem-chat/package.json create mode 100644 lem-chat/src/lem-chat.ts create mode 100644 lem-chat/src/lem-input.ts create mode 100644 lem-chat/src/lem-message.ts create mode 100644 lem-chat/src/lem-messages.ts create mode 100644 lem-chat/src/markdown.ts create mode 100644 lem-chat/src/styles.ts create mode 100644 lem-chat/src/types.ts create mode 100644 lem-chat/tsconfig.json diff --git a/lem-chat/.gitignore b/lem-chat/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/lem-chat/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/lem-chat/index.html b/lem-chat/index.html new file mode 100644 index 0000000..2f2d068 --- /dev/null +++ b/lem-chat/index.html @@ -0,0 +1,34 @@ + + + + + + LEM Chat + + + + + + + diff --git a/lem-chat/package-lock.json b/lem-chat/package-lock.json new file mode 100644 index 0000000..306ea82 --- /dev/null +++ b/lem-chat/package-lock.json @@ -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" + } + } + } +} diff --git a/lem-chat/package.json b/lem-chat/package.json new file mode 100644 index 0000000..fb841e9 --- /dev/null +++ b/lem-chat/package.json @@ -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" + } +} diff --git a/lem-chat/src/lem-chat.ts b/lem-chat/src/lem-chat.ts new file mode 100644 index 0000000..b379333 --- /dev/null +++ b/lem-chat/src/lem-chat.ts @@ -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).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 { + 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 { + 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); diff --git a/lem-chat/src/lem-input.ts b/lem-chat/src/lem-input.ts new file mode 100644 index 0000000..c0df959 --- /dev/null +++ b/lem-chat/src/lem-input.ts @@ -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('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); diff --git a/lem-chat/src/lem-message.ts b/lem-chat/src/lem-message.ts new file mode 100644 index 0000000..e085b8d --- /dev/null +++ b/lem-chat/src/lem-message.ts @@ -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('')) { + 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(''); + if (thinkStart === -1) { + return { think: null, response: text }; + } + const afterOpen = thinkStart + ''.length; + const thinkEnd = text.indexOf('', 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 + ''.length).trim(); + const response = [beforeThink, afterThink].filter(Boolean).join('\n'); + return { think: thinkText, response }; + } +} + +customElements.define('lem-message', LemMessage); diff --git a/lem-chat/src/lem-messages.ts b/lem-chat/src/lem-messages.ts new file mode 100644 index 0000000..1ec0f61 --- /dev/null +++ b/lem-chat/src/lem-messages.ts @@ -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); diff --git a/lem-chat/src/markdown.ts b/lem-chat/src/markdown.ts new file mode 100644 index 0000000..e30a79e --- /dev/null +++ b/lem-chat/src/markdown.ts @@ -0,0 +1,80 @@ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function parseInline(text: string): string { + let result = escapeHtml(text); + result = result.replace(/`([^`]+)`/g, '$1'); + result = result.replace(/\*\*(.+?)\*\*/g, '$1'); + result = result.replace(/__(.+?)__/g, '$1'); + result = result.replace(/(?$1'); + result = result.replace(/(?$1'); + return result; +} + +function wrapParagraph(lines: string[]): string { + const joined = lines.join('
'); + if (joined.startsWith('${joined}

`; +} + +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(`${escapeHtml(codeLines.join('\n'))}`); + 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(`${escapeHtml(codeLines.join('\n'))}`); + } + + 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(''); +} diff --git a/lem-chat/src/styles.ts b/lem-chat/src/styles.ts new file mode 100644 index 0000000..5b26693 --- /dev/null +++ b/lem-chat/src/styles.ts @@ -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; + } +`; diff --git a/lem-chat/src/types.ts b/lem-chat/src/types.ts new file mode 100644 index 0000000..96fbbf4 --- /dev/null +++ b/lem-chat/src/types.ts @@ -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; +} diff --git a/lem-chat/tsconfig.json b/lem-chat/tsconfig.json new file mode 100644 index 0000000..e8fb428 --- /dev/null +++ b/lem-chat/tsconfig.json @@ -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"] +}