Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
165 lines
4.4 KiB
TypeScript
165 lines
4.4 KiB
TypeScript
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
import { Box, Text, useInput, useApp } from 'ink';
|
|
import TextInput from 'ink-text-input';
|
|
import Spinner from 'ink-spinner';
|
|
import { io, type Socket } from 'socket.io-client';
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
interface TuiAppProps {
|
|
gatewayUrl: string;
|
|
conversationId?: string;
|
|
}
|
|
|
|
export function TuiApp({ gatewayUrl, conversationId: initialConversationId }: TuiAppProps) {
|
|
const { exit } = useApp();
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [connected, setConnected] = useState(false);
|
|
const [conversationId, setConversationId] = useState(initialConversationId);
|
|
const [currentStreamText, setCurrentStreamText] = useState('');
|
|
const socketRef = useRef<Socket | null>(null);
|
|
|
|
useEffect(() => {
|
|
const socket = io(`${gatewayUrl}/chat`, {
|
|
transports: ['websocket'],
|
|
});
|
|
|
|
socketRef.current = socket;
|
|
|
|
socket.on('connect', () => setConnected(true));
|
|
socket.on('disconnect', () => {
|
|
setConnected(false);
|
|
setIsStreaming(false);
|
|
setCurrentStreamText('');
|
|
});
|
|
socket.on('connect_error', (err: Error) => {
|
|
setMessages((msgs) => [
|
|
...msgs,
|
|
{
|
|
role: 'assistant',
|
|
content: `Connection failed: ${err.message}. Check that the gateway is running at ${gatewayUrl}.`,
|
|
},
|
|
]);
|
|
});
|
|
|
|
socket.on('message:ack', (data: { conversationId: string }) => {
|
|
setConversationId(data.conversationId);
|
|
});
|
|
|
|
socket.on('agent:start', () => {
|
|
setIsStreaming(true);
|
|
setCurrentStreamText('');
|
|
});
|
|
|
|
socket.on('agent:text', (data: { text: string }) => {
|
|
setCurrentStreamText((prev) => prev + data.text);
|
|
});
|
|
|
|
socket.on('agent:end', () => {
|
|
setCurrentStreamText((prev) => {
|
|
if (prev) {
|
|
setMessages((msgs) => [...msgs, { role: 'assistant', content: prev }]);
|
|
}
|
|
return '';
|
|
});
|
|
setIsStreaming(false);
|
|
});
|
|
|
|
socket.on('error', (data: { error: string }) => {
|
|
setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]);
|
|
setIsStreaming(false);
|
|
});
|
|
|
|
return () => {
|
|
socket.disconnect();
|
|
};
|
|
}, [gatewayUrl]);
|
|
|
|
const handleSubmit = useCallback(
|
|
(value: string) => {
|
|
if (!value.trim() || isStreaming) return;
|
|
if (!socketRef.current?.connected) {
|
|
setMessages((msgs) => [
|
|
...msgs,
|
|
{ role: 'assistant', content: 'Not connected to gateway. Message not sent.' },
|
|
]);
|
|
return;
|
|
}
|
|
|
|
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
|
setInput('');
|
|
|
|
socketRef.current.emit('message', {
|
|
conversationId,
|
|
content: value,
|
|
});
|
|
},
|
|
[conversationId, isStreaming],
|
|
);
|
|
|
|
useInput((ch, key) => {
|
|
if (key.ctrl && ch === 'c') {
|
|
exit();
|
|
}
|
|
});
|
|
|
|
return (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Box marginBottom={1}>
|
|
<Text bold color="blue">
|
|
Mosaic
|
|
</Text>
|
|
<Text> </Text>
|
|
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
|
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
|
</Box>
|
|
|
|
<Box flexDirection="column" marginBottom={1}>
|
|
{messages.map((msg, i) => (
|
|
<Box key={i} marginBottom={1}>
|
|
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
|
{msg.role === 'user' ? '> ' : ' '}
|
|
</Text>
|
|
<Text wrap="wrap">{msg.content}</Text>
|
|
</Box>
|
|
))}
|
|
|
|
{isStreaming && currentStreamText && (
|
|
<Box marginBottom={1}>
|
|
<Text bold color="cyan">
|
|
{' '}
|
|
</Text>
|
|
<Text wrap="wrap">{currentStreamText}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{isStreaming && !currentStreamText && (
|
|
<Box>
|
|
<Text color="cyan">
|
|
<Spinner type="dots" />
|
|
</Text>
|
|
<Text dimColor> thinking...</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Box>
|
|
<Text bold color="green">
|
|
{'> '}
|
|
</Text>
|
|
<TextInput
|
|
value={input}
|
|
onChange={setInput}
|
|
onSubmit={handleSubmit}
|
|
placeholder={isStreaming ? 'waiting...' : 'type a message'}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|