algochat-ai 1.0.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/src/client.js ADDED
@@ -0,0 +1,380 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AlgoChatClient — core HTTP client for algochat.app
5
+ *
6
+ * Handles:
7
+ * 1. GET /api/csrf → csrf_token cookie
8
+ * 2. POST /api/session → zola_sid (anonymous userId)
9
+ * 3. POST /api/create-chat → chatId
10
+ * 4. POST /api/files → (optional) image upload
11
+ * 5. POST /api/chat → SSE stream ← the actual AI response
12
+ *
13
+ * Sessions are cached for 25 minutes and refreshed automatically on 401/403.
14
+ */
15
+
16
+ const axios = require('axios');
17
+ const FormData = require('form-data');
18
+ const { v4: uuidv4 } = require('uuid');
19
+
20
+ const ALGOCHAT_BASE = 'https://algochat.app';
21
+ const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
22
+
23
+ const UA =
24
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
25
+
26
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
27
+
28
+ function parseCookies(setCookieHeaders = []) {
29
+ const cookies = {};
30
+ for (const cookie of setCookieHeaders) {
31
+ const [nameValue] = cookie.split(';');
32
+ const idx = nameValue.indexOf('=');
33
+ if (idx === -1) continue;
34
+ cookies[nameValue.slice(0, idx).trim()] = nameValue.slice(idx + 1).trim();
35
+ }
36
+ return cookies;
37
+ }
38
+
39
+ function buildCookieHeader(cookies) {
40
+ return Object.entries(cookies)
41
+ .map(([k, v]) => `${k}=${v}`)
42
+ .join('; ');
43
+ }
44
+
45
+ function randomId(length = 16) {
46
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
47
+ return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
48
+ }
49
+
50
+ function buildDefaultSystemPrompt() {
51
+ const date = new Date().toLocaleDateString('en-US', {
52
+ month: 'short',
53
+ day: 'numeric',
54
+ year: 'numeric',
55
+ });
56
+ return `Date: ${date}\n\nYou are a helpful AI assistant. Respond clearly and concisely. Use markdown formatting when appropriate.`;
57
+ }
58
+
59
+ // ─── Session management ───────────────────────────────────────────────────────
60
+
61
+ class AlgoChatClient {
62
+ constructor(options = {}) {
63
+ this._sessionCache = null;
64
+ this._sessionFetchedAt = 0;
65
+ this._debug = options.debug === true;
66
+ this._timeout = options.timeout || 90000;
67
+ }
68
+
69
+ _log(...args) {
70
+ if (this._debug) console.log('[AlgoChat]', ...args);
71
+ }
72
+
73
+ _warn(...args) {
74
+ if (this._debug) console.warn('[AlgoChat]', ...args);
75
+ }
76
+
77
+ async _fetchSession() {
78
+ this._log('Initializing session...');
79
+ const cookies = {};
80
+
81
+ // Step 1: CSRF token
82
+ const csrfRes = await axios.get(`${ALGOCHAT_BASE}/api/csrf`, {
83
+ headers: { 'User-Agent': UA, Accept: 'application/json, */*', Referer: ALGOCHAT_BASE },
84
+ });
85
+ Object.assign(cookies, parseCookies(csrfRes.headers['set-cookie']));
86
+ this._log('Got csrf_token:', !!cookies['csrf_token']);
87
+
88
+ // Step 2: Anonymous session (zola_sid)
89
+ const sessionRes = await axios.post(
90
+ `${ALGOCHAT_BASE}/api/session`,
91
+ {},
92
+ {
93
+ headers: {
94
+ 'Content-Type': 'application/json',
95
+ 'User-Agent': UA,
96
+ Cookie: buildCookieHeader(cookies),
97
+ Origin: ALGOCHAT_BASE,
98
+ Referer: ALGOCHAT_BASE,
99
+ },
100
+ }
101
+ );
102
+ Object.assign(cookies, parseCookies(sessionRes.headers['set-cookie']));
103
+ this._log('Got zola_sid:', cookies['zola_sid'] ? 'yes' : 'no');
104
+
105
+ return cookies;
106
+ }
107
+
108
+ async _getSession() {
109
+ const now = Date.now();
110
+ if (!this._sessionCache || now - this._sessionFetchedAt > SESSION_TTL_MS) {
111
+ this._sessionCache = await this._fetchSession();
112
+ this._sessionFetchedAt = now;
113
+ }
114
+ return this._sessionCache;
115
+ }
116
+
117
+ _invalidateSession() {
118
+ this._sessionCache = null;
119
+ this._sessionFetchedAt = 0;
120
+ }
121
+
122
+ async _createChat(cookies, model) {
123
+ const res = await axios.post(
124
+ `${ALGOCHAT_BASE}/api/create-chat`,
125
+ { title: 'New Chat', model: model || 'gemini-3-flash-preview' },
126
+ {
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'User-Agent': UA,
130
+ Cookie: buildCookieHeader(cookies),
131
+ Origin: ALGOCHAT_BASE,
132
+ Referer: ALGOCHAT_BASE,
133
+ },
134
+ }
135
+ );
136
+ const chatId = res.data?.chat?.id || res.data?.id;
137
+ if (!chatId) throw new Error('create-chat did not return a chatId');
138
+ this._log('Created chatId:', chatId);
139
+ return chatId;
140
+ }
141
+
142
+ /**
143
+ * Upload an image to AlgoChat /api/files.
144
+ * @param {string} imageSource - base64 data URI or https URL
145
+ * @param {object} cookies - session cookies
146
+ * @param {string} [filename]
147
+ * @returns {Promise<{url: string, filename: string, mediaType: string}>}
148
+ */
149
+ async uploadImage(imageSource, cookies, filename = 'image.jpg') {
150
+ let imageBuffer;
151
+ let mediaType = 'image/jpeg';
152
+
153
+ if (imageSource.startsWith('data:')) {
154
+ const match = imageSource.match(/^data:([^;]+);base64,(.+)$/);
155
+ if (!match) throw new Error('Invalid base64 data URI format');
156
+ mediaType = match[1];
157
+ imageBuffer = Buffer.from(match[2], 'base64');
158
+ const ext = mediaType.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg';
159
+ if (!filename || filename === 'image.jpg') filename = `image.${ext}`;
160
+ } else if (imageSource.startsWith('http://') || imageSource.startsWith('https://')) {
161
+ const imgRes = await axios.get(imageSource, { responseType: 'arraybuffer', timeout: 30000 });
162
+ imageBuffer = Buffer.from(imgRes.data);
163
+ mediaType = imgRes.headers['content-type']?.split(';')[0] || 'image/jpeg';
164
+ const urlFilename = imageSource.split('/').pop()?.split('?')[0];
165
+ if (urlFilename && urlFilename.includes('.')) filename = urlFilename;
166
+ } else {
167
+ throw new Error('Image source must be a base64 data URI or an https URL');
168
+ }
169
+
170
+ const form = new FormData();
171
+ form.append('file', imageBuffer, { filename, contentType: mediaType });
172
+
173
+ this._log(`Uploading image: ${filename} (${mediaType}, ${imageBuffer.length} bytes)`);
174
+
175
+ const res = await axios.post(`${ALGOCHAT_BASE}/api/files`, form, {
176
+ headers: {
177
+ ...form.getHeaders(),
178
+ 'User-Agent': UA,
179
+ Cookie: buildCookieHeader(cookies),
180
+ Origin: ALGOCHAT_BASE,
181
+ Referer: ALGOCHAT_BASE,
182
+ },
183
+ timeout: 60000,
184
+ });
185
+
186
+ const fileId = res.data?.id || res.data?.fileId || res.data?.file?.id;
187
+ if (!fileId) throw new Error(`Image upload failed – unexpected response: ${JSON.stringify(res.data)}`);
188
+
189
+ const fileUrl = `/api/files/${fileId}`;
190
+ this._log(`Image uploaded → ${fileUrl}`);
191
+ return { url: fileUrl, filename, mediaType };
192
+ }
193
+
194
+ async _contentToParts(content, cookies) {
195
+ if (typeof content === 'string') return [{ type: 'text', text: content }];
196
+ if (!Array.isArray(content)) return [{ type: 'text', text: String(content) }];
197
+
198
+ const parts = [];
199
+ for (const item of content) {
200
+ if (item.type === 'text') {
201
+ parts.push({ type: 'text', text: item.text || '' });
202
+ } else if (item.type === 'image_url') {
203
+ const imageUrl = item.image_url?.url || item.image_url;
204
+ if (!imageUrl) continue;
205
+
206
+ // Strategy 1: Try /api/files upload
207
+ try {
208
+ const uploaded = await this.uploadImage(imageUrl, cookies);
209
+ parts.push({ type: 'file', url: uploaded.url, filename: uploaded.filename, mediaType: uploaded.mediaType });
210
+ continue;
211
+ } catch (uploadErr) {
212
+ const status = uploadErr.response?.status;
213
+ const isAuthErr = status === 401 || status === 403;
214
+ if (!isAuthErr) {
215
+ this._warn('Image upload failed:', uploadErr.message);
216
+ parts.push({ type: 'text', text: '[Image could not be processed]' });
217
+ continue;
218
+ }
219
+ this._warn('/api/files upload denied. Using direct URL fallback.');
220
+ }
221
+
222
+ // Strategy 2: Pass URL/base64 directly
223
+ if (imageUrl.startsWith('data:')) {
224
+ const match = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
225
+ if (match) {
226
+ parts.push({
227
+ type: 'file',
228
+ url: imageUrl,
229
+ mediaType: match[1],
230
+ filename: `image.${match[1].split('/')[1]?.replace('jpeg', 'jpg') || 'jpg'}`,
231
+ });
232
+ } else {
233
+ parts.push({ type: 'text', text: '[Invalid base64 image]' });
234
+ }
235
+ } else {
236
+ const urlFilename = imageUrl.split('/').pop()?.split('?')[0] || 'image.jpg';
237
+ const guessedMediaType = urlFilename.endsWith('.png') ? 'image/png'
238
+ : urlFilename.endsWith('.webp') ? 'image/webp'
239
+ : urlFilename.endsWith('.gif') ? 'image/gif'
240
+ : 'image/jpeg';
241
+ parts.push({ type: 'file', url: imageUrl, filename: urlFilename, mediaType: guessedMediaType });
242
+ }
243
+ }
244
+ }
245
+ return parts.length > 0 ? parts : [{ type: 'text', text: '' }];
246
+ }
247
+
248
+ async _convertMessages(messages, cookies) {
249
+ const results = [];
250
+ for (const msg of messages) {
251
+ const parts = await this._contentToParts(msg.content, cookies);
252
+ results.push({
253
+ id: randomId(8),
254
+ role: msg.role === 'assistant' ? 'assistant' : 'user',
255
+ parts,
256
+ });
257
+ }
258
+ return results;
259
+ }
260
+
261
+ /**
262
+ * Create a chat and return the raw SSE stream.
263
+ *
264
+ * @param {object} params
265
+ * @param {string} [params.model]
266
+ * @param {Array} params.messages - Array of {role, content} objects
267
+ * @param {string} [params.systemPrompt]
268
+ * @returns {Promise<{stream: import('stream').Readable, chatId: string}>}
269
+ */
270
+ async createChatStream({ model, messages, systemPrompt }) {
271
+ const resolvedModel = model || 'gemini-3-flash-preview';
272
+ let session = await this._getSession();
273
+
274
+ let chatId;
275
+ try {
276
+ chatId = await this._createChat(session, resolvedModel);
277
+ } catch (err) {
278
+ if (err.response?.status === 401 || err.response?.status === 403) {
279
+ this._invalidateSession();
280
+ session = await this._getSession();
281
+ chatId = await this._createChat(session, resolvedModel);
282
+ } else {
283
+ throw err;
284
+ }
285
+ }
286
+
287
+ const convertedMessages = await this._convertMessages(messages, session);
288
+
289
+ const requestBody = {
290
+ chatId,
291
+ userId: session['zola_sid'],
292
+ model: resolvedModel,
293
+ isAuthenticated: false,
294
+ systemPrompt: systemPrompt || buildDefaultSystemPrompt(),
295
+ enableSearch: false,
296
+ id: randomId(16),
297
+ messages: convertedMessages,
298
+ trigger: 'submit-message',
299
+ };
300
+
301
+ const headers = {
302
+ 'Content-Type': 'application/json',
303
+ 'User-Agent': UA,
304
+ Cookie: buildCookieHeader(session),
305
+ Origin: ALGOCHAT_BASE,
306
+ Referer: `${ALGOCHAT_BASE}/c/${chatId}`,
307
+ 'x-vercel-ai-ui-message-stream': 'v1',
308
+ Accept: 'text/event-stream',
309
+ 'Accept-Language': 'en-US,en;q=0.9',
310
+ 'Cache-Control': 'no-cache',
311
+ };
312
+
313
+ this._log(`POST /api/chat | model=${resolvedModel} | chatId=${chatId}`);
314
+
315
+ let response;
316
+ try {
317
+ response = await axios.post(`${ALGOCHAT_BASE}/api/chat`, requestBody, {
318
+ headers,
319
+ responseType: 'stream',
320
+ timeout: this._timeout,
321
+ });
322
+ } catch (err) {
323
+ const status = err.response?.status;
324
+ if (status === 401 || status === 403) {
325
+ this._invalidateSession();
326
+ session = await this._getSession();
327
+ chatId = await this._createChat(session, resolvedModel);
328
+ requestBody.chatId = chatId;
329
+ requestBody.userId = session['zola_sid'];
330
+ headers.Cookie = buildCookieHeader(session);
331
+ headers.Referer = `${ALGOCHAT_BASE}/c/${chatId}`;
332
+ response = await axios.post(`${ALGOCHAT_BASE}/api/chat`, requestBody, {
333
+ headers,
334
+ responseType: 'stream',
335
+ timeout: this._timeout,
336
+ });
337
+ } else {
338
+ throw err;
339
+ }
340
+ }
341
+
342
+ return { stream: response.data, chatId };
343
+ }
344
+
345
+ /**
346
+ * Collect the full SSE stream into a plain text string.
347
+ * @param {import('stream').Readable} stream
348
+ * @returns {Promise<string>}
349
+ */
350
+ async streamToText(stream) {
351
+ return new Promise((resolve, reject) => {
352
+ let fullText = '';
353
+ let buffer = '';
354
+
355
+ stream.on('data', (chunk) => {
356
+ buffer += chunk.toString();
357
+ const lines = buffer.split('\n');
358
+ buffer = lines.pop();
359
+
360
+ for (const line of lines) {
361
+ const trimmed = line.trim();
362
+ if (!trimmed.startsWith('data:')) continue;
363
+ const jsonStr = trimmed.slice(5).trim();
364
+ if (!jsonStr || jsonStr === '[DONE]') continue;
365
+ try {
366
+ const parsed = JSON.parse(jsonStr);
367
+ if (parsed.type === 'text-delta' && parsed.delta) fullText += parsed.delta;
368
+ } catch {
369
+ // skip malformed chunks
370
+ }
371
+ }
372
+ });
373
+
374
+ stream.on('end', () => resolve(fullText));
375
+ stream.on('error', reject);
376
+ });
377
+ }
378
+ }
379
+
380
+ module.exports = AlgoChatClient;
package/src/index.d.ts ADDED
@@ -0,0 +1,144 @@
1
+ // Type definitions for algochat-ai
2
+ // ──────────────────────────────────────────────────────────────────────────────
3
+
4
+ export interface ChatOptions {
5
+ /** AlgoChat model ID or OpenAI alias. Default: 'gemini-3-flash-preview' */
6
+ model?: string;
7
+ /** Override the system prompt. */
8
+ systemPrompt?: string;
9
+ /** Enable verbose debug logging. Default: false */
10
+ debug?: boolean;
11
+ /** Request timeout in milliseconds. Default: 90000 */
12
+ timeout?: number;
13
+ }
14
+
15
+ export interface Message {
16
+ role: 'user' | 'assistant' | 'system';
17
+ content: string | ContentPart[];
18
+ }
19
+
20
+ export interface ContentPart {
21
+ type: 'text' | 'image_url';
22
+ text?: string;
23
+ image_url?: { url: string };
24
+ }
25
+
26
+ export interface ChatCompletion {
27
+ id: string;
28
+ object: 'chat.completion';
29
+ created: number;
30
+ model: string;
31
+ choices: Array<{
32
+ index: number;
33
+ message: { role: string; content: string };
34
+ finish_reason: string;
35
+ logprobs: null;
36
+ }>;
37
+ usage: {
38
+ prompt_tokens: number;
39
+ completion_tokens: number;
40
+ total_tokens: number;
41
+ };
42
+ system_fingerprint: null;
43
+ }
44
+
45
+ export interface ModelInfo {
46
+ id: string;
47
+ object: 'model';
48
+ created: number;
49
+ owned_by: string;
50
+ permission: any[];
51
+ root: string;
52
+ parent: null;
53
+ requiresAuth: boolean;
54
+ }
55
+
56
+ export interface UploadedFile {
57
+ url: string;
58
+ filename: string;
59
+ mediaType: string;
60
+ }
61
+
62
+ export interface AlgoChatClientOptions {
63
+ /** Enable verbose debug logging. Default: false */
64
+ debug?: boolean;
65
+ /** Request timeout in milliseconds. Default: 90000 */
66
+ timeout?: number;
67
+ }
68
+
69
+ export interface CreateChatStreamParams {
70
+ model?: string;
71
+ messages: Message[];
72
+ systemPrompt?: string;
73
+ }
74
+
75
+ export interface CreateChatStreamResult {
76
+ stream: NodeJS.ReadableStream;
77
+ chatId: string;
78
+ }
79
+
80
+ export declare class AlgoChatClient {
81
+ constructor(options?: AlgoChatClientOptions);
82
+
83
+ createChatStream(params: CreateChatStreamParams): Promise<CreateChatStreamResult>;
84
+ streamToText(stream: NodeJS.ReadableStream): Promise<string>;
85
+ uploadImage(imageSource: string, cookies: Record<string, string>, filename?: string): Promise<UploadedFile>;
86
+ }
87
+
88
+ /**
89
+ * Send a message and get the full AI response as a string.
90
+ *
91
+ * @param input - A string message or an OpenAI-style messages array
92
+ * @param options - Optional configuration
93
+ */
94
+ export declare function chat(input: string | Message[], options?: ChatOptions): Promise<string>;
95
+
96
+ /**
97
+ * Stream the AI response chunk by chunk.
98
+ * Calls onChunk(delta) for every piece of text received.
99
+ * Returns the full accumulated response when the stream ends.
100
+ *
101
+ * @param input - A string message or an OpenAI-style messages array
102
+ * @param onChunk - Callback invoked with each text delta
103
+ * @param options - Optional configuration
104
+ */
105
+ export declare function chatStream(
106
+ input: string | Message[],
107
+ onChunk: (chunk: string) => void,
108
+ options?: ChatOptions
109
+ ): Promise<string>;
110
+
111
+ /**
112
+ * Send a message together with an image (multimodal).
113
+ *
114
+ * @param text - The text question about the image
115
+ * @param imageUrl - An https:// URL or a base64 data URI (data:image/...)
116
+ * @param options - Optional configuration
117
+ */
118
+ export declare function chatWithImage(text: string, imageUrl: string, options?: ChatOptions): Promise<string>;
119
+
120
+ /**
121
+ * Get a full OpenAI-compatible chat completion object (non-streaming).
122
+ *
123
+ * @param messages - OpenAI-style messages array
124
+ * @param options - Optional configuration
125
+ */
126
+ export declare function createCompletion(messages: Message[], options?: ChatOptions): Promise<ChatCompletion>;
127
+
128
+ /** Resolve a model name (OpenAI alias or native) to an AlgoChat model ID. */
129
+ export declare function resolveModel(requestedModel: string): string;
130
+
131
+ /** Return all supported models in OpenAI /v1/models format. */
132
+ export declare function getModelList(): ModelInfo[];
133
+
134
+ /** All supported AlgoChat models with metadata. */
135
+ export declare const ALGOCHAT_MODELS: Record<string, {
136
+ id: string;
137
+ displayName: string;
138
+ contextWindow: number;
139
+ maxOutputTokens: number;
140
+ requiresAuth: boolean;
141
+ }>;
142
+
143
+ /** Default model ID used when none is specified. */
144
+ export declare const DEFAULT_MODEL: string;