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