| 1 | import log from 'electron-log/main.js' |
| 2 | import * as cheerio from 'cheerio' |
| 3 | import type { ModelRuntimeConfig } from '../agent-runtime/model' |
| 4 | import { extractModelText, resolveModel } from '../agent-runtime/model' |
| 5 | import { buildImagePromptGenerationMessages } from '../agent-runtime/prompt' |
| 6 | import { resolveModelTimeoutMs } from '@shared/model-timeout' |
| 7 | |
| 8 | export type ImagePromptDirectorConfig = { |
| 9 | provider: string |
| 10 | apiKey: string |
| 11 | model: string |
| 12 | baseUrl?: string |
| 13 | maxTokens: number |
| 14 | modelRuntime: ModelRuntimeConfig |
| 15 | modelTimeoutMs: unknown |
| 16 | locale: 'zh' | 'en' |
| 17 | } |
| 18 | |
| 19 | export type ImagePromptDirectorInput = { |
| 20 | sessionId: string |
| 21 | pageId: string |
| 22 | pageTitle: string |
| 23 | pageOutline: string |
| 24 | pageHtml: string |
| 25 | layoutSlotId: string |
| 26 | role: string |
| 27 | imageGenerationPrompt: string |
| 28 | signal?: AbortSignal |
| 29 | } |
| 30 | |
| 31 | export const compactPageHtmlForImagePrompt = (html: string): string => |
| 32 | html |
| 33 | .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '') |
| 34 | .replace(/<!--[\s\S]*?-->/g, '') |
| 35 | .replace(/\s+/g, ' ') |
| 36 | .trim() |
| 37 | .slice(0, 24000) |
| 38 | |
| 39 | /** The Director needs the slide's meaning, not its full generated CSS and SVG payload. */ |
| 40 | export const compactPageContentForImageDirector = (html: string): string => { |
| 41 | const $ = cheerio.load(html, { scriptingEnabled: false }) |
| 42 | $('script, style, svg, canvas, template').remove() |
| 43 | const content = $('main[data-role="content"]').first() |
| 44 | const root = content.length > 0 ? content : $('main').first() |
| 45 | const slotLines = root |
| 46 | .find('[data-ppt-slot]') |
| 47 | .toArray() |
| 48 | .map((node) => { |
| 49 | const slot = ($(node).attr('data-ppt-slot') || '').trim() |
| 50 | const text = $(node).text().replace(/\s+/g, ' ').trim() |
| 51 | return slot && text ? `${slot}: ${text}` : text |
| 52 | }) |
| 53 | .filter(Boolean) |
| 54 | const fallback = root.text().replace(/\s+/g, ' ').trim() |
| 55 | return (slotLines.length > 0 ? slotLines.join('\n') : fallback).slice(0, 6000) |
| 56 | } |
| 57 | |
| 58 | export const normalizeGeneratedImagePrompt = (raw: string): string => |
| 59 | raw |
| 60 | .replace(/^```(?:text|markdown|md)?\s*/i, '') |
| 61 | .replace(/```$/i, '') |
| 62 | .replace(/^\s*(?:prompt|提示词)\s*[::]\s*/i, '') |
| 63 | .replace(/\s+/g, ' ') |
| 64 | .trim() |
| 65 | |
| 66 | const IMAGE_DIRECTOR_CONSTRAINTS = |
| 67 | 'Return only one concise image-generation prompt. Do not include explanations, JSON, Markdown, logos, watermarks, UI, or remote URLs. Do not invent typography, captions, labels, or lettering-like decoration. Avoid garbled, partial, illegible, or irrelevant text, including pseudo-text and random glyph-like marks. If the image genuinely needs a short text element, state its exact wording in quotation marks, keep it clearly legible and semantically relevant to the page, and do not add any other words.' |
| 68 | |
| 69 | export const createImagePromptDirector = (config: ImagePromptDirectorConfig) => |
| 70 | async (input: ImagePromptDirectorInput): Promise<string> => { |
| 71 | const pageContent = compactPageContentForImageDirector(input.pageHtml) |
| 72 | const model = resolveModel( |
| 73 | config.provider, |
| 74 | config.apiKey, |
| 75 | config.model, |
| 76 | config.baseUrl, |
| 77 | 0.45, |
| 78 | config.maxTokens, |
| 79 | config.modelRuntime |
| 80 | ) |
| 81 | const timeoutSignal = AbortSignal.timeout(resolveModelTimeoutMs(config.modelTimeoutMs, 'agent')) |
| 82 | const signal = input.signal ? AbortSignal.any([input.signal, timeoutSignal]) : timeoutSignal |
| 83 | const userPrompt = [ |
| 84 | `Design one ${input.role} for the rendered layout slot "${input.layoutSlotId}".`, |
| 85 | 'The page title, outline, and content define the subject. Turn that meaning into a visual metaphor that supports the factual content instead of repeating it as extra copy or a chart.', |
| 86 | `Image style direction (treatment only, not subject matter): ${input.imageGenerationPrompt}`, |
| 87 | 'Use the style direction for palette, material, atmosphere, and illustration or photography treatment. Do not reuse literal style motifs unless they are semantically relevant to this page. For legal, data, process, or analytical pages, choose a topic-relevant visual metaphor rather than a generic decorative splash.', |
| 88 | IMAGE_DIRECTOR_CONSTRAINTS |
| 89 | ].join(' ') |
| 90 | |
| 91 | log.info('[images:director] start', { |
| 92 | sessionId: input.sessionId, |
| 93 | pageId: input.pageId, |
| 94 | layoutSlotId: input.layoutSlotId, |
| 95 | role: input.role, |
| 96 | model: config.model, |
| 97 | pageContentLength: pageContent.length |
| 98 | }) |
| 99 | const response = await model.invoke( |
| 100 | buildImagePromptGenerationMessages({ |
| 101 | locale: config.locale, |
| 102 | userPrompt, |
| 103 | pageTitle: input.pageTitle, |
| 104 | pageOutline: input.pageOutline, |
| 105 | pageHtml: pageContent |
| 106 | }), |
| 107 | { signal } |
| 108 | ) |
| 109 | const prompt = normalizeGeneratedImagePrompt(extractModelText(response)) |
| 110 | if (!prompt) throw new Error('Image director returned an empty prompt.') |
| 111 | log.info('[images:director] completed', { |
| 112 | sessionId: input.sessionId, |
| 113 | pageId: input.pageId, |
| 114 | layoutSlotId: input.layoutSlotId, |
| 115 | promptLength: prompt.length |
| 116 | }) |
| 117 | return prompt |
| 118 | } |
| 119 |