archicore 0.2.0 → 0.2.2

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.
@@ -5,7 +5,9 @@ export class EmbeddingService {
5
5
  config;
6
6
  initialized = false;
7
7
  _isAvailable = false;
8
- jinaApiKey;
8
+ jinaApiKeys = [];
9
+ currentKeyIndex = 0;
10
+ keyFailures = new Map();
9
11
  embeddingDimension = 1024; // Jina default, OpenAI small = 1536
10
12
  constructor(config) {
11
13
  this.config = config;
@@ -23,16 +25,21 @@ export class EmbeddingService {
23
25
  }
24
26
  try {
25
27
  if (this.config.provider === 'jina') {
26
- // Jina AI - free embeddings
27
- this.jinaApiKey = process.env.JINA_API_KEY;
28
- if (!this.jinaApiKey) {
29
- Logger.warn('JINA_API_KEY not set - semantic search disabled');
28
+ // Jina AI - free embeddings with key rotation support
29
+ // Support multiple keys: JINA_API_KEYS (comma-separated) or JINA_API_KEY (single)
30
+ const keysEnv = process.env.JINA_API_KEYS || process.env.JINA_API_KEY || '';
31
+ this.jinaApiKeys = keysEnv
32
+ .split(',')
33
+ .map(k => k.trim())
34
+ .filter(k => k.length > 0);
35
+ if (this.jinaApiKeys.length === 0) {
36
+ Logger.warn('JINA_API_KEY(S) not set - semantic search disabled');
30
37
  Logger.info('Get free API key at: https://jina.ai/embeddings/');
31
38
  return;
32
39
  }
33
40
  this.embeddingDimension = 1024;
34
41
  this._isAvailable = true;
35
- Logger.success('Jina AI embeddings enabled');
42
+ Logger.success(`Jina AI embeddings enabled (${this.jinaApiKeys.length} key${this.jinaApiKeys.length > 1 ? 's' : ''} configured)`);
36
43
  }
37
44
  else {
38
45
  // OpenAI embeddings
@@ -51,6 +58,45 @@ export class EmbeddingService {
51
58
  Logger.warn('Embedding init failed: ' + error);
52
59
  }
53
60
  }
61
+ getCurrentJinaKey() {
62
+ if (this.jinaApiKeys.length === 0)
63
+ return undefined;
64
+ return this.jinaApiKeys[this.currentKeyIndex];
65
+ }
66
+ rotateToNextKey() {
67
+ if (this.jinaApiKeys.length <= 1)
68
+ return false;
69
+ const oldIndex = this.currentKeyIndex;
70
+ this.currentKeyIndex = (this.currentKeyIndex + 1) % this.jinaApiKeys.length;
71
+ // Track failure for old key
72
+ const failure = this.keyFailures.get(oldIndex) || { count: 0, lastFail: new Date() };
73
+ failure.count++;
74
+ failure.lastFail = new Date();
75
+ this.keyFailures.set(oldIndex, failure);
76
+ Logger.warn(`Jina API key ${oldIndex + 1} exhausted/failed, rotating to key ${this.currentKeyIndex + 1}`);
77
+ return true;
78
+ }
79
+ shouldSkipKey(index) {
80
+ const failure = this.keyFailures.get(index);
81
+ if (!failure)
82
+ return false;
83
+ // Skip key if it failed recently (within 5 minutes) and has many failures
84
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
85
+ return failure.count >= 3 && failure.lastFail > fiveMinutesAgo;
86
+ }
87
+ findWorkingKeyIndex() {
88
+ const startIndex = this.currentKeyIndex;
89
+ for (let i = 0; i < this.jinaApiKeys.length; i++) {
90
+ const index = (startIndex + i) % this.jinaApiKeys.length;
91
+ if (!this.shouldSkipKey(index)) {
92
+ return index;
93
+ }
94
+ }
95
+ // All keys are marked as failed, reset failures and try first key
96
+ Logger.warn('All Jina API keys marked as failed, resetting failure counters');
97
+ this.keyFailures.clear();
98
+ return 0;
99
+ }
54
100
  isAvailable() {
55
101
  this.ensureInitialized();
56
102
  return this._isAvailable;
@@ -60,7 +106,7 @@ export class EmbeddingService {
60
106
  if (!this._isAvailable)
61
107
  return new Array(this.embeddingDimension).fill(0);
62
108
  try {
63
- if (this.config.provider === 'jina' && this.jinaApiKey) {
109
+ if (this.config.provider === 'jina' && this.jinaApiKeys.length > 0) {
64
110
  return await this.generateJinaEmbedding(text);
65
111
  }
66
112
  else if (this.openai) {
@@ -73,28 +119,51 @@ export class EmbeddingService {
73
119
  return new Array(this.embeddingDimension).fill(0);
74
120
  }
75
121
  }
76
- async generateJinaEmbedding(text) {
77
- if (!this.jinaApiKey)
122
+ async generateJinaEmbedding(text, retryCount = 0) {
123
+ const maxRetries = this.jinaApiKeys.length;
124
+ // Find a working key index before first attempt
125
+ if (retryCount === 0) {
126
+ this.currentKeyIndex = this.findWorkingKeyIndex();
127
+ }
128
+ const apiKey = this.getCurrentJinaKey();
129
+ if (!apiKey)
78
130
  throw new Error('Jina API key not set');
79
- const response = await fetch('https://api.jina.ai/v1/embeddings', {
80
- method: 'POST',
81
- headers: {
82
- 'Content-Type': 'application/json',
83
- 'Authorization': `Bearer ${this.jinaApiKey}`
84
- },
85
- body: JSON.stringify({
86
- model: this.config.model || 'jina-embeddings-v3',
87
- task: 'text-matching',
88
- dimensions: 1024,
89
- input: [text.substring(0, 8000)]
90
- })
91
- });
92
- if (!response.ok) {
93
- const error = await response.text();
94
- throw new Error(`Jina API error: ${response.status} ${error}`);
131
+ try {
132
+ const response = await fetch('https://api.jina.ai/v1/embeddings', {
133
+ method: 'POST',
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'Authorization': `Bearer ${apiKey}`
137
+ },
138
+ body: JSON.stringify({
139
+ model: this.config.model || 'jina-embeddings-v3',
140
+ task: 'text-matching',
141
+ dimensions: 1024,
142
+ input: [text.substring(0, 8000)]
143
+ })
144
+ });
145
+ if (!response.ok) {
146
+ const errorText = await response.text();
147
+ // Check for rate limit or auth errors - try next key
148
+ if (response.status === 429 || response.status === 401 || response.status === 403) {
149
+ if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
150
+ Logger.warn(`Jina API key error (${response.status}), trying next key...`);
151
+ return this.generateJinaEmbedding(text, retryCount + 1);
152
+ }
153
+ }
154
+ throw new Error(`Jina API error: ${response.status} ${errorText}`);
155
+ }
156
+ const data = await response.json();
157
+ return data.data[0].embedding;
158
+ }
159
+ catch (error) {
160
+ // Network error or other issue - try next key
161
+ if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
162
+ Logger.warn(`Jina API request failed, trying next key...`);
163
+ return this.generateJinaEmbedding(text, retryCount + 1);
164
+ }
165
+ throw error;
95
166
  }
96
- const data = await response.json();
97
- return data.data[0].embedding;
98
167
  }
99
168
  /**
100
169
  * Generate embeddings for multiple texts - uses true batch API when available
@@ -110,30 +179,13 @@ export class EmbeddingService {
110
179
  Logger.progress(`Generating embeddings for ${texts.length} texts...`);
111
180
  try {
112
181
  // Use true batch API for Jina (much faster!)
113
- if (this.config.provider === 'jina' && this.jinaApiKey) {
182
+ if (this.config.provider === 'jina' && this.jinaApiKeys.length > 0) {
114
183
  const BATCH_SIZE = 500;
115
184
  const allEmbeddings = [];
116
185
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
117
186
  const batch = texts.slice(i, i + BATCH_SIZE).map(t => t.substring(0, 8000));
118
- const response = await fetch('https://api.jina.ai/v1/embeddings', {
119
- method: 'POST',
120
- headers: {
121
- 'Content-Type': 'application/json',
122
- 'Authorization': `Bearer ${this.jinaApiKey}`
123
- },
124
- body: JSON.stringify({
125
- model: this.config.model || 'jina-embeddings-v3',
126
- task: 'text-matching',
127
- dimensions: 1024,
128
- input: batch
129
- })
130
- });
131
- if (!response.ok) {
132
- const error = await response.text();
133
- throw new Error(`Jina API error: ${response.status} ${error}`);
134
- }
135
- const data = await response.json();
136
- allEmbeddings.push(...data.data.map(d => d.embedding));
187
+ const batchEmbeddings = await this.generateJinaBatchWithRetry(batch);
188
+ allEmbeddings.push(...batchEmbeddings);
137
189
  if (progressCallback) {
138
190
  progressCallback(Math.min(i + BATCH_SIZE, texts.length), texts.length);
139
191
  }
@@ -161,6 +213,48 @@ export class EmbeddingService {
161
213
  return texts.map(() => new Array(this.embeddingDimension).fill(0));
162
214
  }
163
215
  }
216
+ async generateJinaBatchWithRetry(batch, retryCount = 0) {
217
+ const maxRetries = this.jinaApiKeys.length;
218
+ const apiKey = this.getCurrentJinaKey();
219
+ if (!apiKey)
220
+ throw new Error('Jina API key not set');
221
+ try {
222
+ const response = await fetch('https://api.jina.ai/v1/embeddings', {
223
+ method: 'POST',
224
+ headers: {
225
+ 'Content-Type': 'application/json',
226
+ 'Authorization': `Bearer ${apiKey}`
227
+ },
228
+ body: JSON.stringify({
229
+ model: this.config.model || 'jina-embeddings-v3',
230
+ task: 'text-matching',
231
+ dimensions: 1024,
232
+ input: batch
233
+ })
234
+ });
235
+ if (!response.ok) {
236
+ const errorText = await response.text();
237
+ // Check for rate limit or auth errors - try next key
238
+ if (response.status === 429 || response.status === 401 || response.status === 403) {
239
+ if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
240
+ Logger.warn(`Jina API batch error (${response.status}), trying next key...`);
241
+ return this.generateJinaBatchWithRetry(batch, retryCount + 1);
242
+ }
243
+ }
244
+ throw new Error(`Jina API error: ${response.status} ${errorText}`);
245
+ }
246
+ const data = await response.json();
247
+ return data.data.map(d => d.embedding);
248
+ }
249
+ catch (error) {
250
+ // Network error or other issue - try next key
251
+ if (retryCount < maxRetries - 1 && this.rotateToNextKey()) {
252
+ Logger.warn(`Jina API batch request failed, trying next key...`);
253
+ return this.generateJinaBatchWithRetry(batch, retryCount + 1);
254
+ }
255
+ throw error;
256
+ }
257
+ }
164
258
  async generateOpenAIEmbedding(text) {
165
259
  if (!this.openai)
166
260
  throw new Error('OpenAI client not initialized');
@@ -16,6 +16,7 @@ import morgan from 'morgan';
16
16
  import rateLimit from 'express-rate-limit';
17
17
  import { createServer } from 'http';
18
18
  import path from 'path';
19
+ import fs from 'fs';
19
20
  import { fileURLToPath } from 'url';
20
21
  import { Logger } from '../utils/logger.js';
21
22
  import { apiRouter } from './routes/api.js';
@@ -25,6 +26,10 @@ import { adminRouter } from './routes/admin.js';
25
26
  import { developerRouter } from './routes/developer.js';
26
27
  import { githubRouter } from './routes/github.js';
27
28
  import deviceAuthRouter from './routes/device-auth.js';
29
+ import { reportIssueRouter } from './routes/report-issue.js';
30
+ import { cache } from './services/cache.js';
31
+ import { db } from './services/database.js';
32
+ import { AuthService } from './services/auth-service.js';
28
33
  const __filename = fileURLToPath(import.meta.url);
29
34
  const __dirname = path.dirname(__filename);
30
35
  // CORS whitelist - настройте под свои домены
@@ -53,6 +58,89 @@ const createRateLimiter = (windowMs, max, message) => rateLimit({
53
58
  });
54
59
  },
55
60
  });
61
+ // Settings file path
62
+ const SETTINGS_FILE = path.join(process.cwd(), '.archicore', 'settings.json');
63
+ // Load settings helper
64
+ function loadMaintenanceSettings() {
65
+ try {
66
+ if (fs.existsSync(SETTINGS_FILE)) {
67
+ const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
68
+ const settings = JSON.parse(data);
69
+ return {
70
+ enabled: settings.maintenance?.enabled || false,
71
+ message: settings.maintenance?.message || 'ArchiCore is currently undergoing maintenance.'
72
+ };
73
+ }
74
+ }
75
+ catch (error) {
76
+ Logger.error('Failed to load maintenance settings:', error);
77
+ }
78
+ return { enabled: false, message: '' };
79
+ }
80
+ // Maintenance middleware
81
+ const maintenanceMiddleware = async (req, res, next) => {
82
+ const maintenance = loadMaintenanceSettings();
83
+ // If maintenance is not enabled, continue
84
+ if (!maintenance.enabled) {
85
+ return next();
86
+ }
87
+ // Allow certain paths even in maintenance mode
88
+ const allowedPaths = [
89
+ '/api/auth',
90
+ '/api/admin',
91
+ '/auth',
92
+ '/login',
93
+ '/admin',
94
+ '/maintenance.html',
95
+ '/favicon.svg',
96
+ '/favicon.ico',
97
+ '/health',
98
+ '/api/admin/maintenance-status',
99
+ // Static assets
100
+ '/fonts/',
101
+ '/css/',
102
+ '/js/',
103
+ '/images/',
104
+ '/assets/'
105
+ ];
106
+ // Check if path is allowed
107
+ const isAllowed = allowedPaths.some(p => req.path.startsWith(p));
108
+ if (isAllowed) {
109
+ return next();
110
+ }
111
+ // Check if user is admin via token
112
+ const authHeader = req.headers.authorization;
113
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
114
+ // Also check for token in cookies (for browser requests)
115
+ const cookieToken = req.headers.cookie?.split(';')
116
+ .find(c => c.trim().startsWith('archicore_token='))
117
+ ?.split('=')[1];
118
+ const actualToken = token || cookieToken;
119
+ if (actualToken) {
120
+ try {
121
+ const authService = AuthService.getInstance();
122
+ const user = await authService.validateToken(actualToken);
123
+ if (user && user.tier === 'admin') {
124
+ return next(); // Admin can access
125
+ }
126
+ }
127
+ catch (error) {
128
+ // Token invalid, continue to maintenance page
129
+ }
130
+ }
131
+ // For API requests, return JSON
132
+ if (req.path.startsWith('/api/')) {
133
+ res.status(503).json({
134
+ error: 'Service Unavailable',
135
+ message: maintenance.message,
136
+ maintenance: true
137
+ });
138
+ return;
139
+ }
140
+ // For browser requests, serve maintenance page
141
+ const maintenancePath = path.join(__dirname, '../../public/maintenance.html');
142
+ res.status(503).sendFile(maintenancePath);
143
+ };
56
144
  export class ArchiCoreServer {
57
145
  app;
58
146
  server = null;
@@ -151,11 +239,14 @@ export class ArchiCoreServer {
151
239
  res.setHeader('X-Request-ID', requestId);
152
240
  next();
153
241
  });
242
+ // Maintenance mode check
243
+ this.app.use(maintenanceMiddleware);
154
244
  // Статические файлы (фронтенд)
155
245
  const publicPath = path.join(__dirname, '../../public');
156
246
  this.app.use(express.static(publicPath, {
157
247
  maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
158
248
  etag: true,
249
+ index: false, // Отключаем автоматический index.html - используем явные маршруты
159
250
  }));
160
251
  }
161
252
  setupRoutes() {
@@ -173,6 +264,8 @@ export class ArchiCoreServer {
173
264
  this.app.use('/api', apiRouter);
174
265
  // Upload маршруты
175
266
  this.app.use('/api/upload', uploadRouter);
267
+ // Report issue routes
268
+ this.app.use('/api/report-issue', reportIssueRouter);
176
269
  // Health check
177
270
  this.app.get('/health', (_req, res) => {
178
271
  res.json({
@@ -187,7 +280,62 @@ export class ArchiCoreServer {
187
280
  const deviceAuthPath = path.join(__dirname, '../../public/device-auth.html');
188
281
  res.sendFile(deviceAuthPath);
189
282
  });
190
- // SPA fallback - все остальные маршруты отдают index.html
283
+ // Clean URL routing
284
+ // Landing page (главная)
285
+ this.app.get('/', (_req, res) => {
286
+ res.sendFile(path.join(__dirname, '../../public/landing.html'));
287
+ });
288
+ // Dashboard (после авторизации)
289
+ this.app.get('/dashboard', (_req, res) => {
290
+ res.sendFile(path.join(__dirname, '../../public/index.html'));
291
+ });
292
+ // Add new project page
293
+ this.app.get('/new', (_req, res) => {
294
+ res.sendFile(path.join(__dirname, '../../public/index.html'));
295
+ });
296
+ // Pricing page
297
+ this.app.get('/pricing', (_req, res) => {
298
+ res.sendFile(path.join(__dirname, '../../public/pricing.html'));
299
+ });
300
+ // API Dashboard page
301
+ this.app.get('/api-dashboard', (_req, res) => {
302
+ res.sendFile(path.join(__dirname, '../../public/api-dashboard.html'));
303
+ });
304
+ // Auth page
305
+ this.app.get('/auth', (_req, res) => {
306
+ res.sendFile(path.join(__dirname, '../../public/auth.html'));
307
+ });
308
+ // Login alias
309
+ this.app.get('/login', (_req, res) => {
310
+ res.sendFile(path.join(__dirname, '../../public/auth.html'));
311
+ });
312
+ // Register alias
313
+ this.app.get('/register', (_req, res) => {
314
+ res.redirect('/auth?mode=register');
315
+ });
316
+ // Admin page
317
+ this.app.get('/admin', (_req, res) => {
318
+ res.sendFile(path.join(__dirname, '../../public/admin.html'));
319
+ });
320
+ // Blog page
321
+ this.app.get('/blog', (_req, res) => {
322
+ res.sendFile(path.join(__dirname, '../../public/blog.html'));
323
+ });
324
+ // Report issue page (without .html extension)
325
+ this.app.get('/report-issue', (_req, res) => {
326
+ res.sendFile(path.join(__dirname, '../../public/report-issue.html'));
327
+ });
328
+ // Legal pages
329
+ this.app.get('/privacy', (_req, res) => {
330
+ res.sendFile(path.join(__dirname, '../../public/privacy.html'));
331
+ });
332
+ this.app.get('/terms', (_req, res) => {
333
+ res.sendFile(path.join(__dirname, '../../public/terms.html'));
334
+ });
335
+ this.app.get('/security', (_req, res) => {
336
+ res.sendFile(path.join(__dirname, '../../public/security.html'));
337
+ });
338
+ // SPA fallback - все остальные маршруты отдают index.html (dashboard)
191
339
  this.app.get('/*splat', (_req, res) => {
192
340
  const indexPath = path.join(__dirname, '../../public/index.html');
193
341
  res.sendFile(indexPath);
@@ -208,6 +356,17 @@ export class ArchiCoreServer {
208
356
  });
209
357
  }
210
358
  async start() {
359
+ // Initialize database
360
+ try {
361
+ await db.init();
362
+ const authService = AuthService.getInstance();
363
+ await authService.initDatabase();
364
+ }
365
+ catch (error) {
366
+ Logger.warn('Database initialization failed, using JSON fallback:', error);
367
+ }
368
+ // Initialize cache
369
+ await cache.connect();
211
370
  return new Promise((resolve) => {
212
371
  this.server = createServer(this.app);
213
372
  const host = this.config.host || '0.0.0.0';
@@ -221,6 +380,9 @@ export class ArchiCoreServer {
221
380
  });
222
381
  }
223
382
  async stop() {
383
+ // Disconnect cache and database
384
+ await cache.disconnect();
385
+ await db.close();
224
386
  return new Promise((resolve) => {
225
387
  if (this.server) {
226
388
  this.server.close(() => {