feat(F-103): completed feature
This commit is contained in:
114
project/src/shared/ai-content.ts
Normal file
114
project/src/shared/ai-content.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user