@townco/ui 0.1.26 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/hooks/use-chat-messages.js +3 -13
- package/dist/core/hooks/use-chat-session.d.ts +2 -1
- package/dist/core/hooks/use-chat-session.js +119 -8
- package/dist/core/lib/logger.d.ts +59 -0
- package/dist/core/lib/logger.js +191 -0
- package/dist/sdk/client/acp-client.d.ts +5 -0
- package/dist/sdk/client/acp-client.js +51 -0
- package/dist/sdk/transports/http.d.ts +6 -0
- package/dist/sdk/transports/http.js +159 -30
- package/dist/sdk/transports/types.d.ts +4 -0
- package/dist/tui/components/LogsPanel.d.ts +5 -0
- package/dist/tui/components/LogsPanel.js +29 -0
- package/package.json +3 -3
|
@@ -53,20 +53,11 @@ export function useChatMessages(client) {
|
|
|
53
53
|
streamingStartTime: startTime, // Use the same start time from when user sent message
|
|
54
54
|
};
|
|
55
55
|
addMessage(assistantMessage);
|
|
56
|
-
// Build full conversation history (excluding system messages)
|
|
57
|
-
const conversationHistory = messages
|
|
58
|
-
.filter((msg) => msg.role !== "system")
|
|
59
|
-
.map((msg) => `${msg.role === "user" ? "Human" : "Assistant"}: ${msg.content}`)
|
|
60
|
-
.join("\n\n");
|
|
61
|
-
// Combine history with new message
|
|
62
|
-
const fullPrompt = conversationHistory
|
|
63
|
-
? `${conversationHistory}\n\nHuman: ${content}`
|
|
64
|
-
: content;
|
|
65
56
|
// Start receiving chunks (async iterator)
|
|
66
57
|
const messageStream = client.receiveMessages();
|
|
67
|
-
// Send
|
|
68
|
-
//
|
|
69
|
-
client.sendMessage(
|
|
58
|
+
// Send ONLY the new message (not full history)
|
|
59
|
+
// The agent backend now manages conversation context
|
|
60
|
+
client.sendMessage(content, sessionId).catch((error) => {
|
|
70
61
|
const message = error instanceof Error ? error.message : String(error);
|
|
71
62
|
setError(message);
|
|
72
63
|
setIsStreaming(false);
|
|
@@ -115,7 +106,6 @@ export function useChatMessages(client) {
|
|
|
115
106
|
}, [
|
|
116
107
|
client,
|
|
117
108
|
sessionId,
|
|
118
|
-
messages,
|
|
119
109
|
addMessage,
|
|
120
110
|
updateMessage,
|
|
121
111
|
setIsStreaming,
|
|
@@ -2,10 +2,11 @@ import type { AcpClient } from "../../sdk/client/index.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* Hook for managing chat session lifecycle
|
|
4
4
|
*/
|
|
5
|
-
export declare function useChatSession(client: AcpClient | null): {
|
|
5
|
+
export declare function useChatSession(client: AcpClient | null, initialSessionId?: string | null): {
|
|
6
6
|
connectionStatus: "error" | "connecting" | "connected" | "disconnected";
|
|
7
7
|
sessionId: string | null;
|
|
8
8
|
connect: () => Promise<void>;
|
|
9
|
+
loadSession: (sessionIdToLoad: string) => Promise<void>;
|
|
9
10
|
startSession: () => Promise<void>;
|
|
10
11
|
disconnect: () => Promise<void>;
|
|
11
12
|
};
|
|
@@ -5,7 +5,7 @@ const logger = createLogger("use-chat-session", "debug");
|
|
|
5
5
|
/**
|
|
6
6
|
* Hook for managing chat session lifecycle
|
|
7
7
|
*/
|
|
8
|
-
export function useChatSession(client) {
|
|
8
|
+
export function useChatSession(client, initialSessionId) {
|
|
9
9
|
const connectionStatus = useChatStore((state) => state.connectionStatus);
|
|
10
10
|
const sessionId = useChatStore((state) => state.sessionId);
|
|
11
11
|
const setConnectionStatus = useChatStore((state) => state.setConnectionStatus);
|
|
@@ -13,8 +13,42 @@ export function useChatSession(client) {
|
|
|
13
13
|
const setError = useChatStore((state) => state.setError);
|
|
14
14
|
const clearMessages = useChatStore((state) => state.clearMessages);
|
|
15
15
|
const resetTokens = useChatStore((state) => state.resetTokens);
|
|
16
|
+
const addMessage = useChatStore((state) => state.addMessage);
|
|
17
|
+
// Subscribe to session updates to handle replayed messages
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!client)
|
|
20
|
+
return;
|
|
21
|
+
const unsubscribe = client.onSessionUpdate((update) => {
|
|
22
|
+
// Handle replayed messages during session loading
|
|
23
|
+
if (update.message) {
|
|
24
|
+
logger.debug("Session update with message", {
|
|
25
|
+
message: update.message,
|
|
26
|
+
});
|
|
27
|
+
// Convert SDK message to DisplayMessage
|
|
28
|
+
// Filter out tool messages as they're not displayed in the chat
|
|
29
|
+
if (update.message.role !== "tool") {
|
|
30
|
+
const displayMessage = {
|
|
31
|
+
id: update.message.id,
|
|
32
|
+
role: update.message.role,
|
|
33
|
+
content: update.message.content
|
|
34
|
+
.map((c) => {
|
|
35
|
+
if (c.type === "text") {
|
|
36
|
+
return c.text;
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
})
|
|
40
|
+
.join(""),
|
|
41
|
+
timestamp: update.message.timestamp,
|
|
42
|
+
isStreaming: false,
|
|
43
|
+
};
|
|
44
|
+
addMessage(displayMessage);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return unsubscribe;
|
|
49
|
+
}, [client, addMessage]);
|
|
16
50
|
/**
|
|
17
|
-
* Connect to the agent
|
|
51
|
+
* Connect to the agent (without creating a session)
|
|
18
52
|
*/
|
|
19
53
|
const connect = useCallback(async () => {
|
|
20
54
|
if (!client) {
|
|
@@ -36,6 +70,65 @@ export function useChatSession(client) {
|
|
|
36
70
|
setConnectionStatus("error");
|
|
37
71
|
}
|
|
38
72
|
}, [client, setConnectionStatus, setError]);
|
|
73
|
+
/**
|
|
74
|
+
* Load an existing session
|
|
75
|
+
*/
|
|
76
|
+
const loadSession = useCallback(async (sessionIdToLoad) => {
|
|
77
|
+
if (!client) {
|
|
78
|
+
setError("No client available");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
setConnectionStatus("connecting");
|
|
83
|
+
setError(null);
|
|
84
|
+
// Try to load session (this will also connect and replay messages)
|
|
85
|
+
const id = await client.loadSession(sessionIdToLoad);
|
|
86
|
+
setSessionId(id);
|
|
87
|
+
setConnectionStatus("connected");
|
|
88
|
+
// Don't clear messages - they will be populated by session replay
|
|
89
|
+
resetTokens();
|
|
90
|
+
logger.info("Session loaded successfully", { sessionId: id });
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
logger.warn("Failed to load session, creating new one instead", {
|
|
94
|
+
sessionId: sessionIdToLoad,
|
|
95
|
+
error: error instanceof Error ? error.message : String(error),
|
|
96
|
+
});
|
|
97
|
+
// If session not found, create a new one instead
|
|
98
|
+
try {
|
|
99
|
+
// Connect and create new session
|
|
100
|
+
await connect();
|
|
101
|
+
const newId = await client.startSession();
|
|
102
|
+
setSessionId(newId);
|
|
103
|
+
clearMessages();
|
|
104
|
+
resetTokens();
|
|
105
|
+
// Update URL with new session ID
|
|
106
|
+
if (typeof window !== "undefined") {
|
|
107
|
+
const url = new URL(window.location.href);
|
|
108
|
+
url.searchParams.set("session", newId);
|
|
109
|
+
window.history.replaceState({}, "", url.toString());
|
|
110
|
+
}
|
|
111
|
+
logger.info("Created new session after failed load", {
|
|
112
|
+
sessionId: newId,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (fallbackError) {
|
|
116
|
+
const message = fallbackError instanceof Error
|
|
117
|
+
? fallbackError.message
|
|
118
|
+
: String(fallbackError);
|
|
119
|
+
setError(`Failed to load or create session: ${message}`);
|
|
120
|
+
setConnectionStatus("error");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}, [
|
|
124
|
+
client,
|
|
125
|
+
setConnectionStatus,
|
|
126
|
+
setSessionId,
|
|
127
|
+
setError,
|
|
128
|
+
clearMessages,
|
|
129
|
+
resetTokens,
|
|
130
|
+
connect,
|
|
131
|
+
]);
|
|
39
132
|
/**
|
|
40
133
|
* Start a new session
|
|
41
134
|
*/
|
|
@@ -49,6 +142,12 @@ export function useChatSession(client) {
|
|
|
49
142
|
setSessionId(id);
|
|
50
143
|
clearMessages();
|
|
51
144
|
resetTokens();
|
|
145
|
+
// Update URL with new session ID
|
|
146
|
+
if (typeof window !== "undefined") {
|
|
147
|
+
const url = new URL(window.location.href);
|
|
148
|
+
url.searchParams.set("session", id);
|
|
149
|
+
window.history.pushState({}, "", url.toString());
|
|
150
|
+
}
|
|
52
151
|
}
|
|
53
152
|
catch (error) {
|
|
54
153
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -71,22 +170,34 @@ export function useChatSession(client) {
|
|
|
71
170
|
setError(message);
|
|
72
171
|
}
|
|
73
172
|
}, [client, setConnectionStatus, setSessionId, setError]);
|
|
74
|
-
// Auto-connect on mount if client is available
|
|
173
|
+
// Auto-connect on mount if client is available and handle initial session
|
|
75
174
|
useEffect(() => {
|
|
76
|
-
if (client
|
|
175
|
+
if (!client || connectionStatus !== "disconnected") {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// If we have an initial session ID, load it
|
|
179
|
+
if (initialSessionId) {
|
|
180
|
+
logger.info("Loading initial session from URL", {
|
|
181
|
+
sessionId: initialSessionId,
|
|
182
|
+
});
|
|
183
|
+
loadSession(initialSessionId);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Otherwise, connect normally (will create new session after)
|
|
77
187
|
connect();
|
|
78
188
|
}
|
|
79
|
-
}, [client, connectionStatus, connect]);
|
|
80
|
-
// Auto-start session after connecting
|
|
189
|
+
}, [client, connectionStatus, initialSessionId, connect, loadSession]);
|
|
190
|
+
// Auto-start new session after connecting (only if no initial session)
|
|
81
191
|
useEffect(() => {
|
|
82
|
-
if (connectionStatus === "connected" && !sessionId) {
|
|
192
|
+
if (connectionStatus === "connected" && !sessionId && !initialSessionId) {
|
|
83
193
|
startSession();
|
|
84
194
|
}
|
|
85
|
-
}, [connectionStatus, sessionId, startSession]);
|
|
195
|
+
}, [connectionStatus, sessionId, initialSessionId, startSession]);
|
|
86
196
|
return {
|
|
87
197
|
connectionStatus,
|
|
88
198
|
sessionId,
|
|
89
199
|
connect,
|
|
200
|
+
loadSession,
|
|
90
201
|
startSession,
|
|
91
202
|
disconnect,
|
|
92
203
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible logger
|
|
3
|
+
* Outputs structured JSON logs to console with color-coding
|
|
4
|
+
* Also captures logs to a global store for in-app viewing
|
|
5
|
+
* In Node.js environment with logsDir option, also writes to .logs/ directory
|
|
6
|
+
*/
|
|
7
|
+
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
8
|
+
export interface LogEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
level: LogLevel;
|
|
12
|
+
service: string;
|
|
13
|
+
message: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get all captured logs
|
|
18
|
+
*/
|
|
19
|
+
export declare function getCapturedLogs(): LogEntry[];
|
|
20
|
+
/**
|
|
21
|
+
* Clear all captured logs
|
|
22
|
+
*/
|
|
23
|
+
export declare function clearCapturedLogs(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to log updates
|
|
26
|
+
*/
|
|
27
|
+
type LogSubscriber = (entry: LogEntry) => void;
|
|
28
|
+
export declare function subscribeToLogs(callback: LogSubscriber): () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Configure global logs directory for file writing
|
|
31
|
+
* Must be called before creating any loggers (typically at TUI startup)
|
|
32
|
+
*/
|
|
33
|
+
export declare function configureLogsDir(logsDir: string): void;
|
|
34
|
+
export declare class Logger {
|
|
35
|
+
private service;
|
|
36
|
+
private minLevel;
|
|
37
|
+
private logFilePath?;
|
|
38
|
+
private logsDir?;
|
|
39
|
+
private writeQueue;
|
|
40
|
+
private isWriting;
|
|
41
|
+
constructor(service: string, minLevel?: LogLevel);
|
|
42
|
+
private setupFileLogging;
|
|
43
|
+
private writeToFile;
|
|
44
|
+
private shouldLog;
|
|
45
|
+
private log;
|
|
46
|
+
trace(message: string, metadata?: Record<string, unknown>): void;
|
|
47
|
+
debug(message: string, metadata?: Record<string, unknown>): void;
|
|
48
|
+
info(message: string, metadata?: Record<string, unknown>): void;
|
|
49
|
+
warn(message: string, metadata?: Record<string, unknown>): void;
|
|
50
|
+
error(message: string, metadata?: Record<string, unknown>): void;
|
|
51
|
+
fatal(message: string, metadata?: Record<string, unknown>): void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create a logger instance for a service
|
|
55
|
+
* @param service - Service name (e.g., "gui", "http-agent", "tui")
|
|
56
|
+
* @param minLevel - Minimum log level to display (default: "debug")
|
|
57
|
+
*/
|
|
58
|
+
export declare function createLogger(service: string, minLevel?: LogLevel): Logger;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible logger
|
|
3
|
+
* Outputs structured JSON logs to console with color-coding
|
|
4
|
+
* Also captures logs to a global store for in-app viewing
|
|
5
|
+
* In Node.js environment with logsDir option, also writes to .logs/ directory
|
|
6
|
+
*/
|
|
7
|
+
// Check if running in Node.js
|
|
8
|
+
const isNode = typeof process !== "undefined" && process.versions?.node;
|
|
9
|
+
// Global logs directory configuration (set once at app startup for TUI)
|
|
10
|
+
let globalLogsDir;
|
|
11
|
+
// Global log store
|
|
12
|
+
const globalLogStore = [];
|
|
13
|
+
let logIdCounter = 0;
|
|
14
|
+
/**
|
|
15
|
+
* Get all captured logs
|
|
16
|
+
*/
|
|
17
|
+
export function getCapturedLogs() {
|
|
18
|
+
return [...globalLogStore];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Clear all captured logs
|
|
22
|
+
*/
|
|
23
|
+
export function clearCapturedLogs() {
|
|
24
|
+
globalLogStore.length = 0;
|
|
25
|
+
}
|
|
26
|
+
const logSubscribers = new Set();
|
|
27
|
+
export function subscribeToLogs(callback) {
|
|
28
|
+
logSubscribers.add(callback);
|
|
29
|
+
return () => logSubscribers.delete(callback);
|
|
30
|
+
}
|
|
31
|
+
function notifyLogSubscribers(entry) {
|
|
32
|
+
for (const callback of logSubscribers) {
|
|
33
|
+
callback(entry);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Configure global logs directory for file writing
|
|
38
|
+
* Must be called before creating any loggers (typically at TUI startup)
|
|
39
|
+
*/
|
|
40
|
+
export function configureLogsDir(logsDir) {
|
|
41
|
+
globalLogsDir = logsDir;
|
|
42
|
+
}
|
|
43
|
+
const LOG_LEVELS = {
|
|
44
|
+
trace: 0,
|
|
45
|
+
debug: 1,
|
|
46
|
+
info: 2,
|
|
47
|
+
warn: 3,
|
|
48
|
+
error: 4,
|
|
49
|
+
fatal: 5,
|
|
50
|
+
};
|
|
51
|
+
const _LOG_COLORS = {
|
|
52
|
+
trace: "#6B7280", // gray
|
|
53
|
+
debug: "#3B82F6", // blue
|
|
54
|
+
info: "#10B981", // green
|
|
55
|
+
warn: "#F59E0B", // orange
|
|
56
|
+
error: "#EF4444", // red
|
|
57
|
+
fatal: "#DC2626", // dark red
|
|
58
|
+
};
|
|
59
|
+
const _LOG_STYLES = {
|
|
60
|
+
trace: "color: #6B7280",
|
|
61
|
+
debug: "color: #3B82F6; font-weight: bold",
|
|
62
|
+
info: "color: #10B981; font-weight: bold",
|
|
63
|
+
warn: "color: #F59E0B; font-weight: bold",
|
|
64
|
+
error: "color: #EF4444; font-weight: bold",
|
|
65
|
+
fatal: "color: #DC2626; font-weight: bold; background: #FEE2E2",
|
|
66
|
+
};
|
|
67
|
+
export class Logger {
|
|
68
|
+
service;
|
|
69
|
+
minLevel;
|
|
70
|
+
logFilePath;
|
|
71
|
+
logsDir;
|
|
72
|
+
writeQueue = [];
|
|
73
|
+
isWriting = false;
|
|
74
|
+
constructor(service, minLevel = "debug") {
|
|
75
|
+
this.service = service;
|
|
76
|
+
this.minLevel = minLevel;
|
|
77
|
+
// In production, suppress trace and debug logs
|
|
78
|
+
if (typeof process !== "undefined" &&
|
|
79
|
+
process.env?.NODE_ENV === "production") {
|
|
80
|
+
this.minLevel = "info";
|
|
81
|
+
}
|
|
82
|
+
// Note: File logging setup is done lazily in log() method
|
|
83
|
+
// This allows loggers created before configureLogsDir() to still write to files
|
|
84
|
+
}
|
|
85
|
+
setupFileLogging() {
|
|
86
|
+
if (!isNode || !globalLogsDir)
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
// Dynamic import for Node.js modules
|
|
90
|
+
const path = require("node:path");
|
|
91
|
+
const fs = require("node:fs");
|
|
92
|
+
this.logsDir = globalLogsDir;
|
|
93
|
+
this.logFilePath = path.join(this.logsDir, `${this.service}.log`);
|
|
94
|
+
// Create logs directory if it doesn't exist
|
|
95
|
+
if (!fs.existsSync(this.logsDir)) {
|
|
96
|
+
fs.mkdirSync(this.logsDir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (_error) {
|
|
100
|
+
// Silently fail if we can't set up file logging
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async writeToFile(content) {
|
|
104
|
+
if (!this.logFilePath || !isNode)
|
|
105
|
+
return;
|
|
106
|
+
this.writeQueue.push(content);
|
|
107
|
+
if (this.isWriting) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.isWriting = true;
|
|
111
|
+
while (this.writeQueue.length > 0) {
|
|
112
|
+
const batch = this.writeQueue.splice(0, this.writeQueue.length);
|
|
113
|
+
const data = `${batch.join("\n")}\n`;
|
|
114
|
+
try {
|
|
115
|
+
// Dynamic import for Node.js modules
|
|
116
|
+
const fs = require("node:fs");
|
|
117
|
+
await fs.promises.appendFile(this.logFilePath, data, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
catch (_error) {
|
|
120
|
+
// Silently fail
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.isWriting = false;
|
|
124
|
+
}
|
|
125
|
+
shouldLog(level) {
|
|
126
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
|
|
127
|
+
}
|
|
128
|
+
log(level, message, metadata) {
|
|
129
|
+
if (!this.shouldLog(level)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const entry = {
|
|
133
|
+
id: `log_${++logIdCounter}`,
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
level,
|
|
136
|
+
service: this.service,
|
|
137
|
+
message,
|
|
138
|
+
...(metadata && { metadata }),
|
|
139
|
+
};
|
|
140
|
+
// Store in global log store
|
|
141
|
+
globalLogStore.push(entry);
|
|
142
|
+
// Notify subscribers
|
|
143
|
+
notifyLogSubscribers(entry);
|
|
144
|
+
// Write to file in Node.js (for logs tab to read)
|
|
145
|
+
// Lazily set up file logging if globalLogsDir was configured after this logger was created
|
|
146
|
+
if (isNode && !this.logFilePath && globalLogsDir) {
|
|
147
|
+
this.setupFileLogging();
|
|
148
|
+
}
|
|
149
|
+
if (isNode && this.logFilePath) {
|
|
150
|
+
// Write as JSON without the id field (to match expected format)
|
|
151
|
+
const fileEntry = {
|
|
152
|
+
timestamp: entry.timestamp,
|
|
153
|
+
level: entry.level,
|
|
154
|
+
service: entry.service,
|
|
155
|
+
message: entry.message,
|
|
156
|
+
...(entry.metadata && { metadata: entry.metadata }),
|
|
157
|
+
};
|
|
158
|
+
this.writeToFile(JSON.stringify(fileEntry)).catch(() => {
|
|
159
|
+
// Silently fail
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// No console output - logs are only captured and displayed in UI
|
|
163
|
+
// This prevents logs from polluting stdout/stderr in TUI mode
|
|
164
|
+
}
|
|
165
|
+
trace(message, metadata) {
|
|
166
|
+
this.log("trace", message, metadata);
|
|
167
|
+
}
|
|
168
|
+
debug(message, metadata) {
|
|
169
|
+
this.log("debug", message, metadata);
|
|
170
|
+
}
|
|
171
|
+
info(message, metadata) {
|
|
172
|
+
this.log("info", message, metadata);
|
|
173
|
+
}
|
|
174
|
+
warn(message, metadata) {
|
|
175
|
+
this.log("warn", message, metadata);
|
|
176
|
+
}
|
|
177
|
+
error(message, metadata) {
|
|
178
|
+
this.log("error", message, metadata);
|
|
179
|
+
}
|
|
180
|
+
fatal(message, metadata) {
|
|
181
|
+
this.log("fatal", message, metadata);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Create a logger instance for a service
|
|
186
|
+
* @param service - Service name (e.g., "gui", "http-agent", "tui")
|
|
187
|
+
* @param minLevel - Minimum log level to display (default: "debug")
|
|
188
|
+
*/
|
|
189
|
+
export function createLogger(service, minLevel = "debug") {
|
|
190
|
+
return new Logger(service, minLevel);
|
|
191
|
+
}
|
|
@@ -41,8 +41,13 @@ export declare class AcpClient {
|
|
|
41
41
|
isConnected(): boolean;
|
|
42
42
|
/**
|
|
43
43
|
* Start a new chat session
|
|
44
|
+
* Note: For HTTP transport, the session is created during connect() and this just returns the existing session ID
|
|
44
45
|
*/
|
|
45
46
|
startSession(config?: Partial<SessionConfig>): Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Load an existing session
|
|
49
|
+
*/
|
|
50
|
+
loadSession(sessionId: string, config?: Partial<SessionConfig>): Promise<string>;
|
|
46
51
|
/**
|
|
47
52
|
* Send a message in the current session
|
|
48
53
|
*/
|
|
@@ -46,8 +46,31 @@ export class AcpClient {
|
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
48
|
* Start a new chat session
|
|
49
|
+
* Note: For HTTP transport, the session is created during connect() and this just returns the existing session ID
|
|
49
50
|
*/
|
|
50
51
|
async startSession(config) {
|
|
52
|
+
// For HTTP transport, the session is already created during connect()
|
|
53
|
+
// Just get the session ID from the transport's current session
|
|
54
|
+
const transportSessionId = this.transport.currentSessionId;
|
|
55
|
+
if (transportSessionId) {
|
|
56
|
+
// Use the session ID from the transport
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const session = {
|
|
59
|
+
id: transportSessionId,
|
|
60
|
+
status: "connected",
|
|
61
|
+
config: config
|
|
62
|
+
? { ...config, agentPath: config.agentPath || "" }
|
|
63
|
+
: { agentPath: "" },
|
|
64
|
+
messages: [],
|
|
65
|
+
metadata: {
|
|
66
|
+
startedAt: now,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
this.sessions.set(transportSessionId, session);
|
|
70
|
+
this.currentSessionId = transportSessionId;
|
|
71
|
+
return transportSessionId;
|
|
72
|
+
}
|
|
73
|
+
// Fallback: generate session ID (for transports that don't auto-create sessions)
|
|
51
74
|
const sessionId = this.generateSessionId();
|
|
52
75
|
const now = new Date().toISOString();
|
|
53
76
|
const session = {
|
|
@@ -67,6 +90,34 @@ export class AcpClient {
|
|
|
67
90
|
this.updateSessionStatus(sessionId, "connected");
|
|
68
91
|
return sessionId;
|
|
69
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Load an existing session
|
|
95
|
+
*/
|
|
96
|
+
async loadSession(sessionId, config) {
|
|
97
|
+
if (!this.transport.loadSession) {
|
|
98
|
+
throw new Error("Transport does not support loading sessions");
|
|
99
|
+
}
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
// Create session object
|
|
102
|
+
const session = {
|
|
103
|
+
id: sessionId,
|
|
104
|
+
status: "connecting",
|
|
105
|
+
config: config
|
|
106
|
+
? { ...config, agentPath: config.agentPath || "" }
|
|
107
|
+
: { agentPath: "" },
|
|
108
|
+
messages: [],
|
|
109
|
+
metadata: {
|
|
110
|
+
startedAt: now,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
this.sessions.set(sessionId, session);
|
|
114
|
+
this.currentSessionId = sessionId;
|
|
115
|
+
// Load session from transport (will replay messages via session updates)
|
|
116
|
+
await this.transport.loadSession(sessionId);
|
|
117
|
+
// Update session status
|
|
118
|
+
this.updateSessionStatus(sessionId, "connected");
|
|
119
|
+
return sessionId;
|
|
120
|
+
}
|
|
70
121
|
/**
|
|
71
122
|
* Send a message in the current session
|
|
72
123
|
*/
|
|
@@ -19,8 +19,14 @@ export declare class HttpTransport implements Transport {
|
|
|
19
19
|
private reconnecting;
|
|
20
20
|
private abortController;
|
|
21
21
|
private options;
|
|
22
|
+
private isReceivingMessages;
|
|
22
23
|
constructor(options: HttpTransportOptions);
|
|
23
24
|
connect(): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Load an existing session instead of creating a new one
|
|
27
|
+
* @param sessionId - The session ID to load
|
|
28
|
+
*/
|
|
29
|
+
loadSession(sessionId: string): Promise<void>;
|
|
24
30
|
disconnect(): Promise<void>;
|
|
25
31
|
send(message: Message): Promise<void>;
|
|
26
32
|
receive(): AsyncIterableIterator<MessageChunk>;
|
|
@@ -20,6 +20,7 @@ export class HttpTransport {
|
|
|
20
20
|
reconnecting = false;
|
|
21
21
|
abortController = null;
|
|
22
22
|
options;
|
|
23
|
+
isReceivingMessages = false;
|
|
23
24
|
constructor(options) {
|
|
24
25
|
// Ensure baseUrl doesn't end with a slash
|
|
25
26
|
this.options = { ...options, baseUrl: options.baseUrl.replace(/\/$/, "") };
|
|
@@ -62,6 +63,72 @@ export class HttpTransport {
|
|
|
62
63
|
throw err;
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Load an existing session instead of creating a new one
|
|
68
|
+
* @param sessionId - The session ID to load
|
|
69
|
+
*/
|
|
70
|
+
async loadSession(sessionId) {
|
|
71
|
+
if (this.connected) {
|
|
72
|
+
logger.warn("Transport already connected, disconnecting first", {
|
|
73
|
+
sessionId,
|
|
74
|
+
});
|
|
75
|
+
await this.disconnect();
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
this.abortController = new AbortController();
|
|
79
|
+
// Step 1: Initialize the ACP connection
|
|
80
|
+
const initRequest = {
|
|
81
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
82
|
+
clientCapabilities: {
|
|
83
|
+
fs: {
|
|
84
|
+
readTextFile: true,
|
|
85
|
+
writeTextFile: true,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
logger.info("Loading session - initializing connection", { sessionId });
|
|
90
|
+
const initResponse = await this.sendRpcRequest("initialize", initRequest);
|
|
91
|
+
// Check if loadSession is supported
|
|
92
|
+
if (!initResponse.agentCapabilities?.loadSession) {
|
|
93
|
+
logger.error("Agent does not support loading sessions", {
|
|
94
|
+
capabilities: initResponse.agentCapabilities,
|
|
95
|
+
});
|
|
96
|
+
throw new Error("Agent does not support loading sessions");
|
|
97
|
+
}
|
|
98
|
+
logger.info("ACP connection initialized, loading session", {
|
|
99
|
+
sessionId,
|
|
100
|
+
capabilities: initResponse.agentCapabilities,
|
|
101
|
+
});
|
|
102
|
+
// Step 2: Open SSE connection FIRST so we can receive replayed messages
|
|
103
|
+
this.currentSessionId = sessionId;
|
|
104
|
+
await this.connectSSE();
|
|
105
|
+
// Step 3: Load existing session (will trigger message replay)
|
|
106
|
+
const loadRequest = {
|
|
107
|
+
sessionId,
|
|
108
|
+
cwd: "/",
|
|
109
|
+
mcpServers: [],
|
|
110
|
+
};
|
|
111
|
+
logger.info("Sending session/load request", { loadRequest });
|
|
112
|
+
const loadResponse = await this.sendRpcRequest("session/load", loadRequest);
|
|
113
|
+
logger.info("Session loaded successfully", {
|
|
114
|
+
sessionId: this.currentSessionId,
|
|
115
|
+
loadResponse,
|
|
116
|
+
});
|
|
117
|
+
this.connected = true;
|
|
118
|
+
this.reconnectAttempts = 0;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.connected = false;
|
|
122
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
123
|
+
logger.error("Failed to load session", {
|
|
124
|
+
sessionId,
|
|
125
|
+
error: err.message,
|
|
126
|
+
stack: err.stack,
|
|
127
|
+
});
|
|
128
|
+
this.notifyError(err);
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
65
132
|
async disconnect() {
|
|
66
133
|
if (!this.connected) {
|
|
67
134
|
return;
|
|
@@ -147,46 +214,54 @@ export class HttpTransport {
|
|
|
147
214
|
}
|
|
148
215
|
}
|
|
149
216
|
async *receive() {
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if
|
|
156
|
-
|
|
217
|
+
// Mark that we're actively receiving messages (prevents duplicate session updates)
|
|
218
|
+
this.isReceivingMessages = true;
|
|
219
|
+
try {
|
|
220
|
+
// Keep yielding chunks until stream is complete
|
|
221
|
+
while (!this.streamComplete) {
|
|
222
|
+
// Check if there are queued messages
|
|
223
|
+
if (this.messageQueue.length > 0) {
|
|
224
|
+
const chunk = this.messageQueue.shift();
|
|
225
|
+
if (chunk) {
|
|
226
|
+
yield chunk;
|
|
227
|
+
if (chunk.isComplete) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
// Wait for next chunk to arrive
|
|
234
|
+
const chunk = await new Promise((resolve) => {
|
|
235
|
+
this.chunkResolvers.push(resolve);
|
|
236
|
+
});
|
|
157
237
|
if (chunk.isComplete) {
|
|
238
|
+
yield chunk;
|
|
158
239
|
return;
|
|
159
240
|
}
|
|
241
|
+
else {
|
|
242
|
+
yield chunk;
|
|
243
|
+
}
|
|
160
244
|
}
|
|
161
245
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const chunk =
|
|
165
|
-
|
|
166
|
-
});
|
|
167
|
-
if (chunk.isComplete) {
|
|
168
|
-
yield chunk;
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
246
|
+
// Yield any remaining queued messages
|
|
247
|
+
while (this.messageQueue.length > 0) {
|
|
248
|
+
const chunk = this.messageQueue.shift();
|
|
249
|
+
if (chunk) {
|
|
172
250
|
yield chunk;
|
|
173
251
|
}
|
|
174
252
|
}
|
|
253
|
+
// Mark the stream as complete
|
|
254
|
+
yield {
|
|
255
|
+
id: this.currentSessionId || "unknown",
|
|
256
|
+
role: "assistant",
|
|
257
|
+
contentDelta: { type: "text", text: "" },
|
|
258
|
+
isComplete: true,
|
|
259
|
+
};
|
|
175
260
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (chunk) {
|
|
180
|
-
yield chunk;
|
|
181
|
-
}
|
|
261
|
+
finally {
|
|
262
|
+
// Reset flag when receive() completes
|
|
263
|
+
this.isReceivingMessages = false;
|
|
182
264
|
}
|
|
183
|
-
// Mark the stream as complete
|
|
184
|
-
yield {
|
|
185
|
-
id: this.currentSessionId || "unknown",
|
|
186
|
-
role: "assistant",
|
|
187
|
-
contentDelta: { type: "text", text: "" },
|
|
188
|
-
isComplete: true,
|
|
189
|
-
};
|
|
190
265
|
}
|
|
191
266
|
isConnected() {
|
|
192
267
|
return this.connected;
|
|
@@ -215,6 +290,7 @@ export class HttpTransport {
|
|
|
215
290
|
method,
|
|
216
291
|
params,
|
|
217
292
|
};
|
|
293
|
+
logger.debug("Sending RPC request", { method, params, request });
|
|
218
294
|
const headers = {
|
|
219
295
|
"Content-Type": "application/json",
|
|
220
296
|
...this.options.headers,
|
|
@@ -694,9 +770,62 @@ export class HttpTransport {
|
|
|
694
770
|
this.messageQueue.push(chunk);
|
|
695
771
|
}
|
|
696
772
|
}
|
|
773
|
+
// Also notify as a complete message for session replay
|
|
774
|
+
// Only send session updates when NOT actively receiving messages (prevents duplication during normal streaming)
|
|
775
|
+
if (chunk &&
|
|
776
|
+
typeof contentObj.text === "string" &&
|
|
777
|
+
!this.isReceivingMessages) {
|
|
778
|
+
const messageSessionUpdate = {
|
|
779
|
+
type: "generic",
|
|
780
|
+
sessionId,
|
|
781
|
+
status: "active",
|
|
782
|
+
message: {
|
|
783
|
+
id: `msg_${Date.now()}_assistant`,
|
|
784
|
+
role: "assistant",
|
|
785
|
+
content: [
|
|
786
|
+
{
|
|
787
|
+
type: "text",
|
|
788
|
+
text: contentObj.text,
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
timestamp: new Date().toISOString(),
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
// Notify as a complete message (for session replay)
|
|
795
|
+
this.notifySessionUpdate(messageSessionUpdate);
|
|
796
|
+
}
|
|
697
797
|
}
|
|
698
798
|
this.notifySessionUpdate(sessionUpdate);
|
|
699
799
|
}
|
|
800
|
+
else if (update?.sessionUpdate === "user_message_chunk") {
|
|
801
|
+
// Handle user message chunks (could be from replay or new messages)
|
|
802
|
+
logger.debug("Received user_message_chunk", { update });
|
|
803
|
+
const content = update.content;
|
|
804
|
+
if (content && typeof content === "object") {
|
|
805
|
+
const contentObj = content;
|
|
806
|
+
if (contentObj.type === "text" && typeof contentObj.text === "string") {
|
|
807
|
+
// Notify session update with user message
|
|
808
|
+
const sessionUpdate = {
|
|
809
|
+
type: "generic",
|
|
810
|
+
sessionId,
|
|
811
|
+
status: "active",
|
|
812
|
+
message: {
|
|
813
|
+
id: `msg_${Date.now()}_user`,
|
|
814
|
+
role: "user",
|
|
815
|
+
content: [
|
|
816
|
+
{
|
|
817
|
+
type: "text",
|
|
818
|
+
text: contentObj.text,
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
timestamp: new Date().toISOString(),
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
logger.debug("Notifying session update for user message");
|
|
825
|
+
this.notifySessionUpdate(sessionUpdate);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
700
829
|
else {
|
|
701
830
|
// Handle other session updates
|
|
702
831
|
const sessionUpdate = {
|
|
@@ -7,6 +7,10 @@ export interface Transport {
|
|
|
7
7
|
* Initialize the transport connection
|
|
8
8
|
*/
|
|
9
9
|
connect(): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Load an existing session (optional, not all transports support this)
|
|
12
|
+
*/
|
|
13
|
+
loadSession?(sessionId: string): Promise<void>;
|
|
10
14
|
/**
|
|
11
15
|
* Close the transport connection
|
|
12
16
|
*/
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { subscribeToLogs } from "../../core/lib/logger.js";
|
|
5
|
+
// Color mapping for log levels
|
|
6
|
+
const LOG_LEVEL_COLORS = {
|
|
7
|
+
trace: "gray",
|
|
8
|
+
debug: "blue",
|
|
9
|
+
info: "green",
|
|
10
|
+
warn: "yellow",
|
|
11
|
+
error: "red",
|
|
12
|
+
fatal: "red",
|
|
13
|
+
};
|
|
14
|
+
export function LogsPanel({ logs: initialLogs }) {
|
|
15
|
+
const [logs, setLogs] = useState(initialLogs);
|
|
16
|
+
// Subscribe to new logs
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const unsubscribe = subscribeToLogs((entry) => {
|
|
19
|
+
setLogs((prev) => [...prev, entry]);
|
|
20
|
+
});
|
|
21
|
+
return unsubscribe;
|
|
22
|
+
}, []);
|
|
23
|
+
if (logs.length === 0) {
|
|
24
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(Text, { dimColor: true, children: "No logs yet..." }) }));
|
|
25
|
+
}
|
|
26
|
+
// Show last 100 logs
|
|
27
|
+
const displayLogs = logs.slice(-100);
|
|
28
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: displayLogs.map((log) => (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { dimColor: true, children: new Date(log.timestamp).toLocaleTimeString() }), _jsxs(Text, { color: LOG_LEVEL_COLORS[log.level], bold: true, children: ["[", log.level.toUpperCase(), "]"] }), _jsxs(Text, { dimColor: true, children: ["[", log.service, "]"] }), _jsx(Text, { children: log.message }), log.metadata && (_jsx(Text, { dimColor: true, children: JSON.stringify(log.metadata) }))] }, log.id))) }));
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@townco/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@agentclientprotocol/sdk": "^0.5.1",
|
|
43
|
-
"@townco/core": "0.0.
|
|
43
|
+
"@townco/core": "0.0.5",
|
|
44
44
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
45
45
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
46
46
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@tailwindcss/postcss": "^4.1.17",
|
|
65
|
-
"@townco/tsconfig": "0.1.
|
|
65
|
+
"@townco/tsconfig": "0.1.24",
|
|
66
66
|
"@types/node": "^24.10.0",
|
|
67
67
|
"@types/react": "^19.2.2",
|
|
68
68
|
"ink": "^6.4.0",
|