feat: communication spine — gateway, TUI, Discord #61
@@ -5,7 +5,7 @@
|
|||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsx watch src/main.ts",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
@@ -14,12 +14,19 @@
|
|||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-fastify": "^11.0.0",
|
"@nestjs/platform-fastify": "^11.0.0",
|
||||||
|
"@nestjs/platform-socket.io": "^11.0.0",
|
||||||
|
"@nestjs/websockets": "^11.0.0",
|
||||||
|
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0"
|
"rxjs": "^7.8.0",
|
||||||
|
"socket.io": "^4.8.0",
|
||||||
|
"uuid": "^11.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
9
apps/gateway/src/agent/agent.module.ts
Normal file
9
apps/gateway/src/agent/agent.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AgentService } from './agent.service.js';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AgentService],
|
||||||
|
exports: [AgentService],
|
||||||
|
})
|
||||||
|
export class AgentModule {}
|
||||||
131
apps/gateway/src/agent/agent.service.ts
Normal file
131
apps/gateway/src/agent/agent.service.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
createAgentSession,
|
||||||
|
SessionManager,
|
||||||
|
type AgentSession as PiAgentSession,
|
||||||
|
type AgentSessionEvent,
|
||||||
|
} from '@mariozechner/pi-coding-agent';
|
||||||
|
|
||||||
|
export interface AgentSession {
|
||||||
|
id: string;
|
||||||
|
piSession: PiAgentSession;
|
||||||
|
listeners: Set<(event: AgentSessionEvent) => void>;
|
||||||
|
unsubscribe: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentService implements OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(AgentService.name);
|
||||||
|
private readonly sessions = new Map<string, AgentSession>();
|
||||||
|
private readonly creating = new Map<string, Promise<AgentSession>>();
|
||||||
|
|
||||||
|
async createSession(sessionId: string): Promise<AgentSession> {
|
||||||
|
const existing = this.sessions.get(sessionId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const inflight = this.creating.get(sessionId);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
|
||||||
|
const promise = this.doCreateSession(sessionId).finally(() => {
|
||||||
|
this.creating.delete(sessionId);
|
||||||
|
});
|
||||||
|
this.creating.set(sessionId, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doCreateSession(sessionId: string): Promise<AgentSession> {
|
||||||
|
this.logger.log(`Creating agent session: ${sessionId}`);
|
||||||
|
|
||||||
|
let piSession: PiAgentSession;
|
||||||
|
try {
|
||||||
|
const result = await createAgentSession({
|
||||||
|
sessionManager: SessionManager.inMemory(),
|
||||||
|
tools: [],
|
||||||
|
});
|
||||||
|
piSession = result.session;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create Pi SDK session for ${sessionId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
throw new Error(`Agent session creation failed for ${sessionId}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = new Set<(event: AgentSessionEvent) => void>();
|
||||||
|
|
||||||
|
const unsubscribe = piSession.subscribe((event) => {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Event listener error in session ${sessionId}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const session: AgentSession = {
|
||||||
|
id: sessionId,
|
||||||
|
piSession,
|
||||||
|
listeners,
|
||||||
|
unsubscribe,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sessions.set(sessionId, session);
|
||||||
|
this.logger.log(`Agent session ${sessionId} ready`);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(sessionId: string): AgentSession | undefined {
|
||||||
|
return this.sessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(sessionId: string, message: string): Promise<void> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await session.piSession.prompt(message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Pi SDK prompt failed for session=${sessionId}, messageLength=${message.length}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
|
}
|
||||||
|
session.listeners.add(listener);
|
||||||
|
return () => session.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySession(sessionId: string): Promise<void> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||||
|
try {
|
||||||
|
session.unsubscribe();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to unsubscribe Pi session ${sessionId}`, String(err));
|
||||||
|
}
|
||||||
|
session.listeners.clear();
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.logger.log('Shutting down all agent sessions');
|
||||||
|
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||||
|
const results = await Promise.allSettled(stops);
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
this.logger.error('Session shutdown failure', String(result.reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HealthController } from './health/health.controller.js';
|
import { HealthController } from './health/health.controller.js';
|
||||||
|
import { AgentModule } from './agent/agent.module.js';
|
||||||
|
import { ChatModule } from './chat/chat.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AgentModule, ChatModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
75
apps/gateway/src/chat/chat.controller.ts
Normal file
75
apps/gateway/src/chat/chat.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Controller, Post, Body, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
interface ChatRequest {
|
||||||
|
conversationId?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatResponse {
|
||||||
|
conversationId: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/chat')
|
||||||
|
export class ChatController {
|
||||||
|
private readonly logger = new Logger(ChatController.name);
|
||||||
|
|
||||||
|
constructor(private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async chat(@Body() body: ChatRequest): Promise<ChatResponse> {
|
||||||
|
const conversationId = body.conversationId ?? uuid();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
|
if (!agentSession) {
|
||||||
|
agentSession = await this.agentService.createSession(conversationId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Session creation failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
||||||
|
reject(new Error('Agent response timed out'));
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||||
|
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||||
|
responseText += event.assistantMessageEvent.delta;
|
||||||
|
}
|
||||||
|
if (event.type === 'agent_end') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.agentService.prompt(conversationId, body.content);
|
||||||
|
await done;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('timed out')) {
|
||||||
|
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
|
||||||
|
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conversationId, text: responseText };
|
||||||
|
}
|
||||||
|
}
|
||||||
164
apps/gateway/src/chat/chat.gateway.ts
Normal file
164
apps/gateway/src/chat/chat.gateway.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
type OnGatewayInit,
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
conversationId?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: { origin: '*' },
|
||||||
|
namespace: '/chat',
|
||||||
|
})
|
||||||
|
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(ChatGateway.name);
|
||||||
|
private readonly clientSessions = new Map<
|
||||||
|
string,
|
||||||
|
{ conversationId: string; cleanup: () => void }
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor(private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
|
afterInit(): void {
|
||||||
|
this.logger.log('Chat WebSocket gateway initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnection(client: Socket): void {
|
||||||
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
this.logger.log(`Client disconnected: ${client.id}`);
|
||||||
|
const session = this.clientSessions.get(client.id);
|
||||||
|
if (session) {
|
||||||
|
session.cleanup();
|
||||||
|
this.clientSessions.delete(client.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('message')
|
||||||
|
async handleMessage(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: ChatMessage,
|
||||||
|
): Promise<void> {
|
||||||
|
const conversationId = data.conversationId ?? uuid();
|
||||||
|
|
||||||
|
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||||
|
|
||||||
|
// Ensure agent session exists for this conversation
|
||||||
|
try {
|
||||||
|
let agentSession = this.agentService.getSession(conversationId);
|
||||||
|
if (!agentSession) {
|
||||||
|
agentSession = await this.agentService.createSession(conversationId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Session creation failed for client=${client.id}, conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
client.emit('error', {
|
||||||
|
conversationId,
|
||||||
|
error: 'Failed to start agent session. Please try again.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clean up previous listener to prevent leak
|
||||||
|
const existing = this.clientSessions.get(client.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to agent events and relay to client
|
||||||
|
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||||
|
this.relayEvent(client, conversationId, event);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clientSessions.set(client.id, { conversationId, cleanup });
|
||||||
|
|
||||||
|
// Send acknowledgment
|
||||||
|
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||||
|
|
||||||
|
// Dispatch to agent
|
||||||
|
try {
|
||||||
|
await this.agentService.prompt(conversationId, data.content);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
client.emit('error', {
|
||||||
|
conversationId,
|
||||||
|
error: 'The agent failed to process your message. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
|
if (!client.connected) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Dropping event ${event.type} for disconnected client=${client.id}, conversation=${conversationId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'agent_start':
|
||||||
|
client.emit('agent:start', { conversationId });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'agent_end':
|
||||||
|
client.emit('agent:end', { conversationId });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_update': {
|
||||||
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
if (assistantEvent.type === 'text_delta') {
|
||||||
|
client.emit('agent:text', {
|
||||||
|
conversationId,
|
||||||
|
text: assistantEvent.delta,
|
||||||
|
});
|
||||||
|
} else if (assistantEvent.type === 'thinking_delta') {
|
||||||
|
client.emit('agent:thinking', {
|
||||||
|
conversationId,
|
||||||
|
text: assistantEvent.delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_execution_start':
|
||||||
|
client.emit('agent:tool:start', {
|
||||||
|
conversationId,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
toolName: event.toolName,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_execution_end':
|
||||||
|
client.emit('agent:tool:end', {
|
||||||
|
conversationId,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
toolName: event.toolName,
|
||||||
|
isError: event.isError,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/gateway/src/chat/chat.module.ts
Normal file
9
apps/gateway/src/chat/chat.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
|
import { ChatController } from './chat.controller.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [ChatGateway],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/cli",
|
"name": "@mosaic/cli",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"mosaic": "dist/index.js"
|
"mosaic": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
@@ -14,11 +15,22 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"dev": "tsx src/cli.ts",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ink": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"ink-spinner": "^5.0.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"socket.io-client": "^4.8.0",
|
||||||
|
"commander": "^13.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
28
packages/cli/src/cli.ts
Normal file
28
packages/cli/src/cli.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program.name('mosaic').description('Mosaic Stack CLI').version('0.0.0');
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('tui')
|
||||||
|
.description('Launch interactive TUI connected to the gateway')
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
||||||
|
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||||||
|
.action(async (opts: { gateway: string; conversation?: string }) => {
|
||||||
|
// Dynamic import to avoid loading React/Ink for other commands
|
||||||
|
const { render } = await import('ink');
|
||||||
|
const React = await import('react');
|
||||||
|
const { TuiApp } = await import('./tui/app.js');
|
||||||
|
|
||||||
|
render(
|
||||||
|
React.createElement(TuiApp, {
|
||||||
|
gatewayUrl: opts.gateway,
|
||||||
|
conversationId: opts.conversation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
164
packages/cli/src/tui/app.tsx
Normal file
164
packages/cli/src/tui/app.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
@@ -11,11 +11,17 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"discord.js": "^14.16.0",
|
||||||
|
"socket.io-client": "^4.8.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,185 @@
|
|||||||
export const VERSION = '0.0.0';
|
import { Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||||
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
export interface DiscordPluginConfig {
|
||||||
|
token: string;
|
||||||
|
gatewayUrl: string;
|
||||||
|
/** Which guild to bind to (single-guild only for v0.1.0) */
|
||||||
|
guildId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiscordPlugin {
|
||||||
|
private client: Client;
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private config: DiscordPluginConfig;
|
||||||
|
/** Map Discord channel ID → Mosaic conversation ID */
|
||||||
|
private channelConversations = new Map<string, string>();
|
||||||
|
/** Track in-flight responses to avoid duplicate streaming */
|
||||||
|
private pendingResponses = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(config: DiscordPluginConfig) {
|
||||||
|
this.config = config;
|
||||||
|
this.client = new Client({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.DirectMessages,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
// Connect to gateway WebSocket
|
||||||
|
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('[discord] Connected to gateway');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', (reason: string) => {
|
||||||
|
console.error(`[discord] Disconnected from gateway: ${reason}`);
|
||||||
|
this.pendingResponses.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (err: Error) => {
|
||||||
|
console.error(`[discord] Gateway connection error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle streaming text from gateway
|
||||||
|
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||||
|
const pending = this.pendingResponses.get(data.conversationId);
|
||||||
|
if (pending !== undefined) {
|
||||||
|
this.pendingResponses.set(data.conversationId, pending + data.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When agent finishes, send the accumulated response
|
||||||
|
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
||||||
|
const text = this.pendingResponses.get(data.conversationId);
|
||||||
|
if (text) {
|
||||||
|
this.pendingResponses.delete(data.conversationId);
|
||||||
|
this.sendToDiscord(data.conversationId, text).catch((err) => {
|
||||||
|
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('agent:start', (data: { conversationId: string }) => {
|
||||||
|
this.pendingResponses.set(data.conversationId, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up Discord message handler
|
||||||
|
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
|
||||||
|
|
||||||
|
this.client.on('ready', () => {
|
||||||
|
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.login(this.config.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.socket?.disconnect();
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDiscordMessage(message: DiscordMessage): void {
|
||||||
|
// Ignore bot messages
|
||||||
|
if (message.author.bot) return;
|
||||||
|
|
||||||
|
// Not ready yet
|
||||||
|
if (!this.client.user) return;
|
||||||
|
|
||||||
|
// Check guild binding
|
||||||
|
if (this.config.guildId && message.guildId !== this.config.guildId) return;
|
||||||
|
|
||||||
|
// Respond to DMs always, or mentions in channels
|
||||||
|
const isDM = !message.guildId;
|
||||||
|
const isMention = message.mentions.has(this.client.user);
|
||||||
|
|
||||||
|
if (!isDM && !isMention) return;
|
||||||
|
|
||||||
|
// Strip bot mention from message content
|
||||||
|
const content = message.content
|
||||||
|
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
// Get or create conversation for this Discord channel
|
||||||
|
const channelId = message.channelId;
|
||||||
|
let conversationId = this.channelConversations.get(channelId);
|
||||||
|
if (!conversationId) {
|
||||||
|
conversationId = `discord-${channelId}`;
|
||||||
|
this.channelConversations.set(channelId, conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to gateway
|
||||||
|
if (!this.socket?.connected) {
|
||||||
|
console.error(
|
||||||
|
`[discord] Cannot forward message: not connected to gateway. channel=${channelId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.socket.emit('message', {
|
||||||
|
conversationId,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
|
||||||
|
// Find the Discord channel for this conversation
|
||||||
|
const channelId = Array.from(this.channelConversations.entries()).find(
|
||||||
|
([, convId]) => convId === conversationId,
|
||||||
|
)?.[0];
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
console.error(`[discord] No channel found for conversation ${conversationId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = this.client.channels.cache.get(channelId);
|
||||||
|
if (!channel || !('send' in channel)) {
|
||||||
|
console.error(
|
||||||
|
`[discord] Channel ${channelId} not sendable for conversation ${conversationId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk responses for Discord's 2000-char limit
|
||||||
|
const chunks = this.chunkText(text, 1900);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
|
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private chunkText(text: string, maxLength: number): string[] {
|
||||||
|
if (text.length <= maxLength) return [text];
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
if (remaining.length <= maxLength) {
|
||||||
|
chunks.push(remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to break at a newline
|
||||||
|
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
||||||
|
if (breakPoint <= 0) breakPoint = maxLength;
|
||||||
|
|
||||||
|
chunks.push(remaining.slice(0, breakPoint));
|
||||||
|
remaining = remaining.slice(breakPoint).trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
4350
pnpm-lock.yaml
generated
4350
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user