botschat 0.1.8 → 0.1.10
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 +10 -0
- package/package.json +1 -1
- package/packages/api/src/do/connection-do.ts +57 -8
- package/packages/api/src/routes/upload.ts +2 -1
- package/packages/api/src/utils/uuid.ts +17 -0
- package/packages/plugin/dist/src/channel.js +27 -1
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/e2e-crypto.d.ts +47 -0
- package/packages/plugin/dist/src/e2e-crypto.d.ts.map +1 -0
- package/packages/plugin/dist/src/e2e-crypto.js +210 -0
- package/packages/plugin/dist/src/e2e-crypto.js.map +1 -0
- package/packages/plugin/dist/src/types.d.ts +6 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +1 -1
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +2 -3
- package/packages/web/dist/assets/index-DpW6VzZK.js +1497 -0
- package/packages/web/dist/assets/{index-B1sFqYiM.css → index-Ev5M8VmV.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/src/App.tsx +7 -7
- package/packages/web/src/api.ts +1 -1
- package/packages/web/src/components/ChatWindow.tsx +114 -23
- package/packages/web/src/components/LoginPage.tsx +3 -0
- package/packages/web/src/components/MobileLayout.tsx +29 -2
- package/packages/web/src/components/SessionTabs.tsx +138 -21
- package/packages/web/src/components/Sidebar.tsx +14 -1
- package/packages/web/src/components/ThreadPanel.tsx +155 -13
- package/packages/web/src/utils/uuid.ts +21 -0
- package/packages/web/src/ws.ts +24 -4
- package/packages/web/dist/assets/index-C-FpELeN.js +0 -1497
|
@@ -3,6 +3,45 @@ import { useAppState, useAppDispatch } from "../store";
|
|
|
3
3
|
import { sessionsApi, channelsApi, agentsApi } from "../api";
|
|
4
4
|
import { dlog } from "../debug-log";
|
|
5
5
|
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Session history — tracks per-channel usage order in localStorage
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const SESSION_HISTORY_KEY = "botschat:sessionHistory";
|
|
10
|
+
|
|
11
|
+
/** Get the ordered list of recently-used session IDs for a channel. */
|
|
12
|
+
function getSessionHistory(channelId: string): string[] {
|
|
13
|
+
try {
|
|
14
|
+
const all = JSON.parse(localStorage.getItem(SESSION_HISTORY_KEY) || "{}");
|
|
15
|
+
return Array.isArray(all[channelId]) ? all[channelId] : [];
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Record a session as the most-recently-used for its channel. */
|
|
22
|
+
function recordSessionUsage(channelId: string, sessionId: string) {
|
|
23
|
+
try {
|
|
24
|
+
const all = JSON.parse(localStorage.getItem(SESSION_HISTORY_KEY) || "{}");
|
|
25
|
+
const list: string[] = Array.isArray(all[channelId]) ? all[channelId] : [];
|
|
26
|
+
// Move to front (most recent)
|
|
27
|
+
const filtered = list.filter((id) => id !== sessionId);
|
|
28
|
+
filtered.unshift(sessionId);
|
|
29
|
+
// Keep at most 50 entries per channel
|
|
30
|
+
all[channelId] = filtered.slice(0, 50);
|
|
31
|
+
localStorage.setItem(SESSION_HISTORY_KEY, JSON.stringify(all));
|
|
32
|
+
} catch { /* ignore */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Remove a session from the history for a channel. */
|
|
36
|
+
function removeSessionFromHistory(channelId: string, sessionId: string) {
|
|
37
|
+
try {
|
|
38
|
+
const all = JSON.parse(localStorage.getItem(SESSION_HISTORY_KEY) || "{}");
|
|
39
|
+
const list: string[] = Array.isArray(all[channelId]) ? all[channelId] : [];
|
|
40
|
+
all[channelId] = list.filter((id) => id !== sessionId);
|
|
41
|
+
localStorage.setItem(SESSION_HISTORY_KEY, JSON.stringify(all));
|
|
42
|
+
} catch { /* ignore */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
6
45
|
type SessionTabsProps = {
|
|
7
46
|
channelId: string | null;
|
|
8
47
|
};
|
|
@@ -12,8 +51,10 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
|
|
|
12
51
|
const dispatch = useAppDispatch();
|
|
13
52
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
14
53
|
const [editValue, setEditValue] = useState("");
|
|
54
|
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
15
55
|
const editRef = useRef<HTMLInputElement>(null);
|
|
16
56
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
const confirmRef = useRef<HTMLDivElement>(null);
|
|
17
58
|
|
|
18
59
|
const sessions = state.sessions;
|
|
19
60
|
const selectedId = state.selectedSessionId;
|
|
@@ -26,19 +67,33 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
|
|
|
26
67
|
}
|
|
27
68
|
}, [editingId]);
|
|
28
69
|
|
|
70
|
+
// Close confirmation popover on outside click
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!confirmDeleteId) return;
|
|
73
|
+
const handler = (e: MouseEvent) => {
|
|
74
|
+
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
|
|
75
|
+
setConfirmDeleteId(null);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
document.addEventListener("mousedown", handler);
|
|
79
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
80
|
+
}, [confirmDeleteId]);
|
|
81
|
+
|
|
29
82
|
const handleSelect = useCallback(
|
|
30
83
|
(sessionId: string) => {
|
|
31
84
|
if (sessionId === selectedId) return;
|
|
32
85
|
const session = sessions.find((s) => s.id === sessionId);
|
|
33
86
|
if (!session) return;
|
|
34
87
|
dlog.info("Session", `Switched to session: ${session.name} (${session.id})`);
|
|
88
|
+
// Record usage for smart navigation on close
|
|
89
|
+
if (channelId) recordSessionUsage(channelId, session.id);
|
|
35
90
|
dispatch({
|
|
36
91
|
type: "SELECT_SESSION",
|
|
37
92
|
sessionId: session.id,
|
|
38
93
|
sessionKey: session.sessionKey,
|
|
39
94
|
});
|
|
40
95
|
},
|
|
41
|
-
[selectedId, sessions, dispatch],
|
|
96
|
+
[selectedId, sessions, channelId, dispatch],
|
|
42
97
|
);
|
|
43
98
|
|
|
44
99
|
const handleCreate = useCallback(async () => {
|
|
@@ -73,6 +128,7 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
|
|
|
73
128
|
const session = await sessionsApi.create(effectiveChannelId);
|
|
74
129
|
dlog.info("Session", `Created session: ${session.name} (${session.id})`);
|
|
75
130
|
dispatch({ type: "ADD_SESSION", session });
|
|
131
|
+
recordSessionUsage(effectiveChannelId, session.id);
|
|
76
132
|
dispatch({
|
|
77
133
|
type: "SELECT_SESSION",
|
|
78
134
|
sessionId: session.id,
|
|
@@ -96,14 +152,22 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
|
|
|
96
152
|
await sessionsApi.delete(channelId, sessionId);
|
|
97
153
|
dlog.info("Session", `Deleted session: ${sessionId}`);
|
|
98
154
|
dispatch({ type: "REMOVE_SESSION", sessionId });
|
|
99
|
-
|
|
155
|
+
removeSessionFromHistory(channelId, sessionId);
|
|
156
|
+
setConfirmDeleteId(null);
|
|
157
|
+
|
|
158
|
+
// If deleted the selected session, navigate to the last-used session
|
|
159
|
+
// in the same channel (from history), or fall back to first remaining
|
|
100
160
|
if (selectedId === sessionId) {
|
|
101
161
|
const remaining = sessions.filter((s) => s.id !== sessionId);
|
|
102
162
|
if (remaining.length > 0) {
|
|
163
|
+
const history = getSessionHistory(channelId);
|
|
164
|
+
const target = history
|
|
165
|
+
.map((hId) => remaining.find((s) => s.id === hId))
|
|
166
|
+
.find(Boolean) ?? remaining[0];
|
|
103
167
|
dispatch({
|
|
104
168
|
type: "SELECT_SESSION",
|
|
105
|
-
sessionId:
|
|
106
|
-
sessionKey:
|
|
169
|
+
sessionId: target.id,
|
|
170
|
+
sessionKey: target.sessionKey,
|
|
107
171
|
});
|
|
108
172
|
}
|
|
109
173
|
}
|
|
@@ -197,23 +261,6 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
|
|
|
197
261
|
title={`${session.name} (double-click to rename)`}
|
|
198
262
|
>
|
|
199
263
|
<span className="max-w-[120px] truncate">{session.name}</span>
|
|
200
|
-
|
|
201
|
-
{/* Close button — only show on hover, not for last session */}
|
|
202
|
-
{sessions.length > 1 && (
|
|
203
|
-
<span
|
|
204
|
-
onClick={(e) => {
|
|
205
|
-
e.stopPropagation();
|
|
206
|
-
handleDelete(session.id);
|
|
207
|
-
}}
|
|
208
|
-
className="ml-0.5 w-4 h-4 flex items-center justify-center rounded-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[--bg-hover]"
|
|
209
|
-
style={{ color: "var(--text-muted)" }}
|
|
210
|
-
title="Close session"
|
|
211
|
-
>
|
|
212
|
-
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
213
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
214
|
-
</svg>
|
|
215
|
-
</span>
|
|
216
|
-
)}
|
|
217
264
|
</button>
|
|
218
265
|
)}
|
|
219
266
|
</div>
|
|
@@ -241,6 +288,76 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
|
|
|
241
288
|
</svg>
|
|
242
289
|
</button>
|
|
243
290
|
</div>
|
|
291
|
+
|
|
292
|
+
{/* Close current session button — separate from tabs, with confirmation */}
|
|
293
|
+
{sessions.length > 1 && selectedId && (
|
|
294
|
+
<div className="relative ml-auto pl-2 flex-shrink-0">
|
|
295
|
+
<button
|
|
296
|
+
onClick={() => setConfirmDeleteId(selectedId)}
|
|
297
|
+
className="w-7 h-7 flex items-center justify-center rounded-md transition-colors"
|
|
298
|
+
style={{ color: "var(--text-muted)" }}
|
|
299
|
+
title="Close current session"
|
|
300
|
+
aria-label="Close current session"
|
|
301
|
+
onMouseEnter={(e) => {
|
|
302
|
+
e.currentTarget.style.background = "var(--bg-hover)";
|
|
303
|
+
e.currentTarget.style.color = "var(--text-primary)";
|
|
304
|
+
}}
|
|
305
|
+
onMouseLeave={(e) => {
|
|
306
|
+
e.currentTarget.style.background = "";
|
|
307
|
+
e.currentTarget.style.color = "var(--text-muted)";
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
311
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
312
|
+
</svg>
|
|
313
|
+
</button>
|
|
314
|
+
|
|
315
|
+
{/* Confirmation popover */}
|
|
316
|
+
{confirmDeleteId && (
|
|
317
|
+
<div
|
|
318
|
+
ref={confirmRef}
|
|
319
|
+
className="absolute right-0 top-full mt-1 z-50 rounded-md shadow-lg py-2 px-3 min-w-[180px]"
|
|
320
|
+
style={{
|
|
321
|
+
background: "var(--bg-surface)",
|
|
322
|
+
border: "1px solid var(--border)",
|
|
323
|
+
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
<p className="text-caption mb-2" style={{ color: "var(--text-primary)" }}>
|
|
327
|
+
Close this session?
|
|
328
|
+
</p>
|
|
329
|
+
<div className="flex items-center gap-2 justify-end">
|
|
330
|
+
<button
|
|
331
|
+
onClick={() => setConfirmDeleteId(null)}
|
|
332
|
+
className="px-2.5 py-1 text-caption rounded-md transition-colors"
|
|
333
|
+
style={{ color: "var(--text-secondary)" }}
|
|
334
|
+
onMouseEnter={(e) => {
|
|
335
|
+
e.currentTarget.style.background = "var(--bg-hover)";
|
|
336
|
+
}}
|
|
337
|
+
onMouseLeave={(e) => {
|
|
338
|
+
e.currentTarget.style.background = "";
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
Cancel
|
|
342
|
+
</button>
|
|
343
|
+
<button
|
|
344
|
+
onClick={() => handleDelete(confirmDeleteId)}
|
|
345
|
+
className="px-2.5 py-1 text-caption font-bold rounded-md text-white transition-colors"
|
|
346
|
+
style={{ background: "#e74c3c" }}
|
|
347
|
+
onMouseEnter={(e) => {
|
|
348
|
+
e.currentTarget.style.background = "#c0392b";
|
|
349
|
+
}}
|
|
350
|
+
onMouseLeave={(e) => {
|
|
351
|
+
e.currentTarget.style.background = "#e74c3c";
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
Close
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
244
361
|
</div>
|
|
245
362
|
);
|
|
246
363
|
}
|
|
@@ -3,7 +3,7 @@ import { useAppState, useAppDispatch } from "../store";
|
|
|
3
3
|
import { agentsApi, channelsApi } from "../api";
|
|
4
4
|
import { dlog } from "../debug-log";
|
|
5
5
|
|
|
6
|
-
export function Sidebar() {
|
|
6
|
+
export function Sidebar({ onOpenSettings }: { onOpenSettings?: () => void } = {}) {
|
|
7
7
|
const state = useAppState();
|
|
8
8
|
const dispatch = useAppDispatch();
|
|
9
9
|
const [showCreate, setShowCreate] = useState(false);
|
|
@@ -88,6 +88,19 @@ export function Sidebar() {
|
|
|
88
88
|
<span className="text-[--text-sidebar-active] font-bold text-h2 truncate flex-1">
|
|
89
89
|
BotsChat
|
|
90
90
|
</span>
|
|
91
|
+
{onOpenSettings && (
|
|
92
|
+
<button
|
|
93
|
+
onClick={onOpenSettings}
|
|
94
|
+
className="p-1 rounded transition-colors hover:bg-[--sidebar-hover]"
|
|
95
|
+
style={{ color: "var(--text-sidebar)" }}
|
|
96
|
+
title="Settings"
|
|
97
|
+
>
|
|
98
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
99
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
|
100
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
101
|
+
</svg>
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
91
104
|
<svg className="w-3 h-3 text-[--text-sidebar]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
92
105
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
93
106
|
</svg>
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { useAppState, useAppDispatch, type ChatMessage } from "../store";
|
|
3
3
|
import { messagesApi } from "../api";
|
|
4
4
|
import type { WSMessage } from "../ws";
|
|
5
5
|
import { MessageContent } from "./MessageContent";
|
|
6
6
|
import { dlog } from "../debug-log";
|
|
7
|
+
import { randomUUID } from "../utils/uuid";
|
|
8
|
+
|
|
9
|
+
/** Simple string hash for action prompt keys (matches MessageContent / ChatWindow) */
|
|
10
|
+
function simpleHash(str: string): string {
|
|
11
|
+
let hash = 0;
|
|
12
|
+
for (let i = 0; i < str.length; i++) {
|
|
13
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
14
|
+
}
|
|
15
|
+
return hash.toString(36);
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
type ThreadPanelProps = {
|
|
9
19
|
sendMessage: (msg: WSMessage) => void;
|
|
@@ -39,15 +49,85 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
|
|
|
39
49
|
(m) => m.id === state.activeThreadId,
|
|
40
50
|
);
|
|
41
51
|
|
|
52
|
+
const threadSessionKey = state.selectedSessionKey
|
|
53
|
+
? `${state.selectedSessionKey}:thread:${state.activeThreadId}`
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
/** Handle A2UI action button clicks in thread — sends as user message */
|
|
57
|
+
const handleA2UIAction = useCallback((action: string) => {
|
|
58
|
+
if (!threadSessionKey) return;
|
|
59
|
+
dlog.info("Thread/A2UI", `Action triggered: ${action}`);
|
|
60
|
+
const msg: ChatMessage = {
|
|
61
|
+
id: randomUUID(),
|
|
62
|
+
sender: "user",
|
|
63
|
+
text: action,
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
threadId: state.activeThreadId ?? undefined,
|
|
66
|
+
};
|
|
67
|
+
dispatch({ type: "ADD_THREAD_MESSAGE", message: msg });
|
|
68
|
+
sendMessage({
|
|
69
|
+
type: "user.message",
|
|
70
|
+
sessionKey: threadSessionKey,
|
|
71
|
+
text: action,
|
|
72
|
+
userId: state.user?.id ?? "",
|
|
73
|
+
messageId: msg.id,
|
|
74
|
+
});
|
|
75
|
+
}, [threadSessionKey, state.activeThreadId, state.user?.id, sendMessage, dispatch]);
|
|
76
|
+
|
|
77
|
+
/** Handle ActionCard resolve in thread — marks widget done + sends choice */
|
|
78
|
+
const handleResolveAction = useCallback((messageId: string, value: string, label: string) => {
|
|
79
|
+
if (!threadSessionKey) return;
|
|
80
|
+
dlog.info("Thread/ActionCard", `Resolved: "${label}" (value="${value}")`);
|
|
81
|
+
const promptHash = simpleHash(label + value);
|
|
82
|
+
dispatch({ type: "RESOLVE_ACTION", messageId, promptHash, value, label });
|
|
83
|
+
const msg: ChatMessage = {
|
|
84
|
+
id: randomUUID(),
|
|
85
|
+
sender: "user",
|
|
86
|
+
text: label,
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
threadId: state.activeThreadId ?? undefined,
|
|
89
|
+
};
|
|
90
|
+
dispatch({ type: "ADD_THREAD_MESSAGE", message: msg });
|
|
91
|
+
sendMessage({
|
|
92
|
+
type: "user.message",
|
|
93
|
+
sessionKey: threadSessionKey,
|
|
94
|
+
text: label,
|
|
95
|
+
userId: state.user?.id ?? "",
|
|
96
|
+
messageId: msg.id,
|
|
97
|
+
});
|
|
98
|
+
}, [threadSessionKey, state.activeThreadId, state.user?.id, sendMessage, dispatch]);
|
|
99
|
+
|
|
100
|
+
/** Stop the current thread streaming — sends /stop */
|
|
101
|
+
const handleStop = useCallback(() => {
|
|
102
|
+
if (!threadSessionKey || !state.streamingRunId || !state.streamingThreadId) return;
|
|
103
|
+
dlog.info("Thread", "Stop streaming requested");
|
|
104
|
+
const msg: ChatMessage = {
|
|
105
|
+
id: randomUUID(),
|
|
106
|
+
sender: "user",
|
|
107
|
+
text: "/stop",
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
threadId: state.activeThreadId ?? undefined,
|
|
110
|
+
};
|
|
111
|
+
dispatch({ type: "ADD_THREAD_MESSAGE", message: msg });
|
|
112
|
+
sendMessage({
|
|
113
|
+
type: "user.message",
|
|
114
|
+
sessionKey: threadSessionKey,
|
|
115
|
+
text: "/stop",
|
|
116
|
+
userId: state.user?.id ?? "",
|
|
117
|
+
messageId: msg.id,
|
|
118
|
+
});
|
|
119
|
+
}, [threadSessionKey, state.streamingRunId, state.streamingThreadId, state.activeThreadId, state.user?.id, sendMessage, dispatch]);
|
|
120
|
+
|
|
121
|
+
const isThreadStreaming = !!state.streamingRunId && !!state.streamingThreadId;
|
|
122
|
+
|
|
42
123
|
const handleSend = () => {
|
|
43
124
|
if (!input.trim() || !state.selectedSessionKey) return;
|
|
44
125
|
|
|
45
126
|
const trimmed = input.trim();
|
|
46
|
-
const threadSessionKey = `${state.selectedSessionKey}:thread:${state.activeThreadId}`;
|
|
47
127
|
dlog.info("Thread", `Send reply: ${trimmed.length > 120 ? trimmed.slice(0, 120) + "…" : trimmed}`, { threadId: state.activeThreadId });
|
|
48
128
|
|
|
49
129
|
const msg: ChatMessage = {
|
|
50
|
-
id:
|
|
130
|
+
id: randomUUID(),
|
|
51
131
|
sender: "user",
|
|
52
132
|
text: trimmed,
|
|
53
133
|
timestamp: Date.now(),
|
|
@@ -58,7 +138,7 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
|
|
|
58
138
|
|
|
59
139
|
sendMessage({
|
|
60
140
|
type: "user.message",
|
|
61
|
-
sessionKey: threadSessionKey
|
|
141
|
+
sessionKey: threadSessionKey!,
|
|
62
142
|
text: trimmed,
|
|
63
143
|
userId: state.user?.id ?? "",
|
|
64
144
|
messageId: msg.id,
|
|
@@ -113,7 +193,14 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
|
|
|
113
193
|
{new Date(parentMessage.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
|
114
194
|
</span>
|
|
115
195
|
</div>
|
|
116
|
-
<MessageContent
|
|
196
|
+
<MessageContent
|
|
197
|
+
text={parentMessage.text}
|
|
198
|
+
mediaUrl={parentMessage.mediaUrl}
|
|
199
|
+
a2ui={parentMessage.a2ui}
|
|
200
|
+
onAction={handleA2UIAction}
|
|
201
|
+
onResolveAction={(value, label) => handleResolveAction(parentMessage.id, value, label)}
|
|
202
|
+
resolvedActions={parentMessage.resolvedActions}
|
|
203
|
+
/>
|
|
117
204
|
</div>
|
|
118
205
|
</div>
|
|
119
206
|
</div>
|
|
@@ -164,7 +251,44 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
|
|
|
164
251
|
text={msg.text}
|
|
165
252
|
mediaUrl={msg.mediaUrl}
|
|
166
253
|
a2ui={msg.a2ui}
|
|
254
|
+
isStreaming={msg.isStreaming}
|
|
255
|
+
onAction={handleA2UIAction}
|
|
256
|
+
onResolveAction={(value, label) => handleResolveAction(msg.id, value, label)}
|
|
257
|
+
resolvedActions={msg.resolvedActions}
|
|
167
258
|
/>
|
|
259
|
+
{msg.isStreaming && (
|
|
260
|
+
<div className="flex items-center gap-2 mt-1">
|
|
261
|
+
<span
|
|
262
|
+
className="inline-block w-1.5 h-4 rounded-sm animate-pulse"
|
|
263
|
+
style={{ background: "var(--text-link)", verticalAlign: "text-bottom" }}
|
|
264
|
+
/>
|
|
265
|
+
<button
|
|
266
|
+
onClick={handleStop}
|
|
267
|
+
className="flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium transition-colors"
|
|
268
|
+
style={{
|
|
269
|
+
color: "var(--text-secondary)",
|
|
270
|
+
background: "var(--bg-hover)",
|
|
271
|
+
border: "1px solid var(--border)",
|
|
272
|
+
}}
|
|
273
|
+
onMouseEnter={(e) => {
|
|
274
|
+
e.currentTarget.style.background = "#e74c3c";
|
|
275
|
+
e.currentTarget.style.color = "#fff";
|
|
276
|
+
e.currentTarget.style.borderColor = "#e74c3c";
|
|
277
|
+
}}
|
|
278
|
+
onMouseLeave={(e) => {
|
|
279
|
+
e.currentTarget.style.background = "var(--bg-hover)";
|
|
280
|
+
e.currentTarget.style.color = "var(--text-secondary)";
|
|
281
|
+
e.currentTarget.style.borderColor = "var(--border)";
|
|
282
|
+
}}
|
|
283
|
+
title="Stop generating"
|
|
284
|
+
>
|
|
285
|
+
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
|
286
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
287
|
+
</svg>
|
|
288
|
+
Stop
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
168
292
|
</div>
|
|
169
293
|
</div>
|
|
170
294
|
</div>
|
|
@@ -193,14 +317,32 @@ export function ThreadPanel({ sendMessage }: ThreadPanelProps) {
|
|
|
193
317
|
style={{ color: "var(--text-primary)", minHeight: 36 }}
|
|
194
318
|
/>
|
|
195
319
|
<div className="flex justify-end px-3 pb-2">
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
320
|
+
{isThreadStreaming ? (
|
|
321
|
+
<button
|
|
322
|
+
onClick={handleStop}
|
|
323
|
+
className="px-3 py-1 rounded-sm text-caption font-bold text-white transition-colors"
|
|
324
|
+
style={{ background: "#e74c3c" }}
|
|
325
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = "#c0392b"; }}
|
|
326
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = "#e74c3c"; }}
|
|
327
|
+
title="Stop generating"
|
|
328
|
+
>
|
|
329
|
+
<div className="flex items-center gap-1">
|
|
330
|
+
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
331
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
332
|
+
</svg>
|
|
333
|
+
Stop
|
|
334
|
+
</div>
|
|
335
|
+
</button>
|
|
336
|
+
) : (
|
|
337
|
+
<button
|
|
338
|
+
onClick={handleSend}
|
|
339
|
+
disabled={!input.trim()}
|
|
340
|
+
className="px-3 py-1 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
341
|
+
style={{ background: "var(--bg-active)" }}
|
|
342
|
+
>
|
|
343
|
+
Send
|
|
344
|
+
</button>
|
|
345
|
+
)}
|
|
204
346
|
</div>
|
|
205
347
|
</div>
|
|
206
348
|
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a UUID v4. Uses crypto.randomUUID() when available (HTTPS),
|
|
3
|
+
* otherwise falls back to crypto.getRandomValues() for HTTP/insecure contexts
|
|
4
|
+
* where randomUUID is not defined.
|
|
5
|
+
*/
|
|
6
|
+
export function randomUUID(): string {
|
|
7
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
8
|
+
return crypto.randomUUID();
|
|
9
|
+
}
|
|
10
|
+
// Fallback for insecure context (e.g. http://0.0.0.0:8787): UUID v4 via getRandomValues
|
|
11
|
+
const bytes = new Uint8Array(16);
|
|
12
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
13
|
+
crypto.getRandomValues(bytes);
|
|
14
|
+
} else {
|
|
15
|
+
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
16
|
+
}
|
|
17
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
18
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
19
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
20
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
21
|
+
}
|
package/packages/web/src/ws.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { dlog } from "./debug-log";
|
|
4
4
|
import { E2eService } from "./e2e";
|
|
5
|
+
import { getToken, tryRefreshAccessToken } from "./api";
|
|
5
6
|
|
|
6
7
|
export type WSMessage = {
|
|
7
8
|
type: string;
|
|
@@ -11,7 +12,8 @@ export type WSMessage = {
|
|
|
11
12
|
export type WSClientOptions = {
|
|
12
13
|
userId: string;
|
|
13
14
|
sessionId: string;
|
|
14
|
-
token
|
|
15
|
+
/** Function that returns the current access token (reads from localStorage). */
|
|
16
|
+
getToken: () => string | null;
|
|
15
17
|
onMessage: (msg: WSMessage) => void;
|
|
16
18
|
onStatusChange: (connected: boolean) => void;
|
|
17
19
|
};
|
|
@@ -41,8 +43,14 @@ export class BotsChatWSClient {
|
|
|
41
43
|
|
|
42
44
|
this.ws.onopen = () => {
|
|
43
45
|
dlog.info("WS", "Socket opened, sending auth");
|
|
46
|
+
const token = this.opts.getToken();
|
|
47
|
+
if (!token) {
|
|
48
|
+
dlog.error("WS", "No access token available, closing");
|
|
49
|
+
this.ws?.close();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
44
52
|
// Authenticate with the ConnectionDO
|
|
45
|
-
this.ws!.send(JSON.stringify({ type: "auth", token
|
|
53
|
+
this.ws!.send(JSON.stringify({ type: "auth", token }));
|
|
46
54
|
};
|
|
47
55
|
|
|
48
56
|
this.ws.onmessage = async (evt) => {
|
|
@@ -124,9 +132,21 @@ export class BotsChatWSClient {
|
|
|
124
132
|
this._connected = false;
|
|
125
133
|
this.opts.onStatusChange(false);
|
|
126
134
|
if (!this.intentionalClose) {
|
|
127
|
-
|
|
128
|
-
this.
|
|
135
|
+
const isAuthFail = evt.code === 4001;
|
|
136
|
+
dlog.warn("WS", `Connection closed (code=${evt.code}), reconnecting in ${this.backoffMs}ms${isAuthFail ? " (will refresh token)" : ""}`);
|
|
137
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
129
138
|
this.backoffMs = Math.min(this.backoffMs * 2, 30000);
|
|
139
|
+
// On auth failure (4001), refresh the access token before reconnecting
|
|
140
|
+
if (isAuthFail) {
|
|
141
|
+
dlog.info("WS", "Refreshing access token before reconnect...");
|
|
142
|
+
const ok = await tryRefreshAccessToken();
|
|
143
|
+
if (ok) {
|
|
144
|
+
dlog.info("WS", "Token refreshed, reconnecting");
|
|
145
|
+
this.backoffMs = 1000; // reset backoff on successful refresh
|
|
146
|
+
} else {
|
|
147
|
+
dlog.error("WS", "Token refresh failed — will retry on next cycle");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
130
150
|
this.connect();
|
|
131
151
|
}, this.backoffMs);
|
|
132
152
|
} else {
|