summaryrefslogtreecommitdiff
path: root/src/engine/aiGenerator.ts
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-18 22:32:53 -0500
committerChristian Cleberg <[email protected]>2026-04-18 22:32:53 -0500
commit580e798e16346eb894eb899edac82d2efdf40adc (patch)
tree6dec3b7db678f781138b40d34da37d21ac8fbe7d /src/engine/aiGenerator.ts
downloadbrand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.gz
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.bz2
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.zip
initial commit
Diffstat (limited to 'src/engine/aiGenerator.ts')
-rw-r--r--src/engine/aiGenerator.ts138
1 files changed, 138 insertions, 0 deletions
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<BrandOutputs> {
+ 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<string[]> {
+ 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$/, ''));
+}