getfilepress 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/app/src/lib/genie/GeniePanel.svelte +4 -0
- package/packages/app/src/lib/genie/ops.ts +5 -1
- package/packages/app/src/lib/theme-entry.ts +4 -3
- package/packages/app/src/lib/theme-layers.css +3 -0
- package/packages/app/vite-plugin-critical-theme.ts +49 -19
- package/packages/import/src/ollama.ts +222 -59
package/package.json
CHANGED
|
@@ -554,6 +554,10 @@
|
|
|
554
554
|
{loading ? `Asking Ollama… ${formatWait(jobSecs)}` : 'Refine & activate'}
|
|
555
555
|
</button>
|
|
556
556
|
<p class="genie-muted">
|
|
557
|
+
The raw Ollama JSON is printed in the <code>filepress dev</code> terminal and saved to
|
|
558
|
+
<code>.filepress-genie/last-ollama.json</code>. “Icy / Antarctica / bright” means a
|
|
559
|
+
light page — activate <strong>baseline</strong> in History first if a prior dark look
|
|
560
|
+
is still the seed.
|
|
557
561
|
<code>gemma4:12b</code> often spends the first few minutes loading into VRAM — that is
|
|
558
562
|
normal. Progress prints in the filepress terminal every 15s. Raise the budget with
|
|
559
563
|
<code>FILEPRESS_OLLAMA_TIMEOUT_MS</code> (default 10 minutes). Tip: set
|
|
@@ -25,6 +25,7 @@ import type { DesignBrief, SiteIR } from '../../../../import/src/ir.ts';
|
|
|
25
25
|
import {
|
|
26
26
|
activateVersion,
|
|
27
27
|
ensureBaseline,
|
|
28
|
+
genieRoot,
|
|
28
29
|
getActive,
|
|
29
30
|
listVersions,
|
|
30
31
|
readVersionBrief,
|
|
@@ -416,7 +417,10 @@ export async function refineWithOllama(
|
|
|
416
417
|
inspireSignals: [],
|
|
417
418
|
seed,
|
|
418
419
|
strictParse: true,
|
|
419
|
-
logLabel
|
|
420
|
+
logLabel,
|
|
421
|
+
intent: 'steer',
|
|
422
|
+
steer: prompt,
|
|
423
|
+
dumpPath: join(genieRoot(siteRoot), 'last-ollama.json')
|
|
420
424
|
});
|
|
421
425
|
const themeCss = themeCssFromBrief(brief);
|
|
422
426
|
const meta = writeSnapshot(siteRoot, {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Essay chrome
|
|
3
|
-
* Site rules
|
|
4
|
-
*
|
|
2
|
+
* Layer order is declared first. Essay chrome, then preset, then site theme.
|
|
3
|
+
* Site rules live in `@layer site` so they win even if Vite injects Essay CSS
|
|
4
|
+
* after the site sheet (dev) or splits the CSS (build).
|
|
5
5
|
*/
|
|
6
|
+
import './theme-layers.css';
|
|
6
7
|
import '@filepress/core/theme';
|
|
7
8
|
import '$site-preset';
|
|
8
9
|
import '$site-theme';
|
|
@@ -2,28 +2,42 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import type { Plugin } from 'vite';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* @import cannot live inside @layer. Pull them out, wrap the rest.
|
|
6
|
+
* Quoted URLs may contain `;` (Google Fonts `wght@400;700`).
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
|
-
const chunks: string[] = [];
|
|
8
|
+
const IMPORT_RE = /@import\s+(?:url\(\s*)?(["'])(?:\\.|(?!\1).)*\1\s*\)?\s*;/g;
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
export function takeImports(css: string): { imports: string[]; rest: string } {
|
|
11
|
+
const imports: string[] = [];
|
|
12
|
+
const rest = css.replace(IMPORT_RE, (m) => {
|
|
13
|
+
imports.push(m.trim());
|
|
14
|
+
return '';
|
|
15
|
+
});
|
|
16
|
+
return { imports, rest };
|
|
17
|
+
}
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
export function hoistImportsAndLayer(css: string, layer: string): string {
|
|
20
|
+
const { imports, rest } = takeImports(css);
|
|
21
|
+
const body = rest.trim();
|
|
22
|
+
const hoisted = imports.length ? `${imports.join('\n')}\n` : '';
|
|
23
|
+
if (!body) return hoisted;
|
|
24
|
+
return `${hoisted}@layer ${layer} {\n${body}\n}\n`;
|
|
25
|
+
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
/** Site tokens must beat later Essay `:root` (same specificity, later source). */
|
|
28
|
+
export function boostRoot(css: string): string {
|
|
29
|
+
return css.replace(/:root(?::root)?/g, ':root:root');
|
|
30
|
+
}
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Inline the site theme for first paint (minus remote @import).
|
|
34
|
+
* Cascade layer `site` wins over Essay even after Vite injects core CSS.
|
|
35
|
+
*/
|
|
36
|
+
export function extractCriticalTheme(css: string): string {
|
|
37
|
+
const withoutImport = takeImports(css).rest;
|
|
38
|
+
const meaningful = withoutImport.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
|
39
|
+
if (!meaningful) return '';
|
|
40
|
+
return `@layer filepress, site;\n${hoistImportsAndLayer(boostRoot(withoutImport), 'site')}`;
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
export function writeCriticalThemeModule(siteThemePath: string, outPath: string): string {
|
|
@@ -41,16 +55,32 @@ export function writeCriticalThemeModule(siteThemePath: string, outPath: string)
|
|
|
41
55
|
return critical;
|
|
42
56
|
}
|
|
43
57
|
|
|
58
|
+
function fileId(id: string): string {
|
|
59
|
+
return id.replace(/\\/g, '/').split('?')[0] ?? id;
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
/**
|
|
45
|
-
* Keep `$
|
|
46
|
-
*
|
|
63
|
+
* Keep `$critical-theme` in sync, and put Essay vs site CSS in cascade
|
|
64
|
+
* layers so Vite's inject order cannot flash the default look.
|
|
47
65
|
*/
|
|
48
66
|
export function criticalThemePlugin(siteThemePath: string, outPath: string): Plugin {
|
|
67
|
+
const siteNorm = siteThemePath.replace(/\\/g, '/');
|
|
68
|
+
|
|
49
69
|
return {
|
|
50
70
|
name: 'filepress-critical-theme',
|
|
51
71
|
buildStart() {
|
|
52
72
|
writeCriticalThemeModule(siteThemePath, outPath);
|
|
53
73
|
},
|
|
74
|
+
transform(code, id) {
|
|
75
|
+
const path = fileId(id);
|
|
76
|
+
if (path === siteNorm) {
|
|
77
|
+
return hoistImportsAndLayer(boostRoot(code), 'site');
|
|
78
|
+
}
|
|
79
|
+
if (path.endsWith('/lib/styles/theme.css') || path.includes('/lib/styles/presets/')) {
|
|
80
|
+
return hoistImportsAndLayer(code, 'filepress');
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
},
|
|
54
84
|
configureServer(server) {
|
|
55
85
|
writeCriticalThemeModule(siteThemePath, outPath);
|
|
56
86
|
server.watcher.add(siteThemePath);
|
|
@@ -1,7 +1,170 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
1
3
|
import type { DesignBrief, SiteIR } from './ir.ts';
|
|
2
4
|
import type { InspirationSignals } from './inspire.ts';
|
|
3
5
|
import { DEFAULT_BRIEF, parseBriefJson } from './theme.ts';
|
|
4
6
|
|
|
7
|
+
export type BriefIntent = 'import' | 'steer';
|
|
8
|
+
|
|
9
|
+
const BRIEF_SHAPE = `{
|
|
10
|
+
"mood": "short phrase",
|
|
11
|
+
"do": ["..."],
|
|
12
|
+
"dont": ["..."],
|
|
13
|
+
"tokens": {
|
|
14
|
+
"accent": "#rrggbb",
|
|
15
|
+
"accentStrong": "#rrggbb",
|
|
16
|
+
"bg": "#rrggbb",
|
|
17
|
+
"ink": "#rrggbb",
|
|
18
|
+
"inkSoft": "#rrggbb",
|
|
19
|
+
"surface": "#rrggbb",
|
|
20
|
+
"rule": "#rrggbb",
|
|
21
|
+
"ruleStrong": "#rrggbb"
|
|
22
|
+
},
|
|
23
|
+
"density": "sparse" | "balanced" | "dense",
|
|
24
|
+
"paletteMode": "dark" | "light",
|
|
25
|
+
"fonts": {
|
|
26
|
+
"serif": "Font Name",
|
|
27
|
+
"sans": "Font Name",
|
|
28
|
+
"mono": "Font Name",
|
|
29
|
+
"googleHref": "https://fonts.googleapis.com/css2?..." or null
|
|
30
|
+
},
|
|
31
|
+
"hero": "bold" | "editorial",
|
|
32
|
+
"atmosphere": "noise" | "none",
|
|
33
|
+
"navStyle": "uppercase-tracked" | "soft",
|
|
34
|
+
"elevatedCards": true | false,
|
|
35
|
+
"cssNotes": ["..."]
|
|
36
|
+
}`;
|
|
37
|
+
|
|
38
|
+
/** Paper / ice defaults when the author asked for light and the model still returns a cave. */
|
|
39
|
+
export const LIGHT_FLOOR = {
|
|
40
|
+
bg: '#f3f7fb',
|
|
41
|
+
surface: '#ffffff',
|
|
42
|
+
ink: '#16324a',
|
|
43
|
+
inkSoft: '#3d5a70',
|
|
44
|
+
rule: '#c5d6e4',
|
|
45
|
+
ruleStrong: '#8eafc8',
|
|
46
|
+
accent: '#1a6fa8',
|
|
47
|
+
accentStrong: '#0e4d7a'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function paletteHintFromSteer(text: string): 'light' | 'dark' | null {
|
|
51
|
+
const t = text.toLowerCase();
|
|
52
|
+
const wantsLight =
|
|
53
|
+
/\b(light|bright|white|snow|ice|icy|antarctica|antarctic|arctic|glacier|frost|daylight|crisp|parchment|paper)\b/.test(
|
|
54
|
+
t
|
|
55
|
+
);
|
|
56
|
+
const wantsDark = /\b(dark|night|black|midnight|noir|charcoal|obsidian)\b/.test(t);
|
|
57
|
+
if (wantsLight && !wantsDark) return 'light';
|
|
58
|
+
if (wantsDark && !wantsLight) return 'dark';
|
|
59
|
+
if (wantsLight) return 'light';
|
|
60
|
+
if (wantsDark) return 'dark';
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hexRelativeLuminance(hex: string | undefined): number | null {
|
|
65
|
+
if (!hex) return null;
|
|
66
|
+
const m = hex.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
67
|
+
if (!m) return null;
|
|
68
|
+
let h = m[1];
|
|
69
|
+
if (h.length === 3) h = [...h].map((c) => `${c}${c}`).join('');
|
|
70
|
+
const n = parseInt(h, 16);
|
|
71
|
+
const r = (n >> 16) & 255;
|
|
72
|
+
const g = (n >> 8) & 255;
|
|
73
|
+
const b = n & 255;
|
|
74
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function applyPaletteHint(brief: DesignBrief, hint: 'light' | 'dark' | null): DesignBrief {
|
|
78
|
+
if (!hint) return brief;
|
|
79
|
+
const tokens = { ...brief.tokens };
|
|
80
|
+
if (hint === 'light') {
|
|
81
|
+
const bgLum = hexRelativeLuminance(tokens.bg);
|
|
82
|
+
const inkLum = hexRelativeLuminance(tokens.ink);
|
|
83
|
+
if (bgLum === null || bgLum < 0.75) {
|
|
84
|
+
tokens.bg = LIGHT_FLOOR.bg;
|
|
85
|
+
tokens.surface = LIGHT_FLOOR.surface;
|
|
86
|
+
tokens.rule = tokens.rule && (hexRelativeLuminance(tokens.rule) ?? 0) > 0.55 ? tokens.rule : LIGHT_FLOOR.rule;
|
|
87
|
+
tokens.ruleStrong =
|
|
88
|
+
tokens.ruleStrong && (hexRelativeLuminance(tokens.ruleStrong) ?? 0) > 0.4
|
|
89
|
+
? tokens.ruleStrong
|
|
90
|
+
: LIGHT_FLOOR.ruleStrong;
|
|
91
|
+
}
|
|
92
|
+
if (inkLum === null || inkLum > 0.45) {
|
|
93
|
+
tokens.ink = LIGHT_FLOOR.ink;
|
|
94
|
+
tokens.inkSoft = LIGHT_FLOOR.inkSoft;
|
|
95
|
+
}
|
|
96
|
+
return { ...brief, paletteMode: 'light', tokens };
|
|
97
|
+
}
|
|
98
|
+
return { ...brief, paletteMode: 'dark' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildDesignBriefPrompt(opts: {
|
|
102
|
+
intent: BriefIntent;
|
|
103
|
+
seed: DesignBrief;
|
|
104
|
+
ir: SiteIR;
|
|
105
|
+
inspireSummaries: string[];
|
|
106
|
+
inspireSignals: InspirationSignals[];
|
|
107
|
+
steer?: string;
|
|
108
|
+
}): { system: string; user: string } {
|
|
109
|
+
const notes = opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)';
|
|
110
|
+
const snippets =
|
|
111
|
+
opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)';
|
|
112
|
+
if (opts.intent === 'steer') {
|
|
113
|
+
return {
|
|
114
|
+
system:
|
|
115
|
+
'You output only valid JSON. Honor the author\'s written palette. Light, ice, snow, or Antarctica means a bright page — never a black one unless they also say dark or night.',
|
|
116
|
+
user: `You are restyling a filepress Essay site from the author's written direction.
|
|
117
|
+
The seed is only a starting point. When the direction conflicts with the seed, the direction wins.
|
|
118
|
+
Return ONLY JSON (no fences) with this shape:
|
|
119
|
+
${BRIEF_SHAPE}
|
|
120
|
+
|
|
121
|
+
Hard rules:
|
|
122
|
+
- Follow the author's palette. Light / bright / white / snow / ice / Antarctica / daylight → paletteMode "light", background #e8 or lighter (paper, snow, ice), dark readable ink. Do not use near-black backgrounds.
|
|
123
|
+
- "Cold" or "icy" is color temperature (blue, white, crisp) — not a night scene unless they also say dark/night/black.
|
|
124
|
+
- Dark/night/black only when the author asks for that.
|
|
125
|
+
- Do not invent marketing section layouts.
|
|
126
|
+
- Avoid purple-on-white clichés.
|
|
127
|
+
|
|
128
|
+
Author direction:
|
|
129
|
+
${opts.steer?.trim() || '(none)'}
|
|
130
|
+
|
|
131
|
+
Seed brief (override freely when the direction conflicts):
|
|
132
|
+
${JSON.stringify(opts.seed, null, 2)}
|
|
133
|
+
|
|
134
|
+
Site identity (content only — do not force its old palette):
|
|
135
|
+
${JSON.stringify(opts.ir.identity, null, 2)}
|
|
136
|
+
`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
system:
|
|
141
|
+
'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.',
|
|
142
|
+
user: `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
|
|
143
|
+
The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
|
|
144
|
+
Return ONLY JSON (no fences) with this shape:
|
|
145
|
+
${BRIEF_SHAPE}
|
|
146
|
+
|
|
147
|
+
Hard rules:
|
|
148
|
+
- If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
|
|
149
|
+
- Personal essay site: do NOT invent marketing section layouts.
|
|
150
|
+
- Prefer inspiration fonts/colors over the source site's cream-editorial look.
|
|
151
|
+
- Avoid purple-on-white clichés.
|
|
152
|
+
|
|
153
|
+
Seed brief (from inspiration extraction):
|
|
154
|
+
${JSON.stringify(opts.seed, null, 2)}
|
|
155
|
+
|
|
156
|
+
Site identity (content only — do not force its old palette):
|
|
157
|
+
${JSON.stringify(opts.ir.identity, null, 2)}
|
|
158
|
+
|
|
159
|
+
Inspiration notes:
|
|
160
|
+
${notes}
|
|
161
|
+
|
|
162
|
+
Inspiration text snippets:
|
|
163
|
+
${snippets}
|
|
164
|
+
`
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
5
168
|
/** Strip trailing slashes; used to compare Genie picker values with env. */
|
|
6
169
|
export function normalizeOllamaHost(host: string): string {
|
|
7
170
|
return host.trim().replace(/\/+$/, '');
|
|
@@ -147,62 +310,28 @@ export async function generateDesignBrief(opts: {
|
|
|
147
310
|
/** When true, unparseable model JSON fails instead of silently using the seed. */
|
|
148
311
|
strictParse?: boolean;
|
|
149
312
|
logLabel?: string;
|
|
313
|
+
/** `steer` uses the author-direction prompt; default is the import/inspire prompt. */
|
|
314
|
+
intent?: BriefIntent;
|
|
315
|
+
steer?: string;
|
|
316
|
+
/** Write raw + applied brief JSON here (Genie: `.filepress-genie/last-ollama.json`). */
|
|
317
|
+
dumpPath?: string;
|
|
150
318
|
}): Promise<DesignBrief> {
|
|
151
319
|
const host = opts.host.replace(/\/+$/, '');
|
|
152
320
|
const timeoutMs = ollamaChatTimeoutMs();
|
|
153
321
|
const started = Date.now();
|
|
154
322
|
const logLabel = opts.logLabel ?? `filepress: Ollama ${opts.model} @ ${host}`;
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"ink": "#rrggbb",
|
|
168
|
-
"inkSoft": "#rrggbb",
|
|
169
|
-
"surface": "#rrggbb",
|
|
170
|
-
"rule": "#rrggbb",
|
|
171
|
-
"ruleStrong": "#rrggbb"
|
|
172
|
-
},
|
|
173
|
-
"density": "sparse" | "balanced" | "dense",
|
|
174
|
-
"paletteMode": "dark" | "light",
|
|
175
|
-
"fonts": {
|
|
176
|
-
"serif": "Font Name",
|
|
177
|
-
"sans": "Font Name",
|
|
178
|
-
"mono": "Font Name",
|
|
179
|
-
"googleHref": "https://fonts.googleapis.com/css2?..." or null
|
|
180
|
-
},
|
|
181
|
-
"hero": "bold" | "editorial",
|
|
182
|
-
"atmosphere": "noise" | "none",
|
|
183
|
-
"navStyle": "uppercase-tracked" | "soft",
|
|
184
|
-
"elevatedCards": true | false,
|
|
185
|
-
"cssNotes": ["..."]
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
Hard rules:
|
|
189
|
-
- If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
|
|
190
|
-
- Personal essay site: do NOT invent marketing section layouts.
|
|
191
|
-
- Prefer inspiration fonts/colors over the source site's cream-editorial look.
|
|
192
|
-
- Avoid purple-on-white clichés.
|
|
193
|
-
|
|
194
|
-
Seed brief (from inspiration extraction):
|
|
195
|
-
${JSON.stringify(opts.seed, null, 2)}
|
|
196
|
-
|
|
197
|
-
Site identity (content only — do not force its old palette):
|
|
198
|
-
${JSON.stringify(opts.ir.identity, null, 2)}
|
|
199
|
-
|
|
200
|
-
Inspiration notes:
|
|
201
|
-
${opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)'}
|
|
202
|
-
|
|
203
|
-
Inspiration text snippets:
|
|
204
|
-
${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
|
|
205
|
-
`;
|
|
323
|
+
const intent = opts.intent ?? 'import';
|
|
324
|
+
const hint = intent === 'steer' ? paletteHintFromSteer(opts.steer ?? '') : null;
|
|
325
|
+
const messages = buildDesignBriefPrompt({
|
|
326
|
+
intent,
|
|
327
|
+
seed: opts.seed,
|
|
328
|
+
ir: opts.ir,
|
|
329
|
+
inspireSummaries: opts.inspireSummaries,
|
|
330
|
+
inspireSignals: opts.inspireSignals,
|
|
331
|
+
steer: opts.steer
|
|
332
|
+
});
|
|
333
|
+
console.log(`${logLabel}: starting chat (up to ${Math.round(timeoutMs / 1000)}s) intent=${intent}`);
|
|
334
|
+
if (hint) console.log(`${logLabel}: author palette hint = ${hint}`);
|
|
206
335
|
|
|
207
336
|
let content = '';
|
|
208
337
|
try {
|
|
@@ -215,12 +344,8 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
|
|
|
215
344
|
format: 'json',
|
|
216
345
|
options: { temperature: 0.35 },
|
|
217
346
|
messages: [
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
content:
|
|
221
|
-
'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.'
|
|
222
|
-
},
|
|
223
|
-
{ role: 'user', content: prompt }
|
|
347
|
+
{ role: 'system', content: messages.system },
|
|
348
|
+
{ role: 'user', content: messages.user }
|
|
224
349
|
]
|
|
225
350
|
}),
|
|
226
351
|
signal: AbortSignal.timeout(timeoutMs)
|
|
@@ -233,6 +358,7 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
|
|
|
233
358
|
|
|
234
359
|
content = await readOllamaChatStream(res, logLabel, started);
|
|
235
360
|
console.log(`${logLabel}: done in ${Math.round((Date.now() - started) / 1000)}s (${content.length} chars)`);
|
|
361
|
+
console.log(`${logLabel}: raw JSON\n${content}`);
|
|
236
362
|
} catch (e) {
|
|
237
363
|
if (isAbortLike(e)) {
|
|
238
364
|
throw new Error(ollamaTimeoutMessage({ host, model: opts.model, timeoutMs }));
|
|
@@ -242,19 +368,56 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
|
|
|
242
368
|
try {
|
|
243
369
|
const refined = parseBriefJson(content);
|
|
244
370
|
// Never let the model drop fonts/googleHref from a rich seed
|
|
245
|
-
|
|
371
|
+
let brief: DesignBrief = {
|
|
246
372
|
...opts.seed,
|
|
247
373
|
...refined,
|
|
248
374
|
tokens: { ...opts.seed.tokens, ...refined.tokens },
|
|
249
375
|
fonts: refined.fonts || opts.seed.fonts,
|
|
250
|
-
paletteMode: refined.paletteMode || opts.seed.paletteMode,
|
|
376
|
+
paletteMode: refined.paletteMode || (intent === 'steer' ? undefined : opts.seed.paletteMode),
|
|
251
377
|
hero: refined.hero || opts.seed.hero,
|
|
252
378
|
atmosphere: refined.atmosphere || opts.seed.atmosphere,
|
|
253
379
|
navStyle: refined.navStyle || opts.seed.navStyle,
|
|
254
380
|
elevatedCards: refined.elevatedCards ?? opts.seed.elevatedCards
|
|
255
381
|
};
|
|
382
|
+
brief = applyPaletteHint(brief, hint);
|
|
383
|
+
console.log(
|
|
384
|
+
`${logLabel}: applied paletteMode=${brief.paletteMode ?? '(unset)'} bg=${brief.tokens.bg ?? '(unset)'} ink=${brief.tokens.ink ?? '(unset)'} accent=${brief.tokens.accent}`
|
|
385
|
+
);
|
|
386
|
+
if (opts.dumpPath) {
|
|
387
|
+
mkdirSync(dirname(opts.dumpPath), { recursive: true });
|
|
388
|
+
writeFileSync(
|
|
389
|
+
opts.dumpPath,
|
|
390
|
+
`${JSON.stringify(
|
|
391
|
+
{
|
|
392
|
+
intent,
|
|
393
|
+
steer: opts.steer ?? null,
|
|
394
|
+
hint,
|
|
395
|
+
model: opts.model,
|
|
396
|
+
host,
|
|
397
|
+
raw: content,
|
|
398
|
+
applied: {
|
|
399
|
+
paletteMode: brief.paletteMode,
|
|
400
|
+
tokens: brief.tokens,
|
|
401
|
+
mood: brief.mood
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
null,
|
|
405
|
+
2
|
|
406
|
+
)}\n`
|
|
407
|
+
);
|
|
408
|
+
console.log(`${logLabel}: dumped ${opts.dumpPath}`);
|
|
409
|
+
}
|
|
410
|
+
return brief;
|
|
256
411
|
} catch (e) {
|
|
257
412
|
const detail = e instanceof Error ? e.message : String(e);
|
|
413
|
+
if (opts.dumpPath && content) {
|
|
414
|
+
mkdirSync(dirname(opts.dumpPath), { recursive: true });
|
|
415
|
+
writeFileSync(
|
|
416
|
+
opts.dumpPath,
|
|
417
|
+
`${JSON.stringify({ intent, steer: opts.steer ?? null, parseError: detail, raw: content }, null, 2)}\n`
|
|
418
|
+
);
|
|
419
|
+
console.log(`${logLabel}: dumped unparsed response to ${opts.dumpPath}`);
|
|
420
|
+
}
|
|
258
421
|
if (opts.strictParse) {
|
|
259
422
|
throw new Error(
|
|
260
423
|
`Ollama returned a brief we could not parse (${detail}). First 200 chars: ${content.slice(0, 200)}`
|