返回 oh-my-ppt
page-beautify-agent.ts
根目录 / src / main / edit-jobs / page-beautify-agent.ts
1 import { tool } from '@langchain/core/tools'
2 import { createDeepAgent, FilesystemBackend, type EditResult, type WriteResult } from 'deepagents'
3 import { z } from 'zod'
4 import fs from 'fs'
5 import path from 'path'
6 import { resolveModel } from '../agent-runtime/model'
7 import type { ModelRuntimeConfig } from '../agent-runtime/model'
8 import { validateHtmlContent } from '../presentation/html/html-utils'
9 import { attachProductSkillsBackend } from '../agent-runtime/skills'
10 import type { RequiredProductSkillName } from '../product-skills/contract'
11 import { resolveModelTimeoutMs, type ModelTimeoutProfile } from '@shared/model-timeout'
12 import {
13 buildPageBeautifyCanvasContract,
14 buildPageBeautifySystemPrompt,
15 type PageBeautifyPromptArgs
16 } from './page-beautify-prompt'
17
18 export { buildPageBeautifySystemPrompt } from './page-beautify-prompt'
19
20 // Applied only when the selected model configuration supports temperature. Beautify
21 // needs enough latitude to materially recompose an overcrowded page instead of
22 // preserving the existing layout with superficial class changes.
23 const PAGE_BEAUTIFY_TEMPERATURE = 0.5
24
25 // LangChain's `createAgent` accepts the chat model produced here, but the project's
26 // resolveModel return type is narrower than BaseChatModel in some introspection
27 // paths. This keeps the interface honest without a broader resolveModel refactor.
28 type AgentModel = ReturnType<typeof resolveModel>
29
30 export type PageBeautifyAgentArgs = PageBeautifyPromptArgs & {
31 provider: string
32 apiKey: string
33 model: string
34 baseUrl: string
35 maxTokens: number
36 modelRuntime?: ModelRuntimeConfig
37 modelTimeoutMs: Record<ModelTimeoutProfile, number>
38 signal: AbortSignal
39 // Absolute path to the persisted page HTML on disk. The agent reads this file
40 // in full (head + body + inline scripts/styles) so it can reason about fonts,
41 // global CSS, root background, and embedded chart data before beautifying.
42 targetHtmlPath: string
43 // Monotonic 0..1 progress hint. Emitted on every agent stream update so the
44 // caller can render a smooth progress bar; capped under 0.85 because the
45 // final 15% belongs to post-agent work (validate, write, commit, persist).
46 onProgress?: (ratio: number) => void
47 // Set only for the single host-validation retry. The page on disk is unchanged,
48 // so the agent can reread it and correct the rejected candidate from scratch.
49 retryFeedback?: string
50 }
51
52 // Backend that rejects every write/edit. Beautify's only persist path is the
53 // save_current_page_content tool, which validates and stores the fragment in
54 // memory. Letting the model edit the page file directly would bypass content
55 // manifest guards, so the filesystem tools are read-only by construction.
56 class ReadOnlyProjectBackend extends FilesystemBackend {
57 async write(filePath: string, _content: string): Promise<WriteResult> {
58 return { error: `Beautify is read-only: write_file is disabled (${filePath})` }
59 }
60 async edit(
61 filePath: string,
62 _oldString: string,
63 _newString: string,
64 _replaceAll?: boolean
65 ): Promise<EditResult> {
66 return { error: `Beautify is read-only: edit_file is disabled (${filePath})` }
67 }
68 }
69
70 type StreamUpdateChunk = [namespace: unknown, mode: string, data: unknown]
71
72 const isUpdatesChunk = (chunk: unknown): chunk is StreamUpdateChunk =>
73 Array.isArray(chunk) &&
74 chunk.length >= 3 &&
75 typeof chunk[1] === 'string' &&
76 chunk[1] === 'updates'
77
78 const inferCancellationReason = (
79 error: unknown,
80 timeoutMs: number,
81 userSignal: AbortSignal
82 ): 'cancelled' | 'timeout' | null => {
83 if (userSignal.aborted) return 'cancelled'
84 const name = error instanceof Error ? error.name : ''
85 const message = error instanceof Error ? error.message : String(error ?? '')
86 if (name === 'TimeoutError' || name === 'AbortError') return 'timeout'
87 if (/timed?\s*out|aborted|cancel/i.test(message)) return 'timeout'
88 // Heuristic: if the failure happened close to the wall-clock budget and the message
89 // looks like a transport error, treat it as a timeout so the user gets a clearer hint.
90 void timeoutMs
91 return null
92 }
93
94 export async function runPageBeautifyAgent(args: PageBeautifyAgentArgs): Promise<string> {
95 let hasReadCurrentPage = false
96 let savedContent: string | null = null
97 let savedReported = false
98 let modelUpdateCount = 0
99 const canvasContract = buildPageBeautifyCanvasContract(args.slideSize)
100
101 // Beautify uses the same DeepAgents + product-skills machinery as the deck/edit
102 // pipelines: a read-only project backend plus attachProductSkillsBackend, which
103 // mounts the layout skill for this slide size and injects the skill-index into
104 // the system prompt. The model then `read_file`s SKILL.md / references on demand.
105 const projectBackend = new ReadOnlyProjectBackend({
106 rootDir: path.dirname(args.targetHtmlPath),
107 virtualMode: true
108 })
109 const requiredSkillNames: readonly RequiredProductSkillName[] = [args.layoutSkillName]
110 const agentBackend = attachProductSkillsBackend(projectBackend, 'page-beautify', requiredSkillNames)
111
112 const agent = createDeepAgent({
113 model: resolveModel(
114 args.provider,
115 args.apiKey,
116 args.model,
117 args.baseUrl,
118 PAGE_BEAUTIFY_TEMPERATURE,
119 args.maxTokens,
120 args.modelRuntime
121 ) as AgentModel,
122 backend: agentBackend.backend,
123 middleware: agentBackend.middleware as any,
124 tools: [
125 tool(
126 async () => {
127 if (hasReadCurrentPage) {
128 return 'You have already read the full page HTML. Proceed directly to beautifying and saving the updated .ppt-page-content fragment.'
129 }
130 hasReadCurrentPage = true
131 // Read the full persisted page HTML (head + body + inline scripts/styles)
132 // so the model can reason about fonts, global CSS, root background, and
133 // embedded chart data. The save tool still only accepts the inner
134 // .ppt-page-content fragment, so the shell stays immutable in practice.
135 const html = await fs.promises.readFile(args.targetHtmlPath, 'utf-8')
136 return `${canvasContract}\n\n## Current persisted page HTML\n${html}`
137 },
138 {
139 name: 'read_page_html',
140 description:
141 'Read the complete persisted HTML of the selected current page (head, body, inline styles, scripts, and chart data). Call this once to understand the page context before beautifying.',
142 schema: z.object({})
143 }
144 ),
145 tool(
146 async (input: { content: string }) => {
147 if (!hasReadCurrentPage) {
148 return 'Call read_page_html before saving the page.'
149 }
150 if (savedContent !== null) {
151 return 'The page has already been submitted. Do not save it again.'
152 }
153 const validation = validateHtmlContent(input.content)
154 if (!validation.valid) {
155 return `The page fragment was rejected: ${validation.errors.join(';')}。修正后再次调用 save_current_page_content。`
156 }
157 savedContent = input.content
158 return 'The current-page content fragment was submitted for host validation.'
159 },
160 {
161 name: 'save_current_page_content',
162 description:
163 'Submit the complete beautified creative HTML fragment for the selected current page. The host preserves and reuses the injected page shell.',
164 schema: z.object({
165 content: z.string().min(1).describe('Complete creative HTML fragment for the current page')
166 })
167 }
168 )
169 ],
170 systemPrompt: buildPageBeautifySystemPrompt(args)
171 })
172 const timeoutMs = resolveModelTimeoutMs(args.modelTimeoutMs.agent, 'agent')
173 const timeoutController = new AbortController()
174 const timer = setTimeout(() => timeoutController.abort(), timeoutMs)
175 const streamSignal = AbortSignal.any([timeoutController.signal, args.signal])
176
177 let stream: AsyncIterable<unknown>
178 try {
179 stream = await agent.stream(
180 {
181 messages: [
182 {
183 role: 'user',
184 content: args.retryFeedback
185 ? `${canvasContract}\n\nThe previous candidate was rejected by host validation: ${args.retryFeedback}\n\nThis is your one correction retry. Produce a visibly new creative version of the page within the selected style, not a text or formatting-only correction. Read the layout skill and page HTML again, re-layout and audit the finished composition for fit, hierarchy, overlap, and clipping, then submit only the complete fragment.`
186 : `${canvasContract}${args.layoutAudit ? `\n\nThe current page's browser-measured layout audit is in your system instructions. Resolve every reported defect.` : ''}\n\nProduce a visibly new creative version of the selected current page within its established style. This is not proofreading: do not submit a text, number-format, comment, animation, attribute, color, or isolated CSS-only change. Read the layout skill first, then the page HTML, re-layout and audit the finished composition for fit, hierarchy, overlap, and clipping, then submit only the complete fragment.`
187 }
188 ]
189 },
190 {
191 streamMode: ['updates', 'messages'],
192 subgraphs: true,
193 signal: streamSignal
194 }
195 )
196 } catch (error) {
197 clearTimeout(timer)
198 const reason = inferCancellationReason(error, timeoutMs, args.signal)
199 if (reason === 'cancelled') throw new Error('生成已取消')
200 if (reason === 'timeout')
201 throw new Error(`模型响应超时(${Math.round(timeoutMs / 1000)}s),请重试。`)
202 throw error
203 }
204
205 // Heartbeat: the agent goes silent while the model reads the (large) page HTML and waits
206 // for first-token. During that window there are no `updates` chunks, so the asymptotic
207 // formula below would never fire and the bar would freeze at 20% for 10–30s. This timer
208 // pushes progress forward based on elapsed wall-clock until the first real model update
209 // arrives, then stops. Capped at 0.4 so model-update-driven progress always overtakes it.
210 let heartbeatRatio = 0
211 const heartbeatStartedAt = Date.now()
212 const heartbeat = setInterval(() => {
213 if (modelUpdateCount > 0 || savedContent !== null) return
214 const elapsedMs = Date.now() - heartbeatStartedAt
215 // Ease toward 0.4 with a 1/(1+t) curve: fast early movement, gentle near the cap.
216 heartbeatRatio = Math.min(0.4, 0.4 - 0.4 / (1 + elapsedMs / 4000))
217 if (heartbeatRatio > 0.02) args.onProgress?.(heartbeatRatio)
218 }, 800)
219
220 try {
221 for await (const chunk of stream as AsyncIterable<unknown>) {
222 if (!isUpdatesChunk(chunk)) continue
223 const updates = chunk[2]
224 if (!updates || typeof updates !== 'object' || !('model' in updates)) continue
225
226 // Once the agent has called save_current_page_content we know authoring is
227 // done; jump to the post-agent ceiling regardless of further chunks.
228 if (savedContent !== null) {
229 if (!savedReported) {
230 savedReported = true
231 args.onProgress?.(0.82)
232 }
233 continue
234 }
235 modelUpdateCount += 1
236 // Asymptotic growth inside (0.25, 0.75): each model update advances a bit
237 // less, so a long iteration still converges instead of pinning at the cap.
238 // The floor of 0.25 guarantees the first real update overtakes the heartbeat
239 // (which is capped at 0.4) once model output starts flowing.
240 const ratio = Math.min(0.75, 0.25 + (1 - 1 / (modelUpdateCount + 1)) * 0.5)
241 args.onProgress?.(Math.max(ratio, heartbeatRatio))
242 }
243 } catch (error) {
244 const reason = inferCancellationReason(error, timeoutMs, args.signal)
245 if (reason === 'cancelled') throw new Error('生成已取消')
246 if (reason === 'timeout')
247 throw new Error(`模型响应超时(${Math.round(timeoutMs / 1000)}s),请重试。`)
248 throw error
249 } finally {
250 clearInterval(heartbeat)
251 clearTimeout(timer)
252 }
253
254 if (!savedContent) throw new Error('一键美化未提交有效页面内容,请重试。')
255 return savedContent
256 }
257
257 lines TYPESCRIPT