| 1 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' |
| 2 | import os from 'node:os' |
| 3 | import path from 'node:path' |
| 4 | import { FilesystemBackend, createDeepAgent } from 'deepagents' |
| 5 | import { resolveModelTimeoutMs } from '@shared/model-timeout' |
| 6 | import { extractJsonBlock, extractModelText, resolveModel } from '../agent-runtime/model' |
| 7 | import type { ModelRuntimeConfig } from '../agent-runtime/model' |
| 8 | import type { StylePackageJson } from './style-package' |
| 9 | |
| 10 | const MAX_RECOMMENDATION_COUNT = 4 |
| 11 | const STYLE_CATALOG_PATH = '/style-catalog.json' |
| 12 | |
| 13 | export type StyleRecommendationInput = { |
| 14 | topic: string |
| 15 | brief?: string |
| 16 | styles: StylePackageJson[] |
| 17 | } |
| 18 | |
| 19 | type StyleRecommendationAgentArgs = StyleRecommendationInput & { |
| 20 | provider: string |
| 21 | apiKey: string |
| 22 | model: string |
| 23 | baseUrl: string |
| 24 | maxTokens?: number |
| 25 | modelRuntime?: ModelRuntimeConfig |
| 26 | modelTimeoutMs: number |
| 27 | workspaceDir: string |
| 28 | } |
| 29 | |
| 30 | export function buildStyleRecommendationPrompt(input: StyleRecommendationInput): string { |
| 31 | return [ |
| 32 | `Read ${STYLE_CATALOG_PATH} before choosing presentation styles.`, |
| 33 | 'Select exactly four distinct values from each style entry\'s "style" field. Use fewer only when fewer than four styles are available.', |
| 34 | 'Match the presentation topic and brief to the style descriptions, use cases, and visual directions.', |
| 35 | 'Prioritize styles with a non-empty "imageGeneration.prompt" when they fit the content. Use a style without image generation only when its data, process, or framework direction is clearly a better fit.', |
| 36 | 'Return only a JSON array of the selected style values, ordered from the best fit to the next best fit. Do not include explanations, markdown, or any other text.', |
| 37 | '', |
| 38 | `Topic: ${input.topic}`, |
| 39 | input.brief?.trim() ? `Brief: ${input.brief.trim()}` : '' |
| 40 | ] |
| 41 | .filter(Boolean) |
| 42 | .join('\n') |
| 43 | } |
| 44 | |
| 45 | export function serializeStyleRecommendationCatalog(styles: StylePackageJson[]): string { |
| 46 | return JSON.stringify({ styles }, null, 2) + '\n' |
| 47 | } |
| 48 | |
| 49 | export function parseStyleRecommendationResponse( |
| 50 | response: unknown, |
| 51 | availableStyleKeys: Iterable<string> |
| 52 | ): string[] { |
| 53 | const available = new Set( |
| 54 | Array.from(availableStyleKeys, (styleKey) => String(styleKey || '').trim()).filter(Boolean) |
| 55 | ) |
| 56 | const text = extractModelText(response) || (typeof response === 'string' ? response : '') |
| 57 | const jsonText = extractJsonBlock(text).trim() |
| 58 | if (!jsonText) throw new Error('风格推荐失败:AI 未返回推荐结果。') |
| 59 | |
| 60 | let parsed: unknown |
| 61 | try { |
| 62 | parsed = JSON.parse(jsonText) |
| 63 | } catch { |
| 64 | throw new Error('风格推荐失败:AI 返回格式无效。') |
| 65 | } |
| 66 | const values = Array.isArray(parsed) |
| 67 | ? parsed |
| 68 | : parsed && typeof parsed === 'object' && Array.isArray((parsed as { styles?: unknown }).styles) |
| 69 | ? (parsed as { styles: unknown[] }).styles |
| 70 | : [] |
| 71 | |
| 72 | const result: string[] = [] |
| 73 | for (const value of values) { |
| 74 | const styleKey = typeof value === 'string' ? value.trim() : '' |
| 75 | if (!styleKey || !available.has(styleKey) || result.includes(styleKey)) continue |
| 76 | result.push(styleKey) |
| 77 | if (result.length === MAX_RECOMMENDATION_COUNT) break |
| 78 | } |
| 79 | if (result.length === 0) throw new Error('风格推荐失败:AI 未返回可用风格。') |
| 80 | return result |
| 81 | } |
| 82 | |
| 83 | async function runStyleRecommendationAgent(args: StyleRecommendationAgentArgs): Promise<string> { |
| 84 | const model = resolveModel( |
| 85 | args.provider, |
| 86 | args.apiKey, |
| 87 | args.model, |
| 88 | args.baseUrl, |
| 89 | 0.2, |
| 90 | args.maxTokens, |
| 91 | args.modelRuntime |
| 92 | ) |
| 93 | const agent = createDeepAgent({ |
| 94 | model, |
| 95 | backend: new FilesystemBackend({ rootDir: args.workspaceDir, virtualMode: true }), |
| 96 | systemPrompt: |
| 97 | 'You are a presentation style recommendation agent. You must use read_file to read /style-catalog.json before selecting styles. Your final response must be only the requested JSON array.' |
| 98 | }) |
| 99 | const stream = await agent.stream( |
| 100 | { |
| 101 | messages: [{ role: 'user', content: buildStyleRecommendationPrompt(args) }] |
| 102 | }, |
| 103 | { |
| 104 | streamMode: ['messages'], |
| 105 | subgraphs: true, |
| 106 | signal: AbortSignal.timeout(resolveModelTimeoutMs(args.modelTimeoutMs, 'agent')) |
| 107 | } |
| 108 | ) |
| 109 | |
| 110 | let response = '' |
| 111 | for await (const chunk of stream as AsyncIterable<unknown>) { |
| 112 | if (!Array.isArray(chunk) || chunk[1] !== 'messages' || !Array.isArray(chunk[2])) continue |
| 113 | for (const message of chunk[2] as Array<Record<string, unknown>>) { |
| 114 | const text = extractModelText(message).trim() |
| 115 | if (text) response += text |
| 116 | } |
| 117 | } |
| 118 | return response |
| 119 | } |
| 120 | |
| 121 | export async function recommendStyles( |
| 122 | args: Omit<StyleRecommendationAgentArgs, 'workspaceDir'> |
| 123 | ): Promise<string[]> { |
| 124 | const styles = args.styles.filter((style) => style.style.trim()) |
| 125 | if (styles.length === 0) return [] |
| 126 | |
| 127 | const workspaceDir = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-recommendation-')) |
| 128 | try { |
| 129 | await writeFile( |
| 130 | path.join(workspaceDir, STYLE_CATALOG_PATH.slice(1)), |
| 131 | serializeStyleRecommendationCatalog(styles), |
| 132 | 'utf8' |
| 133 | ) |
| 134 | const response = await runStyleRecommendationAgent({ ...args, styles, workspaceDir }) |
| 135 | return parseStyleRecommendationResponse( |
| 136 | response, |
| 137 | styles.map((style) => style.style) |
| 138 | ) |
| 139 | } finally { |
| 140 | await rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined) |
| 141 | } |
| 142 | } |
| 143 |