@hef2024/llmasaservice-ui 0.16.8
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/README.md +162 -0
- package/dist/index.css +3239 -0
- package/dist/index.d.mts +521 -0
- package/dist/index.d.ts +521 -0
- package/dist/index.js +5885 -0
- package/dist/index.mjs +5851 -0
- package/index.ts +28 -0
- package/package.json +70 -0
- package/src/AIAgentPanel.css +1354 -0
- package/src/AIAgentPanel.tsx +1883 -0
- package/src/AIChatPanel.css +1618 -0
- package/src/AIChatPanel.tsx +1725 -0
- package/src/AgentPanel.tsx +323 -0
- package/src/ChatPanel.css +1093 -0
- package/src/ChatPanel.tsx +3583 -0
- package/src/ChatStatus.tsx +40 -0
- package/src/EmailModal.tsx +56 -0
- package/src/ToolInfoModal.tsx +49 -0
- package/src/components/ui/Button.tsx +57 -0
- package/src/components/ui/Dialog.tsx +153 -0
- package/src/components/ui/Input.tsx +33 -0
- package/src/components/ui/ScrollArea.tsx +29 -0
- package/src/components/ui/Select.tsx +156 -0
- package/src/components/ui/Tooltip.tsx +73 -0
- package/src/components/ui/index.ts +20 -0
- package/src/hooks/useAgentRegistry.ts +349 -0
- package/src/hooks/useConversationStore.ts +313 -0
- package/src/mcpClient.ts +107 -0
- package/tsconfig.json +108 -0
- package/types/declarations.d.ts +22 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent metadata returned from the LLMAsAService API
|
|
5
|
+
*/
|
|
6
|
+
export interface AgentMetadata {
|
|
7
|
+
id: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
groupId?: string;
|
|
10
|
+
displayTitle?: string;
|
|
11
|
+
displayTheme?: 'light' | 'dark';
|
|
12
|
+
displayPlaceholder?: string;
|
|
13
|
+
displayInitialMessageOrPrompt?: string;
|
|
14
|
+
displayStartMessageOrPrompt?: 'message' | 'prompt';
|
|
15
|
+
displayPromptTemplate?: string;
|
|
16
|
+
displayActions?: string;
|
|
17
|
+
displayFollowOnPrompts?: string;
|
|
18
|
+
displayShowEmailButton?: boolean;
|
|
19
|
+
displayShowSaveButton?: boolean;
|
|
20
|
+
displayShowCallToAction?: boolean;
|
|
21
|
+
displayCallToActionButtonText?: string;
|
|
22
|
+
displayCallToActionEmailAddress?: string;
|
|
23
|
+
displayCallToActionEmailSubject?: string;
|
|
24
|
+
displayHideInitialPrompt?: boolean;
|
|
25
|
+
displayScrollToEnd?: boolean;
|
|
26
|
+
displayWidth?: string;
|
|
27
|
+
displayHeight?: string;
|
|
28
|
+
cssUrl?: string;
|
|
29
|
+
data?: string;
|
|
30
|
+
createConversationOnFirstChat?: boolean;
|
|
31
|
+
customerEmailCaptureMode?: 'HIDE' | 'OPTIONAL' | 'REQUIRED';
|
|
32
|
+
customerEmailCapturePlaceholder?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MCPServer {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
status: 'active' | 'inactive';
|
|
40
|
+
executionMode: 'CLIENT' | 'SERVER';
|
|
41
|
+
url?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AgentProfile {
|
|
45
|
+
status: 'idle' | 'loading' | 'ready' | 'error';
|
|
46
|
+
metadata?: AgentMetadata;
|
|
47
|
+
mcpServers?: MCPServer[];
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AgentRegistryState {
|
|
52
|
+
agents: Record<string, AgentProfile>;
|
|
53
|
+
isLoading: boolean;
|
|
54
|
+
error: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Local overrides for agent display properties
|
|
59
|
+
*/
|
|
60
|
+
export interface AgentLocalOverride {
|
|
61
|
+
localName?: string;
|
|
62
|
+
avatarUrl?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface UseAgentRegistryOptions {
|
|
66
|
+
url?: string;
|
|
67
|
+
autoFetch?: boolean;
|
|
68
|
+
localOverrides?: Record<string, AgentLocalOverride>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolveApiEndpoint = (baseUrl: string, agentId: string): string => {
|
|
72
|
+
const apiRoot = baseUrl.endsWith('dev')
|
|
73
|
+
? 'https://8ftw8droff.execute-api.us-east-1.amazonaws.com/dev'
|
|
74
|
+
: 'https://api.llmasaservice.io';
|
|
75
|
+
return `${apiRoot}/agents/${agentId}`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Hook to fetch and cache metadata for multiple agents
|
|
80
|
+
*/
|
|
81
|
+
export function useAgentRegistry(
|
|
82
|
+
agentIds: string[],
|
|
83
|
+
options: UseAgentRegistryOptions = {}
|
|
84
|
+
) {
|
|
85
|
+
const { url = 'https://chat.llmasaservice.io', autoFetch = true, localOverrides = {} } = options;
|
|
86
|
+
|
|
87
|
+
const [state, setState] = useState<AgentRegistryState>({
|
|
88
|
+
agents: {},
|
|
89
|
+
isLoading: false,
|
|
90
|
+
error: null,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Fetch a single agent's metadata
|
|
94
|
+
const fetchAgentMetadata = useCallback(
|
|
95
|
+
async (agentId: string, signal?: AbortSignal): Promise<AgentProfile> => {
|
|
96
|
+
const endpoint = resolveApiEndpoint(url, agentId);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// Fetch agent metadata
|
|
100
|
+
const response = await fetch(endpoint, {
|
|
101
|
+
method: 'GET',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
signal,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Failed to fetch agent ${agentId}: ${response.status}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const data = await response.json();
|
|
111
|
+
const agentData = Array.isArray(data) ? data[0] : data;
|
|
112
|
+
|
|
113
|
+
if (!agentData) {
|
|
114
|
+
throw new Error(`No data returned for agent ${agentId}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fetch MCP servers
|
|
118
|
+
let mcpServers: MCPServer[] = [];
|
|
119
|
+
try {
|
|
120
|
+
const mcpResponse = await fetch(`${endpoint}/mcp`, {
|
|
121
|
+
method: 'GET',
|
|
122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
123
|
+
signal,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (mcpResponse.ok) {
|
|
127
|
+
const mcpData = await mcpResponse.json();
|
|
128
|
+
if (Array.isArray(mcpData)) {
|
|
129
|
+
mcpServers = mcpData.filter(
|
|
130
|
+
(mcp: MCPServer) =>
|
|
131
|
+
mcp.status === 'active' && mcp.executionMode !== 'SERVER'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// MCP fetch is optional, continue without it
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
status: 'ready',
|
|
141
|
+
metadata: { ...agentData, id: agentId },
|
|
142
|
+
mcpServers,
|
|
143
|
+
};
|
|
144
|
+
} catch (error: any) {
|
|
145
|
+
if (error.name === 'AbortError') {
|
|
146
|
+
return { status: 'idle' };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
status: 'error',
|
|
150
|
+
error: error.message || `Failed to load agent ${agentId}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
[url]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Fetch all agents
|
|
158
|
+
const fetchAllAgents = useCallback(async () => {
|
|
159
|
+
if (agentIds.length === 0) return;
|
|
160
|
+
|
|
161
|
+
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
|
162
|
+
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const results: Record<string, AgentProfile> = {};
|
|
165
|
+
|
|
166
|
+
// Initialize all agents as loading
|
|
167
|
+
agentIds.forEach((id) => {
|
|
168
|
+
results[id] = { status: 'loading' };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
setState((prev) => ({
|
|
172
|
+
...prev,
|
|
173
|
+
agents: { ...prev.agents, ...results },
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
// Fetch all agents in parallel
|
|
177
|
+
const promises = agentIds.map(async (agentId) => {
|
|
178
|
+
const profile = await fetchAgentMetadata(agentId, controller.signal);
|
|
179
|
+
return { agentId, profile };
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const responses = await Promise.all(promises);
|
|
183
|
+
|
|
184
|
+
const finalAgents: Record<string, AgentProfile> = {};
|
|
185
|
+
responses.forEach(({ agentId, profile }) => {
|
|
186
|
+
finalAgents[agentId] = profile;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
setState({
|
|
190
|
+
agents: finalAgents,
|
|
191
|
+
isLoading: false,
|
|
192
|
+
error: null,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return () => controller.abort();
|
|
196
|
+
}, [agentIds, fetchAgentMetadata]);
|
|
197
|
+
|
|
198
|
+
// Auto-fetch on mount and when agentIds change
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!autoFetch) return;
|
|
201
|
+
|
|
202
|
+
const abortController = new AbortController();
|
|
203
|
+
|
|
204
|
+
// Only fetch agents that aren't already loaded
|
|
205
|
+
const agentsToFetch = agentIds.filter(
|
|
206
|
+
(id) => !state.agents[id] || state.agents[id].status === 'idle'
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (agentsToFetch.length === 0) return;
|
|
210
|
+
|
|
211
|
+
fetchAllAgents();
|
|
212
|
+
|
|
213
|
+
return () => abortController.abort();
|
|
214
|
+
}, [agentIds.join(','), autoFetch]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
215
|
+
|
|
216
|
+
// Get a single agent's profile
|
|
217
|
+
const getAgent = useCallback(
|
|
218
|
+
(agentId: string): AgentProfile | undefined => {
|
|
219
|
+
return state.agents[agentId];
|
|
220
|
+
},
|
|
221
|
+
[state.agents]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Check if a specific agent is ready
|
|
225
|
+
const isAgentReady = useCallback(
|
|
226
|
+
(agentId: string): boolean => {
|
|
227
|
+
return state.agents[agentId]?.status === 'ready';
|
|
228
|
+
},
|
|
229
|
+
[state.agents]
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Build agent awareness instructions for a given agent
|
|
233
|
+
const buildAgentAwarenessInstructions = useCallback(
|
|
234
|
+
(currentAgentId: string): string => {
|
|
235
|
+
const otherAgents = agentIds
|
|
236
|
+
.filter((id) => id !== currentAgentId)
|
|
237
|
+
.map((id) => {
|
|
238
|
+
const profile = state.agents[id];
|
|
239
|
+
if (profile?.status !== 'ready' || !profile.metadata) return null;
|
|
240
|
+
|
|
241
|
+
const override = localOverrides[id];
|
|
242
|
+
const name = override?.localName || profile.metadata.displayTitle || id;
|
|
243
|
+
const description = profile.metadata.description || 'An AI assistant';
|
|
244
|
+
return { id, name, description };
|
|
245
|
+
})
|
|
246
|
+
.filter((a): a is { id: string; name: string; description: string } => a !== null);
|
|
247
|
+
|
|
248
|
+
if (otherAgents.length === 0) return '';
|
|
249
|
+
|
|
250
|
+
// Use the first other agent for the example
|
|
251
|
+
const exampleAgent = otherAgents[0];
|
|
252
|
+
const exampleMarker = exampleAgent
|
|
253
|
+
? `[SUGGEST_AGENT:${exampleAgent.id}|${exampleAgent.name}|${exampleAgent.description}]`
|
|
254
|
+
: '[SUGGEST_AGENT:agent-id|Agent Name|Brief reason]';
|
|
255
|
+
|
|
256
|
+
return `
|
|
257
|
+
## Available Agent Team
|
|
258
|
+
|
|
259
|
+
You are part of a team of AI agents. When a user's request would be better handled by a specialized agent, you should suggest switching.
|
|
260
|
+
|
|
261
|
+
### Other Agents Available:
|
|
262
|
+
${otherAgents.map(a => `- **${a.name}** (ID: ${a.id}): ${a.description}`).join('\n')}
|
|
263
|
+
|
|
264
|
+
### How to Suggest an Agent Switch:
|
|
265
|
+
When you believe another agent would better serve the user, naturally suggest it in your response and include this exact marker format at the end of your message:
|
|
266
|
+
[SUGGEST_AGENT:actual-agent-id|Agent Name|Brief reason why]
|
|
267
|
+
|
|
268
|
+
IMPORTANT: Use the actual agent ID from the list above, not a placeholder.
|
|
269
|
+
|
|
270
|
+
Example: "I think ${exampleAgent?.name || 'another agent'} would be perfect for this! ${exampleMarker}"
|
|
271
|
+
`.trim();
|
|
272
|
+
},
|
|
273
|
+
[agentIds, state.agents, localOverrides]
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Get list of ready agents
|
|
277
|
+
const readyAgents = useMemo(() => {
|
|
278
|
+
return agentIds.filter((id) => state.agents[id]?.status === 'ready');
|
|
279
|
+
}, [agentIds, state.agents]);
|
|
280
|
+
|
|
281
|
+
// Get list of agents with their display info
|
|
282
|
+
const agentList = useMemo(() => {
|
|
283
|
+
return agentIds.map((id) => {
|
|
284
|
+
const profile = state.agents[id];
|
|
285
|
+
const metadata = profile?.metadata;
|
|
286
|
+
const override = localOverrides[id];
|
|
287
|
+
|
|
288
|
+
// Use localName if provided, otherwise try API fields, fallback to ID
|
|
289
|
+
const name =
|
|
290
|
+
override?.localName ||
|
|
291
|
+
metadata?.displayTitle ||
|
|
292
|
+
(metadata?.displayInitialMessageOrPrompt
|
|
293
|
+
? extractAgentNameFromMessage(metadata.displayInitialMessageOrPrompt)
|
|
294
|
+
: null) ||
|
|
295
|
+
id;
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
id,
|
|
299
|
+
name,
|
|
300
|
+
description: metadata?.description || '',
|
|
301
|
+
status: profile?.status || 'idle',
|
|
302
|
+
isReady: profile?.status === 'ready',
|
|
303
|
+
avatarUrl: override?.avatarUrl,
|
|
304
|
+
localName: override?.localName,
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
}, [agentIds, state.agents, localOverrides]);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
...state,
|
|
311
|
+
getAgent,
|
|
312
|
+
isAgentReady,
|
|
313
|
+
fetchAllAgents,
|
|
314
|
+
buildAgentAwarenessInstructions,
|
|
315
|
+
readyAgents,
|
|
316
|
+
agentList,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Helper to extract agent name from initial message like "I'm your FocusedFit Navigator..."
|
|
321
|
+
function extractAgentNameFromMessage(message: string): string | null {
|
|
322
|
+
// Try patterns like "I'm your X", "I'm X", "I am your X", "I am X"
|
|
323
|
+
const patterns = [
|
|
324
|
+
/I'm your\s+([^.!?,]+)/i,
|
|
325
|
+
/I am your\s+([^.!?,]+)/i,
|
|
326
|
+
/I'm\s+([^.!?,]+)/i,
|
|
327
|
+
/I am\s+([^.!?,]+)/i,
|
|
328
|
+
/Welcome! I'm\s+([^.!?,]+)/i,
|
|
329
|
+
/Hi! I'm\s+([^.!?,]+)/i,
|
|
330
|
+
/Hello! I'm\s+([^.!?,]+)/i,
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
for (const pattern of patterns) {
|
|
334
|
+
const match = message.match(pattern);
|
|
335
|
+
if (match && match[1]) {
|
|
336
|
+
// Clean up the name
|
|
337
|
+
const name = match[1].trim();
|
|
338
|
+
// Don't return if it looks like the rest of the message
|
|
339
|
+
if (name.split(' ').length <= 4) {
|
|
340
|
+
return name;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export default useAgentRegistry;
|
|
349
|
+
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single conversation entry
|
|
5
|
+
*/
|
|
6
|
+
export interface Conversation {
|
|
7
|
+
id: string;
|
|
8
|
+
agentId: string;
|
|
9
|
+
title: string;
|
|
10
|
+
preview: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
history: Record<string, { content: string; callId: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Grouped conversations by time period
|
|
18
|
+
*/
|
|
19
|
+
export interface ConversationGroup {
|
|
20
|
+
label: string;
|
|
21
|
+
conversations: Conversation[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UseConversationStoreOptions {
|
|
25
|
+
namespace?: string;
|
|
26
|
+
maxConversations?: number;
|
|
27
|
+
autoPersist?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STORAGE_VERSION = 'v1';
|
|
31
|
+
|
|
32
|
+
const generateId = (): string => {
|
|
33
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
34
|
+
return crypto.randomUUID();
|
|
35
|
+
}
|
|
36
|
+
return `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getStorageKey = (namespace: string): string => {
|
|
40
|
+
return `ai-agent-panel:${namespace}:conversations:${STORAGE_VERSION}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const readFromStorage = (namespace: string): Conversation[] => {
|
|
44
|
+
if (typeof window === 'undefined') return [];
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const raw = localStorage.getItem(getStorageKey(namespace));
|
|
48
|
+
if (!raw) return [];
|
|
49
|
+
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (!Array.isArray(parsed)) return [];
|
|
52
|
+
|
|
53
|
+
return parsed;
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const writeToStorage = (namespace: string, conversations: Conversation[]): void => {
|
|
60
|
+
if (typeof window === 'undefined') return;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
localStorage.setItem(getStorageKey(namespace), JSON.stringify(conversations));
|
|
64
|
+
} catch {
|
|
65
|
+
// Storage full or unavailable
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const deriveTitle = (history: Record<string, { content: string; callId: string }>): string => {
|
|
70
|
+
const prompts = Object.keys(history);
|
|
71
|
+
if (prompts.length === 0) return 'New Conversation';
|
|
72
|
+
|
|
73
|
+
// Get first user prompt (strip timestamp prefix if present)
|
|
74
|
+
let firstPrompt = prompts[0] || 'New Conversation';
|
|
75
|
+
|
|
76
|
+
// Remove ISO timestamp prefix (e.g., "2025-01-01T14:30:00.000Z:")
|
|
77
|
+
const isoMatch = firstPrompt.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:(.+)/);
|
|
78
|
+
if (isoMatch && isoMatch[1]) {
|
|
79
|
+
firstPrompt = isoMatch[1];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Remove legacy timestamp prefix (e.g., "1234567890:")
|
|
83
|
+
const legacyMatch = firstPrompt.match(/^\d+:(.+)/);
|
|
84
|
+
if (legacyMatch && legacyMatch[1]) {
|
|
85
|
+
firstPrompt = legacyMatch[1];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Truncate if too long
|
|
89
|
+
if (firstPrompt.length > 50) {
|
|
90
|
+
return firstPrompt.slice(0, 47) + '...';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return firstPrompt;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const derivePreview = (history: Record<string, { content: string; callId: string }>): string => {
|
|
97
|
+
const entries = Object.entries(history);
|
|
98
|
+
if (entries.length === 0) return '';
|
|
99
|
+
|
|
100
|
+
// Get last response
|
|
101
|
+
const lastEntry = entries[entries.length - 1];
|
|
102
|
+
if (!lastEntry) return '';
|
|
103
|
+
|
|
104
|
+
const lastResponse = lastEntry[1].content;
|
|
105
|
+
if (!lastResponse) return '';
|
|
106
|
+
|
|
107
|
+
// Strip markdown and truncate
|
|
108
|
+
const plainText = lastResponse
|
|
109
|
+
.replace(/```[\s\S]*?```/g, '[code]')
|
|
110
|
+
.replace(/`[^`]+`/g, '[code]')
|
|
111
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
112
|
+
.replace(/[#*_~]/g, '')
|
|
113
|
+
.trim();
|
|
114
|
+
|
|
115
|
+
if (plainText.length > 100) {
|
|
116
|
+
return plainText.slice(0, 97) + '...';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return plainText;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const groupByTimePeriod = (conversations: Conversation[]): ConversationGroup[] => {
|
|
123
|
+
const now = new Date();
|
|
124
|
+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
125
|
+
const startOfYesterday = new Date(startOfToday);
|
|
126
|
+
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
|
127
|
+
const startOfWeek = new Date(startOfToday);
|
|
128
|
+
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
|
|
129
|
+
|
|
130
|
+
const groups: ConversationGroup[] = [
|
|
131
|
+
{ label: 'Today', conversations: [] },
|
|
132
|
+
{ label: 'Yesterday', conversations: [] },
|
|
133
|
+
{ label: 'This Week', conversations: [] },
|
|
134
|
+
{ label: 'Older', conversations: [] },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
conversations.forEach((conv) => {
|
|
138
|
+
const updatedAt = new Date(conv.updatedAt);
|
|
139
|
+
|
|
140
|
+
if (updatedAt >= startOfToday) {
|
|
141
|
+
groups[0]!.conversations.push(conv);
|
|
142
|
+
} else if (updatedAt >= startOfYesterday) {
|
|
143
|
+
groups[1]!.conversations.push(conv);
|
|
144
|
+
} else if (updatedAt >= startOfWeek) {
|
|
145
|
+
groups[2]!.conversations.push(conv);
|
|
146
|
+
} else {
|
|
147
|
+
groups[3]!.conversations.push(conv);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Only return groups that have conversations
|
|
152
|
+
return groups.filter((group) => group.conversations.length > 0);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Hook to manage conversation persistence and history
|
|
157
|
+
*/
|
|
158
|
+
export function useConversationStore(options: UseConversationStoreOptions = {}) {
|
|
159
|
+
const {
|
|
160
|
+
namespace = 'default',
|
|
161
|
+
maxConversations = 50,
|
|
162
|
+
autoPersist = true,
|
|
163
|
+
} = options;
|
|
164
|
+
|
|
165
|
+
const [conversations, setConversations] = useState<Conversation[]>(() =>
|
|
166
|
+
readFromStorage(namespace)
|
|
167
|
+
);
|
|
168
|
+
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
|
169
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
170
|
+
|
|
171
|
+
// Persist to storage when conversations change
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (autoPersist) {
|
|
174
|
+
writeToStorage(namespace, conversations);
|
|
175
|
+
}
|
|
176
|
+
}, [conversations, namespace, autoPersist]);
|
|
177
|
+
|
|
178
|
+
// Reload from storage when namespace changes
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
setConversations(readFromStorage(namespace));
|
|
181
|
+
setActiveConversationId(null);
|
|
182
|
+
}, [namespace]);
|
|
183
|
+
|
|
184
|
+
// Get the active conversation
|
|
185
|
+
const activeConversation = useMemo(() => {
|
|
186
|
+
if (!activeConversationId) return null;
|
|
187
|
+
return conversations.find((c) => c.id === activeConversationId) || null;
|
|
188
|
+
}, [conversations, activeConversationId]);
|
|
189
|
+
|
|
190
|
+
// Filter conversations by search query
|
|
191
|
+
const filteredConversations = useMemo(() => {
|
|
192
|
+
if (!searchQuery.trim()) return conversations;
|
|
193
|
+
|
|
194
|
+
const query = searchQuery.toLowerCase().trim();
|
|
195
|
+
return conversations.filter((conv) => {
|
|
196
|
+
const searchable = [conv.title, conv.preview].join(' ').toLowerCase();
|
|
197
|
+
return searchable.includes(query);
|
|
198
|
+
});
|
|
199
|
+
}, [conversations, searchQuery]);
|
|
200
|
+
|
|
201
|
+
// Group filtered conversations by time period
|
|
202
|
+
const groupedConversations = useMemo(() => {
|
|
203
|
+
// Sort by updatedAt descending
|
|
204
|
+
const sorted = [...filteredConversations].sort(
|
|
205
|
+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
206
|
+
);
|
|
207
|
+
return groupByTimePeriod(sorted);
|
|
208
|
+
}, [filteredConversations]);
|
|
209
|
+
|
|
210
|
+
// Create a new conversation
|
|
211
|
+
const createConversation = useCallback(
|
|
212
|
+
(agentId: string, initialHistory?: Record<string, { content: string; callId: string }>): Conversation => {
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
const history = initialHistory || {};
|
|
215
|
+
|
|
216
|
+
const newConversation: Conversation = {
|
|
217
|
+
id: generateId(),
|
|
218
|
+
agentId,
|
|
219
|
+
title: deriveTitle(history),
|
|
220
|
+
preview: derivePreview(history),
|
|
221
|
+
createdAt: now,
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
history,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
setConversations((prev) => {
|
|
227
|
+
const updated = [newConversation, ...prev];
|
|
228
|
+
// Limit to maxConversations
|
|
229
|
+
return updated.slice(0, maxConversations);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
setActiveConversationId(newConversation.id);
|
|
233
|
+
return newConversation;
|
|
234
|
+
},
|
|
235
|
+
[maxConversations]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Update a conversation's history
|
|
239
|
+
const updateConversationHistory = useCallback(
|
|
240
|
+
(
|
|
241
|
+
conversationId: string,
|
|
242
|
+
history: Record<string, { content: string; callId: string }>
|
|
243
|
+
) => {
|
|
244
|
+
setConversations((prev) =>
|
|
245
|
+
prev.map((conv) => {
|
|
246
|
+
if (conv.id !== conversationId) return conv;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
...conv,
|
|
250
|
+
history,
|
|
251
|
+
title: deriveTitle(history),
|
|
252
|
+
preview: derivePreview(history),
|
|
253
|
+
updatedAt: new Date().toISOString(),
|
|
254
|
+
};
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
},
|
|
258
|
+
[]
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// Delete a conversation
|
|
262
|
+
const deleteConversation = useCallback((conversationId: string) => {
|
|
263
|
+
setConversations((prev) => prev.filter((c) => c.id !== conversationId));
|
|
264
|
+
|
|
265
|
+
if (activeConversationId === conversationId) {
|
|
266
|
+
setActiveConversationId(null);
|
|
267
|
+
}
|
|
268
|
+
}, [activeConversationId]);
|
|
269
|
+
|
|
270
|
+
// Select a conversation
|
|
271
|
+
const selectConversation = useCallback((conversationId: string | null) => {
|
|
272
|
+
setActiveConversationId(conversationId);
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
// Get conversations for a specific agent
|
|
276
|
+
const getConversationsForAgent = useCallback(
|
|
277
|
+
(agentId: string): Conversation[] => {
|
|
278
|
+
return conversations.filter((c) => c.agentId === agentId);
|
|
279
|
+
},
|
|
280
|
+
[conversations]
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Clear all conversations
|
|
284
|
+
const clearAllConversations = useCallback(() => {
|
|
285
|
+
setConversations([]);
|
|
286
|
+
setActiveConversationId(null);
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
// Start a new conversation (deselect current)
|
|
290
|
+
const startNewConversation = useCallback(() => {
|
|
291
|
+
setActiveConversationId(null);
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
conversations,
|
|
296
|
+
activeConversation,
|
|
297
|
+
activeConversationId,
|
|
298
|
+
filteredConversations,
|
|
299
|
+
groupedConversations,
|
|
300
|
+
searchQuery,
|
|
301
|
+
setSearchQuery,
|
|
302
|
+
createConversation,
|
|
303
|
+
updateConversationHistory,
|
|
304
|
+
deleteConversation,
|
|
305
|
+
selectConversation,
|
|
306
|
+
getConversationsForAgent,
|
|
307
|
+
clearAllConversations,
|
|
308
|
+
startNewConversation,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default useConversationStore;
|
|
313
|
+
|