返回 oh-my-ppt
deck-tools.ts
根目录 / src / main / agent-runtime / tools / deck-tools.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 type { ToolStreamConfig } from './types'
6 import { emitToolStatus } from './types'
7 import { createPageWriteTools, getAgentNameFromToolConfig } from './page-writer'
8 import { verifyPresentationPageFiles } from '../../presentation/html/page-writer-core'
9 import { progressLabel } from '@shared/progress'
10 import {
11 INDEX_TRANSITION_TYPES,
12 persistIndexTransition,
13 verifyIndexShellFile
14 } from '../../presentation/html/index-transition'
15
16 const uiText = (locale: 'zh' | 'en' | undefined, zh: string, en: string): string =>
17 locale === 'en' ? en : zh
18
19 /** LangChain/DeepAgent deck tool adapter. Presentation rules stay in presentation/. */
20 export function createSessionBoundDeckTools(context: SessionDeckGenerationContext): unknown[] {
21 let lastReportedProgress = 0
22 const explicitTargetPageIds =
23 Array.isArray(context.selectPageIds) && context.selectPageIds.length > 0
24 ? context.selectPageIds.filter((pid) => Boolean(context.pageFileMap[pid]))
25 : []
26 const targetPageIds =
27 explicitTargetPageIds.length > 0
28 ? explicitTargetPageIds
29 : Array.isArray(context.allowedPageIds) && context.allowedPageIds.length > 0
30 ? context.allowedPageIds.filter((pid) => Boolean(context.pageFileMap[pid]))
31 : []
32
33 const totalScopedPages = Math.max(
34 1,
35 (targetPageIds.length > 0
36 ? targetPageIds.length
37 : Object.keys(context.pageFileMap).length) || 1
38 )
39 const isEditMode = context.mode === 'edit'
40 const isContainerScopeEdit = isEditMode && context.editScope === 'presentation-container'
41 const isDeckScopeEdit = isEditMode && context.editScope === 'deck'
42 const hasSelector = Boolean(context.selectedSelector?.trim())
43 const statusLanguage = context.appLocale === 'en' ? 'English' : 'Simplified Chinese'
44 const isSinglePageTask =
45 !isEditMode &&
46 (Boolean(context.selectedPageId) || targetPageIds.length === 1 || context.outlineTitles.length === 1)
47 const orderedPageIdsForProgress =
48 targetPageIds.length > 0
49 ? targetPageIds
50 : Object.keys(context.pageFileMap)
51
52 const parsePageNumber = (pageId?: string): number | null => {
53 if (!pageId) return null
54 const match = pageId.match(/^page-(\d+)$/i)
55 if (match) {
56 const num = Number(match[1])
57 if (Number.isFinite(num) && num > 0) return num
58 }
59 const fallbackIndex = orderedPageIdsForProgress.indexOf(pageId)
60 return fallbackIndex >= 0 ? fallbackIndex + 1 : null
61 }
62
63 const inferProgressFromStatus = (args: {
64 label: string
65 pageId?: string
66 detail?: string
67 }): number | undefined => {
68 const { label, pageId } = args
69 if (/读取会话上下文|Reading session context/i.test(label)) return 34
70 if (/验证完成状态|Verifying completion/i.test(label)) return 88
71 if (/所有页面已填充|当前页面已填充|All pages filled|Current page filled/i.test(label)) return 95
72 if (/生成完成|修改完成|Generation completed|Edit completed/i.test(label)) return 98
73 const updateMatch = label.match(/(?:更新|Updating)\s*(page-\d+)/i)
74 const resolvedPageId = pageId || updateMatch?.[1]
75 const pageNumber = parsePageNumber(resolvedPageId)
76 if (pageNumber) {
77 const fraction = Math.min(1, Math.max(0, (pageNumber - 0.5) / totalScopedPages))
78 return 40 + fraction * 44
79 }
80 return undefined
81 }
82
83 const normalizeStatusProgress = (args: {
84 label: string
85 progress?: number
86 pageId?: string
87 detail?: string
88 }): number => {
89 const inferred = inferProgressFromStatus(args)
90 const rawValue = Number.isFinite(args.progress) ? Number(args.progress) : inferred
91 if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) return lastReportedProgress
92 const rounded = Math.round(rawValue * 10) / 10
93 const clamped = Math.max(0, Math.min(100, rounded))
94 const monotonic = Math.max(lastReportedProgress, clamped)
95 lastReportedProgress = monotonic
96 return monotonic
97 }
98
99 const emitNormalizedToolStatus = (
100 config: unknown,
101 status: {
102 label: string
103 detail?: string
104 progress?: number
105 pageId?: string
106 agentName?: string
107 }
108 ): void => {
109 emitToolStatus(config as ToolStreamConfig, {
110 ...status,
111 label: progressLabel(context.appLocale, status.label),
112 progress: normalizeStatusProgress(status)
113 })
114 }
115
116 const pageWriteTools = createPageWriteTools({
117 context,
118 isEditMode,
119 isContainerScopeEdit,
120 emitNormalizedToolStatus
121 })
122
123 if (isSinglePageTask) return [...pageWriteTools]
124
125 return [
126 tool(
127 async (_input, config) => {
128 const scopedPageFileMap =
129 targetPageIds.length > 0
130 ? Object.fromEntries(
131 Object.entries(context.pageFileMap).filter(([pageId]) => targetPageIds.includes(pageId))
132 )
133 : context.pageFileMap
134 const visiblePageIds = Object.keys(scopedPageFileMap)
135 const agentPageFileMap = Object.fromEntries(
136 visiblePageIds.map((pageId) => [pageId, `/${pageId}.html`])
137 )
138 const selectedPagePath =
139 context.selectedPageId && agentPageFileMap[context.selectedPageId]
140 ? agentPageFileMap[context.selectedPageId]
141 : undefined
142 const pageFiles = visiblePageIds.map((pageId) => ({ pageId, agentPath: `/${pageId}.html` }))
143 const scopedExistingPageIds =
144 targetPageIds.length > 0
145 ? (context.existingPageIds || []).filter((pid) => targetPageIds.includes(pid))
146 : context.existingPageIds
147
148 emitNormalizedToolStatus(config, {
149 label: uiText(context.appLocale, '读取会话上下文', 'Reading session context'),
150 detail: isContainerScopeEdit
151 ? uiText(
152 context.appLocale,
153 `已提供演示容器文件: ${context.indexPath}`,
154 `Provided presentation container file: ${context.indexPath}`
155 )
156 : selectedPagePath
157 ? uiText(
158 context.appLocale,
159 `已提供目标页文件: ${selectedPagePath}`,
160 `Provided target page file: ${selectedPagePath}`
161 )
162 : uiText(
163 context.appLocale,
164 '已提供页面文件映射与会话上下文',
165 'Provided page-file map and session context'
166 ),
167 progress: 34
168 })
169 const constraints = isContainerScopeEdit
170 ? [
171 '当前为演示容器编辑(presentation-container):只允许修改 index.html 容器能力',
172 '只允许使用 set_index_transition(type, durationMs),禁止调用 update_index_file / update_page_file / update_single_page_file',
173 '禁止修改任何 /<pageId>.html 文件',
174 '必须保留 hash 导航、frameViewport、pages-data、controls、全屏/演示模式逻辑',
175 '禁止使用 CDN/远程 script/link(http/https/协议相对地址);仅允许本地资源'
176 ]
177 : hasSelector
178 ? [
179 'index.html 只是总览壳,主要内容在 /<pageId>.html',
180 '禁止使用 CDN/远程 script/link(http/https/协议相对地址);仅允许系统预注入的本地 ./assets/* 资源',
181 'Selector 编辑模式:先用 read_file 读取目标页面,再用 grep 搜索选择器/文本定位,最后用 edit_file(old_string, new_string) 精准替换',
182 '文件工具只能使用虚拟路径(例如 /<pageId>.html),禁止使用宿主机绝对路径',
183 '不要调用 write_file / update_page_file / update_single_page_file,edit_file 直接修改即可',
184 '仅修改 selector 命中节点,禁止整页重写、禁止改动无关区域',
185 isDeckScopeEdit
186 ? '主会话 deck 编辑禁止修改 index.html,只能改 /<pageId>.html'
187 : '尽量不要修改 index.html 的导航与控制逻辑'
188 ]
189 : [
190 'index.html 只是总览壳,主要内容写入 /<pageId>.html',
191 '禁止使用 CDN/远程 script/link(http/https/协议相对地址);仅允许系统预注入的本地 ./assets/* 资源',
192 '单页任务只允许使用 update_single_page_file(pageId, content),禁止调用 update_page_file',
193 '单页任务必须写入 selectedPagePath 对应的 page 文件,不需要改 index.html',
194 'read_file/edit_file/write_file 等文件工具只能使用虚拟路径(例如 /<pageId>.html),禁止使用宿主机绝对路径',
195 isEditMode
196 ? '多页/全局编辑使用 update_page_file(pageId, content),必须显式传 pageId'
197 : '多页生成优先使用 update_page_file(content)(可选传 pageId 覆盖自动定位)',
198 '每页写入后会自动注入动画运行时与防溢出保护',
199 '不要在最终答案里返回大块 HTML,必须把变更落盘',
200 isDeckScopeEdit
201 ? '主会话 deck 编辑禁止修改 index.html,只能改 /<pageId>.html'
202 : '尽量不要修改 index.html 的导航与控制逻辑'
203 ]
204 return JSON.stringify(
205 {
206 mode: context.mode || 'generate',
207 editScope: context.editScope || null,
208 sessionId: context.sessionId,
209 topic: context.topic,
210 deckTitle: context.deckTitle,
211 styleId: context.styleId || 'minimal-white',
212 designContract: context.designContract ?? null,
213 outlineTitles: context.outlineTitles,
214 outlineItems: context.outlineItems,
215 agentWorkspaceRoot: '/',
216 agentIndexPath: '/index.html',
217 pageFileMap: agentPageFileMap,
218 pageFiles,
219 allowedPageIds: context.allowedPageIds ?? null,
220 selectPageIds: context.selectPageIds ?? [],
221 userMessage: context.userMessage,
222 pageIds: visiblePageIds,
223 selectedPageId: context.selectedPageId ?? undefined,
224 selectedPagePath,
225 selectedPageNumber: context.selectedPageNumber ?? undefined,
226 selectedSelector: context.selectedSelector ?? undefined,
227 elementTag: context.elementTag ?? undefined,
228 elementText: context.elementText ?? undefined,
229 selectedElementContext: context.selectedElementContext ?? undefined,
230 existingPageIds: scopedExistingPageIds ?? undefined,
231 constraints
232 },
233 null,
234 2
235 )
236 },
237 {
238 name: 'get_session_context',
239 description:
240 'Get the current session generation context, directory paths, index.html path, page titles, and constraints.',
241 schema: z.object({})
242 }
243 ),
244
245 tool(
246 async ({ label, detail, progress }, config) => {
247 emitNormalizedToolStatus(config, {
248 label,
249 detail: detail ?? undefined,
250 progress: progress ?? undefined
251 })
252 return `Status recorded: ${label}`
253 },
254 {
255 name: 'report_generation_status',
256 description: `Report the current generation/editing stage to the host UI. The label and detail must be written in ${statusLanguage}, regardless of the deck content language. progress must be a numeric literal such as 10, not a string such as "10".`,
257 schema: z.object({
258 label: z.string().describe(`Current stage label in ${statusLanguage}`),
259 detail: z.string().nullable().optional().describe(`Optional extra detail in ${statusLanguage}`),
260 progress: z.number().min(0).max(100).nullable().optional().describe('Suggested progress')
261 })
262 }
263 ),
264
265 ...(isContainerScopeEdit
266 ? [
267 tool(
268 async ({ type, durationMs }, config) => {
269 const result = await persistIndexTransition({
270 indexPath: context.indexPath,
271 projectDir: context.projectDir,
272 input: { type, durationMs }
273 })
274 if (result.status === 'missing') {
275 throw new Error(`index.html 缺失:${context.indexPath}`)
276 }
277 if (result.status === 'invalid') {
278 emitNormalizedToolStatus(config, {
279 label: uiText(context.appLocale, '切换动画配置失败', 'Transition configuration failed'),
280 detail: result.errors.join('; '),
281 progress: 60
282 })
283 throw new Error(`index.html 验证失败: ${result.errors.join('; ')}`)
284 }
285 const transitionConfig = result.config
286 const transitionType = transitionConfig.type
287 emitNormalizedToolStatus(config, {
288 label:
289 transitionType === 'none'
290 ? uiText(context.appLocale, '关闭切换动画', 'Transition disabled')
291 : uiText(context.appLocale, '更新切换动画', 'Transition updated'),
292 detail:
293 transitionType === 'none'
294 ? uiText(context.appLocale, '已恢复无过渡切换', 'Restored instant page switching')
295 : uiText(
296 context.appLocale,
297 `已设置 ${transitionType} ${transitionConfig.durationMs}ms`,
298 `Set ${transitionType} transition to ${transitionConfig.durationMs}ms`
299 ),
300 progress: 72
301 })
302 log.info('[deepagent] set_index_transition', {
303 sessionId: context.sessionId,
304 indexPath: context.indexPath,
305 type: transitionType,
306 durationMs: transitionType === 'none' ? null : transitionConfig.durationMs,
307 agentName: getAgentNameFromToolConfig(config) || 'unknown'
308 })
309 return `Updated index transition in ${context.indexPath}`
310 },
311 {
312 name: 'set_index_transition',
313 description:
314 'Controlled tool for the main session: configure index.html page transition animation without rewriting the index shell.',
315 schema: z.object({
316 type: z
317 .enum(INDEX_TRANSITION_TYPES)
318 .describe(`Transition type: ${INDEX_TRANSITION_TYPES.join(', ')}`),
319 durationMs: z
320 .number()
321 .optional()
322 .describe('Animation duration, 120-1200ms, default 600ms')
323 })
324 }
325 )
326 ]
327 : []),
328
329 ...pageWriteTools,
330
331 tool(
332 async (_input, config) => {
333 if (isContainerScopeEdit) {
334 emitNormalizedToolStatus(config, {
335 label: uiText(context.appLocale, '验证完成状态', 'Verifying completion'),
336 detail: uiText(
337 context.appLocale,
338 '正在检查 index.html 总览壳结构',
339 'Checking the index.html overview shell structure'
340 ),
341 progress: 88
342 })
343 const verification = await verifyIndexShellFile(context.indexPath)
344 if (verification.status === 'missing') {
345 return `验证失败:index.html 缺失(${context.indexPath})。请检查会话文件是否完整。`
346 }
347 if (verification.status === 'invalid') {
348 return `验证失败:index.html 结构不完整:${verification.errors.join('; ')}`
349 }
350 emitNormalizedToolStatus(config, {
351 label: uiText(context.appLocale, 'index 壳验证通过', 'Index shell verified'),
352 detail: uiText(context.appLocale, 'index.html 关键结构完整', 'Key index.html structure is complete'),
353 progress: 95
354 })
355 return '验证通过:index.html 已更新且结构完整。'
356 }
357 emitNormalizedToolStatus(config, {
358 label: uiText(context.appLocale, '验证完成状态', 'Verifying completion'),
359 detail: uiText(
360 context.appLocale,
361 '正在检查所有 page 文件是否已填充',
362 'Checking whether all page files are filled'
363 ),
364 progress: 88
365 })
366 const pageIds = Object.keys(context.pageFileMap)
367 const verificationPageIds =
368 targetPageIds.length > 0
369 ? pageIds.filter((pid) => targetPageIds.includes(pid))
370 : pageIds
371 const results = await verifyPresentationPageFiles({
372 pageFileMap: context.pageFileMap,
373 pageIds: verificationPageIds
374 })
375 const missingFiles = results.filter((result) => !result.filled).map((result) => result.pageId)
376 const emptyPages = results.filter((result) => result.filled && !result.hasContent).map((result) => result.pageId)
377 const remoteRuntimePages = results.filter((result) => result.hasRemoteRuntime).map((result) => result.pageId)
378 const filledCount = results.filter((result) => result.hasContent).length
379 if (missingFiles.length > 0) {
380 return `验证发现问题:以下页面文件缺失或为空: ${missingFiles.join(', ')}。请检查对应 /<pageId>.html 是否已创建。`
381 }
382 if (emptyPages.length > 0) {
383 return `部分页面尚未填充: ${emptyPages.join(', ')}。已完成 ${filledCount}/${verificationPageIds.length} 页。单页任务请用 update_single_page_file(pageId, content),多页任务请用 update_page_file(content) 继续填充。`
384 }
385 if (remoteRuntimePages.length > 0) {
386 return `验证失败:以下页面包含禁止的 CDN/远程 script/link 资源: ${remoteRuntimePages.join(', ')}。请移除外链并仅使用系统预注入的本地 ./assets/* 资源。`
387 }
388 const isSinglePageCheck = verificationPageIds.length === 1
389 emitNormalizedToolStatus(config, {
390 label: isSinglePageCheck
391 ? uiText(context.appLocale, '当前页面已填充', 'Current page filled')
392 : uiText(context.appLocale, '所有页面已填充', 'All pages filled'),
393 detail: isSinglePageCheck
394 ? uiText(
395 context.appLocale,
396 `${verificationPageIds[0]} 已完成`,
397 `${verificationPageIds[0]} completed`
398 )
399 : uiText(
400 context.appLocale,
401 `${filledCount}/${verificationPageIds.length} 页已完成`,
402 `${filledCount}/${verificationPageIds.length} pages completed`
403 ),
404 progress: 95
405 })
406 return isSinglePageCheck
407 ? `验证通过:${verificationPageIds[0]} 已成功填充。${JSON.stringify(results, null, 2)}`
408 : `验证通过:全部 ${verificationPageIds.length} 页已成功填充。${JSON.stringify(results, null, 2)}`
409 },
410 {
411 name: 'verify_completion',
412 description:
413 'Verify that all page files have been filled correctly. Use after update_single_page_file or update_page_file.',
414 schema: z.object({})
415 }
416 )
417 ]
418 }
419
419 lines TYPESCRIPT