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}