@zenith-open/zenithcms-plugin-ai-architect 1.0.0-beta.10 → 1.0.0-beta.11

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.
@@ -0,0 +1,666 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AIService = void 0;
4
+ // AI Service currently relies on standard fetch for anthropic or plugins
5
+ const zenithcms_core_1 = require("@zenith-open/zenithcms-core");
6
+ /**
7
+ * Zenith AI Service
8
+ * ─────────────────────────────────────────────────────────────────────────────
9
+ * Enterprise multi-provider AI engine with automatic fallback chain.
10
+ *
11
+ * Supported providers (in priority order):
12
+ * 1. OpenRouter — 200+ models via unified gateway (OPENROUTER_API_KEY)
13
+ * 2. xAI / Grok — Grok models from xAI (XAI_API_KEY)
14
+ * 3. NVIDIA NIM — GPU-accelerated open models (NVIDIA_API_KEY)
15
+ * 4. Groq — Ultra-fast LPU inference (GROQ_API_KEY)
16
+ * 5. Together AI — Open-source models at scale (TOGETHER_API_KEY)
17
+ * 6. Mistral AI — Mistral & Codestral models (MISTRAL_API_KEY)
18
+ * 7. Cohere — Enterprise RAG-optimized models (COHERE_API_KEY)
19
+ * 8. OpenAI — GPT-4o, o1, o3 family (OPENAI_API_KEY)
20
+ * 9. Anthropic — Claude 3.5 Sonnet/Haiku/Opus (ANTHROPIC_API_KEY)
21
+ * 10. Google Gemini— Gemini Pro/Flash (GOOGLE_API_KEY)
22
+ *
23
+ * Keys can be set via environment variables OR via the z_settings database
24
+ * record (configured through the Admin UI — takes precedence over env).
25
+ */
26
+ class AIService {
27
+ static async fetchWithTimeout(url, options, timeoutMs = 30000) {
28
+ const controller = new AbortController();
29
+ const id = setTimeout(() => controller.abort(), timeoutMs);
30
+ try {
31
+ return await fetch(url, { ...options, signal: controller.signal });
32
+ }
33
+ finally {
34
+ clearTimeout(id);
35
+ }
36
+ }
37
+ static sanitizeInput(input) {
38
+ let sanitized = input.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, '');
39
+ if (sanitized.length > 50000) {
40
+ sanitized = sanitized.substring(0, 50000);
41
+ }
42
+ return sanitized;
43
+ }
44
+ /**
45
+ * Resolve all AI credentials — DB settings take precedence over env vars.
46
+ * This allows keys configured via the Admin UI to override local .env values.
47
+ */
48
+ static async resolveKeys(siteId) {
49
+ // Start from env vars
50
+ const keys = {
51
+ openRouterKey: zenithcms_core_1.env.OPENROUTER_API_KEY,
52
+ xaiKey: process.env.XAI_API_KEY,
53
+ nvidiaKey: process.env.NVIDIA_API_KEY,
54
+ groqKey: process.env.GROQ_API_KEY,
55
+ togetherKey: process.env.TOGETHER_API_KEY,
56
+ mistralKey: process.env.MISTRAL_API_KEY,
57
+ cohereKey: process.env.COHERE_API_KEY,
58
+ openaiKey: zenithcms_core_1.env.OPENAI_API_KEY,
59
+ anthropicKey: process.env.ANTHROPIC_API_KEY,
60
+ googleKey: process.env.GOOGLE_API_KEY,
61
+ aiModel: 'anthropic/claude-3.5-sonnet',
62
+ aiProvider: 'openrouter',
63
+ };
64
+ try {
65
+ const adapter = zenithcms_core_1.AdapterFactory.getActiveAdapter();
66
+ if (!adapter)
67
+ return keys;
68
+ const query = siteId ? { siteId } : {};
69
+ const settings = await adapter.findOne('z_settings', query);
70
+ if (!settings)
71
+ return keys;
72
+ // DB settings override env (skip masked placeholder values)
73
+ const notMasked = (v) => v && v !== '[MASKED_CREDENTIAL]' && v.trim() !== '';
74
+ if (notMasked(settings.openRouterApiKey))
75
+ keys.openRouterKey = settings.openRouterApiKey;
76
+ if (notMasked(settings.xaiApiKey))
77
+ keys.xaiKey = settings.xaiApiKey;
78
+ if (notMasked(settings.nvidiaApiKey))
79
+ keys.nvidiaKey = settings.nvidiaApiKey;
80
+ if (notMasked(settings.groqApiKey))
81
+ keys.groqKey = settings.groqApiKey;
82
+ if (notMasked(settings.togetherApiKey))
83
+ keys.togetherKey = settings.togetherApiKey;
84
+ if (notMasked(settings.mistralApiKey))
85
+ keys.mistralKey = settings.mistralApiKey;
86
+ if (notMasked(settings.cohereApiKey))
87
+ keys.cohereKey = settings.cohereApiKey;
88
+ if (notMasked(settings.openaiApiKey))
89
+ keys.openaiKey = settings.openaiApiKey;
90
+ if (notMasked(settings.anthropicApiKey))
91
+ keys.anthropicKey = settings.anthropicApiKey;
92
+ if (notMasked(settings.googleApiKey))
93
+ keys.googleKey = settings.googleApiKey;
94
+ if (settings.aiModel)
95
+ keys.aiModel = settings.aiModel;
96
+ if (settings.aiProvider)
97
+ keys.aiProvider = settings.aiProvider;
98
+ // Legacy: generic aiApiKey field — infer provider from model name
99
+ if (notMasked(settings.aiApiKey)) {
100
+ const model = keys.aiModel;
101
+ if (model.includes('claude') && !model.startsWith('anthropic/'))
102
+ keys.anthropicKey = settings.aiApiKey;
103
+ else if (model.includes('gpt') || model.includes('o1') || model.includes('o3'))
104
+ keys.openaiKey = settings.aiApiKey;
105
+ else
106
+ keys.openRouterKey = settings.aiApiKey;
107
+ }
108
+ }
109
+ catch (err) {
110
+ zenithcms_core_1.logger.warn({ err: err.message }, 'Failed to fetch AI keys from settings, using env fallback');
111
+ }
112
+ return keys;
113
+ }
114
+ /**
115
+ * Core dispatch — calls the preferred provider based on aiProvider setting,
116
+ * then falls back through the full chain if that fails.
117
+ */
118
+ static async callAI(prompt, maxTokens = 1024, overrideKeys, strictProvider, siteId) {
119
+ const k = overrideKeys || await this.resolveKeys(siteId);
120
+ const cleanPrompt = this.sanitizeInput(prompt);
121
+ const tryOpenRouter = async () => {
122
+ if (!k.openRouterKey)
123
+ return null;
124
+ const model = k.aiModel.includes('/') ? k.aiModel : `anthropic/${k.aiModel}`;
125
+ const res = await this.fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
126
+ method: 'POST',
127
+ headers: {
128
+ Authorization: `Bearer ${k.openRouterKey}`,
129
+ 'Content-Type': 'application/json',
130
+ 'HTTP-Referer': zenithcms_core_1.env.ADMIN_URL || 'http://localhost:3000',
131
+ 'X-Title': 'Zenith CMS',
132
+ },
133
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
134
+ });
135
+ const data = await res.json();
136
+ if (!res.ok)
137
+ throw new Error(data?.error?.message || `OpenRouter error ${res.status}`);
138
+ return data.choices?.[0]?.message?.content || null;
139
+ };
140
+ const tryXai = async () => {
141
+ if (!k.xaiKey)
142
+ return null;
143
+ const model = k.aiProvider === 'xai' ? (k.aiModel || 'grok-beta') : 'grok-beta';
144
+ const res = await this.fetchWithTimeout('https://api.x.ai/v1/chat/completions', {
145
+ method: 'POST',
146
+ headers: { Authorization: `Bearer ${k.xaiKey}`, 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
148
+ });
149
+ const data = await res.json();
150
+ if (!res.ok)
151
+ throw new Error(data?.error?.message || `xAI error ${res.status}`);
152
+ return data.choices?.[0]?.message?.content || null;
153
+ };
154
+ const tryNvidia = async () => {
155
+ if (!k.nvidiaKey)
156
+ return null;
157
+ const model = k.aiProvider === 'nvidia' ? (k.aiModel || 'meta/llama-3.1-70b-instruct') : 'meta/llama-3.1-70b-instruct';
158
+ const res = await this.fetchWithTimeout('https://integrate.api.nvidia.com/v1/chat/completions', {
159
+ method: 'POST',
160
+ headers: { Authorization: `Bearer ${k.nvidiaKey}`, 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
162
+ });
163
+ const data = await res.json();
164
+ if (!res.ok)
165
+ throw new Error(data?.detail || `NVIDIA NIM error ${res.status}`);
166
+ return data.choices?.[0]?.message?.content || null;
167
+ };
168
+ const tryGroq = async () => {
169
+ if (!k.groqKey)
170
+ return null;
171
+ const model = k.aiProvider === 'groq' ? (k.aiModel || 'llama-3.3-70b-versatile') : 'llama-3.3-70b-versatile';
172
+ const res = await this.fetchWithTimeout('https://api.groq.com/openai/v1/chat/completions', {
173
+ method: 'POST',
174
+ headers: { Authorization: `Bearer ${k.groqKey}`, 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
176
+ });
177
+ const data = await res.json();
178
+ if (!res.ok)
179
+ throw new Error(data?.error?.message || `Groq error ${res.status}`);
180
+ return data.choices?.[0]?.message?.content || null;
181
+ };
182
+ const tryTogether = async () => {
183
+ if (!k.togetherKey)
184
+ return null;
185
+ const model = k.aiProvider === 'together' ? (k.aiModel || 'meta-llama/Llama-3.3-70B-Instruct-Turbo') : 'meta-llama/Llama-3.3-70B-Instruct-Turbo';
186
+ const res = await this.fetchWithTimeout('https://api.together.xyz/v1/chat/completions', {
187
+ method: 'POST',
188
+ headers: { Authorization: `Bearer ${k.togetherKey}`, 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
190
+ });
191
+ const data = await res.json();
192
+ if (!res.ok)
193
+ throw new Error(data?.error?.message || `Together AI error ${res.status}`);
194
+ return data.choices?.[0]?.message?.content || null;
195
+ };
196
+ const tryMistral = async () => {
197
+ if (!k.mistralKey)
198
+ return null;
199
+ const model = k.aiProvider === 'mistral' ? (k.aiModel || 'mistral-large-latest') : 'mistral-large-latest';
200
+ const res = await this.fetchWithTimeout('https://api.mistral.ai/v1/chat/completions', {
201
+ method: 'POST',
202
+ headers: { Authorization: `Bearer ${k.mistralKey}`, 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
204
+ });
205
+ const data = await res.json();
206
+ if (!res.ok)
207
+ throw new Error(data?.message || `Mistral error ${res.status}`);
208
+ return data.choices?.[0]?.message?.content || null;
209
+ };
210
+ const tryCohere = async () => {
211
+ if (!k.cohereKey)
212
+ return null;
213
+ const model = k.aiProvider === 'cohere' ? (k.aiModel || 'command-r-plus') : 'command-r-plus';
214
+ const res = await this.fetchWithTimeout('https://api.cohere.com/v2/chat', {
215
+ method: 'POST',
216
+ headers: { Authorization: `Bearer ${k.cohereKey}`, 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
218
+ });
219
+ const data = await res.json();
220
+ if (!res.ok)
221
+ throw new Error(data?.message || `Cohere error ${res.status}`);
222
+ return data.message?.content?.[0]?.text || null;
223
+ };
224
+ const tryOpenAI = async () => {
225
+ if (!k.openaiKey)
226
+ return null;
227
+ const model = k.aiProvider === 'openai' ? (k.aiModel || 'gpt-4o-mini') : 'gpt-4o-mini';
228
+ const res = await this.fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
229
+ method: 'POST',
230
+ headers: { Authorization: `Bearer ${k.openaiKey}`, 'Content-Type': 'application/json' },
231
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: cleanPrompt }], max_tokens: maxTokens }),
232
+ });
233
+ const data = await res.json();
234
+ if (!res.ok)
235
+ throw new Error(data?.error?.message || `OpenAI error ${res.status}`);
236
+ return data.choices?.[0]?.message?.content || null;
237
+ };
238
+ const tryAnthropic = async () => {
239
+ if (!k.anthropicKey)
240
+ return null;
241
+ const model = k.aiProvider === 'anthropic' ? (k.aiModel || 'claude-3-5-haiku-20241022') : 'claude-3-5-haiku-20241022';
242
+ const res = await this.fetchWithTimeout('https://api.anthropic.com/v1/messages', {
243
+ method: 'POST',
244
+ headers: {
245
+ 'x-api-key': k.anthropicKey,
246
+ 'anthropic-version': '2023-06-01',
247
+ 'content-type': 'application/json',
248
+ },
249
+ body: JSON.stringify({
250
+ model,
251
+ max_tokens: maxTokens,
252
+ messages: [{ role: 'user', content: cleanPrompt }],
253
+ }),
254
+ });
255
+ const data = await res.json();
256
+ if (!res.ok)
257
+ throw new Error(data?.error?.message || `Anthropic error ${res.status}`);
258
+ return data.content?.[0]?.text || null;
259
+ };
260
+ const tryGoogle = async () => {
261
+ if (!k.googleKey)
262
+ return null;
263
+ const model = k.aiProvider === 'google' ? (k.aiModel || 'gemini-1.5-flash-latest') : 'gemini-1.5-flash-latest';
264
+ const res = await this.fetchWithTimeout(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${k.googleKey}`, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({ contents: [{ parts: [{ text: cleanPrompt }] }] }),
268
+ });
269
+ const data = await res.json();
270
+ if (!res.ok)
271
+ throw new Error(data?.error?.message || `Google Gemini error ${res.status}`);
272
+ return data.candidates?.[0]?.content?.parts?.[0]?.text || null;
273
+ };
274
+ // Map provider ID to its primary handler
275
+ const preferredMap = {
276
+ openrouter: tryOpenRouter,
277
+ xai: tryXai,
278
+ nvidia: tryNvidia,
279
+ groq: tryGroq,
280
+ together: tryTogether,
281
+ mistral: tryMistral,
282
+ cohere: tryCohere,
283
+ openai: tryOpenAI,
284
+ anthropic: tryAnthropic,
285
+ google: tryGoogle,
286
+ };
287
+ // Fallback chain (all providers, preferred moved to front)
288
+ const fallbackChain = [
289
+ tryOpenRouter, tryXai, tryNvidia, tryGroq, tryTogether,
290
+ tryMistral, tryCohere, tryOpenAI, tryAnthropic, tryGoogle,
291
+ ];
292
+ const preferred = preferredMap[k.aiProvider];
293
+ let chain = preferred
294
+ ? [preferred, ...fallbackChain.filter(fn => fn !== preferred)]
295
+ : fallbackChain;
296
+ if (strictProvider && preferred) {
297
+ chain = [preferred];
298
+ }
299
+ for (const fn of chain) {
300
+ try {
301
+ const result = await fn();
302
+ if (result && result.trim())
303
+ return result;
304
+ }
305
+ catch (err) {
306
+ zenithcms_core_1.logger.warn({ err: err.message }, `AI provider call failed, trying next`);
307
+ }
308
+ }
309
+ throw new Error('No AI provider configured or all providers failed. ' +
310
+ 'Configure at least one API key under Settings → AI Engine.');
311
+ }
312
+ // ── Public Methods ─────────────────────────────────────────────────────────
313
+ static async testConnection(provider, model, apiKey) {
314
+ const prompt = 'Reply with only: OK';
315
+ const maxTokens = 10;
316
+ const k = {
317
+ openRouterKey: provider === 'openrouter' ? apiKey : undefined,
318
+ xaiKey: provider === 'xai' ? apiKey : undefined,
319
+ nvidiaKey: provider === 'nvidia' ? apiKey : undefined,
320
+ groqKey: provider === 'groq' ? apiKey : undefined,
321
+ togetherKey: provider === 'together' ? apiKey : undefined,
322
+ mistralKey: provider === 'mistral' ? apiKey : undefined,
323
+ cohereKey: provider === 'cohere' ? apiKey : undefined,
324
+ openaiKey: provider === 'openai' ? apiKey : undefined,
325
+ anthropicKey: provider === 'anthropic' ? apiKey : undefined,
326
+ googleKey: provider === 'google' ? apiKey : undefined,
327
+ aiModel: model,
328
+ aiProvider: provider,
329
+ };
330
+ return this.callAI(prompt, maxTokens, k, true);
331
+ }
332
+ static async generateContent(prompt, siteId) {
333
+ return this.callAI(prompt, 1024, undefined, false, siteId);
334
+ }
335
+ static async improveText(text, instruction, siteId) {
336
+ const prompt = `${instruction}\n\nText to improve:\n\n${text}\n\nReturn only the improved text, no commentary.`;
337
+ const res = await this.callAI(prompt, 2048, undefined, false, siteId);
338
+ return res || text;
339
+ }
340
+ static async generateMetaDescription(title, content, siteId) {
341
+ const truncated = content.replace(/<[^>]+>/g, '').substring(0, 500);
342
+ const prompt = `Write a compelling SEO meta description (max 160 characters) for this content.\nTitle: ${title}\nContent excerpt: ${truncated}\nReturn only the description, nothing else.`;
343
+ const res = await this.callAI(prompt, 200, undefined, false, siteId);
344
+ return res.substring(0, 160);
345
+ }
346
+ static async generateAltText(imageUrl, context, siteId) {
347
+ const filename = imageUrl.split('/').pop()?.split('?')[0] || 'image';
348
+ const cleanName = filename.replace(/[-_]/g, ' ').replace(/\.[^.]+$/, '');
349
+ try {
350
+ const prompt = `Write a concise alt text (max 10 words) for an image named "${cleanName}" used in the context of: "${context || 'general content'}". Return only the alt text.`;
351
+ const res = await this.callAI(prompt, 100, undefined, false, siteId);
352
+ return res || cleanName;
353
+ }
354
+ catch (err) {
355
+ zenithcms_core_1.logger.warn({ err }, 'Alt text generation failed, using filename');
356
+ return cleanName;
357
+ }
358
+ }
359
+ // ── Smart Image Tagging ────────────────────────────────────────────────────
360
+ static async generateImageTags(imageUrl, siteId) {
361
+ const filename = imageUrl.split('/').pop()?.split('?')[0] || 'image';
362
+ const cleanName = filename.replace(/[-_]/g, ' ').replace(/\.[^.]+$/, '');
363
+ // Try Anthropic vision API first (supports image input directly)
364
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
365
+ if (anthropicKey) {
366
+ try {
367
+ const imageRes = await fetch(imageUrl);
368
+ const contentType = imageRes.headers.get('content-type') || 'image/jpeg';
369
+ const buffer = Buffer.from(await imageRes.arrayBuffer());
370
+ const base64 = buffer.toString('base64');
371
+ const res = await this.fetchWithTimeout('https://api.anthropic.com/v1/messages', {
372
+ method: 'POST',
373
+ headers: {
374
+ 'x-api-key': anthropicKey,
375
+ 'anthropic-version': '2023-06-01',
376
+ 'content-type': 'application/json',
377
+ },
378
+ body: JSON.stringify({
379
+ model: 'claude-3-5-haiku-20241022',
380
+ max_tokens: 512,
381
+ messages: [{
382
+ role: 'user',
383
+ content: [
384
+ {
385
+ type: 'image',
386
+ source: {
387
+ type: 'base64',
388
+ media_type: contentType,
389
+ data: base64,
390
+ },
391
+ },
392
+ {
393
+ type: 'text',
394
+ text: `Analyze this image and return ONLY valid JSON (no markdown, no explanation):
395
+ {
396
+ "tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
397
+ "categories": ["primary_category"],
398
+ "colors": ["dominant_color1", "dominant_color2"],
399
+ "mood": "one_word_mood",
400
+ "description": "A concise 1-sentence description"
401
+ }`,
402
+ },
403
+ ],
404
+ }],
405
+ }),
406
+ });
407
+ const msgData = await res.json();
408
+ if (!res.ok)
409
+ throw new Error(msgData?.error?.message || `Anthropic vision error ${res.status}`);
410
+ const text = msgData.content?.[0]?.text || '{}';
411
+ const jsonStart = text.indexOf('{');
412
+ const jsonEnd = text.lastIndexOf('}');
413
+ if (jsonStart !== -1 && jsonEnd !== -1) {
414
+ const parsed = JSON.parse(text.substring(jsonStart, jsonEnd + 1));
415
+ return {
416
+ tags: parsed.tags || [],
417
+ categories: parsed.categories || [],
418
+ colors: parsed.colors || [],
419
+ mood: parsed.mood || 'neutral',
420
+ description: parsed.description || '',
421
+ };
422
+ }
423
+ }
424
+ catch (err) {
425
+ zenithcms_core_1.logger.warn({ err }, 'Anthropic vision tagging failed, falling back to text-based');
426
+ }
427
+ }
428
+ // Fallback: text-based AI analysis
429
+ try {
430
+ const prompt = `Analyze an image with filename "${cleanName}" found at URL "${imageUrl}". Return ONLY valid JSON (no markdown):
431
+ {
432
+ "tags": ["5 descriptive tags"],
433
+ "categories": ["primary category"],
434
+ "colors": ["2 dominant colors"],
435
+ "mood": "one word mood",
436
+ "description": "1 sentence description"
437
+ }`;
438
+ const res = await this.callAI(prompt, 300, undefined, false, siteId);
439
+ const jsonStart = res.indexOf('{');
440
+ const jsonEnd = res.lastIndexOf('}');
441
+ if (jsonStart !== -1 && jsonEnd !== -1) {
442
+ const parsed = JSON.parse(res.substring(jsonStart, jsonEnd + 1));
443
+ return {
444
+ tags: parsed.tags || [],
445
+ categories: parsed.categories || [],
446
+ colors: parsed.colors || [],
447
+ mood: parsed.mood || 'neutral',
448
+ description: parsed.description || '',
449
+ };
450
+ }
451
+ }
452
+ catch (err) {
453
+ zenithcms_core_1.logger.warn({ err }, 'Text-based image tagging failed');
454
+ }
455
+ return {
456
+ tags: cleanName.split(' ').filter(Boolean).slice(0, 5),
457
+ categories: ['uncategorized'],
458
+ colors: [],
459
+ mood: 'neutral',
460
+ description: cleanName,
461
+ };
462
+ }
463
+ // ── Content Quality Scoring ───────────────────────────────────────────────
464
+ // No AI needed — pure algorithmic. Instant feedback for editors.
465
+ static analyzeContentQuality(text) {
466
+ const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
467
+ const words = plain.split(/\s+/).filter(Boolean);
468
+ const sentences = plain.split(/[.!?]+/).filter(s => s.trim().length > 0);
469
+ const wordCount = words.length;
470
+ const sentenceCount = sentences.length;
471
+ const avgWordsPerSentence = sentenceCount > 0 ? wordCount / sentenceCount : 0;
472
+ const avgSentenceLength = avgWordsPerSentence;
473
+ const readabilityScore = Math.max(0, Math.min(100, 206.835 - 1.015 * avgSentenceLength));
474
+ const issues = [];
475
+ const suggestions = [];
476
+ if (wordCount < 100)
477
+ issues.push('Content is very short (under 100 words)');
478
+ if (wordCount < 300)
479
+ suggestions.push('Consider expanding to 300+ words for better SEO');
480
+ if (avgWordsPerSentence > 25)
481
+ issues.push('Sentences are too long — aim for under 20 words');
482
+ if (avgWordsPerSentence > 20)
483
+ suggestions.push('Break up long sentences for readability');
484
+ if (readabilityScore < 40)
485
+ issues.push('Content is difficult to read');
486
+ if (readabilityScore < 60)
487
+ suggestions.push('Simplify language for a broader audience');
488
+ let score = 50;
489
+ if (wordCount < 100)
490
+ score -= 30;
491
+ if (wordCount >= 300)
492
+ score += 15;
493
+ if (wordCount >= 600)
494
+ score += 10;
495
+ if (avgWordsPerSentence <= 20)
496
+ score += 15;
497
+ if (readabilityScore >= 60)
498
+ score += 10;
499
+ if (issues.length === 0)
500
+ score += 10;
501
+ score = Math.max(0, Math.min(100, score));
502
+ const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
503
+ return {
504
+ score,
505
+ grade,
506
+ readabilityScore: Math.round(readabilityScore),
507
+ wordCount,
508
+ sentenceCount,
509
+ avgWordsPerSentence: Math.round(avgWordsPerSentence * 10) / 10,
510
+ issues,
511
+ suggestions,
512
+ };
513
+ }
514
+ // ── SEO Analysis ──────────────────────────────────────────────────────────
515
+ static analyzeSeo(data) {
516
+ const issues = [];
517
+ const suggestions = [];
518
+ const passed = [];
519
+ let score = 0;
520
+ const titleLength = (data.title || '').length;
521
+ const descriptionLength = (data.description || '').length;
522
+ const wordCount = (data.content || '').replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
523
+ // Title checks
524
+ if (!data.title) {
525
+ issues.push('Missing page title');
526
+ }
527
+ else if (titleLength < 30) {
528
+ suggestions.push(`Title is short (${titleLength} chars) — aim for 50-60 characters`);
529
+ score += 5;
530
+ }
531
+ else if (titleLength > 60) {
532
+ issues.push(`Title too long (${titleLength} chars) — Google truncates after 60`);
533
+ score += 5;
534
+ }
535
+ else {
536
+ passed.push(`Title length is good (${titleLength} chars)`);
537
+ score += 15;
538
+ }
539
+ // Meta description checks
540
+ if (!data.description) {
541
+ issues.push('Missing meta description — add one to improve click-through rates');
542
+ }
543
+ else if (descriptionLength < 70) {
544
+ suggestions.push(`Meta description too short (${descriptionLength} chars) — aim for 120-160`);
545
+ score += 5;
546
+ }
547
+ else if (descriptionLength > 160) {
548
+ issues.push(`Meta description too long (${descriptionLength} chars) — Google truncates after 160`);
549
+ score += 5;
550
+ }
551
+ else {
552
+ passed.push(`Meta description length is good (${descriptionLength} chars)`);
553
+ score += 20;
554
+ }
555
+ // Slug checks
556
+ if (!data.slug) {
557
+ suggestions.push('Add a URL slug for better SEO');
558
+ }
559
+ else if (data.slug.includes(' ') || /[A-Z]/.test(data.slug)) {
560
+ issues.push('Slug should be lowercase with hyphens, no spaces');
561
+ }
562
+ else {
563
+ passed.push('URL slug is clean');
564
+ score += 10;
565
+ }
566
+ // Content length
567
+ if (wordCount < 100) {
568
+ issues.push(`Content very short (${wordCount} words) — thin content can hurt SEO`);
569
+ }
570
+ else if (wordCount >= 300) {
571
+ passed.push(`Good content length (${wordCount} words)`);
572
+ score += 20;
573
+ }
574
+ else {
575
+ suggestions.push(`Expand content to 300+ words (currently ${wordCount})`);
576
+ score += 10;
577
+ }
578
+ if (score >= 55)
579
+ passed.push('Overall SEO score is good');
580
+ return { score: Math.min(100, score), titleLength, descriptionLength, issues, suggestions, passed };
581
+ }
582
+ // ── Dynamic Model Fetching ────────────────────────────────────────────────
583
+ static async fetchModels(provider, apiKey) {
584
+ const timeoutMs = 15000;
585
+ const makeOpenAICompatibleRequest = async (url) => {
586
+ const res = await this.fetchWithTimeout(url, {
587
+ method: 'GET',
588
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
589
+ }, timeoutMs);
590
+ if (!res.ok)
591
+ throw new Error(`API error ${res.status}`);
592
+ const data = await res.json();
593
+ const list = Array.isArray(data) ? data : (data.data || data.models || []);
594
+ return list.map((m) => ({
595
+ value: m.id || m.name,
596
+ label: m.display_name || m.name || m.id,
597
+ }));
598
+ };
599
+ try {
600
+ switch (provider) {
601
+ case 'openrouter':
602
+ return await makeOpenAICompatibleRequest('https://openrouter.ai/api/v1/models');
603
+ case 'openai':
604
+ return await makeOpenAICompatibleRequest('https://api.openai.com/v1/models');
605
+ case 'xai':
606
+ return await makeOpenAICompatibleRequest('https://api.x.ai/v1/models');
607
+ case 'nvidia':
608
+ return await makeOpenAICompatibleRequest('https://integrate.api.nvidia.com/v1/models');
609
+ case 'groq':
610
+ return await makeOpenAICompatibleRequest('https://api.groq.com/openai/v1/models');
611
+ case 'together':
612
+ return await makeOpenAICompatibleRequest('https://api.together.xyz/v1/models');
613
+ case 'mistral':
614
+ return await makeOpenAICompatibleRequest('https://api.mistral.ai/v1/models');
615
+ case 'cohere': {
616
+ const res = await this.fetchWithTimeout('https://api.cohere.com/v1/models', {
617
+ method: 'GET',
618
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
619
+ }, timeoutMs);
620
+ if (!res.ok)
621
+ throw new Error(`Cohere API error ${res.status}`);
622
+ const data = await res.json();
623
+ return (data.models || []).map((m) => ({ value: m.name, label: m.name }));
624
+ }
625
+ case 'google': {
626
+ const res = await this.fetchWithTimeout(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, {
627
+ method: 'GET',
628
+ headers: { 'Content-Type': 'application/json' },
629
+ }, timeoutMs);
630
+ if (!res.ok)
631
+ throw new Error(`Google API error ${res.status}`);
632
+ const data = await res.json();
633
+ return (data.models || []).map((m) => ({
634
+ value: m.name.replace(/^models\//, ''),
635
+ label: m.displayName || m.name.replace(/^models\//, ''),
636
+ }));
637
+ }
638
+ case 'anthropic': {
639
+ // Anthropic Models API
640
+ const res = await this.fetchWithTimeout('https://api.anthropic.com/v1/models', {
641
+ method: 'GET',
642
+ headers: {
643
+ 'x-api-key': apiKey,
644
+ 'anthropic-version': '2023-06-01',
645
+ 'Content-Type': 'application/json'
646
+ },
647
+ }, timeoutMs);
648
+ if (!res.ok)
649
+ throw new Error(`Anthropic API error ${res.status}`);
650
+ const data = await res.json();
651
+ return (data.data || []).map((m) => ({
652
+ value: m.id,
653
+ label: m.display_name || m.name || m.id,
654
+ }));
655
+ }
656
+ default:
657
+ throw new Error(`Unsupported provider for model fetching: ${provider}`);
658
+ }
659
+ }
660
+ catch (err) {
661
+ zenithcms_core_1.logger.error({ err: err.message, provider }, 'Failed to fetch models dynamically');
662
+ throw new Error(`Failed to fetch models for ${provider}: ${err.message}`);
663
+ }
664
+ }
665
+ }
666
+ exports.AIService = AIService;