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