| 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 |