diff --git a/.gitignore b/.gitignore index fed2d21..18001d7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist_*/ .nogit/ coverage/ *.tgz +demo/demo.web.js +demo/demo.web.js.map diff --git a/.playwright-mcp/console-2026-03-07T00-05-32-640Z.log b/.playwright-mcp/console-2026-03-07T00-05-32-640Z.log new file mode 100644 index 0000000..83bc60b --- /dev/null +++ b/.playwright-mcp/console-2026-03-07T00-05-32-640Z.log @@ -0,0 +1 @@ +[ 233ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8765/favicon.ico:0 diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..c9dab90 --- /dev/null +++ b/changelog.md @@ -0,0 +1,18 @@ +# Changelog + +## 2026-03-07 - 0.1.0 - feat(web) +add web demo and polish Lit web components UI and demo tooling + +- Add web demo artifacts and build tooling: demo.web.html, demo.web.ts, demo.cli.ts, and demo/build.web.ts (esbuild) plus demoWeb and demoCli npm scripts +- Enhance web components (ts_web): smartchat-window, smartchat-message, smartchat-input โ€” new header/status, avatars, timestamps, improved streaming/thinking indicators, responsive textarea input, and refined styles/animations +- Add esbuild to devDependencies to support demo bundling/serving +- Update .gitignore to exclude demo build outputs +- Add README with usage docs and examples + +## 2026-03-06 - 0.0.1 - initial +Scaffold initial @push.rocks/smartchat package with core, CLI, and web layers. + +- Three-layer architecture built on @push.rocks/smartagent. +- ts/: ChatSession wrapper around runAgent() with conversation state management. +- ts_cli/: Ink-based terminal chat TUI implemented using React.createElement (no JSX). +- ts_web/: Lit web components (smartchat-window, smartchat-message, smartchat-input). \ No newline at end of file diff --git a/demo/build.web.ts b/demo/build.web.ts new file mode 100644 index 0000000..987f94e --- /dev/null +++ b/demo/build.web.ts @@ -0,0 +1,41 @@ +import * as esbuild from 'esbuild'; + +// Shim unused Node.js built-ins that get pulled in by dependencies. +const nodeShimPlugin: esbuild.Plugin = { + name: 'node-builtins-shim', + setup(build) { + // Mark node built-ins as empty modules โ€” they are imported but not actually used at runtime. + const builtins = ['path', 'fs', 'os', 'crypto', 'stream', 'util', 'events', 'http', 'https', 'net', 'tls', 'child_process', 'url', 'assert', 'buffer', 'tty', 'readline']; + const filter = new RegExp(`^(${builtins.join('|')})$`); + build.onResolve({ filter }, (args) => ({ + path: args.path, + namespace: 'node-shim', + })); + build.onLoad({ filter: /.*/, namespace: 'node-shim' }, () => ({ + contents: 'export default {};', + loader: 'js', + })); + }, +}; + +const buildOptions: esbuild.BuildOptions = { + entryPoints: ['demo/demo.web.ts'], + bundle: true, + format: 'esm', + platform: 'browser', + outdir: 'demo', + sourcemap: true, + target: 'es2022', + logLevel: 'info', + plugins: [nodeShimPlugin], +}; + +const serve = process.argv.includes('--serve'); + +if (serve) { + const ctx = await esbuild.context(buildOptions); + const { host, port } = await ctx.serve({ servedir: 'demo' }); + console.log(`\n ๐ŸŒ Web demo running at http://localhost:${port}/demo.web.html\n`); +} else { + await esbuild.build(buildOptions); +} diff --git a/demo/demo.cli.ts b/demo/demo.cli.ts new file mode 100644 index 0000000..41df4cf --- /dev/null +++ b/demo/demo.cli.ts @@ -0,0 +1,21 @@ +import { getModel } from '@push.rocks/smartai'; +import { startChat } from '../ts_cli/index.js'; + +const apiKey = process.env.ANTHROPIC_TOKEN; +if (!apiKey) { + console.error('Missing ANTHROPIC_TOKEN environment variable.'); + console.error('Usage: ANTHROPIC_TOKEN=sk-... tsx demo/demo.cli.ts'); + process.exit(1); +} + +const model = getModel({ + provider: 'anthropic', + model: 'claude-sonnet-4-5-20250929', + apiKey, +}); + +await startChat({ + model, + system: 'You are a friendly, concise assistant. Keep responses short unless asked for detail.', + modelName: 'Claude Sonnet', +}); diff --git a/demo/demo.web.html b/demo/demo.web.html new file mode 100644 index 0000000..85fd5f3 --- /dev/null +++ b/demo/demo.web.html @@ -0,0 +1,96 @@ + + + + + + smartchat โ€” Web Demo + + + + + + + + + + + + + + + + + + diff --git a/demo/demo.web.ts b/demo/demo.web.ts new file mode 100644 index 0000000..73caab5 --- /dev/null +++ b/demo/demo.web.ts @@ -0,0 +1,48 @@ +import '../ts_web/index.js'; +import { ChatSession } from '../ts/index.js'; +import { getModel } from '@push.rocks/smartai'; + +// Read API key from a meta tag so it doesn't need to be hardcoded. +// Set it in the HTML: +const metaToken = document.querySelector('meta[name="anthropic-token"]'); +const apiKey = metaToken?.content; + +const init = () => { + const chatWindow = document.querySelector('smartchat-window') as any; + if (!chatWindow) return; + + if (!apiKey || apiKey === 'YOUR_KEY_HERE') { + // Mock mode โ€” show sample messages to preview the UI + chatWindow.headerTitle = 'SmartChat AI (Preview)'; + const mockMessages = [ + { role: 'user', content: 'Hey! Can you explain what TypeScript generics are?', timestamp: Date.now() - 60000 }, + { role: 'assistant', content: 'Generics let you write reusable code that works with multiple types while keeping type safety.\n\nFor example, instead of writing separate functions for arrays of strings and numbers, you write one:\n\n```ts\nfunction first(items: T[]): T {\n return items[0];\n}\n```\n\nThe `` is a type parameter โ€” TypeScript infers the actual type when you call the function. So `first([1, 2, 3])` returns a `number`, and `first(["a", "b"])` returns a `string`.', timestamp: Date.now() - 45000 }, + { role: 'user', content: 'That makes sense! Can generics have constraints?', timestamp: Date.now() - 30000 }, + { role: 'tool', content: '', toolName: 'search_docs', timestamp: Date.now() - 25000 }, + { role: 'assistant', content: 'Yes! Use `extends` to constrain what types are allowed:\n\n```ts\nfunction getLength(item: T): number {\n return item.length;\n}\n```\n\nThis accepts strings, arrays, or any object with a `.length` property โ€” but rejects numbers or booleans at compile time.', timestamp: Date.now() - 20000 }, + ]; + // Inject mock messages by setting internal state + (chatWindow as any).messages = mockMessages; + (chatWindow as any).requestUpdate(); + return; + } + + const model = getModel({ + provider: 'anthropic', + model: 'claude-sonnet-4-5-20250929', + apiKey, + }); + + const session = new ChatSession({ + model, + system: 'You are a friendly, concise assistant. Keep responses short unless asked for detail.', + }); + + chatWindow.chatSession = session; +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/package.json b/package.json index 9ed0be1..aaaea3e 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "license": "MIT", "scripts": { "test": "(tstest test/ --verbose --timeout 120)", - "build": "(tsbuild tsfolders --allowimplicitany)" + "build": "(tsbuild tsfolders --allowimplicitany)", + "demoCli": "tsx demo/demo.cli.ts", + "demoWeb": "tsx demo/build.web.ts --serve" }, "devDependencies": { "@git.zone/tsbuild": "^4.3.0", @@ -32,7 +34,8 @@ "@git.zone/tstest": "^3.3.0", "@push.rocks/qenv": "^6.1.3", "@types/node": "^22.0.0", - "@types/react": "^19.0.0" + "@types/react": "^19.0.0", + "esbuild": "^0.27.3" }, "dependencies": { "@push.rocks/smartagent": "^3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ba55e..66b486b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@types/react': specifier: ^19.0.0 version: 19.2.14 + esbuild: + specifier: ^0.27.3 + version: 0.27.3 packages: diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..cf179ae --- /dev/null +++ b/readme.md @@ -0,0 +1,250 @@ +# @push.rocks/smartchat + +Interactive chat interfaces for AI agents โ€” CLI TUI and web components, built on [`@push.rocks/smartagent`](https://code.foss.global/push.rocks/smartagent). + +## Install + +```bash +pnpm install @push.rocks/smartchat +``` + +## Issue Reporting and Security + +For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. + +## Overview + +`@push.rocks/smartchat` gives you everything you need to build conversational AI interfaces โ€” in the terminal and on the web. It ships three entry points: + +| Entry Point | Import Path | What You Get | +|---|---|---| +| **Core** | `@push.rocks/smartchat` | `ChatSession` โ€” headless conversation state management | +| **CLI** | `@push.rocks/smartchat/cli` | `startChat()` โ€” full-featured terminal TUI (ink-based) | +| **Web** | `@push.rocks/smartchat/web` | `` and friends โ€” Lit-based web components | + +Under the hood, every turn calls [`@push.rocks/smartagent`](https://code.foss.global/push.rocks/smartagent)'s `runAgent()`, giving you streaming tokens, tool calling, and multi-step agentic loops out of the box. + +## Usage + +### ๐Ÿง  Core โ€” `ChatSession` + +`ChatSession` manages in-memory conversation history and wraps the agent loop. It's headless โ€” bring your own UI or use it from scripts, bots, and backend services. + +```typescript +import { ChatSession } from '@push.rocks/smartchat'; +import type { IChatSessionOptions, IChatCallbacks, IChatUsage } from '@push.rocks/smartchat'; + +// Create a session with your model and optional tools +const session = new ChatSession( + { + model: myLanguageModel, // LanguageModelV3 from @push.rocks/smartai + system: 'You are a helpful coding assistant.', + tools: myToolSet, // optional ToolSet + maxSteps: 20, // max agentic steps per turn (default: 20) + }, + { + onToken: (delta) => process.stdout.write(delta), + onToolCall: (name, input) => console.log(`๐Ÿ”ง ${name}`), + onToolResult: (name, result) => console.log(`โœ… ${name}`), + onTurnComplete: (result) => console.log(`Done โ€” ${result.usage.totalTokens} tokens`), + onError: (err) => console.error(err), + } +); + +// Send a message and get the result +const result = await session.send('Explain the builder pattern in TypeScript'); +console.log(result.text); + +// Inspect state +console.log(session.getUsage()); // { inputTokens, outputTokens, totalTokens, turns } +console.log(session.getMessages()); // full conversation history +console.log(session.isBusy()); // false (turn complete) + +// Manage conversation +session.clear(); // reset history + usage +session.setMessages(savedHistory); // restore a saved session +session.updateOptions({ system: 'New system prompt' }); +session.updateCallbacks({ onToken: null }); +``` + +#### `IChatSessionOptions` + +| Property | Type | Description | +|---|---|---| +| `model` | `LanguageModelV3` | The language model to use (required) | +| `system` | `string` | System prompt for the agent | +| `tools` | `ToolSet` | Tools available to the agent | +| `maxSteps` | `number` | Max agentic steps per turn (default: `20`) | + +#### `IChatCallbacks` + +| Callback | Signature | When It Fires | +|---|---|---| +| `onToken` | `(delta: string) => void` | Each streamed text token | +| `onToolCall` | `(name: string, input: unknown) => void` | Tool call starts | +| `onToolResult` | `(name: string, result: string) => void` | Tool call completes | +| `onTurnComplete` | `(result: IAgentRunResult) => void` | Turn finishes | +| `onError` | `(error: Error) => void` | Error occurs | + +#### `IChatUsage` + +| Field | Type | +|---|---| +| `inputTokens` | `number` | +| `outputTokens` | `number` | +| `totalTokens` | `number` | +| `turns` | `number` | + +--- + +### ๐Ÿ’ป CLI โ€” Terminal TUI + +Launch a beautiful, interactive chat right in your terminal. Built with [ink](https://github.com/vadimdemedes/ink) (React for CLIs). + +```typescript +import { startChat } from '@push.rocks/smartchat/cli'; +import type { IStartChatOptions } from '@push.rocks/smartchat/cli'; + +await startChat({ + model: myLanguageModel, + system: 'You are a friendly assistant.', + tools: myToolSet, + maxSteps: 30, + modelName: 'Claude Opus', // displayed in the header & status bar +}); +``` + +#### Features + +- โšก **Real-time streaming** โ€” tokens appear as they're generated +- ๐Ÿ”ง **Tool call visibility** โ€” see tool invocations inline +- ๐Ÿ“Š **Status bar** โ€” model name, token usage, turn count +- โŒจ๏ธ **Keyboard shortcuts**: + - `Ctrl+C` โ€” exit + - `Ctrl+L` โ€” clear conversation +- ๐Ÿ’ฌ **Slash commands**: + - `/clear` โ€” reset the conversation + - `/quit` or `/exit` โ€” exit the TUI + +#### `IStartChatOptions` + +| Property | Type | Description | +|---|---|---| +| `model` | `LanguageModelV3` | The language model (required) | +| `system` | `string` | System prompt | +| `tools` | `ToolSet` | Tools for the agent | +| `maxSteps` | `number` | Max agentic steps per turn | +| `modelName` | `string` | Display name shown in header & status bar | + +--- + +### ๐ŸŒ Web โ€” Lit Web Components + +Drop a full chat UI into any web app. Three custom elements, fully themeable with CSS custom properties. + +```typescript +import '@push.rocks/smartchat/web'; +import { ChatSession } from '@push.rocks/smartchat'; + +// Create a session +const session = new ChatSession({ + model: myLanguageModel, + system: 'You are a helpful assistant.', +}); + +// Get the element and wire it up +const chatWindow = document.querySelector('smartchat-window'); +chatWindow.chatSession = session; +``` + +```html + +``` + +#### Components + +| Element | Description | +|---|---| +| `` | Full chat window โ€” message list + input area. Pass a `ChatSession` via the `chatSession` property. | +| `` | Single message bubble. Attributes: `role` (`user` / `assistant` / `tool`), `content`, `toolName`. | +| `` | Input field + send button. Fires `send` custom event. Attributes: `disabled`, `placeholder`. | + +#### ๐ŸŽจ Theming with CSS Custom Properties + +Every visual aspect is customizable: + +```css +smartchat-window { + /* Layout */ + --smartchat-bg: #111827; + --smartchat-text: #e5e7eb; + --smartchat-font: system-ui, -apple-system, sans-serif; + --smartchat-radius: 12px; + --smartchat-border: #1f2937; + --smartchat-muted: #6b7280; + + /* User messages */ + --smartchat-user-bubble: #2563eb; + --smartchat-user-text: #fff; + + /* Assistant messages */ + --smartchat-assistant-bubble: #374151; + --smartchat-assistant-text: #e5e7eb; + + /* Tool messages */ + --smartchat-tool-bubble: #1e293b; + --smartchat-tool-text: #94a3b8; + --smartchat-tool-accent: #6366f1; + + /* Input area */ + --smartchat-input-bg: #1f2937; + --smartchat-input-border: #374151; + --smartchat-input-focus: #6366f1; + --smartchat-input-text: #e5e7eb; + --smartchat-input-placeholder: #6b7280; + + /* Send button */ + --smartchat-send-bg: #6366f1; + --smartchat-send-text: #fff; + + /* Streaming cursor */ + --smartchat-cursor: #6366f1; +} +``` + +--- + +## Architecture + +``` +@push.rocks/smartchat +โ”œโ”€โ”€ ts/ โ†’ Core module (ChatSession, interfaces) +โ”œโ”€โ”€ ts_cli/ โ†’ CLI TUI (ink + React) +โ””โ”€โ”€ ts_web/ โ†’ Web components (Lit) +``` + +- **Core** (`ts/`) is headless and dependency-light. CLI and Web layers both import from Core. +- **CLI** (`ts_cli/`) uses [ink](https://github.com/vadimdemedes/ink) with `React.createElement` (no JSX). +- **Web** (`ts_web/`) uses [Lit](https://lit.dev/) with `static properties` (no decorators). +- The agentic loop is powered by [`@push.rocks/smartagent`](https://code.foss.global/push.rocks/smartagent), which orchestrates multi-step tool calling with any `LanguageModelV3` from [`@push.rocks/smartai`](https://code.foss.global/push.rocks/smartai). + +## License and Legal Information + +This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. + +**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. + +### Trademarks + +This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein. + +Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar. + +### Company Information + +Task Venture Capital GmbH +Registered at District Court Bremen HRB 35230 HB, Germany + +For any legal inquiries or further information, please contact us via email at hello@task.vc. + +By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts new file mode 100644 index 0000000..8a4453f --- /dev/null +++ b/ts/00_commitinfo_data.ts @@ -0,0 +1,8 @@ +/** + * autocreated commitinfo by @push.rocks/commitinfo + */ +export const commitinfo = { + name: '@push.rocks/smartchat', + version: '0.1.0', + description: 'Interactive chat interfaces for AI agents โ€” CLI TUI and web components, built on @push.rocks/smartagent.' +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts new file mode 100644 index 0000000..8a4453f --- /dev/null +++ b/ts_web/00_commitinfo_data.ts @@ -0,0 +1,8 @@ +/** + * autocreated commitinfo by @push.rocks/commitinfo + */ +export const commitinfo = { + name: '@push.rocks/smartchat', + version: '0.1.0', + description: 'Interactive chat interfaces for AI agents โ€” CLI TUI and web components, built on @push.rocks/smartagent.' +} diff --git a/ts_web/smartchat-input.ts b/ts_web/smartchat-input.ts index 82b0e01..000ed0f 100644 --- a/ts_web/smartchat-input.ts +++ b/ts_web/smartchat-input.ts @@ -16,51 +16,100 @@ export class SmartchatInput extends LitElement { display: block; } - .input-row { + .input-wrap { display: flex; - align-items: center; + align-items: flex-end; gap: 8px; - padding: 8px; - background: var(--smartchat-input-bg, #1f2937); - border-radius: 8px; - border: 1px solid var(--smartchat-input-border, #374151); + padding: 10px 12px; + background: rgba(255, 255, 255, 0.04); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.07); + transition: border-color 0.2s ease, box-shadow 0.2s ease; } - .input-row:focus-within { - border-color: var(--smartchat-input-focus, #6366f1); + .input-wrap:focus-within { + border-color: rgba(99, 102, 241, 0.45); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.08); } - input { + .input-wrap.disabled { + opacity: 0.45; + pointer-events: none; + } + + textarea { flex: 1; background: transparent; border: none; outline: none; - color: var(--smartchat-input-text, #e5e7eb); + color: #e4e4e7; font-size: 14px; font-family: inherit; + line-height: 1.5; + resize: none; + min-height: 22px; + max-height: 110px; + padding: 2px 0; } - input::placeholder { - color: var(--smartchat-input-placeholder, #6b7280); + textarea::placeholder { + color: rgba(255, 255, 255, 0.22); } - button { - background: var(--smartchat-send-bg, #6366f1); - color: var(--smartchat-send-text, #fff); + .send-btn { + width: 32px; + height: 32px; + border-radius: 50%; border: none; - border-radius: 6px; - padding: 6px 16px; + background: linear-gradient(135deg, #6366f1, #7c3aed); + color: #fff; cursor: pointer; - font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: transform 0.12s ease, opacity 0.12s ease, box-shadow 0.12s ease; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); } - button:hover { - opacity: 0.9; + .send-btn:hover:not(:disabled) { + transform: scale(1.08); + box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45); } - button:disabled { - opacity: 0.5; - cursor: not-allowed; + .send-btn:active:not(:disabled) { + transform: scale(0.94); + } + + .send-btn:disabled { + opacity: 0.25; + cursor: default; + box-shadow: none; + } + + .send-btn svg { + width: 15px; + height: 15px; + } + + .hint { + display: flex; + justify-content: flex-end; + padding: 5px 2px 0; + } + + .hint-text { + font-size: 10.5px; + color: rgba(255, 255, 255, 0.13); + } + + kbd { + padding: 0 3px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.07); + font-family: inherit; + font-size: 10px; } `; @@ -71,13 +120,17 @@ export class SmartchatInput extends LitElement { } private handleKeydown(e: KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey && this.value.trim()) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); this.submit(); } } private handleInput(e: Event) { - this.value = (e.target as HTMLInputElement).value; + const textarea = e.target as HTMLTextAreaElement; + this.value = textarea.value; + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 110) + 'px'; } private submit() { @@ -90,25 +143,40 @@ export class SmartchatInput extends LitElement { }), ); this.value = ''; - const input = this.shadowRoot?.querySelector('input'); - if (input) input.value = ''; + const textarea = this.shadowRoot?.querySelector('textarea'); + if (textarea) { + textarea.value = ''; + textarea.style.height = 'auto'; + } } render(): TemplateResult { + const canSend = !this.disabled && !!this.value.trim(); return html` -
- + +
+
+ Enter send ยท Shift+Enter new line +
`; } } diff --git a/ts_web/smartchat-message.ts b/ts_web/smartchat-message.ts index 16896aa..619d9ac 100644 --- a/ts_web/smartchat-message.ts +++ b/ts_web/smartchat-message.ts @@ -5,54 +5,138 @@ export class SmartchatMessage extends LitElement { declare role: 'user' | 'assistant' | 'tool'; declare content: string; declare toolName: string; + declare timestamp: number; static properties = { role: { type: String }, content: { type: String }, toolName: { type: String }, + timestamp: { type: Number }, }; static styles: CSSResult = css` :host { display: block; - margin-bottom: 8px; + animation: msg-in 0.3s ease-out both; } - .message { - padding: 8px 12px; - border-radius: 8px; - max-width: 85%; - word-wrap: break-word; + @keyframes msg-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* โ”€โ”€ Message Row โ”€โ”€ */ + .row { + display: flex; + gap: 10px; + align-items: flex-end; + margin-bottom: 4px; + } + + .row.user { + justify-content: flex-end; + } + + .row.assistant { + justify-content: flex-start; + } + + /* โ”€โ”€ Avatar โ”€โ”€ */ + .avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + flex-shrink: 0; + line-height: 1; + } + + .avatar.user { + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: #fff; + order: 2; + } + + .avatar.assistant { + background: linear-gradient(135deg, #6366f1, #7c3aed); + color: #fff; + } + + /* โ”€โ”€ Bubble โ”€โ”€ */ + .bubble { + padding: 10px 14px; + border-radius: 18px; + word-break: break-word; white-space: pre-wrap; + font-size: 14px; + line-height: 1.55; + max-width: min(75%, 480px); } - .user { - background: var(--smartchat-user-bubble, #2563eb); - color: var(--smartchat-user-text, #fff); - margin-left: auto; - border-bottom-right-radius: 2px; + .bubble.user { + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: #fff; + border-bottom-right-radius: 6px; } - .assistant { - background: var(--smartchat-assistant-bubble, #374151); - color: var(--smartchat-assistant-text, #e5e7eb); - margin-right: auto; - border-bottom-left-radius: 2px; + .bubble.assistant { + background: var(--smartchat-assistant-bg, #1e1e2e); + border: 1px solid var(--smartchat-assistant-border, rgba(255, 255, 255, 0.08)); + color: var(--smartchat-assistant-text, #d1d5db); + border-bottom-left-radius: 6px; } - .tool { - background: var(--smartchat-tool-bubble, #1e293b); - color: var(--smartchat-tool-text, #94a3b8); - margin-right: auto; - font-size: 0.85em; - font-family: monospace; - border-left: 3px solid var(--smartchat-tool-accent, #6366f1); + /* โ”€โ”€ Tool โ”€โ”€ */ + .tool-row { + display: flex; + justify-content: flex-start; + padding: 2px 0 2px 38px; + } + + .tool-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(99, 102, 241, 0.1); + border: 1px solid rgba(99, 102, 241, 0.15); + border-radius: 20px; + font-size: 11px; + font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace; + color: #a5b4fc; + } + + .tool-icon { + font-size: 10px; } .tool-name { - font-weight: bold; - margin-bottom: 4px; - color: var(--smartchat-tool-accent, #6366f1); + font-weight: 600; + } + + /* โ”€โ”€ Timestamp โ”€โ”€ */ + .time-row { + padding: 1px 38px 6px; + font-size: 10px; + color: rgba(255, 255, 255, 0.2); + font-variant-numeric: tabular-nums; + } + + .time-row.user { + text-align: right; + } + + .time-row.assistant { + text-align: left; } `; @@ -61,16 +145,39 @@ export class SmartchatMessage extends LitElement { this.role = 'user'; this.content = ''; this.toolName = ''; + this.timestamp = 0; + } + + private formatTime(ts: number): string { + if (!ts) return ''; + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } render(): TemplateResult { + if (this.role === 'tool') { + return html` +
+
+ โšก + ${this.toolName || 'tool'} +
+
+ `; + } + return html` -
- ${this.role === 'tool' && this.toolName - ? html`
${this.toolName}
` - : ''} -
${this.content}
+
+
+ ${this.role === 'user' ? 'โฌ†' : 'โœฆ'} +
+
+ ${this.content} +
+ ${this.timestamp + ? html`
${this.formatTime(this.timestamp)}
` + : ''} `; } } diff --git a/ts_web/smartchat-window.ts b/ts_web/smartchat-window.ts index 2ad2325..5f6b00b 100644 --- a/ts_web/smartchat-window.ts +++ b/ts_web/smartchat-window.ts @@ -7,11 +7,13 @@ interface IDisplayMessage { role: 'user' | 'assistant' | 'tool'; content: string; toolName?: string; + timestamp?: number; } export class SmartchatWindow extends LitElement { declare chatSession: ChatSession; declare placeholder: string; + declare headerTitle: string; private messages: IDisplayMessage[] = []; private busy = false; private streamingText = ''; @@ -19,6 +21,7 @@ export class SmartchatWindow extends LitElement { static properties = { chatSession: { attribute: false }, placeholder: { type: String }, + headerTitle: { type: String, attribute: 'header-title' }, }; static styles: CSSResult = css` @@ -26,63 +29,244 @@ export class SmartchatWindow extends LitElement { display: flex; flex-direction: column; height: 100%; - background: var(--smartchat-bg, #111827); - color: var(--smartchat-text, #e5e7eb); - font-family: var(--smartchat-font, system-ui, -apple-system, sans-serif); - border-radius: var(--smartchat-radius, 12px); + background: var(--smartchat-bg, #0c0c14); + color: var(--smartchat-text, #e4e4e7); + font-family: var(--smartchat-font, 'Inter', system-ui, -apple-system, sans-serif); + font-size: 14px; + line-height: 1.6; + border-radius: var(--smartchat-radius, 16px); overflow: hidden; } - .message-list { - flex: 1; - overflow-y: auto; - padding: 16px; + /* โ”€โ”€ Header โ”€โ”€ */ + .header { display: flex; - flex-direction: column; - gap: 4px; + align-items: center; + gap: 12px; + padding: 14px 20px; + background: var(--smartchat-header-bg, rgba(255, 255, 255, 0.025)); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; } - .empty-state { + .header-icon { + width: 34px; + height: 34px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1, #7c3aed); display: flex; align-items: center; justify-content: center; - flex: 1; - color: var(--smartchat-muted, #6b7280); - font-size: 14px; + font-size: 15px; + color: #fff; + flex-shrink: 0; + box-shadow: 0 2px 10px rgba(99, 102, 241, 0.35); } - .streaming { - padding: 8px 12px; - background: var(--smartchat-assistant-bubble, #374151); - color: var(--smartchat-assistant-text, #e5e7eb); - border-radius: 8px; - border-bottom-left-radius: 2px; - max-width: 85%; + .header-info { + display: flex; + flex-direction: column; + gap: 1px; + } + + .header-title { + font-weight: 600; + font-size: 14px; + color: #f0f0f3; + letter-spacing: -0.01em; + } + + .header-status { + font-size: 11px; + color: #71717a; + display: flex; + align-items: center; + gap: 5px; + } + + .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); + } + + .status-dot.busy { + background: #f59e0b; + box-shadow: 0 0 6px rgba(245, 158, 11, 0.5); + animation: pulse-dot 1.4s ease-in-out infinite; + } + + @keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } + } + + /* โ”€โ”€ Message List โ”€โ”€ */ + .message-list { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 0; + scroll-behavior: smooth; + overscroll-behavior: contain; + } + + .message-list::-webkit-scrollbar { + width: 5px; + } + + .message-list::-webkit-scrollbar-track { + background: transparent; + } + + .message-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.07); + border-radius: 3px; + } + + .message-list::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.14); + } + + /* โ”€โ”€ Empty State โ”€โ”€ */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 14px; + padding: 40px 20px; + text-align: center; + } + + .empty-icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(124, 58, 237, 0.06)); + border: 1px solid rgba(99, 102, 241, 0.12); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: #818cf8; + } + + .empty-title { + font-size: 15px; + font-weight: 600; + color: #e4e4e7; + } + + .empty-subtitle { + font-size: 13px; + color: #52525b; + max-width: 260px; + line-height: 1.5; + } + + /* โ”€โ”€ Streaming โ”€โ”€ */ + .streaming-row { + display: flex; + gap: 10px; + align-items: flex-end; + margin-bottom: 4px; + padding-top: 4px; + } + + .stream-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1, #7c3aed); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #fff; + flex-shrink: 0; + } + + .stream-bubble { + background: #1e1e2e; + border: 1px solid rgba(255, 255, 255, 0.08); + color: #d1d5db; + padding: 10px 14px; + border-radius: 18px; + border-bottom-left-radius: 6px; + max-width: min(75%, 480px); white-space: pre-wrap; + word-break: break-word; + font-size: 14px; + line-height: 1.55; } .cursor { display: inline-block; width: 2px; - height: 1em; - background: var(--smartchat-cursor, #6366f1); - animation: blink 1s step-end infinite; + height: 1.1em; + background: #818cf8; vertical-align: text-bottom; + margin-left: 1px; + animation: cursor-blink 0.8s step-end infinite; } - @keyframes blink { + @keyframes cursor-blink { 50% { opacity: 0; } } + /* โ”€โ”€ Thinking Indicator โ”€โ”€ */ + .thinking-bubble { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 12px 18px; + background: #1e1e2e; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + border-bottom-left-radius: 6px; + } + + .thinking-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #6366f1; + animation: thinking-bounce 1.4s ease-in-out infinite; + } + + .thinking-dot:nth-child(2) { animation-delay: 0.16s; } + .thinking-dot:nth-child(3) { animation-delay: 0.32s; } + + @keyframes thinking-bounce { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.3; + } + 30% { + transform: translateY(-5px); + opacity: 1; + } + } + + /* โ”€โ”€ Input Area โ”€โ”€ */ .input-area { - padding: 12px 16px; - border-top: 1px solid var(--smartchat-border, #1f2937); + padding: 12px 20px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: var(--smartchat-input-area-bg, rgba(255, 255, 255, 0.015)); + flex-shrink: 0; } `; constructor() { super(); this.placeholder = 'Type a message...'; + this.headerTitle = 'SmartChat'; } connectedCallback() { @@ -97,7 +281,7 @@ export class SmartchatWindow extends LitElement { onToolCall: (name: string) => { this.messages = [ ...this.messages, - { role: 'tool', content: '', toolName: name }, + { role: 'tool', content: '', toolName: name, timestamp: Date.now() }, ]; this.requestUpdate(); this.scrollToBottom(); @@ -119,7 +303,7 @@ export class SmartchatWindow extends LitElement { if (this.busy) return; const text = e.detail.text; - this.messages = [...this.messages, { role: 'user', content: text }]; + this.messages = [...this.messages, { role: 'user', content: text, timestamp: Date.now() }]; this.busy = true; this.streamingText = ''; this.requestUpdate(); @@ -128,12 +312,12 @@ export class SmartchatWindow extends LitElement { try { const result = await this.chatSession.send(text); this.streamingText = ''; - this.messages = [...this.messages, { role: 'assistant', content: result.text }]; + this.messages = [...this.messages, { role: 'assistant', content: result.text, timestamp: Date.now() }]; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); this.messages = [ ...this.messages, - { role: 'assistant', content: `Error: ${errMsg}` }, + { role: 'assistant', content: `Error: ${errMsg}`, timestamp: Date.now() }, ]; } finally { this.busy = false; @@ -144,9 +328,28 @@ export class SmartchatWindow extends LitElement { render(): TemplateResult { return html` +
+
โœฆ
+
+
${this.headerTitle}
+
+ + ${this.busy ? 'Thinking...' : 'Online'} +
+
+
+
${this.messages.length === 0 && !this.busy - ? html`
Start a conversation...
` + ? html` +
+
โœฆ
+
Start a conversation
+
+ Send a message below to begin chatting. +
+
+ ` : ''} ${this.messages.map( (msg) => html` @@ -154,17 +357,34 @@ export class SmartchatWindow extends LitElement { role=${msg.role} content=${msg.content} .toolName=${msg.toolName ?? ''} + .timestamp=${msg.timestamp ?? 0} > `, )} ${this.busy && this.streamingText ? html` -
- ${this.streamingText} +
+
โœฆ
+
+ ${this.streamingText} +
+
+ ` + : ''} + ${this.busy && !this.streamingText + ? html` +
+
โœฆ
+
+ + + +
` : ''}
+