@sybilion/uilib 1.3.87 → 1.3.89
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 +2 -1
- package/dist/esm/components/ui/Chat/ChatSheet/ChatSheet.js +1 -0
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +36 -10
- package/dist/esm/components/ui/Sidebar/Sidebar.js +23 -7
- 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/ChatEmptyState/ChatEmptyState.types.d.ts +5 -1
- 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/ChatSheet.d.ts +2 -0
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.d.ts +8 -1
- package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -1
- package/dist/esm/types/src/components/ui/Sidebar/Sidebar.d.ts +2 -0
- 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/ChatEmptyState/ChatEmptyState.types.ts +9 -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 +2 -1
- package/src/components/ui/Chat/ChatSheet/ChatSheet.tsx +3 -0
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +55 -10
- package/src/components/ui/Chat/index.ts +1 -0
- package/src/components/ui/Sidebar/Sidebar.tsx +27 -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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { LS } from '@homecode/ui';
|
|
2
|
+
import { stripChatsForPersistence, safeLsSet, isEphemeralChatScope } from './chatPersistence.js';
|
|
3
|
+
|
|
4
|
+
const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
|
|
5
|
+
const SESSION_PREFIX = 'chat-session-';
|
|
6
|
+
const SCOPE_PREFIX = 'chat-scope-';
|
|
7
|
+
const CHATS_PREFIX = 'chats-';
|
|
8
|
+
const CHAT_CURRENT_ID_PREFIX = 'chat-current-id-';
|
|
9
|
+
function getSessionKey(sessionId) {
|
|
10
|
+
return `${SESSION_PREFIX}${sessionId}`;
|
|
11
|
+
}
|
|
12
|
+
function getScopeKey(scopeId) {
|
|
13
|
+
return `${SCOPE_PREFIX}${scopeId}`;
|
|
14
|
+
}
|
|
15
|
+
function getLegacyChatsKey(scopeId) {
|
|
16
|
+
return `${CHATS_PREFIX}${scopeId}`;
|
|
17
|
+
}
|
|
18
|
+
function getLegacyCurrentIdKey(scopeId) {
|
|
19
|
+
return `${CHAT_CURRENT_ID_PREFIX}${scopeId}`;
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null;
|
|
23
|
+
}
|
|
24
|
+
function isChat(value) {
|
|
25
|
+
if (!isRecord(value))
|
|
26
|
+
return false;
|
|
27
|
+
return (typeof value.session_id === 'string' &&
|
|
28
|
+
typeof value.name === 'string' &&
|
|
29
|
+
Array.isArray(value.messages));
|
|
30
|
+
}
|
|
31
|
+
function isChatScopeIndex(value) {
|
|
32
|
+
if (!isRecord(value))
|
|
33
|
+
return false;
|
|
34
|
+
const { currentSessionId, sessionIds } = value;
|
|
35
|
+
if (currentSessionId !== null && typeof currentSessionId !== 'string') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(sessionIds))
|
|
39
|
+
return false;
|
|
40
|
+
return sessionIds.every(id => typeof id === 'string');
|
|
41
|
+
}
|
|
42
|
+
function trimScopeIndexForPersistence(scopeId, index) {
|
|
43
|
+
if (!isEphemeralChatScope(scopeId))
|
|
44
|
+
return index;
|
|
45
|
+
const current = index.currentSessionId ?? index.sessionIds[0] ?? null;
|
|
46
|
+
if (!current)
|
|
47
|
+
return { currentSessionId: null, sessionIds: [] };
|
|
48
|
+
return { currentSessionId: current, sessionIds: [current] };
|
|
49
|
+
}
|
|
50
|
+
function mergeSessionChat(existing, incoming) {
|
|
51
|
+
if (!existing)
|
|
52
|
+
return incoming;
|
|
53
|
+
return incoming.messages.length >= existing.messages.length
|
|
54
|
+
? incoming
|
|
55
|
+
: existing;
|
|
56
|
+
}
|
|
57
|
+
function loadSession(sessionId) {
|
|
58
|
+
const raw = LS.get(getSessionKey(sessionId));
|
|
59
|
+
return isChat(raw) ? raw : null;
|
|
60
|
+
}
|
|
61
|
+
function persistSession(chat) {
|
|
62
|
+
const [stripped] = stripChatsForPersistence([chat]);
|
|
63
|
+
safeLsSet(getSessionKey(chat.session_id), stripped);
|
|
64
|
+
}
|
|
65
|
+
function deleteSessionBlob(sessionId) {
|
|
66
|
+
LS.remove(getSessionKey(sessionId));
|
|
67
|
+
}
|
|
68
|
+
function loadScopeIndex(scopeId) {
|
|
69
|
+
const raw = LS.get(getScopeKey(scopeId));
|
|
70
|
+
return isChatScopeIndex(raw) ? raw : null;
|
|
71
|
+
}
|
|
72
|
+
function persistScopeIndex(scopeId, index) {
|
|
73
|
+
safeLsSet(getScopeKey(scopeId), trimScopeIndexForPersistence(scopeId, index));
|
|
74
|
+
}
|
|
75
|
+
function addScopeIdToRegistry(scopeId) {
|
|
76
|
+
const raw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
|
|
77
|
+
const registry = Array.isArray(raw) ? [...raw] : [];
|
|
78
|
+
if (!registry.includes(scopeId)) {
|
|
79
|
+
safeLsSet(CHAT_SCOPE_IDS_REGISTRY_KEY, [...registry, scopeId]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function discoverScopeIdsFromLS() {
|
|
83
|
+
const scopeIds = new Set();
|
|
84
|
+
const registryRaw = LS.get(CHAT_SCOPE_IDS_REGISTRY_KEY);
|
|
85
|
+
const registry = Array.isArray(registryRaw) ? registryRaw : [];
|
|
86
|
+
registry.forEach((id) => {
|
|
87
|
+
if (typeof id === 'string' && id)
|
|
88
|
+
scopeIds.add(id);
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
92
|
+
const key = window.localStorage.key(i);
|
|
93
|
+
if (!key)
|
|
94
|
+
continue;
|
|
95
|
+
if (key.startsWith(SCOPE_PREFIX)) {
|
|
96
|
+
const scopeId = key.slice(SCOPE_PREFIX.length);
|
|
97
|
+
if (scopeId)
|
|
98
|
+
scopeIds.add(scopeId);
|
|
99
|
+
}
|
|
100
|
+
else if (key.startsWith(CHATS_PREFIX)) {
|
|
101
|
+
const scopeId = key.slice(CHATS_PREFIX.length);
|
|
102
|
+
if (scopeId)
|
|
103
|
+
scopeIds.add(scopeId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
return scopeIds;
|
|
111
|
+
}
|
|
112
|
+
function scopeIdsForUser(userId, all) {
|
|
113
|
+
const prefix = `${userId}-`;
|
|
114
|
+
const out = new Set();
|
|
115
|
+
for (const id of all) {
|
|
116
|
+
if (id.startsWith(prefix))
|
|
117
|
+
out.add(id);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function hasLegacyScopeData(scopeId) {
|
|
122
|
+
const legacyRaw = LS.get(getLegacyChatsKey(scopeId));
|
|
123
|
+
return Array.isArray(legacyRaw) && legacyRaw.length > 0;
|
|
124
|
+
}
|
|
125
|
+
function migrateLegacyScope(scopeId) {
|
|
126
|
+
const legacyRaw = LS.get(getLegacyChatsKey(scopeId));
|
|
127
|
+
const legacyChats = Array.isArray(legacyRaw) ? legacyRaw : [];
|
|
128
|
+
const chats = [];
|
|
129
|
+
legacyChats.forEach(item => {
|
|
130
|
+
if (isChat(item))
|
|
131
|
+
chats.push(item);
|
|
132
|
+
});
|
|
133
|
+
if (chats.length === 0)
|
|
134
|
+
return loadScopeIndex(scopeId);
|
|
135
|
+
const savedCurrentRaw = LS.get(getLegacyCurrentIdKey(scopeId));
|
|
136
|
+
const savedCurrentId = typeof savedCurrentRaw === 'string' && savedCurrentRaw !== ''
|
|
137
|
+
? savedCurrentRaw
|
|
138
|
+
: undefined;
|
|
139
|
+
const currentSessionId = savedCurrentId ?? chats[0]?.session_id ?? null;
|
|
140
|
+
const sessionIds = chats.map(chat => chat.session_id);
|
|
141
|
+
chats.forEach(chat => {
|
|
142
|
+
const existing = loadSession(chat.session_id);
|
|
143
|
+
persistSession(mergeSessionChat(existing, chat));
|
|
144
|
+
});
|
|
145
|
+
const index = { currentSessionId, sessionIds };
|
|
146
|
+
persistScopeIndex(scopeId, index);
|
|
147
|
+
addScopeIdToRegistry(scopeId);
|
|
148
|
+
LS.remove(getLegacyChatsKey(scopeId));
|
|
149
|
+
LS.remove(getLegacyCurrentIdKey(scopeId));
|
|
150
|
+
return index;
|
|
151
|
+
}
|
|
152
|
+
function linkSessionToScope(scopeId, sessionId, options) {
|
|
153
|
+
addScopeIdToRegistry(scopeId);
|
|
154
|
+
const existing = loadScopeIndex(scopeId) ?? {
|
|
155
|
+
currentSessionId: null,
|
|
156
|
+
sessionIds: [],
|
|
157
|
+
};
|
|
158
|
+
const sessionIds = existing.sessionIds.includes(sessionId)
|
|
159
|
+
? existing.sessionIds
|
|
160
|
+
: [sessionId, ...existing.sessionIds];
|
|
161
|
+
const makeCurrent = options?.makeCurrent !== false;
|
|
162
|
+
const index = {
|
|
163
|
+
sessionIds,
|
|
164
|
+
currentSessionId: makeCurrent ? sessionId : existing.currentSessionId,
|
|
165
|
+
};
|
|
166
|
+
persistScopeIndex(scopeId, index);
|
|
167
|
+
return index;
|
|
168
|
+
}
|
|
169
|
+
function hydrateChatsForScope(scopeId) {
|
|
170
|
+
const index = loadScopeIndex(scopeId);
|
|
171
|
+
if (!index)
|
|
172
|
+
return [];
|
|
173
|
+
return index.sessionIds
|
|
174
|
+
.map(sessionId => loadSession(sessionId))
|
|
175
|
+
.filter((chat) => chat != null);
|
|
176
|
+
}
|
|
177
|
+
function countSessionReferences(userId, sessionId) {
|
|
178
|
+
const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
|
|
179
|
+
let count = 0;
|
|
180
|
+
for (const scopeId of scopeIds) {
|
|
181
|
+
const index = loadScopeIndex(scopeId);
|
|
182
|
+
if (index?.sessionIds.includes(sessionId))
|
|
183
|
+
count += 1;
|
|
184
|
+
}
|
|
185
|
+
return count;
|
|
186
|
+
}
|
|
187
|
+
function removeSessionFromScope(scopeId, sessionId) {
|
|
188
|
+
const existing = loadScopeIndex(scopeId);
|
|
189
|
+
if (!existing)
|
|
190
|
+
return null;
|
|
191
|
+
const sessionIds = existing.sessionIds.filter(id => id !== sessionId);
|
|
192
|
+
if (sessionIds.length === existing.sessionIds.length)
|
|
193
|
+
return existing;
|
|
194
|
+
let nextCurrent = existing.currentSessionId;
|
|
195
|
+
if (nextCurrent === sessionId) {
|
|
196
|
+
const deletedIndex = existing.sessionIds.indexOf(sessionId);
|
|
197
|
+
nextCurrent =
|
|
198
|
+
deletedIndex > 0
|
|
199
|
+
? existing.sessionIds[deletedIndex - 1]
|
|
200
|
+
: (sessionIds[0] ?? null);
|
|
201
|
+
}
|
|
202
|
+
const index = {
|
|
203
|
+
currentSessionId: nextCurrent,
|
|
204
|
+
sessionIds,
|
|
205
|
+
};
|
|
206
|
+
persistScopeIndex(scopeId, index);
|
|
207
|
+
return index;
|
|
208
|
+
}
|
|
209
|
+
function renameSessionId(userId, oldId, newId) {
|
|
210
|
+
if (oldId === newId)
|
|
211
|
+
return;
|
|
212
|
+
const chat = loadSession(oldId);
|
|
213
|
+
if (!chat)
|
|
214
|
+
return;
|
|
215
|
+
persistSession({ ...chat, session_id: newId });
|
|
216
|
+
LS.remove(getSessionKey(oldId));
|
|
217
|
+
const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
|
|
218
|
+
for (const scopeId of scopeIds) {
|
|
219
|
+
const index = loadScopeIndex(scopeId);
|
|
220
|
+
if (!index?.sessionIds.includes(oldId))
|
|
221
|
+
continue;
|
|
222
|
+
const sessionIds = index.sessionIds.map(id => (id === oldId ? newId : id));
|
|
223
|
+
const currentSessionId = index.currentSessionId === oldId ? newId : index.currentSessionId;
|
|
224
|
+
persistScopeIndex(scopeId, { currentSessionId, sessionIds });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function loadChatsFromSessionStorage(userId) {
|
|
228
|
+
const chats = {};
|
|
229
|
+
const currentChatId = {};
|
|
230
|
+
const scopeIds = scopeIdsForUser(userId, discoverScopeIdsFromLS());
|
|
231
|
+
for (const scopeId of scopeIds) {
|
|
232
|
+
if (hasLegacyScopeData(scopeId)) {
|
|
233
|
+
migrateLegacyScope(scopeId);
|
|
234
|
+
}
|
|
235
|
+
const index = loadScopeIndex(scopeId);
|
|
236
|
+
if (!index || index.sessionIds.length === 0)
|
|
237
|
+
continue;
|
|
238
|
+
chats[scopeId] = hydrateChatsForScope(scopeId);
|
|
239
|
+
currentChatId[scopeId] =
|
|
240
|
+
index.currentSessionId ?? chats[scopeId][0]?.session_id ?? null;
|
|
241
|
+
}
|
|
242
|
+
return { chats, currentChatId };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export { CHAT_SCOPE_IDS_REGISTRY_KEY, addScopeIdToRegistry, countSessionReferences, deleteSessionBlob, discoverScopeIdsFromLS, hasLegacyScopeData, hydrateChatsForScope, linkSessionToScope, loadChatsFromSessionStorage, loadScopeIndex, loadSession, migrateLegacyScope, persistScopeIndex, persistSession, removeSessionFromScope, renameSessionId, scopeIdsForUser };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const FENCE_OPEN = /```\s*json-dashboard\s*/i;
|
|
2
|
+
/** Unescape literal `\n` sequences when the agent double-encodes newlines. */
|
|
3
|
+
function normalizeJsonDashboardChatText(text) {
|
|
4
|
+
if (!text.includes('\\n'))
|
|
5
|
+
return text;
|
|
6
|
+
if (!FENCE_OPEN.test(text))
|
|
7
|
+
return text;
|
|
8
|
+
// Real newlines mean the fence is already formatted; unescaping would
|
|
9
|
+
// corrupt `\n` inside JSON string values (e.g. markdown code fences).
|
|
10
|
+
if (text.includes('\n'))
|
|
11
|
+
return text;
|
|
12
|
+
return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
13
|
+
}
|
|
14
|
+
function findCompleteJsonEnd(text, contentStart) {
|
|
15
|
+
let lastValidEnd = -1;
|
|
16
|
+
for (let i = contentStart + 1; i <= text.length; i++) {
|
|
17
|
+
const candidate = text.slice(contentStart, i);
|
|
18
|
+
if (!candidate.trim())
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
JSON.parse(candidate);
|
|
22
|
+
lastValidEnd = i;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// keep extending until JSON is complete
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return lastValidEnd;
|
|
29
|
+
}
|
|
30
|
+
function findJsonDashboardContentEnd(text, contentStart) {
|
|
31
|
+
const jsonEnd = findCompleteJsonEnd(text, contentStart);
|
|
32
|
+
if (jsonEnd < 0)
|
|
33
|
+
return -1;
|
|
34
|
+
const afterJson = text.slice(jsonEnd);
|
|
35
|
+
const fenceIdx = afterJson.indexOf('```');
|
|
36
|
+
if (fenceIdx >= 0) {
|
|
37
|
+
return jsonEnd + fenceIdx;
|
|
38
|
+
}
|
|
39
|
+
const tail = text.slice(contentStart).trim();
|
|
40
|
+
if (tail.length > 0) {
|
|
41
|
+
try {
|
|
42
|
+
JSON.parse(tail);
|
|
43
|
+
return text.length;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return jsonEnd;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return jsonEnd;
|
|
50
|
+
}
|
|
51
|
+
function jsonDashboardFenceSpan(text) {
|
|
52
|
+
const prepared = normalizeJsonDashboardChatText(text);
|
|
53
|
+
const startMatch = prepared.match(FENCE_OPEN);
|
|
54
|
+
if (!startMatch || startMatch.index === undefined)
|
|
55
|
+
return null;
|
|
56
|
+
const contentStart = startMatch.index + startMatch[0].length;
|
|
57
|
+
const closingFenceStart = findJsonDashboardContentEnd(prepared, contentStart);
|
|
58
|
+
if (closingFenceStart < 0) {
|
|
59
|
+
return { start: startMatch.index, end: prepared.length };
|
|
60
|
+
}
|
|
61
|
+
const end = closingFenceStart < prepared.length &&
|
|
62
|
+
prepared.startsWith('```', closingFenceStart)
|
|
63
|
+
? closingFenceStart + 3
|
|
64
|
+
: closingFenceStart;
|
|
65
|
+
return { start: startMatch.index, end };
|
|
66
|
+
}
|
|
67
|
+
/** Removes fenced json-dashboard blocks (used before persisting chat messages). */
|
|
68
|
+
function stripJsonDashboardFences(text) {
|
|
69
|
+
let result = normalizeJsonDashboardChatText(text);
|
|
70
|
+
while (true) {
|
|
71
|
+
const span = jsonDashboardFenceSpan(result);
|
|
72
|
+
if (!span)
|
|
73
|
+
break;
|
|
74
|
+
result = result.slice(0, span.start) + result.slice(span.end);
|
|
75
|
+
}
|
|
76
|
+
result = result.replace(/\n{3,}/g, '\n\n');
|
|
77
|
+
return result.trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { normalizeJsonDashboardChatText, stripJsonDashboardFences };
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ChatPreset } from '#uilib/components/ui/Chat/Chat.types';
|
|
2
|
+
export type SubmitPresetOptions = {
|
|
3
|
+
/** Composer text when preset row includes addon context (e.g. live news URL). */
|
|
4
|
+
message?: string;
|
|
5
|
+
};
|
|
2
6
|
export type ChatEmptyStateContext = {
|
|
3
|
-
submitPreset: (preset: ChatPreset) => void | Promise<void>;
|
|
7
|
+
submitPreset: (preset: ChatPreset, options?: SubmitPresetOptions) => void | Promise<void>;
|
|
4
8
|
};
|
|
5
9
|
export interface ChatEmptyStateProps {
|
|
6
10
|
icon?: React.ReactNode;
|
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
declare const injectHeaders: (content: string) => {
|
|
3
|
-
elem: import("react/jsx-runtime").JSX.Element;
|
|
4
|
-
index: number;
|
|
5
|
-
length: number;
|
|
6
|
-
};
|
|
7
2
|
declare const injectBold: (content: string) => {
|
|
8
3
|
elem: import("react/jsx-runtime").JSX.Element;
|
|
9
4
|
index: number;
|
|
@@ -32,6 +27,7 @@ type Injector = (content: string) => {
|
|
|
32
27
|
length: number;
|
|
33
28
|
} | null;
|
|
34
29
|
declare const injectAnchor: Injector;
|
|
30
|
+
declare const injectHeaders: Injector;
|
|
35
31
|
declare const applyFormatting: (text: string) => React.ReactNode[];
|
|
36
32
|
declare const applyFormattingInline: (text: string) => React.ReactNode[];
|
|
37
33
|
export { injectHeaders, injectAnchor, injectBold, injectItalic, injectBullet, injectNewlines, convertMarkdownTableToHTML, applyFormatting, applyFormattingInline, };
|
package/dist/esm/types/src/components/ui/Chat/ChatMessage/AgentMessageContent.helpers.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -5,6 +5,8 @@ export type ChatSheetActions = {
|
|
|
5
5
|
openNewChat: () => void;
|
|
6
6
|
/** Starts a new chat, opens the panel, and pre-fills the composer (user sends manually). */
|
|
7
7
|
openNewChatWithPrefill: (prompt: string) => void;
|
|
8
|
+
/** Starts a new chat, opens panel, and sends prompt immediately. */
|
|
9
|
+
openNewChatAndSubmit: (prompt: string) => void;
|
|
8
10
|
};
|
|
9
11
|
export interface ChatSheetProps extends Omit<UseChatPanelChromeModelInput, 'embedAsPage'> {
|
|
10
12
|
title?: string;
|
|
@@ -35,6 +35,11 @@ export type UseChatPanelChromeModelInput = {
|
|
|
35
35
|
copyHistoryOnNewChat?: boolean;
|
|
36
36
|
/** Override or extend the default send payload (e.g. api vs display text split). */
|
|
37
37
|
transformSendPayload?: (message: string, attachments: ChatAttachmentDropItem[] | undefined, defaultPayload: string | ChatSendMessagePayload) => string | ChatSendMessagePayload | Promise<string | ChatSendMessagePayload>;
|
|
38
|
+
/**
|
|
39
|
+
* When true, preset clicks always use the API send path (skip local `answer` /
|
|
40
|
+
* script demo). Use on /reports/new where first message must hit transformSendPayload.
|
|
41
|
+
*/
|
|
42
|
+
submitPresetsViaApi?: boolean;
|
|
38
43
|
};
|
|
39
44
|
export type UseChatPanelChromeModelResult = {
|
|
40
45
|
chromeProps: ChatChromeProps;
|
|
@@ -44,6 +49,8 @@ export type UseChatPanelChromeModelResult = {
|
|
|
44
49
|
newChat: () => void;
|
|
45
50
|
/** New session + open panel + one-shot composer pre-fill (does not send). */
|
|
46
51
|
openNewChatWithPrefill: (prompt: string) => void;
|
|
52
|
+
/** New session + open panel + immediate send. */
|
|
53
|
+
openNewChatAndSubmit: (prompt: string) => void;
|
|
47
54
|
chatPanelContainer: HTMLElement | null;
|
|
48
55
|
};
|
|
49
|
-
export declare function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, renderSystemMessage, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, copyHistoryOnNewChat, transformSendPayload, }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult;
|
|
56
|
+
export declare function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, renderSystemMessage, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, copyHistoryOnNewChat, transformSendPayload, submitPresetsViaApi, }: UseChatPanelChromeModelInput): UseChatPanelChromeModelResult;
|
|
@@ -17,7 +17,7 @@ export type { ChatPromptComposerHandle } from './ChatPrompt/ChatPromptComposer';
|
|
|
17
17
|
export { CHAT_PROMPT_COMMAND_CHIP_CLASS, chatPromptChipHtml, createChatPromptComposerHandle, getChatPromptTokenRangeBeforePos, insertChatPromptContentAtCaret, } from './ChatPrompt/chatPromptComposerInsert';
|
|
18
18
|
export type { ChatPromptComposerInsertOptions } from './ChatPrompt/ChatPromptComposer.types';
|
|
19
19
|
export { ChatPresets } from './ChatPresets';
|
|
20
|
-
export type { ChatEmptyStateConfig, ChatEmptyStateContext, ChatEmptyStateProps, } from './ChatEmptyState/ChatEmptyState.types';
|
|
20
|
+
export type { ChatEmptyStateConfig, ChatEmptyStateContext, ChatEmptyStateProps, SubmitPresetOptions, } from './ChatEmptyState/ChatEmptyState.types';
|
|
21
21
|
export type { Chat as ChatType, ChatAttachmentDropItem, ChatMessageClassNames, ChatMessageTextClassNames, ChatMeta, ChatMetaValue, ChatSendMessagePayload, ChatMessageProps, ChatProps, ChatPreset as ChatPresetType, Message, UserTextFileAttachment, } from './Chat.types';
|
|
22
22
|
export { MessageRole } from './Chat.types';
|
|
23
23
|
export type { SlashCommandItem, SlashItemCommandContext, SlashOnItemCommand, } from '#uilib/tiptap/slash-mention/types';
|
|
@@ -12,6 +12,8 @@ type SetPanelWidthOptions = {
|
|
|
12
12
|
/** Pass `viewTransition: false` to avoid nesting `document.startViewTransition` (e.g. chat open already animating). */
|
|
13
13
|
export type SetSidebarOpenOptions = {
|
|
14
14
|
viewTransition?: boolean;
|
|
15
|
+
/** Nav closed for layout (chat/width); restore when chat closes — do not persist closed cookie. */
|
|
16
|
+
layoutAutoClose?: boolean;
|
|
15
17
|
};
|
|
16
18
|
type SidebarContextProps = {
|
|
17
19
|
isOpen: boolean;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
import { type Chat, type ChatMeta, type ChatSendMessagePayload, type Message, MessageRole, type UserTextFileAttachment } from '#uilib/components/ui/Chat/Chat.types';
|
|
3
3
|
import type { ChatResponse } from '#uilib/types/chat-api.types';
|
|
4
|
+
import type { LinkSessionToScopeOptions } from './chatSessionStorage';
|
|
4
5
|
export type SendChatMessageFn = (message: string, targetChatId: string) => Promise<ChatResponse>;
|
|
5
6
|
export type { ChatSendMessagePayload, UserTextFileAttachment, } from '#uilib/components/ui/Chat/Chat.types';
|
|
6
7
|
export type AddChatMessageOptions = {
|
|
@@ -19,6 +20,7 @@ export type NewChatOptions = {
|
|
|
19
20
|
/** When set, seeds the new session's messages (e.g. continue dialog in reports). */
|
|
20
21
|
seedMessages?: readonly Message[];
|
|
21
22
|
};
|
|
23
|
+
export type { LinkSessionToScopeOptions } from './chatSessionStorage';
|
|
22
24
|
export type SendMessageResult = {
|
|
23
25
|
response: string;
|
|
24
26
|
sessionId: string;
|
|
@@ -26,6 +28,8 @@ export type SendMessageResult = {
|
|
|
26
28
|
export interface ChatContextType {
|
|
27
29
|
/** Returns the new session id, or undefined if no user / not created. */
|
|
28
30
|
newChat: (scopeId: string, options?: NewChatOptions) => string | undefined;
|
|
31
|
+
/** Attach an existing session to a scope without cloning messages. */
|
|
32
|
+
linkSessionToScope: (scopeId: string, sessionId: string, options?: LinkSessionToScopeOptions) => void;
|
|
29
33
|
setCurrentChatId: (currScopeId: string, sessionId: string) => void;
|
|
30
34
|
addMessage: (scopeId: string, chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string | undefined;
|
|
31
35
|
removeMessageById: (scopeId: string, chatId: string, messageId: string) => void;
|
|
@@ -72,6 +76,7 @@ export declare function useChatsForScopeId(scopeId: string): {
|
|
|
72
76
|
currentChatId: string;
|
|
73
77
|
isOutboundPending: boolean;
|
|
74
78
|
setCurrentChatId: (targetId: string) => void;
|
|
79
|
+
linkSessionToScope: (sessionId: string, options?: LinkSessionToScopeOptions) => void;
|
|
75
80
|
newChat: (options?: NewChatOptions) => string;
|
|
76
81
|
addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
|
|
77
82
|
removeMessageById: (chatId: string, messageId: string) => void;
|
|
@@ -88,6 +93,7 @@ export declare function useChatsForDataset(scopeId: string): {
|
|
|
88
93
|
currentChatId: string;
|
|
89
94
|
isOutboundPending: boolean;
|
|
90
95
|
setCurrentChatId: (targetId: string) => void;
|
|
96
|
+
linkSessionToScope: (sessionId: string, options?: LinkSessionToScopeOptions) => void;
|
|
91
97
|
newChat: (options?: NewChatOptions) => string;
|
|
92
98
|
addMessage: (chatId: string, role: MessageRole, text: string, options?: AddChatMessageOptions) => string;
|
|
93
99
|
removeMessageById: (chatId: string, messageId: string) => void;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Chat, type Message } from '#uilib/components/ui/Chat/Chat.types';
|
|
2
|
+
export declare function isEphemeralChatScope(scopeId: string): boolean;
|
|
2
3
|
export declare function stripMessageForPersistence(message: Message): Message;
|
|
3
4
|
export declare function stripChatsForPersistence(chats: Chat[]): Chat[];
|
|
5
|
+
export declare function persistSessionToLS(chat: Chat): void;
|
|
6
|
+
/** @deprecated Legacy scope blob — prefer session + scope index via chatSessionStorage. */
|
|
4
7
|
export declare function persistChatsToLS(scopeId: string, chats: Chat[]): void;
|
|
5
8
|
export declare function safeLsSet(key: string, value: unknown): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Chat } from '#uilib/components/ui/Chat/Chat.types';
|
|
2
|
+
export type ChatScopeIndex = {
|
|
3
|
+
currentSessionId: string | null;
|
|
4
|
+
sessionIds: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare const CHAT_SCOPE_IDS_REGISTRY_KEY = "chat-scope-ids";
|
|
7
|
+
export declare function loadSession(sessionId: string): Chat | null;
|
|
8
|
+
export declare function persistSession(chat: Chat): void;
|
|
9
|
+
export declare function deleteSessionBlob(sessionId: string): void;
|
|
10
|
+
export declare function loadScopeIndex(scopeId: string): ChatScopeIndex | null;
|
|
11
|
+
export declare function persistScopeIndex(scopeId: string, index: ChatScopeIndex): void;
|
|
12
|
+
export declare function addScopeIdToRegistry(scopeId: string): void;
|
|
13
|
+
export declare function discoverScopeIdsFromLS(): Set<string>;
|
|
14
|
+
export declare function scopeIdsForUser(userId: number, all: Set<string>): Set<string>;
|
|
15
|
+
export declare function hasLegacyScopeData(scopeId: string): boolean;
|
|
16
|
+
export declare function migrateLegacyScope(scopeId: string): ChatScopeIndex | null;
|
|
17
|
+
export type LinkSessionToScopeOptions = {
|
|
18
|
+
makeCurrent?: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function linkSessionToScope(scopeId: string, sessionId: string, options?: LinkSessionToScopeOptions): ChatScopeIndex;
|
|
21
|
+
export declare function hydrateChatsForScope(scopeId: string): Chat[];
|
|
22
|
+
export declare function countSessionReferences(userId: number, sessionId: string): number;
|
|
23
|
+
export declare function removeSessionFromScope(scopeId: string, sessionId: string): ChatScopeIndex | null;
|
|
24
|
+
export declare function renameSessionId(userId: number, oldId: string, newId: string): void;
|
|
25
|
+
export declare function loadChatsFromSessionStorage(userId: number): {
|
|
26
|
+
chats: Record<string, Chat[]>;
|
|
27
|
+
currentChatId: Record<string, string | null>;
|
|
28
|
+
};
|
|
29
|
+
/** Alias used by ChatProvider mount hydration. */
|
|
30
|
+
export declare const loadAllScopesForUser: typeof loadChatsFromSessionStorage;
|
|
31
|
+
export declare function isSessionReferencedByScopes(sessionId: string, excludeScopeId: string | null, userId: number): boolean;
|
|
32
|
+
export declare function renameSessionIdInStorage(oldId: string, newId: string, userId: number): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function hasJsonDashboardFenceInText(text: string): boolean;
|
|
2
|
+
/** Unescape literal `\n` sequences when the agent double-encodes newlines. */
|
|
3
|
+
export declare function normalizeJsonDashboardChatText(text: string): string;
|
|
4
|
+
export declare function jsonDashboardFenceBounds(text: string): {
|
|
5
|
+
contentStart: number;
|
|
6
|
+
contentEnd: number;
|
|
7
|
+
} | null;
|
|
8
|
+
/** Removes fenced json-dashboard blocks (used before persisting chat messages). */
|
|
9
|
+
export declare function stripJsonDashboardFences(text: string): string;
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function stripJsonDashboardFences(text: string): string;
|
|
1
|
+
export { hasJsonDashboardFenceInText, normalizeJsonDashboardChatText, stripJsonDashboardFences, } from './jsonDashboardFence';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { ChatPreset } from '#uilib/components/ui/Chat/Chat.types';
|
|
2
2
|
|
|
3
|
+
export type SubmitPresetOptions = {
|
|
4
|
+
/** Composer text when preset row includes addon context (e.g. live news URL). */
|
|
5
|
+
message?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
3
8
|
export type ChatEmptyStateContext = {
|
|
4
|
-
submitPreset: (
|
|
9
|
+
submitPreset: (
|
|
10
|
+
preset: ChatPreset,
|
|
11
|
+
options?: SubmitPresetOptions,
|
|
12
|
+
) => void | Promise<void>;
|
|
5
13
|
};
|
|
6
14
|
|
|
7
15
|
export interface ChatEmptyStateProps {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
|
|
3
|
+
import { applyFormatting } from './AgentMessageContent.helpers';
|
|
4
|
+
|
|
5
|
+
function renderFormatting(text: string) {
|
|
6
|
+
const parts = applyFormatting(text);
|
|
7
|
+
return render(<>{parts}</>);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('applyFormatting headers', () => {
|
|
11
|
+
it('renders bold inside markdown headers', () => {
|
|
12
|
+
const { container } = renderFormatting(
|
|
13
|
+
"### 2. **Monitor Estonia's Balance of Payments** ⭐",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const heading = container.querySelector('h3');
|
|
17
|
+
expect(heading).not.toBeNull();
|
|
18
|
+
expect(heading?.textContent).toBe(
|
|
19
|
+
"2. Monitor Estonia's Balance of Payments ⭐",
|
|
20
|
+
);
|
|
21
|
+
expect(heading?.querySelector('strong')?.textContent).toBe(
|
|
22
|
+
"Monitor Estonia's Balance of Payments",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -9,32 +9,6 @@ import logger from '#uilib/lib/logger';
|
|
|
9
9
|
|
|
10
10
|
import S from './ChatMessage.styl';
|
|
11
11
|
|
|
12
|
-
const injectHeaders = (content: string) => {
|
|
13
|
-
// Match #, ##, ###, or #### headers at start of line
|
|
14
|
-
const regex = /(^|\n)(#{1,4})\s+(.+?)(?=\n|$)/m;
|
|
15
|
-
const matches = content.match(regex);
|
|
16
|
-
|
|
17
|
-
if (!matches) return null;
|
|
18
|
-
|
|
19
|
-
const level = matches[2].length;
|
|
20
|
-
const headerText = matches[3].replace(/^\*+|\*+$/g, '');
|
|
21
|
-
const Tag =
|
|
22
|
-
level === 1 ? 'h1' : level === 2 ? 'h2' : level === 3 ? 'h3' : 'h4';
|
|
23
|
-
|
|
24
|
-
// Calculate the actual match position and length
|
|
25
|
-
// matches[0] includes the leading \n if present, but we want to replace from the # position
|
|
26
|
-
const hasLeadingNewline = matches[1] === '\n';
|
|
27
|
-
const startIndex = matches.index! + (hasLeadingNewline ? 1 : 0);
|
|
28
|
-
// Length is: # markers + space + header text (excluding leading newline)
|
|
29
|
-
const length = matches[2].length + 1 + matches[3].length;
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
elem: <Tag>{headerText}</Tag>,
|
|
33
|
-
index: startIndex,
|
|
34
|
-
length: length,
|
|
35
|
-
};
|
|
36
|
-
};
|
|
37
|
-
|
|
38
12
|
/** Match sits inside list/table HTML (those blocks use dangerouslySetInnerHTML elsewhere). */
|
|
39
13
|
const isInsideHtmlListOrTable = (content: string, matchStartIndex: number) => {
|
|
40
14
|
const before = content.substring(0, matchStartIndex);
|
|
@@ -540,6 +514,41 @@ const injectAnchor: Injector = (content: string) => {
|
|
|
540
514
|
};
|
|
541
515
|
};
|
|
542
516
|
|
|
517
|
+
const headerTextInjectors: Injector[] = [
|
|
518
|
+
injectAnchor,
|
|
519
|
+
injectMarkdownLink,
|
|
520
|
+
injectHTMLTags,
|
|
521
|
+
injectBold,
|
|
522
|
+
injectItalic,
|
|
523
|
+
injectAutolinkUrl,
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
const injectHeaders: Injector = (content: string) => {
|
|
527
|
+
// Match #, ##, ###, or #### headers at start of line
|
|
528
|
+
const regex = /(^|\n)(#{1,4})\s+(.+?)(?=\n|$)/m;
|
|
529
|
+
const matches = content.match(regex);
|
|
530
|
+
|
|
531
|
+
if (!matches) return null;
|
|
532
|
+
|
|
533
|
+
const level = matches[2].length;
|
|
534
|
+
const headerText = matches[3].replace(/^\*+|\*+$/g, '');
|
|
535
|
+
const Tag =
|
|
536
|
+
level === 1 ? 'h1' : level === 2 ? 'h2' : level === 3 ? 'h3' : 'h4';
|
|
537
|
+
|
|
538
|
+
// Calculate the actual match position and length
|
|
539
|
+
// matches[0] includes the leading \n if present, but we want to replace from the # position
|
|
540
|
+
const hasLeadingNewline = matches[1] === '\n';
|
|
541
|
+
const startIndex = matches.index! + (hasLeadingNewline ? 1 : 0);
|
|
542
|
+
// Length is: # markers + space + header text (excluding leading newline)
|
|
543
|
+
const length = matches[2].length + 1 + matches[3].length;
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
elem: <Tag>{runFormattingPipeline(headerText, headerTextInjectors)}</Tag>,
|
|
547
|
+
index: startIndex,
|
|
548
|
+
length: length,
|
|
549
|
+
};
|
|
550
|
+
};
|
|
551
|
+
|
|
543
552
|
const applyFormatting = (text: string): React.ReactNode[] =>
|
|
544
553
|
runFormattingPipeline(text, [
|
|
545
554
|
injectHeaders,
|
|
@@ -2,6 +2,7 @@ import cn from 'classnames';
|
|
|
2
2
|
|
|
3
3
|
import { InteractiveContent } from '#uilib/components/ui/InteractiveContent';
|
|
4
4
|
import { TextShimmer } from '#uilib/components/ui/TextShimmer';
|
|
5
|
+
import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
|
|
5
6
|
|
|
6
7
|
import { type ChatMessageProps, MessageRole } from '../Chat.types';
|
|
7
8
|
import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments';
|
|
@@ -47,7 +48,7 @@ export function ChatMessage({
|
|
|
47
48
|
</div>
|
|
48
49
|
) : isAssistant ? (
|
|
49
50
|
<AgentMessageContent
|
|
50
|
-
text={text}
|
|
51
|
+
text={stripJsonDashboardFences(text)}
|
|
51
52
|
textClassName={textClassName}
|
|
52
53
|
onQuickReply={onQuickReply}
|
|
53
54
|
suppressedQuickReplyKeys={suppressedQuickReplyKeys}
|