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:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist_*/
|
||||
.nogit/
|
||||
coverage/
|
||||
*.tgz
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
Generated
+10291
File diff suppressed because it is too large
Load Diff
@@ -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`)
|
||||
@@ -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();
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ChatSession } from './smartchat.classes.chatsession.js';
|
||||
export type {
|
||||
IChatSessionOptions,
|
||||
IChatCallbacks,
|
||||
TChatEvent,
|
||||
IChatUsage,
|
||||
} from './smartchat.interfaces.js';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { startChat } from './smartchat.startchat.js';
|
||||
export type { IStartChatOptions } from './smartchat.startchat.js';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { SmartchatWindow } from './smartchat-window.js';
|
||||
export { SmartchatMessage } from './smartchat-message.js';
|
||||
export { SmartchatInput } from './smartchat-input.js';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
},
|
||||
"exclude": ["dist_*/**/*.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user