From 580e798e16346eb894eb899edac82d2efdf40adc Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 18 Apr 2026 22:32:53 -0500 Subject: initial commit --- src/engine/aiGenerator.ts | 138 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/engine/aiGenerator.ts (limited to 'src/engine/aiGenerator.ts') diff --git a/src/engine/aiGenerator.ts b/src/engine/aiGenerator.ts new file mode 100644 index 0000000..3cd9d5e --- /dev/null +++ b/src/engine/aiGenerator.ts @@ -0,0 +1,138 @@ +import type { BrandInputs, BrandOutputs } from '../types'; +import type { OllamaSettings } from '../hooks/useSettings'; +import { sanitizeOutputs } from '../lib/sanitize'; + +const SYSTEM_PROMPT = `You are an expert brand strategist and copywriter. Generate a complete brand identity package as a JSON object. Be specific and creative — every output must feel crafted for the exact brand described, not generic. Use the project details to inform all choices: tone, color palette, typography, and messaging.`; + +function buildUserPrompt(inputs: BrandInputs): string { + const lines = [ + 'Generate a brand package for this project.', + '', + 'PROJECT DETAILS:', + `Name: ${inputs.name || 'Untitled'}`, + `Category: ${inputs.category || 'general'}`, + `Purpose: ${inputs.purpose || 'not specified'}`, + `Target audience: ${inputs.audience || 'not specified'}`, + inputs.tone.length ? `Tone words: ${inputs.tone.join(', ')}` : '', + inputs.avoid.length ? `Words/phrases to avoid: ${inputs.avoid.join(', ')}` : '', + inputs.notes ? `Additional notes: ${inputs.notes}` : '', + '', + 'Return ONLY a valid JSON object with exactly this structure:', + '', + JSON.stringify({ + overview: 'One crisp sentence: what this project is and who it helps', + positioning: '2–3 sentence strategic positioning statement — what makes this brand distinct', + tone: { + attributes: ['attribute1', 'attribute2', 'attribute3', 'attribute4'], + voiceNotes: '2–3 sentences describing how the brand writes and speaks', + avoidList: ['thing to never say or do', 'another thing to avoid'], + examplePhrases: ['A phrase that shows the brand voice', 'Another example', 'A third example'], + }, + titles: ['Title option 1', 'Title option 2', 'Title option 3'], + subtitles: ['Subtitle option 1', 'Subtitle option 2', 'Subtitle option 3'], + taglines: ['Short punchy tagline', 'Alternative tagline', 'Third tagline option'], + palette: { + swatches: [ + { id: 's0', name: 'Background', hex: '#hexcode', role: 'background' }, + { id: 's1', name: 'Surface', hex: '#hexcode', role: 'neutral' }, + { id: 's2', name: 'Primary', hex: '#hexcode', role: 'primary' }, + { id: 's3', name: 'Accent', hex: '#hexcode', role: 'accent' }, + { id: 's4', name: 'Text', hex: '#hexcode', role: 'text' }, + ], + }, + typography: { + primary: 'Primary font name (e.g. Inter, Playfair Display)', + secondary: 'Secondary font name or same as primary', + mono: 'Monospace font name (e.g. JetBrains Mono, Fira Code)', + pairNote: 'One sentence on why this pairing fits the brand', + }, + visualDirections: [ + { id: 'v1', name: 'Direction name', description: '2–3 sentence visual direction description', palette: 'Color mood description', typography: 'Type style description', references: 'Visual references and inspirations' }, + { id: 'v2', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' }, + { id: 'v3', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' }, + ], + logoConcepts: [ + { id: 'l1', title: 'Logo concept name', concept: 'Concept description and rationale', mark: 'Mark/symbol description', execution: 'Execution and usage guidelines' }, + { id: 'l2', title: 'Second concept name', concept: 'Concept description', mark: 'Mark description', execution: 'Execution guidelines' }, + ], + usageExamples: [ + { context: 'README headline', text: 'Actual example copy here' }, + { context: 'Landing page hero', text: 'Actual example copy here' }, + { context: 'Social bio', text: 'Actual example copy here' }, + { context: 'Email subject line', text: 'Actual example copy here' }, + ], + constraints: [ + 'A specific copy rule', + 'Another brand constraint', + 'A third constraint', + 'A fourth constraint', + ], + }, null, 2), + ]; + + return lines.filter(l => l !== null).join('\n'); +} + +function validate(raw: unknown): BrandOutputs { + const result = sanitizeOutputs(raw); + if (!result) throw new Error('Response is not a valid brand output object'); + return result; +} + +export async function generateWithAI( + inputs: BrandInputs, + settings: OllamaSettings, + signal?: AbortSignal, +): Promise { + const url = `${settings.baseUrl.replace(/\/$/, '')}/api/chat`; + + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal, + body: JSON.stringify({ + model: settings.model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: buildUserPrompt(inputs) }, + ], + format: 'json', + stream: false, + }), + }); + } catch (err) { + if ((err as Error).name === 'AbortError') throw err; + throw new Error(`Cannot reach Ollama at ${settings.baseUrl}. Is it running?`); + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + if (res.status === 404) throw new Error(`Model "${settings.model}" not found. Pull it first: ollama pull ${settings.model}`); + throw new Error(`Ollama error ${res.status}: ${text.slice(0, 120)}`); + } + + const data = await res.json() as { message?: { content?: string } }; + const content = data?.message?.content; + if (!content) throw new Error('Empty response from Ollama'); + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error('Ollama returned invalid JSON. Try a larger model.'); + } + + // validate() calls sanitizeOutputs(), which injects TYPE_SCALE for fresh AI output + return validate(parsed); +} + +// Test connectivity and return available model names +export async function testOllamaConnection(baseUrl: string): Promise { + const url = `${baseUrl.replace(/\/$/, '')}/api/tags`; + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) throw new Error(`Ollama responded with ${res.status}`); + const data = await res.json() as { models?: Array<{ name: string }> }; + return (data.models ?? []).map(m => m.name.replace(/:latest$/, '')); +} -- cgit v1.2.3