threadshelf 1.2.0
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/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +763 -0
- package/SECURITY.md +75 -0
- package/bin/threadshelf-mcp.js +12 -0
- package/bin/threadshelf.js +87 -0
- package/dist/mcp/server.js +388 -0
- package/dist/src/chunking.js +72 -0
- package/dist/src/cli.js +24 -0
- package/dist/src/embedding.js +59 -0
- package/dist/src/env.js +2 -0
- package/dist/src/generation/config.js +344 -0
- package/dist/src/generation/downloader.js +172 -0
- package/dist/src/generation/error-log.js +34 -0
- package/dist/src/generation/filesystem-browser.js +83 -0
- package/dist/src/generation/gguf-metadata.js +179 -0
- package/dist/src/generation/hardware.js +87 -0
- package/dist/src/generation/llama-install.js +563 -0
- package/dist/src/generation/llama-process.js +576 -0
- package/dist/src/generation/llama-profile.js +136 -0
- package/dist/src/generation/master-prompts.js +155 -0
- package/dist/src/generation/model-catalog.js +276 -0
- package/dist/src/generation/model-discovery.js +60 -0
- package/dist/src/generation/model-download.js +151 -0
- package/dist/src/generation/openai-compatible.js +231 -0
- package/dist/src/generation/providers/llama-cpp.js +97 -0
- package/dist/src/generation/providers/openrouter.js +106 -0
- package/dist/src/generation/quick-setup.js +215 -0
- package/dist/src/generation/registry.js +23 -0
- package/dist/src/generation/service.js +100 -0
- package/dist/src/generation/threads.js +311 -0
- package/dist/src/generation/types.js +1 -0
- package/dist/src/ingest-cli.js +95 -0
- package/dist/src/ingest.js +257 -0
- package/dist/src/load-env.js +17 -0
- package/dist/src/model-label.js +15 -0
- package/dist/src/parser.js +811 -0
- package/dist/src/paths.js +79 -0
- package/dist/src/routes/collections.js +97 -0
- package/dist/src/routes/files.js +136 -0
- package/dist/src/routes/generation.js +536 -0
- package/dist/src/routes/health.js +6 -0
- package/dist/src/routes/index.js +21 -0
- package/dist/src/routes/ingest.js +300 -0
- package/dist/src/routes/insights.js +24 -0
- package/dist/src/routes/loopback.js +15 -0
- package/dist/src/routes/model-catalog.js +178 -0
- package/dist/src/routes/search.js +57 -0
- package/dist/src/routes/stream-abort.js +23 -0
- package/dist/src/routes/thread.js +43 -0
- package/dist/src/search-cli.js +93 -0
- package/dist/src/server.js +78 -0
- package/dist/src/services/collections.js +58 -0
- package/dist/src/services/insights.js +111 -0
- package/dist/src/services/search.js +68 -0
- package/dist/src/services/stats.js +35 -0
- package/dist/src/services/thread.js +140 -0
- package/dist/src/store.js +1138 -0
- package/dist/src/validation.js +250 -0
- package/dist/src/watch.js +83 -0
- package/package.json +103 -0
- package/public/assets/index-CIm_Idqi.js +38 -0
- package/public/assets/index-Dv09K2vS.css +1 -0
- package/public/favicon.svg +6 -0
- package/public/index.html +28 -0
- package/scripts/openrouter-export-all.js +228 -0
- package/scripts/openrouter-export-browser.js +153 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
// --- Public API ---
|
|
2
|
+
export const parseExport = (input, options = {}) => {
|
|
3
|
+
const parsed = parseConversations(input, options);
|
|
4
|
+
if (parsed.error)
|
|
5
|
+
return { turns: [], error: parsed.error };
|
|
6
|
+
return { turns: parsed.conversations.flatMap((c) => c.turns) };
|
|
7
|
+
};
|
|
8
|
+
export const parseConversationGroups = (input, options = {}) => {
|
|
9
|
+
return parseConversations(input, options);
|
|
10
|
+
};
|
|
11
|
+
export const listConversationsFromExport = (input, options = {}) => {
|
|
12
|
+
const parsed = parseConversations(input, options);
|
|
13
|
+
if (parsed.error)
|
|
14
|
+
return { conversations: [], error: parsed.error };
|
|
15
|
+
return {
|
|
16
|
+
conversations: parsed.conversations.map((c) => ({
|
|
17
|
+
key: c.key,
|
|
18
|
+
title: c.title,
|
|
19
|
+
turnCount: c.turns.length,
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export const getConversationFromExport = (input, conversationKey, options = {}) => {
|
|
24
|
+
const parsed = parseConversations(input, options);
|
|
25
|
+
if (parsed.error)
|
|
26
|
+
return { conversation: null, error: parsed.error };
|
|
27
|
+
const conversation = conversationKey
|
|
28
|
+
? parsed.conversations.find((entry) => entry.key === conversationKey)
|
|
29
|
+
: parsed.conversations[0];
|
|
30
|
+
if (!conversation) {
|
|
31
|
+
return { conversation: null, error: `Conversation not found: ${conversationKey}` };
|
|
32
|
+
}
|
|
33
|
+
return { conversation };
|
|
34
|
+
};
|
|
35
|
+
export const parseFile = async (filePath, options = {}) => {
|
|
36
|
+
const fs = await import('fs/promises');
|
|
37
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
38
|
+
return parseExport(content, options);
|
|
39
|
+
};
|
|
40
|
+
export const parseGoogleAIStudio = (input, options = {}) => {
|
|
41
|
+
const parsed = parseInput(input);
|
|
42
|
+
if (parsed.error)
|
|
43
|
+
return { turns: [], error: parsed.error };
|
|
44
|
+
const result = buildGoogleConversation(parsed.data, options);
|
|
45
|
+
return flattenParseResult(result);
|
|
46
|
+
};
|
|
47
|
+
export const parseAnthropic = (input, options = {}) => {
|
|
48
|
+
const parsed = parseInput(input);
|
|
49
|
+
if (parsed.error)
|
|
50
|
+
return { turns: [], error: parsed.error };
|
|
51
|
+
return { turns: buildAnthropicConversations(parsed.data, options).flatMap((c) => c.turns) };
|
|
52
|
+
};
|
|
53
|
+
export const parseOpenAI = (input, options = {}) => {
|
|
54
|
+
const parsed = parseInput(input);
|
|
55
|
+
if (parsed.error)
|
|
56
|
+
return { turns: [], error: parsed.error };
|
|
57
|
+
return { turns: buildOpenAIConversations(parsed.data, options).flatMap((c) => c.turns) };
|
|
58
|
+
};
|
|
59
|
+
export const parseOpenRouter = (input, options = {}) => {
|
|
60
|
+
const parsed = parseInput(input);
|
|
61
|
+
if (parsed.error)
|
|
62
|
+
return { turns: [], error: parsed.error };
|
|
63
|
+
return flattenParseResult(buildOpenRouterConversation(parsed.data, options));
|
|
64
|
+
};
|
|
65
|
+
export const parseLMStudio = (input, options = {}) => {
|
|
66
|
+
const parsed = parseInput(input);
|
|
67
|
+
if (parsed.error)
|
|
68
|
+
return { turns: [], error: parsed.error };
|
|
69
|
+
return flattenParseResult(buildLMStudioConversation(parsed.data, options));
|
|
70
|
+
};
|
|
71
|
+
export const parseGrok = (input, options = {}) => {
|
|
72
|
+
const parsed = parseInput(input);
|
|
73
|
+
if (parsed.error)
|
|
74
|
+
return { turns: [], error: parsed.error };
|
|
75
|
+
return { turns: buildGrokConversations(parsed.data, options).flatMap((c) => c.turns) };
|
|
76
|
+
};
|
|
77
|
+
export const detectProvider = (data) => {
|
|
78
|
+
if (isGoogleAIStudio(data))
|
|
79
|
+
return 'google-ai-studio';
|
|
80
|
+
if (isGrokFormat(data))
|
|
81
|
+
return 'grok';
|
|
82
|
+
if (isAnthropicFormat(data))
|
|
83
|
+
return 'anthropic';
|
|
84
|
+
if (isOpenAIFormat(data))
|
|
85
|
+
return 'openai';
|
|
86
|
+
if (isOpenRouterFormat(data))
|
|
87
|
+
return 'openrouter';
|
|
88
|
+
if (isLMStudioFormat(data))
|
|
89
|
+
return 'lm-studio';
|
|
90
|
+
return 'unknown';
|
|
91
|
+
};
|
|
92
|
+
// --- Format detection helpers ---
|
|
93
|
+
const isGoogleAIStudio = (data) => {
|
|
94
|
+
const d = data;
|
|
95
|
+
return ((!!d?.chunkedPrompt && Array.isArray(d.chunkedPrompt?.chunks)) ||
|
|
96
|
+
(!!d?.imagenPrompt && Array.isArray(d.imagenPrompt?.imagenTurns)));
|
|
97
|
+
};
|
|
98
|
+
const isAnthropicFormat = (data) => {
|
|
99
|
+
if (Array.isArray(data) && data.length > 0 && data[0]?.chat_messages != null)
|
|
100
|
+
return true;
|
|
101
|
+
if (Array.isArray(data))
|
|
102
|
+
return false;
|
|
103
|
+
const d = data;
|
|
104
|
+
if (Array.isArray(d?.chat_messages))
|
|
105
|
+
return true;
|
|
106
|
+
// Project conversation files in current Anthropic exports use
|
|
107
|
+
// `{ project, messages: [{ role, content: { content } }] }` rather than the
|
|
108
|
+
// top-level export's `chat_messages` array.
|
|
109
|
+
if (d?.project &&
|
|
110
|
+
typeof d.project === 'object' &&
|
|
111
|
+
Array.isArray(d?.messages) &&
|
|
112
|
+
d.messages.some((message) => (message?.role === 'user' || message?.role === 'assistant') &&
|
|
113
|
+
!!message?.content &&
|
|
114
|
+
typeof message.content === 'object' &&
|
|
115
|
+
typeof message.content.content === 'string')) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// Some Anthropic exports wrap the conversation array in an object. The
|
|
119
|
+
// builder has always supported this shape, but provider detection did not,
|
|
120
|
+
// making that code path unreachable through the public parser.
|
|
121
|
+
return (Array.isArray(d?.conversations) &&
|
|
122
|
+
d.conversations.some((conversation) => !!conversation && Array.isArray(conversation.chat_messages)));
|
|
123
|
+
};
|
|
124
|
+
const isOpenAIFormat = (data) => {
|
|
125
|
+
if (Array.isArray(data) && data.length > 0 && data[0]?.mapping != null)
|
|
126
|
+
return true;
|
|
127
|
+
return !Array.isArray(data) && data?.mapping != null;
|
|
128
|
+
};
|
|
129
|
+
const isOpenRouterFormat = (data) => {
|
|
130
|
+
const d = data;
|
|
131
|
+
return d?.platform === 'openrouter' && Array.isArray(d?.turns);
|
|
132
|
+
};
|
|
133
|
+
// LM Studio stores one JSON file per conversation. There is no documented,
|
|
134
|
+
// stable schema (LM Studio's own docs say not to rely on the format), so this
|
|
135
|
+
// detector keys off the structural shape: a `messages` array whose entries are
|
|
136
|
+
// `{ versions, currentlySelected }`. Empty conversations are matched via the
|
|
137
|
+
// LM-Studio-specific prediction-config fields.
|
|
138
|
+
const isLMStudioFormat = (data) => {
|
|
139
|
+
if (Array.isArray(data))
|
|
140
|
+
return false;
|
|
141
|
+
const d = data;
|
|
142
|
+
if (!Array.isArray(d?.messages))
|
|
143
|
+
return false;
|
|
144
|
+
const messages = d.messages;
|
|
145
|
+
if (messages.length === 0) {
|
|
146
|
+
return 'usePerChatPredictionConfig' in d || 'perChatPredictionConfig' in d;
|
|
147
|
+
}
|
|
148
|
+
return messages.some((msg) => Array.isArray(msg?.versions) && 'currentlySelected' in msg);
|
|
149
|
+
};
|
|
150
|
+
// Grok (x.ai) account export. The dump is a single `prod-grok-backend.json`
|
|
151
|
+
// with `{ conversations, projects, tasks, media_posts }` at the root, where each
|
|
152
|
+
// conversation is `{ conversation: {…meta}, responses: [{ response: {…} }] }`.
|
|
153
|
+
// There is no documented, stable schema, so this detector keys off that nested
|
|
154
|
+
// shape rather than any one field.
|
|
155
|
+
const isGrokFormat = (data) => {
|
|
156
|
+
if (Array.isArray(data))
|
|
157
|
+
return false;
|
|
158
|
+
const d = data;
|
|
159
|
+
if (!Array.isArray(d?.conversations))
|
|
160
|
+
return false;
|
|
161
|
+
const entries = d.conversations;
|
|
162
|
+
if (entries.length === 0) {
|
|
163
|
+
// Empty account export: fall back to the Grok-specific sibling collections.
|
|
164
|
+
return 'media_posts' in d && 'tasks' in d && 'projects' in d;
|
|
165
|
+
}
|
|
166
|
+
const first = entries[0];
|
|
167
|
+
return !!first && typeof first === 'object' && 'conversation' in first && 'responses' in first;
|
|
168
|
+
};
|
|
169
|
+
// --- Core dispatch ---
|
|
170
|
+
const parseInput = (input) => {
|
|
171
|
+
try {
|
|
172
|
+
return { data: typeof input === 'string' ? JSON.parse(input) : input };
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
return { data: undefined, error: `Invalid JSON: ${e.message}` };
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const flattenParseResult = (result) => {
|
|
179
|
+
if (result.error)
|
|
180
|
+
return { turns: [], error: result.error };
|
|
181
|
+
return { turns: result.conversations.flatMap((c) => c.turns) };
|
|
182
|
+
};
|
|
183
|
+
const parseConversations = (input, options = {}) => {
|
|
184
|
+
const parsed = parseInput(input);
|
|
185
|
+
if (parsed.error)
|
|
186
|
+
return { conversations: [], error: parsed.error };
|
|
187
|
+
const { data } = parsed;
|
|
188
|
+
switch (detectProvider(data)) {
|
|
189
|
+
case 'google-ai-studio':
|
|
190
|
+
return buildGoogleConversation(data, options);
|
|
191
|
+
case 'grok':
|
|
192
|
+
return { conversations: buildGrokConversations(data, options) };
|
|
193
|
+
case 'anthropic':
|
|
194
|
+
return { conversations: buildAnthropicConversations(data, options) };
|
|
195
|
+
case 'openai':
|
|
196
|
+
return { conversations: buildOpenAIConversations(data, options) };
|
|
197
|
+
case 'openrouter':
|
|
198
|
+
return buildOpenRouterConversation(data, options);
|
|
199
|
+
case 'lm-studio':
|
|
200
|
+
return buildLMStudioConversation(data, options);
|
|
201
|
+
case 'unknown':
|
|
202
|
+
return {
|
|
203
|
+
conversations: [],
|
|
204
|
+
error: 'Unknown export format (expected Google AI Studio, Anthropic, OpenAI, OpenRouter, LM Studio, or Grok)',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
// --- Timestamp normalization ---
|
|
209
|
+
const normalizeTimestamp = (value) => {
|
|
210
|
+
if (value === undefined || value === null || value === '')
|
|
211
|
+
return undefined;
|
|
212
|
+
if (typeof value === 'string') {
|
|
213
|
+
const asNumber = Number(value);
|
|
214
|
+
if (Number.isFinite(asNumber)) {
|
|
215
|
+
const fromEpoch = normalizeTimestamp(asNumber);
|
|
216
|
+
if (fromEpoch)
|
|
217
|
+
return fromEpoch;
|
|
218
|
+
}
|
|
219
|
+
const parsed = Date.parse(value);
|
|
220
|
+
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
|
|
221
|
+
}
|
|
222
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
223
|
+
return undefined;
|
|
224
|
+
const ms = value < 10_000_000_000 ? value * 1000 : value;
|
|
225
|
+
// Reject implausible epochs: compact date strings like "20240101120000"
|
|
226
|
+
// are Number()-finite but land centuries away when read as milliseconds.
|
|
227
|
+
if (ms < Date.UTC(2000, 0, 1) || ms >= Date.UTC(2100, 0, 1))
|
|
228
|
+
return undefined;
|
|
229
|
+
return new Date(ms).toISOString();
|
|
230
|
+
};
|
|
231
|
+
const getTimestamp = (...objects) => {
|
|
232
|
+
for (const obj of objects) {
|
|
233
|
+
const o = obj;
|
|
234
|
+
const value = o?.create_time ??
|
|
235
|
+
o?.created_at ??
|
|
236
|
+
o?.createdAt ??
|
|
237
|
+
o?.timestamp ??
|
|
238
|
+
o?.time ??
|
|
239
|
+
o?.date ??
|
|
240
|
+
o?.update_time ??
|
|
241
|
+
o?.updated_at ??
|
|
242
|
+
o?.updatedAt;
|
|
243
|
+
const normalized = normalizeTimestamp(value);
|
|
244
|
+
if (normalized)
|
|
245
|
+
return normalized;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
};
|
|
249
|
+
const withMeta = (turn, { createdAt } = {}) => {
|
|
250
|
+
return createdAt ? { ...turn, createdAt } : turn;
|
|
251
|
+
};
|
|
252
|
+
// --- Title normalization ---
|
|
253
|
+
const summarizeConversationTitle = (turns, fallback) => {
|
|
254
|
+
const firstText = turns
|
|
255
|
+
.map((turn) => turn.user ?? turn.ai ?? turn.thinking ?? '')
|
|
256
|
+
.find((text) => typeof text === 'string' && text.trim());
|
|
257
|
+
if (!firstText)
|
|
258
|
+
return fallback;
|
|
259
|
+
const normalized = firstText.replace(/\s+/g, ' ').trim();
|
|
260
|
+
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
|
261
|
+
};
|
|
262
|
+
const normalizeConversationTitle = (rawTitle, fallback, turns = []) => {
|
|
263
|
+
const title = typeof rawTitle === 'string' ? rawTitle.trim() : '';
|
|
264
|
+
return title || summarizeConversationTitle(turns, fallback);
|
|
265
|
+
};
|
|
266
|
+
// --- Google AI Studio ---
|
|
267
|
+
const buildGoogleConversation = (data, options = {}) => {
|
|
268
|
+
const { includeUser = true, includeThinking = true, includeAi = true } = options;
|
|
269
|
+
const chunks = data?.chunkedPrompt?.chunks;
|
|
270
|
+
const imagenTurns = data?.imagenPrompt?.imagenTurns;
|
|
271
|
+
if (Array.isArray(imagenTurns)) {
|
|
272
|
+
const turns = [];
|
|
273
|
+
const model = typeof data?.runSettings?.model === 'string'
|
|
274
|
+
? data.runSettings.model
|
|
275
|
+
: undefined;
|
|
276
|
+
const defaultCreatedAt = getTimestamp(data, data?.metadata, data?.runSettings);
|
|
277
|
+
for (const entry of imagenTurns) {
|
|
278
|
+
const imagenTurn = entry;
|
|
279
|
+
const createdAt = getTimestamp(imagenTurn, imagenTurn?.metadata) ?? defaultCreatedAt;
|
|
280
|
+
const prompt = typeof imagenTurn?.prompt === 'string' ? imagenTurn.prompt.trim() : '';
|
|
281
|
+
if (includeUser && prompt)
|
|
282
|
+
turns.push(withMeta({ user: prompt }, { createdAt }));
|
|
283
|
+
if (includeAi &&
|
|
284
|
+
Array.isArray(imagenTurn?.generatedImages) &&
|
|
285
|
+
imagenTurn.generatedImages.length > 0) {
|
|
286
|
+
turns.push(withMeta(model ? { ai: '[image]', model } : { ai: '[image]' }, {
|
|
287
|
+
createdAt,
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!turns.length)
|
|
292
|
+
return { conversations: [] };
|
|
293
|
+
return {
|
|
294
|
+
conversations: [
|
|
295
|
+
{
|
|
296
|
+
key: 'google:0',
|
|
297
|
+
title: normalizeConversationTitle(data?.title ?? data?.name ?? data?.metadata?.title, 'Conversation 1', turns),
|
|
298
|
+
turns,
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (!Array.isArray(chunks)) {
|
|
304
|
+
return { conversations: [], error: 'Missing or invalid chunkedPrompt.chunks' };
|
|
305
|
+
}
|
|
306
|
+
const turns = [];
|
|
307
|
+
const model = typeof data?.runSettings?.model === 'string'
|
|
308
|
+
? data.runSettings.model
|
|
309
|
+
: undefined;
|
|
310
|
+
const defaultCreatedAt = getTimestamp(data, data?.metadata, data?.runSettings);
|
|
311
|
+
for (const chunk of chunks) {
|
|
312
|
+
const c = chunk;
|
|
313
|
+
const role = c?.role;
|
|
314
|
+
const createdAt = getTimestamp(c, c?.metadata) ?? defaultCreatedAt;
|
|
315
|
+
if (role === 'user') {
|
|
316
|
+
if (!includeUser)
|
|
317
|
+
continue;
|
|
318
|
+
const text = c.text;
|
|
319
|
+
if (typeof text === 'string' && text.trim()) {
|
|
320
|
+
turns.push(withMeta({ user: text.trim() }, { createdAt }));
|
|
321
|
+
}
|
|
322
|
+
else if (c.driveImage) {
|
|
323
|
+
turns.push(withMeta({ user: '[image]' }, { createdAt }));
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (role === 'model') {
|
|
328
|
+
const text = c.text;
|
|
329
|
+
const isThought = c.isThought === true;
|
|
330
|
+
if (isThought && includeThinking && typeof text === 'string') {
|
|
331
|
+
const trimmed = text.trim();
|
|
332
|
+
if (trimmed)
|
|
333
|
+
turns.push(withMeta(model ? { thinking: trimmed, model } : { thinking: trimmed }, { createdAt }));
|
|
334
|
+
}
|
|
335
|
+
else if (!isThought && includeAi && typeof text === 'string') {
|
|
336
|
+
const trimmed = text.trim();
|
|
337
|
+
if (trimmed)
|
|
338
|
+
turns.push(withMeta(model ? { ai: trimmed, model } : { ai: trimmed }, { createdAt }));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!turns.length)
|
|
343
|
+
return { conversations: [] };
|
|
344
|
+
return {
|
|
345
|
+
conversations: [
|
|
346
|
+
{
|
|
347
|
+
key: 'google:0',
|
|
348
|
+
title: normalizeConversationTitle(data?.title ?? data?.name ?? data?.metadata?.title, 'Conversation 1', turns),
|
|
349
|
+
turns,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
// --- Anthropic ---
|
|
355
|
+
const extractAnthropicMessageContent = (msg) => {
|
|
356
|
+
const contentObject = msg?.content && !Array.isArray(msg.content) && typeof msg.content === 'object'
|
|
357
|
+
? msg.content
|
|
358
|
+
: undefined;
|
|
359
|
+
const content = Array.isArray(msg?.content)
|
|
360
|
+
? msg.content
|
|
361
|
+
: Array.isArray(contentObject?.contentBlocks)
|
|
362
|
+
? contentObject.contentBlocks
|
|
363
|
+
: [];
|
|
364
|
+
const thinkingTexts = [];
|
|
365
|
+
const aiTexts = [];
|
|
366
|
+
for (const block of content) {
|
|
367
|
+
if (!block || typeof block !== 'object')
|
|
368
|
+
continue;
|
|
369
|
+
if (block.type === 'thinking') {
|
|
370
|
+
const rawThinking = typeof block.thinking === 'string'
|
|
371
|
+
? block.thinking
|
|
372
|
+
: typeof block.text === 'string'
|
|
373
|
+
? block.text
|
|
374
|
+
: '';
|
|
375
|
+
const text = rawThinking.trim();
|
|
376
|
+
if (text)
|
|
377
|
+
thinkingTexts.push(text);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (block.type === 'text') {
|
|
381
|
+
const text = typeof block.text === 'string' ? block.text.trim() : '';
|
|
382
|
+
if (text)
|
|
383
|
+
aiTexts.push(text);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const fallbackText = typeof msg?.text === 'string'
|
|
387
|
+
? msg.text.trim()
|
|
388
|
+
: typeof contentObject?.content === 'string'
|
|
389
|
+
? contentObject.content.trim()
|
|
390
|
+
: '';
|
|
391
|
+
if (!contentObject && content.length === 0 && fallbackText) {
|
|
392
|
+
aiTexts.push(fallbackText);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
userText: contentObject
|
|
396
|
+
? fallbackText
|
|
397
|
+
: content.length === 0
|
|
398
|
+
? fallbackText
|
|
399
|
+
: aiTexts.join('\n\n') || fallbackText,
|
|
400
|
+
thinkingTexts,
|
|
401
|
+
aiTexts: contentObject
|
|
402
|
+
? fallbackText
|
|
403
|
+
? [fallbackText]
|
|
404
|
+
: []
|
|
405
|
+
: content.length === 0
|
|
406
|
+
? aiTexts
|
|
407
|
+
: aiTexts.length
|
|
408
|
+
? aiTexts
|
|
409
|
+
: fallbackText
|
|
410
|
+
? [fallbackText]
|
|
411
|
+
: [],
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
const buildAnthropicConversations = (data, options = {}) => {
|
|
415
|
+
const { includeUser = true, includeThinking = true, includeAi = true } = options;
|
|
416
|
+
const d = data;
|
|
417
|
+
const isProjectConversation = !Array.isArray(data) &&
|
|
418
|
+
!!d?.project &&
|
|
419
|
+
typeof d.project === 'object' &&
|
|
420
|
+
Array.isArray(d?.messages);
|
|
421
|
+
const sourceConversations = isProjectConversation
|
|
422
|
+
? [{ ...d, chat_messages: d.messages }]
|
|
423
|
+
: Array.isArray(data)
|
|
424
|
+
? data
|
|
425
|
+
: d?.conversations
|
|
426
|
+
? d.conversations
|
|
427
|
+
: [d];
|
|
428
|
+
const conversations = [];
|
|
429
|
+
for (const [index, conv] of sourceConversations.entries()) {
|
|
430
|
+
const messages = conv?.chat_messages;
|
|
431
|
+
if (!Array.isArray(messages))
|
|
432
|
+
continue;
|
|
433
|
+
const turns = [];
|
|
434
|
+
for (const msg of messages) {
|
|
435
|
+
const sender = (msg?.sender ?? msg?.role);
|
|
436
|
+
const model = typeof msg?.model === 'string'
|
|
437
|
+
? msg.model
|
|
438
|
+
: typeof msg?.model_slug === 'string'
|
|
439
|
+
? msg.model_slug
|
|
440
|
+
: typeof conv?.model === 'string'
|
|
441
|
+
? conv.model
|
|
442
|
+
: undefined;
|
|
443
|
+
const createdAt = getTimestamp(msg) ?? getTimestamp(conv);
|
|
444
|
+
const extracted = extractAnthropicMessageContent(msg);
|
|
445
|
+
if ((sender === 'human' || sender === 'user') && includeUser) {
|
|
446
|
+
const text = extracted.userText;
|
|
447
|
+
if (text)
|
|
448
|
+
turns.push(withMeta({ user: text }, { createdAt }));
|
|
449
|
+
}
|
|
450
|
+
else if (sender === 'assistant' || sender === 'model') {
|
|
451
|
+
if (includeThinking) {
|
|
452
|
+
for (const thought of extracted.thinkingTexts) {
|
|
453
|
+
turns.push(withMeta(model ? { thinking: thought, model } : { thinking: thought }, { createdAt }));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (includeAi) {
|
|
457
|
+
for (const text of extracted.aiTexts) {
|
|
458
|
+
turns.push(withMeta(model ? { ai: text, model } : { ai: text }, { createdAt }));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!turns.length)
|
|
464
|
+
continue;
|
|
465
|
+
const rawKey = conv?.uuid ?? conv?.id ?? conv?.conversation_id ?? index;
|
|
466
|
+
conversations.push({
|
|
467
|
+
key: `anthropic:${String(rawKey)}`,
|
|
468
|
+
title: normalizeConversationTitle(conv?.name ?? conv?.title, `Conversation ${index + 1}`, turns),
|
|
469
|
+
turns,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return conversations;
|
|
473
|
+
};
|
|
474
|
+
// --- OpenAI / ChatGPT ---
|
|
475
|
+
const extractOpenAIText = (content) => {
|
|
476
|
+
if (!content)
|
|
477
|
+
return '';
|
|
478
|
+
if (typeof content === 'string')
|
|
479
|
+
return content.trim();
|
|
480
|
+
if (Array.isArray(content)) {
|
|
481
|
+
return content
|
|
482
|
+
.map((part) => extractOpenAIText(part))
|
|
483
|
+
.filter(Boolean)
|
|
484
|
+
.join('\n\n')
|
|
485
|
+
.trim();
|
|
486
|
+
}
|
|
487
|
+
const c = content;
|
|
488
|
+
if (typeof c.text === 'string')
|
|
489
|
+
return c.text.trim();
|
|
490
|
+
if (Array.isArray(c.parts)) {
|
|
491
|
+
return c.parts
|
|
492
|
+
.map((part) => {
|
|
493
|
+
if (typeof part === 'string')
|
|
494
|
+
return part.trim();
|
|
495
|
+
if (part && typeof part.text === 'string')
|
|
496
|
+
return part.text.trim();
|
|
497
|
+
return '';
|
|
498
|
+
})
|
|
499
|
+
.filter(Boolean)
|
|
500
|
+
.join('\n\n')
|
|
501
|
+
.trim();
|
|
502
|
+
}
|
|
503
|
+
return '';
|
|
504
|
+
};
|
|
505
|
+
const buildOpenAIPathToNode = (mapping, nodeId) => {
|
|
506
|
+
const chain = [];
|
|
507
|
+
let cursor = nodeId;
|
|
508
|
+
const seen = new Set();
|
|
509
|
+
while (cursor && mapping[cursor] && !seen.has(cursor)) {
|
|
510
|
+
seen.add(cursor);
|
|
511
|
+
chain.push(mapping[cursor]);
|
|
512
|
+
cursor = mapping[cursor]?.parent;
|
|
513
|
+
}
|
|
514
|
+
return chain.reverse();
|
|
515
|
+
};
|
|
516
|
+
const getOpenAINodeCreateTime = (node) => {
|
|
517
|
+
const value = node?.message?.create_time;
|
|
518
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
519
|
+
};
|
|
520
|
+
const getOpenAIConversationPath = (conversation) => {
|
|
521
|
+
const mapping = conversation?.mapping;
|
|
522
|
+
if (!mapping || typeof mapping !== 'object')
|
|
523
|
+
return [];
|
|
524
|
+
const currentNodeId = conversation?.current_node;
|
|
525
|
+
if (typeof currentNodeId === 'string' && mapping[currentNodeId]) {
|
|
526
|
+
return buildOpenAIPathToNode(mapping, currentNodeId);
|
|
527
|
+
}
|
|
528
|
+
const nodesById = mapping;
|
|
529
|
+
const childIds = new Set(Object.values(nodesById)
|
|
530
|
+
.map((node) => node?.parent)
|
|
531
|
+
.filter((parent) => typeof parent === 'string'));
|
|
532
|
+
const timedNodes = Object.entries(nodesById)
|
|
533
|
+
.filter(([, node]) => getOpenAINodeCreateTime(node) > 0)
|
|
534
|
+
.map(([id, node]) => ({ id, node: node }));
|
|
535
|
+
if (childIds.size === 0) {
|
|
536
|
+
return timedNodes
|
|
537
|
+
.map(({ node }) => node)
|
|
538
|
+
.sort((a, b) => getOpenAINodeCreateTime(a) - getOpenAINodeCreateTime(b));
|
|
539
|
+
}
|
|
540
|
+
const leafNodes = timedNodes.filter(({ id }) => !childIds.has(id));
|
|
541
|
+
const candidates = leafNodes.length ? leafNodes : timedNodes;
|
|
542
|
+
const latest = candidates.sort((a, b) => getOpenAINodeCreateTime(b.node) - getOpenAINodeCreateTime(a.node))[0];
|
|
543
|
+
return latest ? buildOpenAIPathToNode(nodesById, latest.id) : [];
|
|
544
|
+
};
|
|
545
|
+
const getOpenAIModel = (message, conversation) => {
|
|
546
|
+
if (typeof message?.metadata?.model_slug === 'string')
|
|
547
|
+
return message.metadata.model_slug;
|
|
548
|
+
if (typeof message?.model_slug === 'string')
|
|
549
|
+
return message.model_slug;
|
|
550
|
+
if (typeof conversation?.default_model_slug === 'string')
|
|
551
|
+
return conversation.default_model_slug;
|
|
552
|
+
return undefined;
|
|
553
|
+
};
|
|
554
|
+
const isOpenAITechnicalArtifact = (message, text) => {
|
|
555
|
+
const contentType = message?.content?.content_type;
|
|
556
|
+
const trimmed = text.trim();
|
|
557
|
+
if (trimmed === '{}' || trimmed === '<' || trimmed === '[]')
|
|
558
|
+
return true;
|
|
559
|
+
if (contentType === 'code' && /^search\(/i.test(trimmed))
|
|
560
|
+
return true;
|
|
561
|
+
return false;
|
|
562
|
+
};
|
|
563
|
+
const buildOpenAIConversations = (data, options = {}) => {
|
|
564
|
+
const { includeUser = true, includeAi = true } = options;
|
|
565
|
+
const sourceConversations = Array.isArray(data) ? data : [data];
|
|
566
|
+
const conversations = [];
|
|
567
|
+
for (const [index, conversation] of sourceConversations.entries()) {
|
|
568
|
+
const path = getOpenAIConversationPath(conversation);
|
|
569
|
+
const turns = [];
|
|
570
|
+
for (const node of path) {
|
|
571
|
+
const message = node?.message;
|
|
572
|
+
const role = message?.author?.role;
|
|
573
|
+
const text = extractOpenAIText(message?.content);
|
|
574
|
+
const model = getOpenAIModel(message, conversation);
|
|
575
|
+
const createdAt = getTimestamp(message, conversation);
|
|
576
|
+
if (!text)
|
|
577
|
+
continue;
|
|
578
|
+
if (role === 'assistant' && message && isOpenAITechnicalArtifact(message, text))
|
|
579
|
+
continue;
|
|
580
|
+
if (role === 'user' && includeUser) {
|
|
581
|
+
turns.push(withMeta({ user: text }, { createdAt }));
|
|
582
|
+
}
|
|
583
|
+
else if (role === 'assistant' && includeAi) {
|
|
584
|
+
turns.push(withMeta(model ? { ai: text, model } : { ai: text }, { createdAt }));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!turns.length)
|
|
588
|
+
continue;
|
|
589
|
+
const rawKey = conversation?.id ?? conversation?.conversation_id ?? conversation?.title ?? index;
|
|
590
|
+
conversations.push({
|
|
591
|
+
key: `openai:${String(rawKey)}`,
|
|
592
|
+
title: normalizeConversationTitle(conversation?.title, `Conversation ${index + 1}`, turns),
|
|
593
|
+
turns,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return conversations;
|
|
597
|
+
};
|
|
598
|
+
// --- OpenRouter ---
|
|
599
|
+
const extractOpenRouterText = (turn) => {
|
|
600
|
+
const value = turn?.content ?? turn?.text ?? turn?.message;
|
|
601
|
+
if (typeof value === 'string')
|
|
602
|
+
return value.trim();
|
|
603
|
+
if (Array.isArray(value)) {
|
|
604
|
+
return value
|
|
605
|
+
.map((part) => {
|
|
606
|
+
if (typeof part === 'string')
|
|
607
|
+
return part.trim();
|
|
608
|
+
if (typeof part?.text === 'string')
|
|
609
|
+
return part.text.trim();
|
|
610
|
+
if (typeof part?.content === 'string')
|
|
611
|
+
return part.content.trim();
|
|
612
|
+
return '';
|
|
613
|
+
})
|
|
614
|
+
.filter(Boolean)
|
|
615
|
+
.join('\n\n')
|
|
616
|
+
.trim();
|
|
617
|
+
}
|
|
618
|
+
return '';
|
|
619
|
+
};
|
|
620
|
+
const buildOpenRouterConversation = (data, options = {}) => {
|
|
621
|
+
const { includeUser = true, includeAi = true } = options;
|
|
622
|
+
const sourceTurns = Array.isArray(data?.turns) ? data.turns : [];
|
|
623
|
+
const turns = [];
|
|
624
|
+
for (const turn of sourceTurns) {
|
|
625
|
+
const role = String(turn?.role || '').toLowerCase();
|
|
626
|
+
const text = extractOpenRouterText(turn);
|
|
627
|
+
if (!text)
|
|
628
|
+
continue;
|
|
629
|
+
const createdAt = getTimestamp(turn);
|
|
630
|
+
if (role === 'user' && includeUser) {
|
|
631
|
+
turns.push(withMeta({ user: text }, { createdAt }));
|
|
632
|
+
}
|
|
633
|
+
else if ((role === 'assistant' || role === 'model' || role === 'ai') && includeAi) {
|
|
634
|
+
const model = typeof turn?.model === 'string' && turn.model.trim()
|
|
635
|
+
? turn.model.trim()
|
|
636
|
+
: undefined;
|
|
637
|
+
turns.push(withMeta(model ? { ai: text, model } : { ai: text }, { createdAt }));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (!turns.length)
|
|
641
|
+
return { conversations: [] };
|
|
642
|
+
return {
|
|
643
|
+
conversations: [
|
|
644
|
+
{
|
|
645
|
+
key: 'openrouter:0',
|
|
646
|
+
title: normalizeConversationTitle(data?.title ?? data?.name, 'Conversation 1', turns),
|
|
647
|
+
turns,
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
};
|
|
651
|
+
};
|
|
652
|
+
// --- LM Studio ---
|
|
653
|
+
// Harmony / channel control tokens (e.g. `<|start|>assistant<|channel|>final<|message|>`)
|
|
654
|
+
// leak into the stored text for some local models (gpt-oss et al.). In that format the
|
|
655
|
+
// real content follows the last `<|message|>` marker; everything before it is role/channel
|
|
656
|
+
// metadata. Strip it so it does not pollute search.
|
|
657
|
+
const stripControlTokens = (text) => {
|
|
658
|
+
const marker = '<|message|>';
|
|
659
|
+
const lastMessage = text.lastIndexOf(marker);
|
|
660
|
+
const body = lastMessage >= 0 ? text.slice(lastMessage + marker.length) : text;
|
|
661
|
+
return body.replace(/<\|[^|]*\|>/g, '').trim();
|
|
662
|
+
};
|
|
663
|
+
const extractLMStudioText = (content) => {
|
|
664
|
+
if (!Array.isArray(content))
|
|
665
|
+
return '';
|
|
666
|
+
return content
|
|
667
|
+
.map((block) => block && block.type === 'text' && typeof block.text === 'string'
|
|
668
|
+
? stripControlTokens(block.text)
|
|
669
|
+
: '')
|
|
670
|
+
.filter(Boolean)
|
|
671
|
+
.join('\n\n')
|
|
672
|
+
.trim();
|
|
673
|
+
};
|
|
674
|
+
const buildLMStudioConversation = (data, options = {}) => {
|
|
675
|
+
const { includeUser = true, includeThinking = true, includeAi = true } = options;
|
|
676
|
+
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
677
|
+
const model = typeof data?.lastUsedModel?.identifier === 'string'
|
|
678
|
+
? data.lastUsedModel.identifier
|
|
679
|
+
: undefined;
|
|
680
|
+
const createdAt = getTimestamp(data);
|
|
681
|
+
const turns = [];
|
|
682
|
+
for (const message of messages) {
|
|
683
|
+
const versions = Array.isArray(message?.versions) ? message.versions : [];
|
|
684
|
+
if (!versions.length)
|
|
685
|
+
continue;
|
|
686
|
+
const selected = message?.currentlySelected;
|
|
687
|
+
const index = typeof selected === 'number' && versions[selected] ? selected : 0;
|
|
688
|
+
const version = versions[index];
|
|
689
|
+
const role = version?.role;
|
|
690
|
+
if (role === 'user') {
|
|
691
|
+
if (!includeUser)
|
|
692
|
+
continue;
|
|
693
|
+
const text = extractLMStudioText(version?.content);
|
|
694
|
+
if (text)
|
|
695
|
+
turns.push(withMeta({ user: text }, { createdAt }));
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (role !== 'assistant')
|
|
699
|
+
continue;
|
|
700
|
+
const senderName = version?.senderInfo?.senderName;
|
|
701
|
+
const messageModel = typeof senderName === 'string' && senderName.trim() ? senderName.trim() : model;
|
|
702
|
+
// multiStep assistant messages split reasoning ("thinking") and answer into
|
|
703
|
+
// separate steps; singleStep messages carry text directly on `content`.
|
|
704
|
+
const steps = Array.isArray(version?.steps) ? version.steps : null;
|
|
705
|
+
if (!steps) {
|
|
706
|
+
const text = extractLMStudioText(version?.content);
|
|
707
|
+
if (text && includeAi) {
|
|
708
|
+
turns.push(withMeta(messageModel ? { ai: text, model: messageModel } : { ai: text }, { createdAt }));
|
|
709
|
+
}
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
for (const step of steps) {
|
|
713
|
+
if (step?.type !== 'contentBlock')
|
|
714
|
+
continue; // skip debugInfoBlock and friends
|
|
715
|
+
const text = extractLMStudioText(step?.content);
|
|
716
|
+
if (!text)
|
|
717
|
+
continue;
|
|
718
|
+
const isThought = step?.style?.type === 'thinking';
|
|
719
|
+
if (isThought) {
|
|
720
|
+
if (includeThinking) {
|
|
721
|
+
turns.push(withMeta(messageModel ? { thinking: text, model: messageModel } : { thinking: text }, {
|
|
722
|
+
createdAt,
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
else if (includeAi) {
|
|
727
|
+
turns.push(withMeta(messageModel ? { ai: text, model: messageModel } : { ai: text }, { createdAt }));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (!turns.length)
|
|
732
|
+
return { conversations: [] };
|
|
733
|
+
return {
|
|
734
|
+
conversations: [
|
|
735
|
+
{
|
|
736
|
+
key: 'lmstudio:0',
|
|
737
|
+
title: normalizeConversationTitle(data?.name, 'Conversation 1', turns),
|
|
738
|
+
turns,
|
|
739
|
+
},
|
|
740
|
+
],
|
|
741
|
+
};
|
|
742
|
+
};
|
|
743
|
+
// --- Grok (x.ai) ---
|
|
744
|
+
// Grok stores timestamps as MongoDB extended JSON, e.g.
|
|
745
|
+
// `{ "$date": { "$numberLong": "1772641368389" } }` (sometimes `{ "$date": <ms|iso> }`).
|
|
746
|
+
// Unwrap to a value `normalizeTimestamp` understands.
|
|
747
|
+
const unwrapGrokDate = (value) => {
|
|
748
|
+
if (value && typeof value === 'object' && '$date' in value) {
|
|
749
|
+
const inner = value.$date;
|
|
750
|
+
if (inner && typeof inner === 'object' && '$numberLong' in inner) {
|
|
751
|
+
return inner.$numberLong;
|
|
752
|
+
}
|
|
753
|
+
return inner;
|
|
754
|
+
}
|
|
755
|
+
return value;
|
|
756
|
+
};
|
|
757
|
+
const grokTimestamp = (response) => normalizeTimestamp(unwrapGrokDate(response?.create_time));
|
|
758
|
+
// Grok keeps the model's reasoning in `agent_thinking_traces: [{ thinking_trace }]`.
|
|
759
|
+
const extractGrokThinking = (response) => {
|
|
760
|
+
const traces = Array.isArray(response?.agent_thinking_traces)
|
|
761
|
+
? response.agent_thinking_traces
|
|
762
|
+
: [];
|
|
763
|
+
return traces
|
|
764
|
+
.map((trace) => (typeof trace?.thinking_trace === 'string' ? trace.thinking_trace.trim() : ''))
|
|
765
|
+
.filter(Boolean)
|
|
766
|
+
.join('\n\n')
|
|
767
|
+
.trim();
|
|
768
|
+
};
|
|
769
|
+
const buildGrokConversations = (data, options = {}) => {
|
|
770
|
+
const { includeUser = true, includeThinking = true, includeAi = true } = options;
|
|
771
|
+
const entries = Array.isArray(data?.conversations) ? data.conversations : [];
|
|
772
|
+
const conversations = [];
|
|
773
|
+
for (const [index, entry] of entries.entries()) {
|
|
774
|
+
const meta = entry?.conversation ?? {};
|
|
775
|
+
const responses = Array.isArray(entry?.responses) ? entry.responses : [];
|
|
776
|
+
const turns = [];
|
|
777
|
+
for (const wrapper of responses) {
|
|
778
|
+
const response = wrapper?.response ?? {};
|
|
779
|
+
const sender = String(response?.sender || '').toLowerCase();
|
|
780
|
+
const message = typeof response?.message === 'string' ? response.message.trim() : '';
|
|
781
|
+
const createdAt = grokTimestamp(response) ?? grokTimestamp(meta);
|
|
782
|
+
if (sender === 'human') {
|
|
783
|
+
if (includeUser && message)
|
|
784
|
+
turns.push(withMeta({ user: message }, { createdAt }));
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// Everything else is an assistant turn (sender is "assistant"/"ASSISTANT").
|
|
788
|
+
const model = typeof response?.model === 'string' && response.model.trim()
|
|
789
|
+
? response.model.trim()
|
|
790
|
+
: undefined;
|
|
791
|
+
if (includeThinking) {
|
|
792
|
+
const thinking = extractGrokThinking(response);
|
|
793
|
+
if (thinking) {
|
|
794
|
+
turns.push(withMeta(model ? { thinking, model } : { thinking }, { createdAt }));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (includeAi && message) {
|
|
798
|
+
turns.push(withMeta(model ? { ai: message, model } : { ai: message }, { createdAt }));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (!turns.length)
|
|
802
|
+
continue;
|
|
803
|
+
const rawKey = meta?.id ?? meta?.conversation_id ?? index;
|
|
804
|
+
conversations.push({
|
|
805
|
+
key: `grok:${String(rawKey)}`,
|
|
806
|
+
title: normalizeConversationTitle(meta?.title, `Conversation ${index + 1}`, turns),
|
|
807
|
+
turns,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
return conversations;
|
|
811
|
+
};
|