返回 oh-my-ppt
recommendation.ts
根目录 / src / main / styles / recommendation.ts
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
143 lines TYPESCRIPT