nothumanallowed 9.7.2 → 9.8.1
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/package.json +1 -1
- package/src/commands/ask.mjs +206 -18
- package/src/commands/chat.mjs +482 -64
- package/src/commands/ui.mjs +843 -89
- package/src/constants.mjs +1 -1
- package/src/services/browser-engine.mjs +1240 -0
- package/src/services/conversations.mjs +277 -0
- package/src/services/llm.mjs +120 -89
- package/src/services/tool-executor.mjs +384 -59
- package/src/services/web-tools.mjs +430 -0
- package/src/services/web-ui.mjs +422 -175
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-conversation manager for NHA Chat.
|
|
3
|
+
*
|
|
4
|
+
* Stores conversations in ~/.nha/conversations/ as JSON files.
|
|
5
|
+
* Each conversation has an ID, auto-generated title, and message history.
|
|
6
|
+
*
|
|
7
|
+
* Zero npm dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import { NHA_DIR } from '../constants.mjs';
|
|
14
|
+
|
|
15
|
+
const CONVERSATIONS_DIR = path.join(NHA_DIR, 'conversations');
|
|
16
|
+
const ACTIVE_FILE = path.join(CONVERSATIONS_DIR, '.active');
|
|
17
|
+
const MAX_TITLE_LENGTH = 60;
|
|
18
|
+
const MAX_CONVERSATIONS = 100;
|
|
19
|
+
|
|
20
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function ensureDir() {
|
|
23
|
+
fs.mkdirSync(CONVERSATIONS_DIR, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function convPath(id) {
|
|
27
|
+
return path.join(CONVERSATIONS_DIR, `${id}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function generateId() {
|
|
31
|
+
return crypto.randomUUID().slice(0, 8);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Auto-generate a title from the first user message.
|
|
36
|
+
* Takes the first ~50 chars, trims to last word boundary.
|
|
37
|
+
*/
|
|
38
|
+
function autoTitle(firstMessage) {
|
|
39
|
+
if (!firstMessage) return 'New Chat';
|
|
40
|
+
let title = firstMessage.replace(/\s+/g, ' ').trim();
|
|
41
|
+
if (title.length > MAX_TITLE_LENGTH) {
|
|
42
|
+
title = title.slice(0, MAX_TITLE_LENGTH);
|
|
43
|
+
const lastSpace = title.lastIndexOf(' ');
|
|
44
|
+
if (lastSpace > 20) title = title.slice(0, lastSpace);
|
|
45
|
+
title += '...';
|
|
46
|
+
}
|
|
47
|
+
return title;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a new conversation and set it as active.
|
|
54
|
+
* @returns {{ id: string, title: string, messages: Array, createdAt: string, updatedAt: string }}
|
|
55
|
+
*/
|
|
56
|
+
export function createConversation(title = '') {
|
|
57
|
+
ensureDir();
|
|
58
|
+
const id = generateId();
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
const conv = {
|
|
61
|
+
id,
|
|
62
|
+
title: title || 'New Chat',
|
|
63
|
+
messages: [],
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
};
|
|
67
|
+
fs.writeFileSync(convPath(id), JSON.stringify(conv, null, 2) + '\n', 'utf-8');
|
|
68
|
+
setActiveId(id);
|
|
69
|
+
return conv;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load a conversation by ID.
|
|
74
|
+
* @returns {object|null}
|
|
75
|
+
*/
|
|
76
|
+
export function loadConversation(id) {
|
|
77
|
+
try {
|
|
78
|
+
const data = fs.readFileSync(convPath(id), 'utf-8');
|
|
79
|
+
return JSON.parse(data);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Save a conversation (full overwrite).
|
|
87
|
+
*/
|
|
88
|
+
export function saveConversation(conv) {
|
|
89
|
+
ensureDir();
|
|
90
|
+
conv.updatedAt = new Date().toISOString();
|
|
91
|
+
fs.writeFileSync(convPath(conv.id), JSON.stringify(conv, null, 2) + '\n', 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Delete a conversation by ID.
|
|
96
|
+
* If it was active, clears active state.
|
|
97
|
+
* @returns {boolean} true if deleted
|
|
98
|
+
*/
|
|
99
|
+
export function deleteConversation(id) {
|
|
100
|
+
const filePath = convPath(id);
|
|
101
|
+
if (!fs.existsSync(filePath)) return false;
|
|
102
|
+
fs.unlinkSync(filePath);
|
|
103
|
+
if (getActiveId() === id) {
|
|
104
|
+
clearActiveId();
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* List all conversations, sorted by updatedAt (newest first).
|
|
111
|
+
* Returns summary objects (no messages).
|
|
112
|
+
* @returns {Array<{ id: string, title: string, messageCount: number, createdAt: string, updatedAt: string }>}
|
|
113
|
+
*/
|
|
114
|
+
export function listConversations() {
|
|
115
|
+
ensureDir();
|
|
116
|
+
const files = fs.readdirSync(CONVERSATIONS_DIR)
|
|
117
|
+
.filter(f => f.endsWith('.json') && !f.startsWith('.'));
|
|
118
|
+
|
|
119
|
+
const convs = [];
|
|
120
|
+
for (const f of files) {
|
|
121
|
+
try {
|
|
122
|
+
const data = JSON.parse(fs.readFileSync(path.join(CONVERSATIONS_DIR, f), 'utf-8'));
|
|
123
|
+
convs.push({
|
|
124
|
+
id: data.id,
|
|
125
|
+
title: data.title || 'Untitled',
|
|
126
|
+
messageCount: (data.messages || []).length,
|
|
127
|
+
createdAt: data.createdAt,
|
|
128
|
+
updatedAt: data.updatedAt,
|
|
129
|
+
});
|
|
130
|
+
} catch { /* skip corrupt files */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
convs.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
134
|
+
|
|
135
|
+
// Auto-prune old conversations beyond limit
|
|
136
|
+
if (convs.length > MAX_CONVERSATIONS) {
|
|
137
|
+
for (const old of convs.slice(MAX_CONVERSATIONS)) {
|
|
138
|
+
try { fs.unlinkSync(convPath(old.id)); } catch {}
|
|
139
|
+
}
|
|
140
|
+
return convs.slice(0, MAX_CONVERSATIONS);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return convs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Active Conversation ──────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get the active conversation ID.
|
|
150
|
+
* @returns {string|null}
|
|
151
|
+
*/
|
|
152
|
+
export function getActiveId() {
|
|
153
|
+
try {
|
|
154
|
+
return fs.readFileSync(ACTIVE_FILE, 'utf-8').trim() || null;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Set the active conversation ID.
|
|
162
|
+
*/
|
|
163
|
+
export function setActiveId(id) {
|
|
164
|
+
ensureDir();
|
|
165
|
+
fs.writeFileSync(ACTIVE_FILE, id, 'utf-8');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Clear the active conversation.
|
|
170
|
+
*/
|
|
171
|
+
export function clearActiveId() {
|
|
172
|
+
try { fs.unlinkSync(ACTIVE_FILE); } catch {}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get or create the active conversation.
|
|
177
|
+
* If none exists, creates a new one.
|
|
178
|
+
* @returns {object} conversation object
|
|
179
|
+
*/
|
|
180
|
+
export function getOrCreateActive() {
|
|
181
|
+
const activeId = getActiveId();
|
|
182
|
+
if (activeId) {
|
|
183
|
+
const conv = loadConversation(activeId);
|
|
184
|
+
if (conv) return conv;
|
|
185
|
+
}
|
|
186
|
+
return createConversation();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Add a message pair (user + assistant) to a conversation.
|
|
191
|
+
* Auto-titles the conversation from the first user message.
|
|
192
|
+
*/
|
|
193
|
+
export function addMessages(conv, userContent, assistantContent) {
|
|
194
|
+
conv.messages.push({ role: 'user', content: userContent });
|
|
195
|
+
conv.messages.push({ role: 'assistant', content: assistantContent });
|
|
196
|
+
|
|
197
|
+
// Auto-title from first user message
|
|
198
|
+
if (conv.title === 'New Chat' && conv.messages.length === 2) {
|
|
199
|
+
conv.title = autoTitle(userContent);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
saveConversation(conv);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get the message history from a conversation, capped at maxTurns pairs.
|
|
207
|
+
* @returns {Array<{role: string, content: string}>}
|
|
208
|
+
*/
|
|
209
|
+
export function getHistory(conv, maxTurns = 20) {
|
|
210
|
+
const messages = conv.messages || [];
|
|
211
|
+
return messages.slice(-(maxTurns * 2));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Export ────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Export conversation as Markdown.
|
|
218
|
+
*/
|
|
219
|
+
export function exportAsMarkdown(conv) {
|
|
220
|
+
const lines = [
|
|
221
|
+
`# ${conv.title}`,
|
|
222
|
+
`*Created: ${new Date(conv.createdAt).toLocaleString()}*`,
|
|
223
|
+
`*Messages: ${conv.messages.length}*`,
|
|
224
|
+
'',
|
|
225
|
+
'---',
|
|
226
|
+
'',
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
for (const msg of conv.messages) {
|
|
230
|
+
if (msg.role === 'user') {
|
|
231
|
+
lines.push(`### You`);
|
|
232
|
+
lines.push(msg.content);
|
|
233
|
+
} else {
|
|
234
|
+
lines.push(`### NHA`);
|
|
235
|
+
lines.push(msg.content);
|
|
236
|
+
}
|
|
237
|
+
lines.push('');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return lines.join('\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Export conversation as JSON.
|
|
245
|
+
*/
|
|
246
|
+
export function exportAsJson(conv) {
|
|
247
|
+
return JSON.stringify({
|
|
248
|
+
id: conv.id,
|
|
249
|
+
title: conv.title,
|
|
250
|
+
createdAt: conv.createdAt,
|
|
251
|
+
updatedAt: conv.updatedAt,
|
|
252
|
+
messages: conv.messages,
|
|
253
|
+
}, null, 2);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Migration ────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Migrate old single-file chat history to multi-conversation format.
|
|
260
|
+
* Called once on first run if old history exists.
|
|
261
|
+
*/
|
|
262
|
+
export function migrateOldHistory() {
|
|
263
|
+
const oldFile = path.join(NHA_DIR, 'memory', 'chat-history.json');
|
|
264
|
+
if (!fs.existsSync(oldFile)) return;
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const messages = JSON.parse(fs.readFileSync(oldFile, 'utf-8'));
|
|
268
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
269
|
+
|
|
270
|
+
const conv = createConversation('Previous Chat');
|
|
271
|
+
conv.messages = messages;
|
|
272
|
+
saveConversation(conv);
|
|
273
|
+
|
|
274
|
+
// Rename old file to avoid re-migration
|
|
275
|
+
fs.renameSync(oldFile, oldFile + '.migrated');
|
|
276
|
+
} catch { /* migration failed — non-critical */ }
|
|
277
|
+
}
|
package/src/services/llm.mjs
CHANGED
|
@@ -276,110 +276,141 @@ export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* Call LLM with
|
|
280
|
-
*
|
|
281
|
-
* @
|
|
282
|
-
* @param {Array} messages - Array of { role, content } where content can be string or array of content blocks
|
|
283
|
-
* @returns {Promise<string>}
|
|
279
|
+
* Call an LLM provider with streaming enabled.
|
|
280
|
+
* Calls onToken(chunk) for each token, returns full text at the end.
|
|
281
|
+
* @returns {Promise<string>} The full LLM response text.
|
|
284
282
|
*/
|
|
285
|
-
export async function
|
|
286
|
-
const provider = config.llm.provider || 'anthropic';
|
|
287
|
-
const model = config.llm.model || null;
|
|
283
|
+
export async function callLLMStream(config, systemPrompt, userMessage, onToken, opts = {}) {
|
|
284
|
+
const provider = opts.provider || config.llm.provider || 'anthropic';
|
|
285
|
+
const model = opts.model || config.llm.model || null;
|
|
288
286
|
const apiKey = getApiKey(config, provider);
|
|
289
|
-
if (!apiKey) throw new Error(`No API key for ${provider}
|
|
287
|
+
if (!apiKey) throw new Error(`No API key for ${provider}`);
|
|
290
288
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
289
|
+
const callFn = getProviderCall(provider);
|
|
290
|
+
if (!callFn) throw new Error(`Unknown provider: ${provider}`);
|
|
291
|
+
|
|
292
|
+
// Gemini and Cohere don't support streaming — fall back to non-streaming
|
|
293
|
+
if (provider === 'gemini' || provider === 'cohere') {
|
|
294
|
+
const text = await callFn(apiKey, model, systemPrompt, userMessage, false);
|
|
295
|
+
if (onToken) onToken(text);
|
|
296
|
+
return text;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const format = provider === 'anthropic' ? 'anthropic' : 'openai';
|
|
300
|
+
const body = buildRequestBody(provider, model, systemPrompt, userMessage, true);
|
|
301
|
+
const url = getProviderUrl(provider, model, apiKey);
|
|
302
|
+
const headers = getProviderHeaders(provider, apiKey);
|
|
303
|
+
|
|
304
|
+
const res = await fetch(url, {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers,
|
|
307
|
+
body: JSON.stringify(body),
|
|
308
|
+
});
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
const err = await res.text();
|
|
311
|
+
throw new Error(`${provider} ${res.status}: ${err}`);
|
|
312
|
+
}
|
|
312
313
|
|
|
313
|
-
|
|
314
|
+
return streamSSEWithCallback(res, format, onToken);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Build request body for a provider */
|
|
318
|
+
function buildRequestBody(provider, model, systemPrompt, userMessage, stream) {
|
|
319
|
+
if (provider === 'anthropic') {
|
|
320
|
+
return {
|
|
314
321
|
model: model || 'claude-sonnet-4-20250514',
|
|
315
|
-
max_tokens:
|
|
316
|
-
system:
|
|
317
|
-
messages:
|
|
322
|
+
max_tokens: 8192,
|
|
323
|
+
system: systemPrompt,
|
|
324
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
325
|
+
stream,
|
|
318
326
|
};
|
|
319
|
-
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
320
|
-
method: 'POST',
|
|
321
|
-
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
322
|
-
body: JSON.stringify(body),
|
|
323
|
-
});
|
|
324
|
-
if (!res.ok) throw new Error(`Anthropic vision ${res.status}: ${await res.text()}`);
|
|
325
|
-
const data = await res.json();
|
|
326
|
-
return data.content?.[0]?.text || '';
|
|
327
327
|
}
|
|
328
|
+
// OpenAI-compatible format (OpenAI, DeepSeek, Grok, Mistral)
|
|
329
|
+
const modelDefaults = {
|
|
330
|
+
openai: 'gpt-4o',
|
|
331
|
+
deepseek: 'deepseek-chat',
|
|
332
|
+
grok: 'grok-3-latest',
|
|
333
|
+
mistral: 'mistral-large-latest',
|
|
334
|
+
};
|
|
335
|
+
return {
|
|
336
|
+
model: model || modelDefaults[provider] || 'gpt-4o',
|
|
337
|
+
max_tokens: 8192,
|
|
338
|
+
messages: [
|
|
339
|
+
{ role: 'system', content: systemPrompt },
|
|
340
|
+
{ role: 'user', content: userMessage },
|
|
341
|
+
],
|
|
342
|
+
stream,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Get provider API URL */
|
|
347
|
+
function getProviderUrl(provider, model, apiKey) {
|
|
348
|
+
const urls = {
|
|
349
|
+
anthropic: 'https://api.anthropic.com/v1/messages',
|
|
350
|
+
openai: 'https://api.openai.com/v1/chat/completions',
|
|
351
|
+
deepseek: 'https://api.deepseek.com/v1/chat/completions',
|
|
352
|
+
grok: 'https://api.x.ai/v1/chat/completions',
|
|
353
|
+
mistral: 'https://api.mistral.ai/v1/chat/completions',
|
|
354
|
+
};
|
|
355
|
+
return urls[provider] || urls.openai;
|
|
356
|
+
}
|
|
328
357
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const body = { model: visionModel, max_tokens: 4096, messages };
|
|
338
|
-
const res = await fetch(url, {
|
|
339
|
-
method: 'POST',
|
|
340
|
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
341
|
-
body: JSON.stringify(body),
|
|
342
|
-
});
|
|
343
|
-
if (!res.ok) throw new Error(`${provider} vision ${res.status}: ${await res.text()}`);
|
|
344
|
-
const data = await res.json();
|
|
345
|
-
return data.choices?.[0]?.message?.content || '';
|
|
358
|
+
/** Get provider request headers */
|
|
359
|
+
function getProviderHeaders(provider, apiKey) {
|
|
360
|
+
if (provider === 'anthropic') {
|
|
361
|
+
return {
|
|
362
|
+
'Content-Type': 'application/json',
|
|
363
|
+
'x-api-key': apiKey,
|
|
364
|
+
'anthropic-version': '2023-06-01',
|
|
365
|
+
};
|
|
346
366
|
}
|
|
367
|
+
return {
|
|
368
|
+
'Content-Type': 'application/json',
|
|
369
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** SSE stream parser with onToken callback (does NOT write to stdout directly) */
|
|
374
|
+
async function streamSSEWithCallback(res, format, onToken) {
|
|
375
|
+
const reader = res.body.getReader();
|
|
376
|
+
const decoder = new TextDecoder();
|
|
377
|
+
let buffer = '';
|
|
378
|
+
let fullText = '';
|
|
379
|
+
|
|
380
|
+
while (true) {
|
|
381
|
+
const { done, value } = await reader.read();
|
|
382
|
+
if (done) break;
|
|
383
|
+
|
|
384
|
+
buffer += decoder.decode(value, { stream: true });
|
|
385
|
+
const lines = buffer.split('\n');
|
|
386
|
+
buffer = lines.pop() || '';
|
|
387
|
+
|
|
388
|
+
for (const line of lines) {
|
|
389
|
+
if (!line.startsWith('data: ')) continue;
|
|
390
|
+
const data = line.slice(6).trim();
|
|
391
|
+
if (data === '[DONE]') continue;
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const json = JSON.parse(data);
|
|
395
|
+
let chunk = '';
|
|
347
396
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const userMsgs = messages.filter(m => m.role !== 'system');
|
|
352
|
-
const parts = [];
|
|
353
|
-
for (const msg of userMsgs) {
|
|
354
|
-
if (typeof msg.content === 'string') {
|
|
355
|
-
parts.push({ text: msg.content });
|
|
356
|
-
} else {
|
|
357
|
-
for (const block of msg.content) {
|
|
358
|
-
if (block.type === 'text') parts.push({ text: block.text });
|
|
359
|
-
if (block.type === 'image_url') {
|
|
360
|
-
const match = block.image_url.url.match(/^data:image\/(.*?);base64,(.+)$/);
|
|
361
|
-
if (match) parts.push({ inline_data: { mime_type: `image/${match[1]}`, data: match[2] } });
|
|
397
|
+
if (format === 'anthropic') {
|
|
398
|
+
if (json.type === 'content_block_delta') {
|
|
399
|
+
chunk = json.delta?.text || '';
|
|
362
400
|
}
|
|
401
|
+
} else {
|
|
402
|
+
chunk = json.choices?.[0]?.delta?.content || '';
|
|
363
403
|
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
404
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
method: 'POST',
|
|
374
|
-
headers: { 'Content-Type': 'application/json' },
|
|
375
|
-
body: JSON.stringify(body),
|
|
376
|
-
});
|
|
377
|
-
if (!res.ok) throw new Error(`Gemini vision ${res.status}: ${await res.text()}`);
|
|
378
|
-
const data = await res.json();
|
|
379
|
-
return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
405
|
+
if (chunk) {
|
|
406
|
+
fullText += chunk;
|
|
407
|
+
if (onToken) onToken(chunk);
|
|
408
|
+
}
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
380
411
|
}
|
|
381
412
|
|
|
382
|
-
|
|
413
|
+
return fullText;
|
|
383
414
|
}
|
|
384
415
|
|
|
385
416
|
/**
|