feat(initial): scaffold @push.rocks/smartchat with core, CLI, and web layers

Three-layer architecture built on @push.rocks/smartagent:
- ts/ — ChatSession wrapping runAgent() with conversation state management
- ts_cli/ — ink-based terminal chat TUI (React.createElement, no JSX)
- ts_web/ — Lit web components (smartchat-window, smartchat-message, smartchat-input)
This commit is contained in:
2026-03-06 23:20:12 +00:00
commit dd04edb420
24 changed files with 11344 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist_*/
.nogit/
coverage/
*.tgz
+27
View File
@@ -0,0 +1,27 @@
{
"gitzone": {
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartchat",
"description": "Interactive chat interfaces for AI agents — CLI TUI and web components, built on @push.rocks/smartagent.",
"npmPackagename": "@push.rocks/smartchat",
"license": "MIT",
"projectDomain": "push.rocks"
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
},
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
}
}
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@push.rocks/smartchat",
"version": "0.0.1",
"private": false,
"description": "Interactive chat interfaces for AI agents — CLI TUI and web components, built on @push.rocks/smartagent.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
"exports": {
".": {
"import": "./dist_ts/index.js",
"types": "./dist_ts/index.d.ts"
},
"./cli": {
"import": "./dist_ts_cli/index.js",
"types": "./dist_ts_cli/index.d.ts"
},
"./web": {
"import": "./dist_ts_web/index.js",
"types": "./dist_ts_web/index.d.ts"
}
},
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --verbose --timeout 120)",
"build": "(tsbuild tsfolders --allowimplicitany)"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.3.0",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.3.0",
"@push.rocks/qenv": "^6.1.3",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0"
},
"dependencies": {
"@push.rocks/smartagent": "^3.0.1",
"@push.rocks/smartai": "^2.0.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",
"lit": "^3.2.0",
"react": "^19.0.0",
"zod": "^3.25.0"
},
"repository": {
"type": "git",
"url": "https://code.foss.global/push.rocks/smartchat.git"
},
"bugs": {
"url": "https://code.foss.global/push.rocks/smartchat/issues"
},
"homepage": "https://code.foss.global/push.rocks/smartchat#readme",
"files": [
"ts/**/*",
"ts_cli/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"assets/**/*",
"npmextra.json",
"readme.md"
],
"pnpm": {
"overrides": {}
}
}
+10291
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
# Project Readme Hints
## Overview
`@push.rocks/smartchat` v0.0.1 provides interactive chat interfaces for AI agents — CLI TUI and web components, built on `@push.rocks/smartagent`.
## Architecture
- **`ts/`** — Core: `ChatSession` wraps `runAgent()` with in-memory conversation state
- **`ts_cli/`** — CLI TUI using ink (React for CLI) with `React.createElement` (no JSX/TSX — tsbuild doesn't support `jsx` tsconfig option)
- **`ts_web/`** — Web components using Lit (no decorators — tsbuild uses TC39 decorators which are incompatible with Lit's legacy decorators; use `static properties` and `customElements.define()` instead)
## Key Dependencies
- `@push.rocks/smartagent ^3.0.1` — agentic loop, `runAgent()`
- `@push.rocks/smartai ^2.0.0` — model providers, types (`LanguageModelV3`, `ModelMessage`, `ToolSet`)
- `ink ^6.8.0` — React-based terminal UI
- `ink-text-input ^6.0.0` — text input component for ink
- `lit ^3.2.0` — web component framework
- `react ^19.0.0` — React for ink
## Build Notes
- **No JSX/TSX**: tsbuild crashes with `jsx` in tsconfig.json. All React components use `React.createElement` (aliased as `h`).
- **No Lit decorators**: tsbuild uses TC39 standard decorators which are incompatible with Lit's `@property`/`@customElement`. Use `static properties` and `customElements.define()`.
- **No ink-spinner**: Dropped due to JSX.Element type incompatibility with React 19. Uses simple text characters instead.
- Types from AI SDK (`ModelMessage`, `ToolSet`) are imported through `@push.rocks/smartai` (which re-exports them from `ai`).
- `IAgentRunResult.usage` uses `inputTokens`/`outputTokens` (not `promptTokens`/`completionTokens`).
## Package Exports
- `.` → Core (`ChatSession`, interfaces)
- `./cli` → CLI TUI (`startChat()`)
- `./web` → Web components (`SmartchatWindow`, `SmartchatMessage`, `SmartchatInput`)
+45
View File
@@ -0,0 +1,45 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartchat from '../ts/index.js';
tap.test('should export ChatSession class', async () => {
expect(smartchat.ChatSession).toBeTypeOf('function');
});
tap.test('ChatSession should initialize with options', async () => {
// Use a mock model (just needs to be an object)
const mockModel = {} as any;
const session = new smartchat.ChatSession({ model: mockModel });
expect(session).toBeInstanceOf(smartchat.ChatSession);
});
tap.test('ChatSession should track usage', async () => {
const mockModel = {} as any;
const session = new smartchat.ChatSession({ model: mockModel });
const usage = session.getUsage();
expect(usage.turns).toEqual(0);
expect(usage.totalTokens).toEqual(0);
});
tap.test('ChatSession should manage messages', async () => {
const mockModel = {} as any;
const session = new smartchat.ChatSession({ model: mockModel });
expect(session.getMessages()).toHaveLength(0);
// Set messages
const fakeMessages = [{ role: 'user' as const, content: 'hello' }] as any[];
session.setMessages(fakeMessages);
expect(session.getMessages()).toHaveLength(1);
// Clear
session.clear();
expect(session.getMessages()).toHaveLength(0);
});
tap.test('ChatSession should report busy state', async () => {
const mockModel = {} as any;
const session = new smartchat.ChatSession({ model: mockModel });
expect(session.isBusy()).toBeFalse();
});
export default tap.start();
+7
View File
@@ -0,0 +1,7 @@
export { ChatSession } from './smartchat.classes.chatsession.js';
export type {
IChatSessionOptions,
IChatCallbacks,
TChatEvent,
IChatUsage,
} from './smartchat.interfaces.js';
+7
View File
@@ -0,0 +1,7 @@
// @push.rocks/smartagent
import { runAgent } from '@push.rocks/smartagent';
export { runAgent };
export type { IAgentRunOptions, IAgentRunResult } from '@push.rocks/smartagent';
// @push.rocks/smartai (types re-exported from ai SDK)
export type { LanguageModelV3, ModelMessage, ToolSet } from '@push.rocks/smartai';
+103
View File
@@ -0,0 +1,103 @@
import { runAgent } from './plugins.js';
import type { ModelMessage, IAgentRunResult } from './plugins.js';
import type { IChatSessionOptions, IChatCallbacks, IChatUsage } from './smartchat.interfaces.js';
/**
* ChatSession manages in-memory conversation state and wraps `runAgent()`.
* Stateless by design — consumers are responsible for persistence.
*/
export class ChatSession {
private messages: ModelMessage[] = [];
private usage: IChatUsage = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
turns: 0,
};
private busy = false;
constructor(
private options: IChatSessionOptions,
private callbacks: IChatCallbacks = {},
) {}
/**
* Send a user message, run the agent, and return the result.
* Updates internal message history automatically.
*/
async send(userMessage: string): Promise<IAgentRunResult> {
if (this.busy) {
throw new Error('ChatSession is busy — wait for the current turn to complete.');
}
this.busy = true;
try {
const result = await runAgent({
model: this.options.model,
system: this.options.system,
prompt: userMessage,
tools: this.options.tools,
maxSteps: this.options.maxSteps ?? 20,
messages: this.messages,
onToken: this.callbacks.onToken,
onToolCall: this.callbacks.onToolCall,
});
// Update conversation history
this.messages = result.messages;
// Accumulate usage
this.usage.inputTokens += result.usage.inputTokens;
this.usage.outputTokens += result.usage.outputTokens;
this.usage.totalTokens += result.usage.totalTokens;
this.usage.turns++;
this.callbacks.onTurnComplete?.(result);
return result;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.callbacks.onError?.(err);
throw err;
} finally {
this.busy = false;
}
}
/** Whether a turn is currently in progress */
isBusy(): boolean {
return this.busy;
}
/** Get current conversation history */
getMessages(): ModelMessage[] {
return [...this.messages];
}
/** Replace messages (e.g. for restoring a saved session) */
setMessages(messages: ModelMessage[]): void {
this.messages = [...messages];
}
/** Clear conversation history and reset usage */
clear(): void {
this.messages = [];
this.usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turns: 0 };
}
/** Get accumulated token usage across all turns */
getUsage(): IChatUsage {
return { ...this.usage };
}
/** Update session options (model, system prompt, tools, etc.) */
updateOptions(options: Partial<IChatSessionOptions>): void {
Object.assign(this.options, options);
}
/** Update callbacks */
updateCallbacks(callbacks: Partial<IChatCallbacks>): void {
Object.assign(this.callbacks, callbacks);
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { LanguageModelV3, ModelMessage, ToolSet, IAgentRunResult } from './plugins.js';
/**
* Options for creating a ChatSession.
*/
export interface IChatSessionOptions {
/** The language model to use */
model: LanguageModelV3;
/** System prompt for the agent */
system?: string;
/** Tools available to the agent */
tools?: ToolSet;
/** Maximum agentic steps per turn */
maxSteps?: number;
}
/**
* Callbacks for real-time streaming events during a turn.
*/
export interface IChatCallbacks {
/** Called for each streamed text token */
onToken?: (delta: string) => void;
/** Called when a tool call starts */
onToolCall?: (name: string, input: unknown) => void;
/** Called when a tool call completes */
onToolResult?: (name: string, result: string) => void;
/** Called when a turn completes */
onTurnComplete?: (result: IAgentRunResult) => void;
/** Called on error */
onError?: (error: Error) => void;
}
/**
* Events emitted during chat.
*/
export type TChatEvent =
| { type: 'token'; delta: string }
| { type: 'tool_call_start'; name: string; input: unknown }
| { type: 'tool_call_end'; name: string; result: string }
| { type: 'turn_start' }
| { type: 'turn_complete'; result: IAgentRunResult }
| { type: 'error'; error: Error };
/**
* Usage stats accumulated across all turns.
*/
export interface IChatUsage {
inputTokens: number;
outputTokens: number;
totalTokens: number;
turns: number;
}
+103
View File
@@ -0,0 +1,103 @@
import { React, h, Box, Text, useInput, useApp, ChatSession } from './plugins.js';
import type { LanguageModelV3, ToolSet, IChatUsage } from './plugins.js';
import { MessageList } from './components.messagelist.js';
import { InputArea } from './components.inputarea.js';
import { StatusBar } from './components.statusbar.js';
import type { IChatMessage } from './components.message.js';
export interface IChatAppProps {
model: LanguageModelV3;
system?: string;
tools?: ToolSet;
maxSteps?: number;
modelName?: string;
}
const emptyUsage: IChatUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turns: 0 };
export function ChatApp({ model, system, tools, maxSteps, modelName }: IChatAppProps): React.ReactElement {
const { exit } = useApp();
const [messages, setMessages] = React.useState<IChatMessage[]>([]);
const [streamingText, setStreamingText] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [usage, setUsage] = React.useState<IChatUsage>({ ...emptyUsage });
const sessionRef = React.useRef<ChatSession | null>(null);
if (!sessionRef.current) {
sessionRef.current = new ChatSession(
{ model, system, tools, maxSteps: maxSteps ?? 20 },
{
onToken: (delta) => {
setStreamingText((prev) => prev + delta);
},
onToolCall: (name, input) => {
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
content: '',
toolName: name,
toolInput: typeof input === 'string' ? input : JSON.stringify(input),
},
]);
},
},
);
}
useInput((input, key) => {
if (input === 'c' && key.ctrl) {
exit();
}
if (input === 'l' && key.ctrl) {
setMessages([]);
setStreamingText('');
sessionRef.current?.clear();
setUsage({ ...emptyUsage });
}
});
const handleSubmit = async (text: string) => {
if (busy) return;
if (text === '/clear') {
setMessages([]);
setStreamingText('');
sessionRef.current?.clear();
setUsage({ ...emptyUsage });
return;
}
if (text === '/quit' || text === '/exit') {
exit();
return;
}
setMessages((prev) => [...prev, { role: 'user', content: text }]);
setBusy(true);
setStreamingText('');
try {
const result = await sessionRef.current!.send(text);
setStreamingText('');
setMessages((prev) => [...prev, { role: 'assistant', content: result.text }]);
setUsage(sessionRef.current!.getUsage());
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
setMessages((prev) => [...prev, { role: 'assistant', content: `Error: ${errMsg}` }]);
} finally {
setBusy(false);
}
};
return h(Box, { flexDirection: 'column' as const },
h(Box, { paddingX: 1 },
h(Text, { bold: true, color: 'cyan' }, 'smartchat'),
modelName ? h(Text, { dimColor: true }, ` \u2014 ${modelName}`) : null,
),
h(MessageList, { messages, streamingText, busy }),
h(InputArea, { disabled: busy, onSubmit: handleSubmit }),
h(StatusBar, { modelName, usage, busy }),
);
}
+29
View File
@@ -0,0 +1,29 @@
import { React, h, Box, Text, TextInput } from './plugins.js';
interface IInputAreaProps {
disabled: boolean;
onSubmit: (text: string) => void;
}
export function InputArea({ disabled, onSubmit }: IInputAreaProps): React.ReactElement {
const [value, setValue] = React.useState('');
const handleSubmit = (text: string) => {
if (!text.trim()) return;
onSubmit(text.trim());
setValue('');
};
return h(
Box,
{ borderStyle: 'single' as const, borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1 },
h(Text, { bold: true, color: disabled ? 'gray' : 'cyan' }, '> '),
h(TextInput, {
value,
onChange: setValue,
onSubmit: handleSubmit,
placeholder: disabled ? 'Waiting for response...' : 'Type a message...',
focus: !disabled,
}),
);
}
+48
View File
@@ -0,0 +1,48 @@
import { React, h, Box, Text } from './plugins.js';
export interface IChatMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
toolName?: string;
toolInput?: string;
}
interface IMessageProps {
message: IChatMessage;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.substring(0, maxLen) + '...';
}
export function Message({ message }: IMessageProps): React.ReactElement | null {
switch (message.role) {
case 'user':
return h(Box, { marginY: 0 },
h(Text, { bold: true, color: 'blue' }, 'You: '),
h(Text, null, message.content),
);
case 'assistant':
return h(Box, { marginY: 0 },
h(Text, { bold: true, color: 'green' }, 'Assistant: '),
h(Text, null, message.content),
);
case 'tool':
return h(Box, { marginY: 0, marginLeft: 2, flexDirection: 'column' as const },
h(Text, { dimColor: true },
`tool: ${message.toolName}${message.toolInput ? ` (${message.toolInput})` : ''}`,
),
message.content
? h(Box, { marginLeft: 2 },
h(Text, { dimColor: true }, truncate(message.content, 200)),
)
: null,
);
default:
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { React, h, Box, Text } from './plugins.js';
import { Message, type IChatMessage } from './components.message.js';
interface IMessageListProps {
messages: IChatMessage[];
streamingText: string;
busy: boolean;
}
export function MessageList({ messages, streamingText, busy }: IMessageListProps): React.ReactElement {
const children: React.ReactNode[] = [];
if (messages.length === 0 && !busy) {
children.push(
h(Box, { justifyContent: 'center', marginY: 1, key: 'empty' },
h(Text, { dimColor: true }, 'Type a message to start chatting.'),
),
);
}
for (let i = 0; i < messages.length; i++) {
children.push(h(Message, { key: `msg-${i}`, message: messages[i]! }));
}
if (busy && streamingText) {
children.push(
h(Box, { marginY: 0, key: 'streaming' },
h(Text, { bold: true, color: 'green' }, 'Assistant: '),
h(Text, null, streamingText),
h(Text, { dimColor: true }, '\u2588'),
),
);
}
if (busy && !streamingText) {
children.push(
h(Box, { marginY: 0, key: 'spinner' },
h(Text, { color: 'yellow' }, '\u25CF'),
h(Text, { dimColor: true }, ' Thinking...'),
),
);
}
return h(Box, { flexDirection: 'column' as const, flexGrow: 1 }, ...children);
}
+31
View File
@@ -0,0 +1,31 @@
import { React, h, Box, Text } from './plugins.js';
import type { IChatUsage } from './plugins.js';
interface IStatusBarProps {
modelName?: string;
usage: IChatUsage;
busy: boolean;
}
export function StatusBar({ modelName, usage, busy }: IStatusBarProps): React.ReactElement {
const children: React.ReactNode[] = [
h(Text, { dimColor: true, key: 'model' }, modelName ? `model: ${modelName}` : 'smartchat'),
h(Text, { dimColor: true, key: 'sep1' }, ' | '),
h(Text, { dimColor: true, key: 'tokens' }, `tokens: ${usage.totalTokens.toLocaleString()}`),
h(Text, { dimColor: true, key: 'sep2' }, ' | '),
h(Text, { dimColor: true, key: 'turns' }, `turns: ${usage.turns}`),
];
if (busy) {
children.push(
h(Text, { dimColor: true, key: 'sep3' }, ' | '),
h(Text, { color: 'yellow', key: 'thinking' }, 'thinking...'),
);
}
return h(
Box,
{ borderStyle: 'single' as const, borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1 },
...children,
);
}
+2
View File
@@ -0,0 +1,2 @@
export { startChat } from './smartchat.startchat.js';
export type { IStartChatOptions } from './smartchat.startchat.js';
+21
View File
@@ -0,0 +1,21 @@
// ink (React-based terminal UI)
import { render, Box, Text, Static, Newline, Spacer, useInput, useApp } from 'ink';
export { render, Box, Text, Static, Newline, Spacer, useInput, useApp };
// ink components
import TextInput from 'ink-text-input';
export { TextInput };
// React
import React from 'react';
export { React };
export const h = React.createElement;
// Core (cross-folder import)
import { ChatSession } from '../ts/smartchat.classes.chatsession.js';
export { ChatSession };
export type { IChatSessionOptions, IChatCallbacks, IChatUsage } from '../ts/smartchat.interfaces.js';
// smartagent types
export type { IAgentRunResult } from '@push.rocks/smartagent';
export type { LanguageModelV3, ToolSet } from '@push.rocks/smartai';
+34
View File
@@ -0,0 +1,34 @@
import { React, h, render } from './plugins.js';
import type { LanguageModelV3, ToolSet } from './plugins.js';
import { ChatApp } from './components.chatapp.js';
export interface IStartChatOptions {
/** The language model to use */
model: LanguageModelV3;
/** System prompt for the agent */
system?: string;
/** Tools available to the agent */
tools?: ToolSet;
/** Maximum agentic steps per turn */
maxSteps?: number;
/** Display name for the model (shown in header/status) */
modelName?: string;
}
/**
* Start an interactive chat TUI in the terminal.
* Returns a promise that resolves when the user exits.
*/
export async function startChat(options: IStartChatOptions): Promise<void> {
const instance = render(
h(ChatApp, {
model: options.model,
system: options.system,
tools: options.tools,
maxSteps: options.maxSteps,
modelName: options.modelName,
}),
);
await instance.waitUntilExit();
}
+3
View File
@@ -0,0 +1,3 @@
export { SmartchatWindow } from './smartchat-window.js';
export { SmartchatMessage } from './smartchat-message.js';
export { SmartchatInput } from './smartchat-input.js';
+10
View File
@@ -0,0 +1,10 @@
// Lit
import { LitElement, html, css } from 'lit';
export { LitElement, html, css };
export type { TemplateResult, CSSResult } from 'lit';
// Core (cross-folder import)
import { ChatSession } from '../ts/smartchat.classes.chatsession.js';
export { ChatSession };
export type { IChatSessionOptions, IChatCallbacks, IChatUsage } from '../ts/smartchat.interfaces.js';
export type { IAgentRunResult } from '@push.rocks/smartagent';
+116
View File
@@ -0,0 +1,116 @@
import { LitElement, html, css } from './plugins.js';
import type { CSSResult, TemplateResult } from './plugins.js';
export class SmartchatInput extends LitElement {
declare disabled: boolean;
declare placeholder: string;
private value = '';
static properties = {
disabled: { type: Boolean },
placeholder: { type: String },
};
static styles: CSSResult = css`
:host {
display: block;
}
.input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--smartchat-input-bg, #1f2937);
border-radius: 8px;
border: 1px solid var(--smartchat-input-border, #374151);
}
.input-row:focus-within {
border-color: var(--smartchat-input-focus, #6366f1);
}
input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--smartchat-input-text, #e5e7eb);
font-size: 14px;
font-family: inherit;
}
input::placeholder {
color: var(--smartchat-input-placeholder, #6b7280);
}
button {
background: var(--smartchat-send-bg, #6366f1);
color: var(--smartchat-send-text, #fff);
border: none;
border-radius: 6px;
padding: 6px 16px;
cursor: pointer;
font-size: 14px;
}
button:hover {
opacity: 0.9;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
constructor() {
super();
this.disabled = false;
this.placeholder = 'Type a message...';
}
private handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && this.value.trim()) {
this.submit();
}
}
private handleInput(e: Event) {
this.value = (e.target as HTMLInputElement).value;
}
private submit() {
if (!this.value.trim() || this.disabled) return;
this.dispatchEvent(
new CustomEvent('send', {
detail: { text: this.value.trim() },
bubbles: true,
composed: true,
}),
);
this.value = '';
const input = this.shadowRoot?.querySelector('input');
if (input) input.value = '';
}
render(): TemplateResult {
return html`
<div class="input-row">
<input
type="text"
.value=${this.value}
@input=${this.handleInput}
@keydown=${this.handleKeydown}
placeholder=${this.disabled ? 'Waiting for response...' : this.placeholder}
?disabled=${this.disabled}
/>
<button @click=${this.submit} ?disabled=${this.disabled}>
Send
</button>
</div>
`;
}
}
customElements.define('smartchat-input', SmartchatInput);
+78
View File
@@ -0,0 +1,78 @@
import { LitElement, html, css } from './plugins.js';
import type { CSSResult, TemplateResult } from './plugins.js';
export class SmartchatMessage extends LitElement {
declare role: 'user' | 'assistant' | 'tool';
declare content: string;
declare toolName: string;
static properties = {
role: { type: String },
content: { type: String },
toolName: { type: String },
};
static styles: CSSResult = css`
:host {
display: block;
margin-bottom: 8px;
}
.message {
padding: 8px 12px;
border-radius: 8px;
max-width: 85%;
word-wrap: break-word;
white-space: pre-wrap;
}
.user {
background: var(--smartchat-user-bubble, #2563eb);
color: var(--smartchat-user-text, #fff);
margin-left: auto;
border-bottom-right-radius: 2px;
}
.assistant {
background: var(--smartchat-assistant-bubble, #374151);
color: var(--smartchat-assistant-text, #e5e7eb);
margin-right: auto;
border-bottom-left-radius: 2px;
}
.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-name {
font-weight: bold;
margin-bottom: 4px;
color: var(--smartchat-tool-accent, #6366f1);
}
`;
constructor() {
super();
this.role = 'user';
this.content = '';
this.toolName = '';
}
render(): TemplateResult {
return html`
<div class="message ${this.role}">
${this.role === 'tool' && this.toolName
? html`<div class="tool-name">${this.toolName}</div>`
: ''}
<div>${this.content}</div>
</div>
`;
}
}
customElements.define('smartchat-message', SmartchatMessage);
+179
View File
@@ -0,0 +1,179 @@
import { LitElement, html, css } from './plugins.js';
import type { CSSResult, TemplateResult, ChatSession, IAgentRunResult } from './plugins.js';
import './smartchat-message.js';
import './smartchat-input.js';
interface IDisplayMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
toolName?: string;
}
export class SmartchatWindow extends LitElement {
declare chatSession: ChatSession;
declare placeholder: string;
private messages: IDisplayMessage[] = [];
private busy = false;
private streamingText = '';
static properties = {
chatSession: { attribute: false },
placeholder: { type: String },
};
static styles: CSSResult = css`
:host {
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);
overflow: hidden;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--smartchat-muted, #6b7280);
font-size: 14px;
}
.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%;
white-space: pre-wrap;
}
.cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--smartchat-cursor, #6366f1);
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@keyframes blink {
50% { opacity: 0; }
}
.input-area {
padding: 12px 16px;
border-top: 1px solid var(--smartchat-border, #1f2937);
}
`;
constructor() {
super();
this.placeholder = 'Type a message...';
}
connectedCallback() {
super.connectedCallback();
if (this.chatSession) {
this.chatSession.updateCallbacks({
onToken: (delta: string) => {
this.streamingText += delta;
this.requestUpdate();
this.scrollToBottom();
},
onToolCall: (name: string) => {
this.messages = [
...this.messages,
{ role: 'tool', content: '', toolName: name },
];
this.requestUpdate();
this.scrollToBottom();
},
});
}
}
private scrollToBottom() {
requestAnimationFrame(() => {
const list = this.shadowRoot?.querySelector('.message-list');
if (list) {
list.scrollTop = list.scrollHeight;
}
});
}
private async handleSend(e: CustomEvent<{ text: string }>) {
if (this.busy) return;
const text = e.detail.text;
this.messages = [...this.messages, { role: 'user', content: text }];
this.busy = true;
this.streamingText = '';
this.requestUpdate();
this.scrollToBottom();
try {
const result = await this.chatSession.send(text);
this.streamingText = '';
this.messages = [...this.messages, { role: 'assistant', content: result.text }];
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
this.messages = [
...this.messages,
{ role: 'assistant', content: `Error: ${errMsg}` },
];
} finally {
this.busy = false;
this.requestUpdate();
this.scrollToBottom();
}
}
render(): TemplateResult {
return html`
<div class="message-list">
${this.messages.length === 0 && !this.busy
? html`<div class="empty-state">Start a conversation...</div>`
: ''}
${this.messages.map(
(msg) => html`
<smartchat-message
role=${msg.role}
content=${msg.content}
.toolName=${msg.toolName ?? ''}
></smartchat-message>
`,
)}
${this.busy && this.streamingText
? html`
<div class="streaming">
${this.streamingText}<span class="cursor"></span>
</div>
`
: ''}
</div>
<div class="input-area">
<smartchat-input
?disabled=${this.busy}
placeholder=${this.placeholder}
@send=${this.handleSend}
></smartchat-input>
</div>
`;
}
}
customElements.define('smartchat-window', SmartchatWindow);
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
},
"exclude": ["dist_*/**/*.d.ts"]
}