feat(F-103): completed feature

This commit is contained in:
chattie
2026-08-21 07:55:18 +02:00
parent 5177a851aa
commit c07776822d
40 changed files with 886 additions and 156 deletions

View File

@@ -0,0 +1,114 @@
import { AppError } from './errors.js';
/**
* Shared helpers for AI content generation (OpenAI-compatible chat API)
* and HTML formatting of the generated text.
*/
export async function generateWithModel(
baseUrl: string,
model: string,
apiKey: string,
prompt: string,
): Promise<string> {
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.4 }),
signal: AbortSignal.timeout(30_000),
});
const payload = (await response.json().catch(() => null)) as {
choices?: Array<{ message?: { content?: unknown } }>;
error?: { message?: string };
} | null;
if (!response.ok) {
throw new AppError(502, 'AI_PROVIDER_ERROR', payload?.error?.message ?? `El proveedor IA respondió ${response.status}`);
}
const content = payload?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new AppError(502, 'AI_EMPTY_RESPONSE', 'El modelo IA no devolvió contenido');
}
return content.trim();
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Converts plain text (as returned by most AI models) into clean HTML so the
* storefront renders paragraphs and lists nicely instead of a wall of text.
* If the input already contains HTML block tags it is returned sanitized.
*/
export function aiTextToHtml(raw: string): string {
const text = raw.trim();
if (!text) return '';
// Already HTML: strip dangerous parts and return as-is.
if (/<\s*(p|ul|ol|li|h[1-6]|div|br|strong|em|b|i|a|table|blockquote)\b/i.test(text)) {
return text
.replace(/<\s*(script|style|iframe|object|embed|form)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(script|style|iframe|object|embed|form|link|meta)\b[^>]*\/?>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/javascript\s*:/gi, '');
}
// Strip markdown fences the model sometimes wraps output in.
const cleaned = text.replace(/^```[a-z]*\s*/i, '').replace(/\s*```$/, '').trim();
const blocks: string[] = [];
let listItems: string[] = [];
const flushList = () => {
if (listItems.length) {
blocks.push(`<ul>${listItems.map((li) => `<li>${li}</li>`).join('')}</ul>`);
listItems = [];
}
};
for (const paragraph of cleaned.split(/\n{2,}/)) {
const lines = paragraph.split('\n').map((l) => l.trim()).filter(Boolean);
if (!lines.length) continue;
if (lines.every((l) => /^[-*•·]\s+/.test(l))) {
for (const line of lines) listItems.push(escapeHtml(line.replace(/^[-*•·]\s+/, '')));
continue;
}
flushList();
const inline = lines.map((line) => escapeHtml(line.replace(/^[-*•·]\s+/, ''))).join('<br/>');
// Render **bold** markdown the model often emits in plain text.
blocks.push(`<p>${inline.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')}</p>`);
}
flushList();
return blocks.join('');
}
/** Loads the shared AI settings (base url, model, api key) from store_settings. */
export async function loadAiSettings(
pool: { query: <T>(sql: string, values?: unknown[]) => Promise<{ rows: T[] }> },
extraKeys: string[] = [],
): Promise<Record<string, string>> {
const keys = ['ai_base_url', 'ai_model', 'ai_api_key', ...extraKeys];
const result = await pool.query<{ key: string; value: string }>(
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
[keys],
);
return Object.fromEntries(result.rows.map((row) => [row.key, row.value]));
}
export function requireAiConfigured(settings: Record<string, string>): {
baseUrl: string;
model: string;
apiKey: string;
} {
const baseUrl = settings.ai_base_url?.trim();
const model = settings.ai_model?.trim();
const apiKey = settings.ai_api_key?.trim();
if (!baseUrl || !model || !apiKey) {
throw new AppError(422, 'AI_NOT_CONFIGURED', 'Configura proveedor, URL base, modelo y API key en Ajustes → IA para SEO');
}
return { baseUrl, model, apiKey };
}