krz/brand-bench
A dynamic brand documentation generator with Ollama integration.
clone: git clone https://gitbay.org/krz/brand-bench.git
main: src/engine/aiGenerator.ts · raw
1import type { BrandInputs, BrandOutputs } from '../types';
2import type { OllamaSettings } from '../hooks/useSettings';
3import { sanitizeOutputs } from '../lib/sanitize';
4
5const 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.`;
6
7function buildUserPrompt(inputs: BrandInputs): string {
8 const lines = [
9 'Generate a brand package for this project.',
10 '',
11 'PROJECT DETAILS:',
12 `Name: ${inputs.name || 'Untitled'}`,
13 `Category: ${inputs.category || 'general'}`,
14 `Purpose: ${inputs.purpose || 'not specified'}`,
15 `Target audience: ${inputs.audience || 'not specified'}`,
16 inputs.tone.length ? `Tone words: ${inputs.tone.join(', ')}` : '',
17 inputs.avoid.length ? `Words/phrases to avoid: ${inputs.avoid.join(', ')}` : '',
18 inputs.notes ? `Additional notes: ${inputs.notes}` : '',
19 '',
20 'Return ONLY a valid JSON object with exactly this structure:',
21 '',
22 JSON.stringify({
23 overview: 'One crisp sentence: what this project is and who it helps',
24 positioning: '2–3 sentence strategic positioning statement — what makes this brand distinct',
25 tone: {
26 attributes: ['attribute1', 'attribute2', 'attribute3', 'attribute4'],
27 voiceNotes: '2–3 sentences describing how the brand writes and speaks',
28 avoidList: ['thing to never say or do', 'another thing to avoid'],
29 examplePhrases: ['A phrase that shows the brand voice', 'Another example', 'A third example'],
30 },
31 titles: ['Title option 1', 'Title option 2', 'Title option 3'],
32 subtitles: ['Subtitle option 1', 'Subtitle option 2', 'Subtitle option 3'],
33 taglines: ['Short punchy tagline', 'Alternative tagline', 'Third tagline option'],
34 palette: {
35 swatches: [
36 { id: 's0', name: 'Background', hex: '#hexcode', role: 'background' },
37 { id: 's1', name: 'Surface', hex: '#hexcode', role: 'neutral' },
38 { id: 's2', name: 'Primary', hex: '#hexcode', role: 'primary' },
39 { id: 's3', name: 'Accent', hex: '#hexcode', role: 'accent' },
40 { id: 's4', name: 'Text', hex: '#hexcode', role: 'text' },
41 ],
42 },
43 typography: {
44 primary: 'Primary font name (e.g. Inter, Playfair Display)',
45 secondary: 'Secondary font name or same as primary',
46 mono: 'Monospace font name (e.g. JetBrains Mono, Fira Code)',
47 pairNote: 'One sentence on why this pairing fits the brand',
48 },
49 visualDirections: [
50 { 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' },
51 { id: 'v2', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' },
52 { id: 'v3', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' },
53 ],
54 logoConcepts: [
55 { id: 'l1', title: 'Logo concept name', concept: 'Concept description and rationale', mark: 'Mark/symbol description', execution: 'Execution and usage guidelines' },
56 { id: 'l2', title: 'Second concept name', concept: 'Concept description', mark: 'Mark description', execution: 'Execution guidelines' },
57 ],
58 usageExamples: [
59 { context: 'README headline', text: 'Actual example copy here' },
60 { context: 'Landing page hero', text: 'Actual example copy here' },
61 { context: 'Social bio', text: 'Actual example copy here' },
62 { context: 'Email subject line', text: 'Actual example copy here' },
63 ],
64 constraints: [
65 'A specific copy rule',
66 'Another brand constraint',
67 'A third constraint',
68 'A fourth constraint',
69 ],
70 }, null, 2),
71 ];
72
73 return lines.filter(l => l !== null).join('\n');
74}
75
76function validate(raw: unknown): BrandOutputs {
77 const result = sanitizeOutputs(raw);
78 if (!result) {
79 const preview = JSON.stringify(raw)?.slice(0, 300) ?? '(unparseable)';
80 throw new Error(`Model returned an unexpected structure. Try a larger model.\n\nGot: ${preview}`);
81 }
82 return result;
83}
84
85export async function generateWithAI(
86 inputs: BrandInputs,
87 settings: OllamaSettings,
88 signal?: AbortSignal,
89): Promise<BrandOutputs> {
90 // Empty baseUrl means "same origin" — nginx proxies /api/ to Ollama internally.
91 // A full URL (e.g. http://localhost:11434) is used as-is for local dev.
92 const base = settings.baseUrl ? settings.baseUrl.replace(/\/$/, '') : '';
93 const url = `${base}/api/chat`;
94
95 let res: Response;
96 try {
97 res = await fetch(url, {
98 method: 'POST',
99 headers: { 'Content-Type': 'application/json' },
100 signal,
101 body: JSON.stringify({
102 model: settings.model,
103 messages: [
104 { role: 'system', content: SYSTEM_PROMPT },
105 { role: 'user', content: buildUserPrompt(inputs) },
106 ],
107 format: 'json',
108 stream: false,
109 }),
110 });
111 } catch (err) {
112 if ((err as Error).name === 'AbortError') throw err;
113 const target = settings.baseUrl || '(same origin /api/)';
114 throw new Error(`Cannot reach Ollama at ${target}. Is it running?`);
115 }
116
117 if (!res.ok) {
118 const text = await res.text().catch(() => '');
119 if (res.status === 404) throw new Error(`Model "${settings.model}" not found. Pull it first: ollama pull ${settings.model}`);
120 throw new Error(`Ollama error ${res.status}: ${text.slice(0, 120)}`);
121 }
122
123 const data = await res.json() as { message?: { content?: string } };
124 const content = data?.message?.content;
125 if (!content) throw new Error('Empty response from Ollama');
126
127 let parsed: unknown;
128 try {
129 parsed = JSON.parse(content);
130 } catch {
131 throw new Error('Ollama returned invalid JSON. Try a larger model.');
132 }
133
134 // validate() calls sanitizeOutputs(), which injects TYPE_SCALE for fresh AI output
135 return validate(parsed);
136}
137
138// Test connectivity and return available model names
139export async function testOllamaConnection(baseUrl: string): Promise<string[]> {
140 const base = baseUrl ? baseUrl.replace(/\/$/, '') : '';
141 const url = `${base}/api/tags`;
142 const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
143 if (!res.ok) throw new Error(`Ollama responded with ${res.status}`);
144 const data = await res.json() as { models?: Array<{ name: string }> };
145 return (data.models ?? []).map(m => m.name.replace(/:latest$/, ''));
146}