@sybilion/uilib 1.3.88 → 1.3.90
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/esm/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.js +29 -21
- package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +4 -1
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +17 -10
- package/dist/esm/contexts/chat-context.js +180 -205
- package/dist/esm/contexts/chatPersistence.js +6 -18
- package/dist/esm/contexts/chatSessionStorage.js +245 -0
- package/dist/esm/lib/dashboard-spec/jsonDashboardFence.js +80 -0
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.d.ts +1 -5
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.d.ts +6 -1
- package/dist/esm/types/src/contexts/chat-context.d.ts +6 -0
- package/dist/esm/types/src/contexts/chatPersistence.d.ts +4 -1
- package/dist/esm/types/src/contexts/chatSessionStorage.d.ts +32 -0
- package/dist/esm/types/src/contexts/chatSessionStorage.test.d.ts +1 -0
- package/dist/esm/types/src/lib/dashboard-spec/jsonDashboardFence.d.ts +9 -0
- package/dist/esm/types/src/lib/dashboard-spec/stripJsonDashboardFences.d.ts +1 -2
- package/dist/esm/types/src/lib/dashboard-spec/stripJsonDashboardFences.test.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.test.tsx +25 -0
- package/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.tsx +35 -26
- package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +7 -1
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +21 -8
- package/src/contexts/chat-context.tsx +253 -220
- package/src/contexts/chatPersistence.test.ts +11 -0
- package/src/contexts/chatPersistence.ts +22 -6
- package/src/contexts/chatSessionStorage.test.ts +125 -0
- package/src/contexts/chatSessionStorage.ts +321 -0
- package/src/lib/dashboard-spec/jsonDashboardFence.ts +98 -0
- package/src/lib/dashboard-spec/stripJsonDashboardFences.test.ts +84 -0
- package/src/lib/dashboard-spec/stripJsonDashboardFences.ts +5 -6
- package/dist/esm/lib/dashboard-spec/stripJsonDashboardFences.js +0 -7
|
@@ -17,11 +17,27 @@ import {
|
|
|
17
17
|
type UserTextFileAttachment,
|
|
18
18
|
} from '#uilib/components/ui/Chat/Chat.types';
|
|
19
19
|
import { normalizeUserTextFileAttachments } from '#uilib/components/ui/Chat/buildChatSendMessagePayload';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
normalizeJsonDashboardChatText,
|
|
22
|
+
stripJsonDashboardFences,
|
|
23
|
+
} from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
|
|
21
24
|
import type { ChatResponse } from '#uilib/types/chat-api.types';
|
|
22
|
-
import { LS } from '@homecode/ui';
|
|
23
25
|
|
|
24
|
-
import {
|
|
26
|
+
import { isEphemeralChatScope } from './chatPersistence';
|
|
27
|
+
import {
|
|
28
|
+
addScopeIdToRegistry,
|
|
29
|
+
countSessionReferences,
|
|
30
|
+
deleteSessionBlob,
|
|
31
|
+
linkSessionToScope as linkSessionToScopeStorage,
|
|
32
|
+
loadChatsFromSessionStorage,
|
|
33
|
+
loadScopeIndex,
|
|
34
|
+
loadSession,
|
|
35
|
+
persistScopeIndex,
|
|
36
|
+
persistSession,
|
|
37
|
+
removeSessionFromScope,
|
|
38
|
+
renameSessionId,
|
|
39
|
+
} from './chatSessionStorage';
|
|
40
|
+
import type { LinkSessionToScopeOptions } from './chatSessionStorage';
|
|
25
41
|
|
|
26
42
|
export type SendChatMessageFn = (
|
|
27
43
|
message: string,
|
|
@@ -33,9 +49,6 @@ export type {
|
|
|
33
49
|
UserTextFileAttachment,
|
|
34
50
|
} from '#uilib/components/ui/Chat/Chat.types';
|
|
35
51
|
|
|
36
|
-
const CHATS_PREFIX = 'chats-';
|
|
37
|
-
const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
|
|
38
|
-
|
|
39
52
|
export type AddChatMessageOptions = {
|
|
40
53
|
userTextFileAttachments?: UserTextFileAttachment[];
|
|
41
54
|
/** SYSTEM-only: mark as transient progress placeholder (shimmer until removed). */
|
|
@@ -55,6 +68,8 @@ export type NewChatOptions = {
|
|
|
55
68
|
seedMessages?: readonly Message[];
|
|
56
69
|
};
|
|
57
70
|
|
|
71
|
+
export type { LinkSessionToScopeOptions } from './chatSessionStorage';
|
|
72
|
+
|
|
58
73
|
export type SendMessageResult = {
|
|
59
74
|
response: string;
|
|
60
75
|
sessionId: string;
|
|
@@ -63,6 +78,12 @@ export type SendMessageResult = {
|
|
|
63
78
|
export interface ChatContextType {
|
|
64
79
|
/** Returns the new session id, or undefined if no user / not created. */
|
|
65
80
|
newChat: (scopeId: string, options?: NewChatOptions) => string | undefined;
|
|
81
|
+
/** Attach an existing session to a scope without cloning messages. */
|
|
82
|
+
linkSessionToScope: (
|
|
83
|
+
scopeId: string,
|
|
84
|
+
sessionId: string,
|
|
85
|
+
options?: LinkSessionToScopeOptions,
|
|
86
|
+
) => void;
|
|
66
87
|
setCurrentChatId: (currScopeId: string, sessionId: string) => void;
|
|
67
88
|
addMessage: (
|
|
68
89
|
scopeId: string,
|
|
@@ -116,78 +137,43 @@ export function outboundPendingKey(scopeId: string, chatSessionId: string) {
|
|
|
116
137
|
return `${scopeId}\0${chatSessionId}`;
|
|
117
138
|
}
|
|
118
139
|
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (id.startsWith(prefix)) out.add(id);
|
|
132
|
-
}
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function discoverScopeIdsFromLS(): Set<string> {
|
|
137
|
-
const scopeIds = new Set<string>();
|
|
138
|
-
const registryRaw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
|
|
139
|
-
const registry = Array.isArray(registryRaw) ? registryRaw : [];
|
|
140
|
-
registry.forEach((id: unknown) => {
|
|
141
|
-
if (typeof id === 'string' && id) scopeIds.add(id);
|
|
142
|
-
});
|
|
143
|
-
try {
|
|
144
|
-
for (let i = 0; i < window.localStorage.length; i++) {
|
|
145
|
-
const key = window.localStorage.key(i);
|
|
146
|
-
if (key?.startsWith(CHATS_PREFIX)) {
|
|
147
|
-
const scopeId = key.slice(CHATS_PREFIX.length);
|
|
148
|
-
if (scopeId) scopeIds.add(scopeId);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
} catch {
|
|
152
|
-
// ignore
|
|
153
|
-
}
|
|
154
|
-
return scopeIds;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function loadChatsFromLS(userId: number | null): {
|
|
158
|
-
chats: Record<string, Chat[]>;
|
|
159
|
-
currentChatId: Record<string, string | null>;
|
|
160
|
-
} {
|
|
161
|
-
if (typeof window === 'undefined' || userId === null) {
|
|
162
|
-
return { chats: {}, currentChatId: {} };
|
|
163
|
-
}
|
|
164
|
-
const chats: Record<string, Chat[]> = {};
|
|
165
|
-
const currentChatId: Record<string, string | null> = {};
|
|
166
|
-
const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
|
|
167
|
-
for (const scopeId of scopeIds) {
|
|
168
|
-
const chatsKey = getChatsKey(scopeId);
|
|
169
|
-
const storedRaw = LS.get(chatsKey);
|
|
170
|
-
const stored = Array.isArray(storedRaw) ? storedRaw : undefined;
|
|
171
|
-
if (stored?.length) {
|
|
172
|
-
chats[scopeId] = stored;
|
|
173
|
-
const ck = getCurrentChatIdKey(scopeId);
|
|
174
|
-
const savedCurrentIdRaw = LS.get(ck);
|
|
175
|
-
const savedCurrentId =
|
|
176
|
-
typeof savedCurrentIdRaw === 'string' && savedCurrentIdRaw !== ''
|
|
177
|
-
? savedCurrentIdRaw
|
|
178
|
-
: undefined;
|
|
179
|
-
currentChatId[scopeId] = savedCurrentId ?? stored[0]?.session_id ?? null;
|
|
140
|
+
function applySessionUpdate(
|
|
141
|
+
prev: Record<string, Chat[]>,
|
|
142
|
+
sessionId: string,
|
|
143
|
+
updater: (chat: Chat) => Chat,
|
|
144
|
+
): Record<string, Chat[]> {
|
|
145
|
+
let updatedChat: Chat | null = null;
|
|
146
|
+
const next: Record<string, Chat[]> = {};
|
|
147
|
+
for (const [scopeId, scopeChats] of Object.entries(prev)) {
|
|
148
|
+
const idx = scopeChats.findIndex(chat => chat.session_id === sessionId);
|
|
149
|
+
if (idx === -1) {
|
|
150
|
+
next[scopeId] = scopeChats;
|
|
151
|
+
continue;
|
|
180
152
|
}
|
|
153
|
+
const chat = updater(scopeChats[idx]);
|
|
154
|
+
updatedChat = chat;
|
|
155
|
+
const scopeNext = [...scopeChats];
|
|
156
|
+
scopeNext[idx] = chat;
|
|
157
|
+
next[scopeId] = scopeNext;
|
|
181
158
|
}
|
|
182
|
-
|
|
159
|
+
if (updatedChat) persistSession(updatedChat);
|
|
160
|
+
return next;
|
|
183
161
|
}
|
|
184
162
|
|
|
185
|
-
function
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
163
|
+
function remapSessionIdInMemory(
|
|
164
|
+
prev: Record<string, Chat[]>,
|
|
165
|
+
oldSessionId: string,
|
|
166
|
+
newSessionId: string,
|
|
167
|
+
): Record<string, Chat[]> {
|
|
168
|
+
const next: Record<string, Chat[]> = {};
|
|
169
|
+
for (const [scopeId, scopeChats] of Object.entries(prev)) {
|
|
170
|
+
next[scopeId] = scopeChats.map(chat =>
|
|
171
|
+
chat.session_id === oldSessionId
|
|
172
|
+
? { ...chat, session_id: newSessionId }
|
|
173
|
+
: chat,
|
|
174
|
+
);
|
|
190
175
|
}
|
|
176
|
+
return next;
|
|
191
177
|
}
|
|
192
178
|
|
|
193
179
|
/** Shallow-clone messages for seeding another session; drops in-progress rows. */
|
|
@@ -196,6 +182,7 @@ function cloneMessagesForNewSession(messages: readonly Message[]): Message[] {
|
|
|
196
182
|
.filter(message => !message.inProgress)
|
|
197
183
|
.map(message => ({
|
|
198
184
|
...message,
|
|
185
|
+
text: stripJsonDashboardFences(message.text),
|
|
199
186
|
...(message.meta ? { meta: { ...message.meta } } : {}),
|
|
200
187
|
...(message.userTextFileAttachments
|
|
201
188
|
? {
|
|
@@ -220,14 +207,14 @@ export function ChatProvider({
|
|
|
220
207
|
sendChatMessage: sendChatMessageFn,
|
|
221
208
|
}: ChatProviderProps) {
|
|
222
209
|
const [chats, setChats] = useState<Record<string, Chat[]>>(() => {
|
|
223
|
-
if (userSwitchKey === null) return {};
|
|
224
|
-
return
|
|
210
|
+
if (userSwitchKey === null || typeof window === 'undefined') return {};
|
|
211
|
+
return loadChatsFromSessionStorage(userSwitchKey).chats;
|
|
225
212
|
});
|
|
226
213
|
const [currentChatId, setCurrentChatIdState] = useState<
|
|
227
214
|
Record<string, string | null>
|
|
228
215
|
>(() => {
|
|
229
|
-
if (userSwitchKey === null) return {};
|
|
230
|
-
return
|
|
216
|
+
if (userSwitchKey === null || typeof window === 'undefined') return {};
|
|
217
|
+
return loadChatsFromSessionStorage(userSwitchKey).currentChatId;
|
|
231
218
|
});
|
|
232
219
|
|
|
233
220
|
const [outboundPendingByKey, setOutboundPendingByKey] = useState<
|
|
@@ -282,6 +269,68 @@ export function ChatProvider({
|
|
|
282
269
|
[currentChatId],
|
|
283
270
|
);
|
|
284
271
|
|
|
272
|
+
const setCurrentChatId = useCallback(
|
|
273
|
+
(currScopeId: string, sessionId: string) => {
|
|
274
|
+
if (!sessionId) return;
|
|
275
|
+
setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
|
|
276
|
+
const index = loadScopeIndex(currScopeId);
|
|
277
|
+
if (index) {
|
|
278
|
+
persistScopeIndex(currScopeId, {
|
|
279
|
+
...index,
|
|
280
|
+
currentSessionId: sessionId,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
[],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const linkSessionToScope = useCallback(
|
|
288
|
+
(
|
|
289
|
+
scopeId: string,
|
|
290
|
+
sessionId: string,
|
|
291
|
+
options?: LinkSessionToScopeOptions,
|
|
292
|
+
) => {
|
|
293
|
+
if (userSwitchKey === null) return;
|
|
294
|
+
linkSessionToScopeStorage(scopeId, sessionId, options);
|
|
295
|
+
setChats(prev => {
|
|
296
|
+
const sessionFromLs = loadSession(sessionId);
|
|
297
|
+
let sessionFromMemory: Chat | null = null;
|
|
298
|
+
for (const scopeChats of Object.values(prev)) {
|
|
299
|
+
const found = scopeChats.find(chat => chat.session_id === sessionId);
|
|
300
|
+
if (
|
|
301
|
+
found &&
|
|
302
|
+
(!sessionFromMemory ||
|
|
303
|
+
found.messages.length > sessionFromMemory.messages.length)
|
|
304
|
+
) {
|
|
305
|
+
sessionFromMemory = found;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const session =
|
|
309
|
+
sessionFromMemory &&
|
|
310
|
+
(!sessionFromLs ||
|
|
311
|
+
sessionFromMemory.messages.length >= sessionFromLs.messages.length)
|
|
312
|
+
? sessionFromMemory
|
|
313
|
+
: sessionFromLs;
|
|
314
|
+
if (!session) return prev;
|
|
315
|
+
if (session === sessionFromMemory) {
|
|
316
|
+
persistSession(sessionFromMemory);
|
|
317
|
+
}
|
|
318
|
+
const scopeChats = prev[scopeId] ?? [];
|
|
319
|
+
const exists = scopeChats.some(chat => chat.session_id === sessionId);
|
|
320
|
+
const updatedChats = exists
|
|
321
|
+
? scopeChats.map(chat =>
|
|
322
|
+
chat.session_id === sessionId ? session : chat,
|
|
323
|
+
)
|
|
324
|
+
: [session, ...scopeChats];
|
|
325
|
+
return { ...prev, [scopeId]: updatedChats };
|
|
326
|
+
});
|
|
327
|
+
if (options?.makeCurrent !== false) {
|
|
328
|
+
setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
[userSwitchKey],
|
|
332
|
+
);
|
|
333
|
+
|
|
285
334
|
const newChat = useCallback(
|
|
286
335
|
(scopeId: string, options?: NewChatOptions): string | undefined => {
|
|
287
336
|
if (userSwitchKey === null) return undefined;
|
|
@@ -292,72 +341,74 @@ export function ChatProvider({
|
|
|
292
341
|
options?.seedMessages && options.seedMessages.length > 0
|
|
293
342
|
? cloneMessagesForNewSession(options.seedMessages)
|
|
294
343
|
: [];
|
|
295
|
-
const
|
|
344
|
+
const created: Chat = {
|
|
296
345
|
session_id: sessionId,
|
|
297
346
|
name: '',
|
|
298
347
|
messages: seededMessages,
|
|
299
348
|
};
|
|
300
349
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const updatedChats = [newChat, ...currentChats];
|
|
304
|
-
|
|
305
|
-
persistChatsToLS(scopeId, updatedChats);
|
|
350
|
+
persistSession(created);
|
|
351
|
+
linkSessionToScopeStorage(scopeId, sessionId, { makeCurrent: true });
|
|
306
352
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
353
|
+
setChats(prev => {
|
|
354
|
+
const scopeChats = prev[scopeId] ?? [];
|
|
355
|
+
const isEphemeral = isEphemeralChatScope(scopeId);
|
|
356
|
+
if (isEphemeral) {
|
|
357
|
+
for (const old of scopeChats) {
|
|
358
|
+
if (old.session_id !== sessionId) {
|
|
359
|
+
removeSessionFromScope(scopeId, old.session_id);
|
|
360
|
+
if (countSessionReferences(userSwitchKey, old.session_id) === 0) {
|
|
361
|
+
deleteSessionBlob(old.session_id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const updatedChats = isEphemeral ? [created] : [created, ...scopeChats];
|
|
367
|
+
return { ...prev, [scopeId]: updatedChats };
|
|
311
368
|
});
|
|
312
369
|
setCurrentChatIdState(prev => ({ ...prev, [scopeId]: sessionId }));
|
|
313
|
-
safeLsSet(getCurrentChatIdKey(scopeId), sessionId);
|
|
314
370
|
return sessionId;
|
|
315
371
|
},
|
|
316
372
|
[userSwitchKey],
|
|
317
373
|
);
|
|
318
374
|
|
|
319
|
-
const
|
|
320
|
-
(
|
|
321
|
-
|
|
322
|
-
setCurrentChatIdState(prev => ({ ...prev, [currScopeId]: sessionId }));
|
|
323
|
-
safeLsSet(getCurrentChatIdKey(currScopeId), sessionId);
|
|
324
|
-
},
|
|
325
|
-
[],
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
const deleteChat = useCallback((scopeId: string, sessionId: string) => {
|
|
329
|
-
const currentKey = getCurrentChatIdKey(scopeId);
|
|
330
|
-
|
|
331
|
-
setChats(prev => {
|
|
332
|
-
const scopeChats = prev[scopeId] ?? [];
|
|
375
|
+
const deleteChat = useCallback(
|
|
376
|
+
(scopeId: string, sessionId: string) => {
|
|
377
|
+
const scopeChats = chats[scopeId] ?? [];
|
|
333
378
|
const deletedIndex = scopeChats.findIndex(
|
|
334
|
-
|
|
379
|
+
chat => chat.session_id === sessionId,
|
|
335
380
|
);
|
|
336
|
-
if (deletedIndex === -1)
|
|
337
|
-
|
|
381
|
+
if (deletedIndex === -1) return;
|
|
382
|
+
|
|
383
|
+
removeSessionFromScope(scopeId, sessionId);
|
|
384
|
+
if (
|
|
385
|
+
userSwitchKey != null &&
|
|
386
|
+
countSessionReferences(userSwitchKey, sessionId) === 0
|
|
387
|
+
) {
|
|
388
|
+
deleteSessionBlob(sessionId);
|
|
338
389
|
}
|
|
339
|
-
|
|
340
|
-
|
|
390
|
+
|
|
391
|
+
setChats(prev => {
|
|
392
|
+
const currentScopeChats = prev[scopeId] ?? [];
|
|
393
|
+
return {
|
|
394
|
+
...prev,
|
|
395
|
+
[scopeId]: currentScopeChats.filter(
|
|
396
|
+
chat => chat.session_id !== sessionId,
|
|
397
|
+
),
|
|
398
|
+
};
|
|
399
|
+
});
|
|
341
400
|
|
|
342
401
|
setCurrentChatIdState(prevCurr => {
|
|
343
|
-
if (prevCurr[scopeId] !== sessionId)
|
|
344
|
-
|
|
345
|
-
}
|
|
402
|
+
if (prevCurr[scopeId] !== sessionId) return prevCurr;
|
|
403
|
+
const index = loadScopeIndex(scopeId);
|
|
346
404
|
const nextId =
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
: (updatedChats[0]?.session_id ?? null);
|
|
350
|
-
if (nextId) {
|
|
351
|
-
safeLsSet(currentKey, nextId);
|
|
352
|
-
} else {
|
|
353
|
-
LS.remove(currentKey);
|
|
354
|
-
}
|
|
405
|
+
index?.currentSessionId ??
|
|
406
|
+
(deletedIndex > 0 ? scopeChats[deletedIndex - 1].session_id : null);
|
|
355
407
|
return { ...prevCurr, [scopeId]: nextId };
|
|
356
408
|
});
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}, []);
|
|
409
|
+
},
|
|
410
|
+
[chats, userSwitchKey],
|
|
411
|
+
);
|
|
361
412
|
|
|
362
413
|
const addMessage = useCallback(
|
|
363
414
|
(
|
|
@@ -369,7 +420,10 @@ export function ChatProvider({
|
|
|
369
420
|
) => {
|
|
370
421
|
if (userSwitchKey === null) return undefined;
|
|
371
422
|
addScopeIdToRegistry(scopeId);
|
|
372
|
-
const storedText =
|
|
423
|
+
const storedText =
|
|
424
|
+
role === MessageRole.ASSISTANT
|
|
425
|
+
? normalizeJsonDashboardChatText(text)
|
|
426
|
+
: stripJsonDashboardFences(text);
|
|
373
427
|
const attachments =
|
|
374
428
|
role === MessageRole.USER
|
|
375
429
|
? options?.userTextFileAttachments
|
|
@@ -386,19 +440,12 @@ export function ChatProvider({
|
|
|
386
440
|
...(options?.meta ? { meta: { ...options.meta } } : {}),
|
|
387
441
|
};
|
|
388
442
|
|
|
389
|
-
setChats(prev =>
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return chat;
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
persistChatsToLS(scopeId, updatedChats);
|
|
399
|
-
|
|
400
|
-
return { ...prev, [scopeId]: updatedChats };
|
|
401
|
-
});
|
|
443
|
+
setChats(prev =>
|
|
444
|
+
applySessionUpdate(prev, chatId, chat => ({
|
|
445
|
+
...chat,
|
|
446
|
+
messages: [...chat.messages, newMessage],
|
|
447
|
+
})),
|
|
448
|
+
);
|
|
402
449
|
return newMessage.id;
|
|
403
450
|
},
|
|
404
451
|
[userSwitchKey],
|
|
@@ -407,18 +454,12 @@ export function ChatProvider({
|
|
|
407
454
|
const removeMessageById = useCallback(
|
|
408
455
|
(scopeId: string, chatId: string, messageId: string) => {
|
|
409
456
|
if (userSwitchKey === null) return;
|
|
410
|
-
setChats(prev =>
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
messages: chat.messages.filter(m => m.id !== messageId),
|
|
417
|
-
};
|
|
418
|
-
});
|
|
419
|
-
persistChatsToLS(scopeId, updatedChats);
|
|
420
|
-
return { ...prev, [scopeId]: updatedChats };
|
|
421
|
-
});
|
|
457
|
+
setChats(prev =>
|
|
458
|
+
applySessionUpdate(prev, chatId, chat => ({
|
|
459
|
+
...chat,
|
|
460
|
+
messages: chat.messages.filter(message => message.id !== messageId),
|
|
461
|
+
})),
|
|
462
|
+
);
|
|
422
463
|
},
|
|
423
464
|
[userSwitchKey],
|
|
424
465
|
);
|
|
@@ -431,39 +472,37 @@ export function ChatProvider({
|
|
|
431
472
|
patch: UpdateChatMessagePatch,
|
|
432
473
|
) => {
|
|
433
474
|
if (userSwitchKey === null) return;
|
|
434
|
-
setChats(prev =>
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
...
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
})
|
|
464
|
-
|
|
465
|
-
return { ...prev, [scopeId]: updatedChats };
|
|
466
|
-
});
|
|
475
|
+
setChats(prev =>
|
|
476
|
+
applySessionUpdate(prev, chatId, chat => ({
|
|
477
|
+
...chat,
|
|
478
|
+
messages: chat.messages.map(message => {
|
|
479
|
+
if (message.id !== messageId) return message;
|
|
480
|
+
const next: Message = { ...message };
|
|
481
|
+
if (patch.role != null) {
|
|
482
|
+
next.role = patch.role;
|
|
483
|
+
}
|
|
484
|
+
if (patch.text != null) {
|
|
485
|
+
const targetRole = patch.role ?? message.role;
|
|
486
|
+
next.text =
|
|
487
|
+
targetRole === MessageRole.ASSISTANT
|
|
488
|
+
? normalizeJsonDashboardChatText(patch.text)
|
|
489
|
+
: stripJsonDashboardFences(patch.text);
|
|
490
|
+
}
|
|
491
|
+
if (patch.inProgress === true) {
|
|
492
|
+
next.inProgress = true;
|
|
493
|
+
} else if (
|
|
494
|
+
patch.inProgress === false ||
|
|
495
|
+
(patch.role != null && patch.role !== MessageRole.SYSTEM)
|
|
496
|
+
) {
|
|
497
|
+
delete next.inProgress;
|
|
498
|
+
}
|
|
499
|
+
if (patch.meta != null) {
|
|
500
|
+
next.meta = { ...next.meta, ...patch.meta };
|
|
501
|
+
}
|
|
502
|
+
return next;
|
|
503
|
+
}),
|
|
504
|
+
})),
|
|
505
|
+
);
|
|
467
506
|
},
|
|
468
507
|
[userSwitchKey],
|
|
469
508
|
);
|
|
@@ -474,20 +513,19 @@ export function ChatProvider({
|
|
|
474
513
|
addScopeIdToRegistry(scopeId);
|
|
475
514
|
const cloned = messages.map(message => ({
|
|
476
515
|
...message,
|
|
516
|
+
text:
|
|
517
|
+
message.role === MessageRole.ASSISTANT
|
|
518
|
+
? message.text
|
|
519
|
+
: stripJsonDashboardFences(message.text),
|
|
477
520
|
...(message.meta ? { meta: { ...message.meta } } : {}),
|
|
478
521
|
}));
|
|
479
522
|
|
|
480
|
-
setChats(prev =>
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
persistChatsToLS(scopeId, updatedChats);
|
|
488
|
-
|
|
489
|
-
return { ...prev, [scopeId]: updatedChats };
|
|
490
|
-
});
|
|
523
|
+
setChats(prev =>
|
|
524
|
+
applySessionUpdate(prev, chatId, chat => ({
|
|
525
|
+
...chat,
|
|
526
|
+
messages: cloned,
|
|
527
|
+
})),
|
|
528
|
+
);
|
|
491
529
|
},
|
|
492
530
|
[userSwitchKey],
|
|
493
531
|
);
|
|
@@ -495,20 +533,12 @@ export function ChatProvider({
|
|
|
495
533
|
const updateChatMeta = useCallback(
|
|
496
534
|
(scopeId: string, chatId: string, patch: ChatMeta) => {
|
|
497
535
|
if (userSwitchKey === null) return;
|
|
498
|
-
setChats(prev =>
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
meta: { ...chat.meta, ...patch },
|
|
505
|
-
};
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
persistChatsToLS(scopeId, updatedChats);
|
|
509
|
-
|
|
510
|
-
return { ...prev, [scopeId]: updatedChats };
|
|
511
|
-
});
|
|
536
|
+
setChats(prev =>
|
|
537
|
+
applySessionUpdate(prev, chatId, chat => ({
|
|
538
|
+
...chat,
|
|
539
|
+
meta: { ...chat.meta, ...patch },
|
|
540
|
+
})),
|
|
541
|
+
);
|
|
512
542
|
},
|
|
513
543
|
[userSwitchKey],
|
|
514
544
|
);
|
|
@@ -563,19 +593,15 @@ export function ChatProvider({
|
|
|
563
593
|
? data.session_id
|
|
564
594
|
: pendingChatSessionId;
|
|
565
595
|
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
)
|
|
574
|
-
|
|
575
|
-
persistChatsToLS(scopeId, updatedChats);
|
|
576
|
-
|
|
577
|
-
return { ...prev, [scopeId]: updatedChats };
|
|
578
|
-
});
|
|
596
|
+
if (
|
|
597
|
+
data.session_id &&
|
|
598
|
+
data.session_id !== pendingChatSessionId &&
|
|
599
|
+
userSwitchKey != null
|
|
600
|
+
) {
|
|
601
|
+
renameSessionId(userSwitchKey, pendingChatSessionId, data.session_id);
|
|
602
|
+
setChats(prev =>
|
|
603
|
+
remapSessionIdInMemory(prev, pendingChatSessionId, data.session_id),
|
|
604
|
+
);
|
|
579
605
|
setCurrentChatId(scopeId, data.session_id);
|
|
580
606
|
}
|
|
581
607
|
|
|
@@ -621,6 +647,7 @@ export function ChatProvider({
|
|
|
621
647
|
getCurrentChatId,
|
|
622
648
|
sendChatMessageFn,
|
|
623
649
|
setCurrentChatId,
|
|
650
|
+
userSwitchKey,
|
|
624
651
|
],
|
|
625
652
|
);
|
|
626
653
|
|
|
@@ -633,7 +660,7 @@ export function ChatProvider({
|
|
|
633
660
|
return;
|
|
634
661
|
}
|
|
635
662
|
|
|
636
|
-
const loaded =
|
|
663
|
+
const loaded = loadChatsFromSessionStorage(userSwitchKey);
|
|
637
664
|
const updatedCurrentChatId = { ...loaded.currentChatId };
|
|
638
665
|
for (const [scopeId, scopeChats] of Object.entries(loaded.chats)) {
|
|
639
666
|
if (
|
|
@@ -653,6 +680,7 @@ export function ChatProvider({
|
|
|
653
680
|
<ChatContext.Provider
|
|
654
681
|
value={{
|
|
655
682
|
newChat,
|
|
683
|
+
linkSessionToScope,
|
|
656
684
|
setCurrentChatId,
|
|
657
685
|
addMessage,
|
|
658
686
|
removeMessageById,
|
|
@@ -742,6 +770,7 @@ export function useChatsForScopeId(scopeId: string) {
|
|
|
742
770
|
getCurrentChatId,
|
|
743
771
|
setCurrentChatId,
|
|
744
772
|
newChat,
|
|
773
|
+
linkSessionToScope,
|
|
745
774
|
addMessage,
|
|
746
775
|
removeMessageById,
|
|
747
776
|
updateMessageById,
|
|
@@ -761,6 +790,10 @@ export function useChatsForScopeId(scopeId: string) {
|
|
|
761
790
|
currentChatId,
|
|
762
791
|
isOutboundPending,
|
|
763
792
|
setCurrentChatId: (targetId: string) => setCurrentChatId(scopeId, targetId),
|
|
793
|
+
linkSessionToScope: (
|
|
794
|
+
sessionId: string,
|
|
795
|
+
options?: LinkSessionToScopeOptions,
|
|
796
|
+
) => linkSessionToScope(scopeId, sessionId, options),
|
|
764
797
|
newChat: (options?: NewChatOptions) => newChat(scopeId, options),
|
|
765
798
|
addMessage: (
|
|
766
799
|
chatId: string,
|
|
@@ -19,6 +19,17 @@ describe('stripMessageForPersistence', () => {
|
|
|
19
19
|
expect(message.text).toBe('Summary');
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
it('strips from an unclosed json-dashboard fence', () => {
|
|
23
|
+
const message = stripMessageForPersistence({
|
|
24
|
+
id: '1b',
|
|
25
|
+
role: MessageRole.ASSISTANT,
|
|
26
|
+
text: 'Summary\n```json-dashboard\n{"tiles":[]}',
|
|
27
|
+
timestamp: 1,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(message.text).toBe('Summary');
|
|
31
|
+
});
|
|
32
|
+
|
|
22
33
|
it('drops attachment content but keeps file metadata', () => {
|
|
23
34
|
const message = stripMessageForPersistence({
|
|
24
35
|
id: '2',
|