返回 oh-my-ppt
page-writer.ts
根目录 / src / main / agent-runtime / tools / page-writer.ts
1 import { tool } from '@langchain/core/tools'
2 import { z } from 'zod'
3 import log from 'electron-log/main.js'
4 import type { SessionDeckGenerationContext } from '../agent/types'
5 import {
6 countHtmlTag,
7 PageWriteValidationError,
8 persistPageHtmlFromFragment
9 } from '../../presentation/html/page-writer-core'
10
11 const uiText = (locale: 'zh' | 'en' | undefined, zh: string, en: string): string =>
12 locale === 'en' ? en : zh
13
14 export function getAgentNameFromToolConfig(config: unknown): string | undefined {
15 const maybe = config as Record<string, unknown> | undefined
16 const metadata = maybe?.metadata as Record<string, unknown> | undefined
17 const configurable = maybe?.configurable as Record<string, unknown> | undefined
18 const fromMetadata = metadata?.lc_agent_name
19 const fromConfigurable = configurable?.lc_agent_name
20 if (typeof fromMetadata === 'string' && fromMetadata.trim().length > 0) return fromMetadata.trim()
21 if (typeof fromConfigurable === 'string' && fromConfigurable.trim().length > 0)
22 return fromConfigurable.trim()
23 return undefined
24 }
25
26 type EmitNormalizedToolStatus = (
27 config: unknown,
28 status: {
29 label: string
30 detail?: string
31 progress?: number
32 pageId?: string
33 agentName?: string
34 }
35 ) => void
36
37 /** LangChain tool adapter for the presentation-domain page persistence capability. */
38 export function createPageWriteTools(args: {
39 context: SessionDeckGenerationContext
40 isEditMode: boolean
41 isContainerScopeEdit: boolean
42 emitNormalizedToolStatus: EmitNormalizedToolStatus
43 }): unknown[] {
44 const { context, isEditMode, isContainerScopeEdit, emitNormalizedToolStatus } = args
45 const writablePageIds =
46 Array.isArray(context.selectPageIds) && context.selectPageIds.length > 0
47 ? context.selectPageIds.filter((pid) => Boolean(context.pageFileMap[pid]))
48 : Array.isArray(context.allowedPageIds) && context.allowedPageIds.length > 0
49 ? context.allowedPageIds.filter((pid) => Boolean(context.pageFileMap[pid]))
50 : []
51 const scopedPageIdsForWrite = (
52 writablePageIds.length > 0
53 ? writablePageIds
54 : Object.keys(context.pageFileMap)
55 ).sort((a, b) => {
56 const an = Number(a.match(/^page-(\d+)$/i)?.[1] || 0)
57 const bn = Number(b.match(/^page-(\d+)$/i)?.[1] || 0)
58 return an - bn
59 })
60 let autoPageCursor = 0
61 const writtenPageIds = new Set<string>()
62
63 const resolveSingleTargetPageId = (): string | undefined => {
64 if (context.selectedPageId && context.pageFileMap[context.selectedPageId]) {
65 return context.selectedPageId
66 }
67 if (writablePageIds.length === 1) {
68 const only = writablePageIds[0]
69 if (context.pageFileMap[only]) return only
70 }
71 return undefined
72 }
73
74 const resolveWriteTargetPage = (
75 requestedPageId?: string
76 ): { pageId: string; isAuto: boolean } => {
77 if (requestedPageId && requestedPageId.trim().length > 0) {
78 return { pageId: requestedPageId.trim(), isAuto: false }
79 }
80 const singleTarget = resolveSingleTargetPageId()
81 if (singleTarget) return { pageId: singleTarget, isAuto: false }
82 if (scopedPageIdsForWrite.length === 0) {
83 throw new Error('当前会话没有可写入页面。')
84 }
85 if (scopedPageIdsForWrite.every((pid) => writtenPageIds.has(pid))) {
86 throw new Error(
87 '当前作用域内页面已经全部写入。请调用 verify_completion() 校验,不要继续自动写入。'
88 )
89 }
90 while (
91 autoPageCursor < scopedPageIdsForWrite.length - 1 &&
92 writtenPageIds.has(scopedPageIdsForWrite[autoPageCursor])
93 ) {
94 autoPageCursor += 1
95 }
96 const idx = Math.min(autoPageCursor, scopedPageIdsForWrite.length - 1)
97 const picked = scopedPageIdsForWrite[idx]
98 return { pageId: picked, isAuto: true }
99 }
100
101 const writePageFile = async (writeArgs: {
102 pageId?: string
103 content: string
104 config: unknown
105 statusLabel?: string
106 }): Promise<string> => {
107 if (isContainerScopeEdit) {
108 throw new Error(
109 '当前为演示容器编辑(presentation-container),不允许通过页面写入工具修改 page 文件。'
110 )
111 }
112 const { pageId, content, config, statusLabel } = writeArgs
113 const { pageId: resolvedPageId, isAuto } = resolveWriteTargetPage(pageId)
114 const agentName = getAgentNameFromToolConfig(config)
115 if (writablePageIds.length > 0 && !writablePageIds.includes(resolvedPageId)) {
116 throw new Error(
117 `当前任务仅允许修改: ${writablePageIds.join(', ')};收到: ${resolvedPageId}`
118 )
119 }
120 const targetPath = context.pageFileMap[resolvedPageId]
121 if (!targetPath) {
122 throw new Error(
123 `未知页面 ${resolvedPageId},可用页面: ${Object.keys(context.pageFileMap).join(', ')}`
124 )
125 }
126 emitNormalizedToolStatus(config, {
127 label:
128 statusLabel ||
129 uiText(context.appLocale, `更新 ${resolvedPageId}`, `Updating ${resolvedPageId}`),
130 detail: uiText(context.appLocale, '正在写入对应 page 文件', 'Writing the target page file'),
131 pageId: resolvedPageId,
132 agentName
133 })
134 let persisted: Awaited<ReturnType<typeof persistPageHtmlFromFragment>>
135 try {
136 const designFonts = {
137 titleFont: context.designContract?.titleFont || 'Inter',
138 bodyFont: context.designContract?.bodyFont || 'Inter'
139 }
140 persisted = await persistPageHtmlFromFragment({
141 content,
142 pageId: resolvedPageId,
143 pageNumber: context.pageNumbers?.[resolvedPageId],
144 projectDir: context.projectDir,
145 targetPath,
146 slideSize: context.slideSize,
147 designFonts,
148 preserveTemplateSkeleton: context.templatePageReadRequired
149 })
150 } catch (error) {
151 if (error instanceof PageWriteValidationError) {
152 if (error.kind === 'template-skeleton') {
153 emitNormalizedToolStatus(config, {
154 label: `模板骨架校验失败 ${resolvedPageId}`,
155 detail: `写入内容丢失模板背景/装饰资源: ${error.details.slice(0, 8).join(', ')}`,
156 progress: 60,
157 pageId: resolvedPageId
158 })
159 } else if (error.kind === 'remote-resource') {
160 emitNormalizedToolStatus(config, {
161 label: `外链资源校验失败 ${resolvedPageId}`,
162 detail: `检测到 ${error.details.length} 个远程 script/link 资源。仅允许使用系统预注入的本地 ./assets/*`,
163 progress: 60,
164 pageId: resolvedPageId
165 })
166 } else {
167 emitNormalizedToolStatus(config, {
168 label: error.kind === 'persisted-validation' ? `落盘校验失败 ${resolvedPageId}` : `验证失败 ${resolvedPageId}`,
169 detail: error.details.join('; '),
170 progress: 60,
171 pageId: resolvedPageId
172 })
173 }
174 }
175 throw error
176 }
177 if (persisted.repaired) {
178 const divCount = countHtmlTag(content, 'div')
179 log.info('[deepagent] repaired malformed page fragment before write', {
180 sessionId: context.sessionId,
181 pageId: resolvedPageId,
182 mode: context.mode || 'generate',
183 editScope: context.editScope ?? null,
184 provider: context.provider || '',
185 model: context.model || '',
186 selectedPageId: context.selectedPageId ?? null,
187 contentLength: content.length,
188 repairedContentLength: persisted.content.length,
189 divOpenCount: divCount.open,
190 divCloseCount: divCount.close,
191 originalErrors: persisted.originalErrors || []
192 })
193 }
194 writtenPageIds.add(resolvedPageId)
195 if (isAuto) {
196 autoPageCursor = Math.min(autoPageCursor + 1, scopedPageIdsForWrite.length)
197 }
198 log.info('[deepagent] update_page_file', {
199 sessionId: context.sessionId,
200 pageId: resolvedPageId,
201 targetPath,
202 agentName: agentName || 'unknown',
203 allowedPageIds: context.allowedPageIds || null,
204 selectPageIds: context.selectPageIds || null
205 })
206 return `Updated ${resolvedPageId} in ${targetPath}`
207 }
208
209 if (isContainerScopeEdit || (isEditMode && context.selectedSelector?.trim())) {
210 return []
211 }
212
213 const singleTargetPageId = resolveSingleTargetPageId()
214 if (singleTargetPageId) {
215 return [
216 tool(
217 async ({ pageId, content }, config) => {
218 const targetPageId = resolveSingleTargetPageId()
219 if (!targetPageId) {
220 throw new Error(
221 isEditMode
222 ? '当前会话未锁定单页。请改用 update_page_file(pageId, content) 并显式传 pageId,或在上下文中指定 selectedPageId。'
223 : '当前会话未锁定单页。请改用 update_page_file(content) 或在上下文中指定 selectedPageId。'
224 )
225 }
226 if (targetPageId && pageId !== targetPageId) {
227 throw new Error(`单页编辑工具仅允许目标页面 ${targetPageId};收到: ${pageId}`)
228 }
229 return writePageFile({
230 pageId,
231 content,
232 config,
233 statusLabel: uiText(context.appLocale, `更新单页 ${pageId}`, `Updating ${pageId}`)
234 })
235 },
236 {
237 name: context.templatePageReadRequired
238 ? 'update_template_page_file'
239 : 'update_single_page_file',
240 description:
241 context.templatePageReadRequired
242 ? 'Template-preserving page generation tool. Pass pageId and a complete creative page fragment based on the copied template page. It validates pageId and rejects writes that drop template background/decorative CSS url(...) resources, SVG image hrefs, or decorative local media references.'
243 : 'Single-page edit tool. Pass pageId and content explicitly; the tool validates pageId against the current single-page context to avoid modifying other pages.',
244 schema: z.object({
245 pageId: z
246 .string()
247 .describe(
248 'Target pageId, for example "page-<slug>". It must match the current single-page context.'
249 ),
250 content: z
251 .string()
252 .describe(
253 context.templatePageReadRequired
254 ? 'Complete creative page HTML fragment based on the copied template page. Keep template background/decorative layers and exact local asset references from the inspected template page while replacing old business text/data. The tool will add the runtime page frame when needed. Do not pass <!doctype>, <html>, <head>, <body>, .ppt-page-root, .ppt-page-content, .ppt-page-fit-scope, data-ppt-guard-root, or runtime shell markup.'
255 : 'Complete creative page HTML fragment only. The tool will add section[data-page-scaffold], main[data-role="content"], editable data-block-id attributes, and the runtime page frame when needed. Do not pass <!doctype>, <html>, <head>, <body>, .ppt-page-root, .ppt-page-content, .ppt-page-fit-scope, data-ppt-guard-root, or any runtime shell markup.'
256 )
257 })
258 }
259 )
260 ]
261 }
262
263 return [
264 tool(
265 async ({ pageId, content }, config) => {
266 if (isEditMode && (!pageId || pageId.trim().length === 0)) {
267 throw new Error(
268 '编辑模式调用 update_page_file 时必须显式传 pageId,避免自动游标误写到其它页面。'
269 )
270 }
271 const singleTargetPageId = resolveSingleTargetPageId()
272 if (singleTargetPageId) {
273 throw new Error(
274 `当前为单页上下文(${singleTargetPageId}),禁止调用 update_page_file。请改用 update_single_page_file(pageId, content)。`
275 )
276 }
277 return writePageFile({ pageId, content, config })
278 },
279 {
280 name: 'update_page_file',
281 description:
282 'Multi-page generation/global edit tool. Disabled in single-page context. In generation mode pageId may be omitted to resolve pages by order; in edit mode pageId is required. content must be a complete creative page fragment. The tool adds section/main content semantics, editable block ids, wraps it as a complete HTML document, and injects runtime assets. Do not pass a full HTML document, runtime page shell, or ppt-page-root/content/fit-scope markup. HTML is validated before writing.',
283 schema: z.object({
284 pageId: z
285 .string()
286 .optional()
287 .describe(
288 'Optional target pageId, for example "page-<slug>". If omitted, the tool resolves the page from context/order.'
289 ),
290 content: z
291 .string()
292 .describe(
293 'Complete creative page HTML fragment only. The tool will add section[data-page-scaffold], main[data-role="content"], editable data-block-id attributes, and the runtime page frame when needed. Do not pass <!doctype>, <html>, <head>, <body>, .ppt-page-root, .ppt-page-content, .ppt-page-fit-scope, data-ppt-guard-root, or any runtime shell markup.'
294 )
295 })
296 }
297 )
298 ]
299 }
300
300 lines TYPESCRIPT