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"]
+}