返回 oh-my-ppt
html-editor-ai-handlers.ts
根目录 / src / main / html-editor / html-editor-ai-handlers.ts
1 import { ipcMain } from 'electron'
2 import log from 'electron-log/main.js'
3 import { tool, type StructuredToolInterface } from '@langchain/core/tools'
4 import { FilesystemBackend, createDeepAgent } from 'deepagents'
5 import { z } from 'zod'
6 import { extractModelText, resolveModel } from '../agent-runtime/model'
7 import { resolveModelTimeoutMs } from '@shared/model-timeout'
8 import type { IpcContext } from '../ipc/context'
9 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../config/model-config-utils'
10 import { readAppLocale } from '../config/locale-utils'
11 import { logAgentToolEvents } from '../utils/agent-tool-logger'
12 import {
13 applyHtmlEditsForDocument,
14 resolveHtmlEditorDocumentWorkspace
15 } from './html-editor-handlers'
16 import { nanoid } from 'nanoid'
17
18 export type HtmlEditorAiMessage = {
19 role: 'user' | 'assistant'
20 content: string
21 }
22
23 export type HtmlEditorAiElementContext = {
24 selector: string
25 label?: string
26 elementTag?: string
27 elementText?: string
28 html?: string
29 }
30
31 export type HtmlEditorAiEditBatch = {
32 propertyEdits: Array<Record<string, unknown>>
33 textEdits: Array<Record<string, unknown>>
34 dragEdits: Array<Record<string, unknown>>
35 deletes: Array<Record<string, unknown>>
36 addElements: Array<Record<string, unknown>>
37 }
38
39 export const HTML_EDITOR_AI_INTENTS = [
40 'inspect',
41 'redesign',
42 'style',
43 'layout',
44 'content',
45 'other'
46 ] as const
47
48 export type HtmlEditorAiIntent = (typeof HTML_EDITOR_AI_INTENTS)[number]
49
50 export type HtmlEditorAiPlan = {
51 intent: HtmlEditorAiIntent
52 target: string
53 summary: string
54 changes: string[]
55 confirmationQuestion: string
56 edits: HtmlEditorAiEditBatch
57 }
58
59 export type HtmlEditorAiPromptArgs = {
60 documentTitle?: string
61 pageHtml?: string
62 selectedElement?: HtmlEditorAiElementContext
63 recentMessages?: HtmlEditorAiMessage[]
64 userMessage: string
65 locale?: 'zh' | 'en'
66 pendingPlan?: HtmlEditorAiPlan
67 }
68
69 const MAX_USER_MESSAGE_LENGTH = 4_000
70 const MAX_HISTORY_MESSAGES = 6
71 const MAX_HISTORY_MESSAGE_LENGTH = 1_800
72 const MAX_ELEMENT_HTML_LENGTH = 10_000
73 const MAX_PAGE_HTML_LENGTH = 12_000
74 const MAX_VERSION_MESSAGE_LENGTH = 180
75
76 const htmlEditorAiStylePatchSchema = z.object({
77 zIndex: z.number().finite().optional(),
78 opacity: z.number().finite().optional(),
79 backgroundColor: z.string().max(100).optional(),
80 color: z.string().max(100).optional(),
81 fontSize: z.string().max(50).optional(),
82 fontWeight: z.string().max(50).optional(),
83 textAlign: z.string().max(30).optional(),
84 objectFit: z.string().max(30).optional()
85 })
86
87 const htmlEditorAiAttrsPatchSchema = z.object({
88 className: z.string().max(2_000).optional(),
89 alt: z.string().max(500).optional(),
90 poster: z.string().max(1_000).optional(),
91 controls: z.boolean().optional(),
92 muted: z.boolean().optional(),
93 loop: z.boolean().optional(),
94 autoplay: z.boolean().optional(),
95 playsInline: z.boolean().optional(),
96 preload: z.enum(['metadata', 'auto', 'none']).optional()
97 })
98
99 const htmlEditorAiPropertyEditSchema = z.object({
100 selector: z.string().min(1).max(2_000),
101 blockId: z.string().max(500).optional(),
102 patch: z.object({
103 html: z.string().max(12_000).optional(),
104 text: z.string().max(500).optional(),
105 style: htmlEditorAiStylePatchSchema.optional(),
106 attrs: htmlEditorAiAttrsPatchSchema.optional()
107 })
108 })
109
110 const htmlEditorAiDragEditSchema = z.object({
111 selector: z.string().min(1).max(2_000),
112 x: z.number().finite().optional(),
113 y: z.number().finite().optional(),
114 width: z.number().finite().optional(),
115 height: z.number().finite().optional(),
116 isAbsoluteMode: z.boolean().optional(),
117 zIndex: z.number().finite().optional(),
118 zIndexOnly: z.boolean().optional()
119 })
120
121 const htmlEditorAiEditBatchSchema = z.object({
122 propertyEdits: z.array(htmlEditorAiPropertyEditSchema).max(8).default([]),
123 textEdits: z.array(htmlEditorAiPropertyEditSchema).max(8).default([]),
124 dragEdits: z.array(htmlEditorAiDragEditSchema).max(8).default([]),
125 deletes: z
126 .array(z.object({ selector: z.string().min(1).max(2_000) }))
127 .max(8)
128 .default([]),
129 addElements: z
130 .array(
131 z.object({
132 parentSelector: z.string().min(1).max(2_000),
133 htmlFragment: z.string().min(1).max(20_000),
134 insertIndex: z.number().int().min(-1).max(10_000).optional()
135 })
136 )
137 .max(4)
138 .default([])
139 })
140
141 const htmlEditorAiPlanSchema = z.object({
142 intent: z.enum(HTML_EDITOR_AI_INTENTS),
143 target: z.string().min(1).max(500),
144 summary: z.string().min(1).max(1_500),
145 changes: z.array(z.string().min(1).max(500)).min(1).max(8),
146 confirmationQuestion: z.string().min(1).max(300),
147 edits: htmlEditorAiEditBatchSchema.default({
148 propertyEdits: [],
149 textEdits: [],
150 dragEdits: [],
151 deletes: [],
152 addElements: []
153 })
154 })
155
156 const clipText = (value: unknown, maxLength: number): string => {
157 const text = typeof value === 'string' ? value.trim() : ''
158 if (text.length <= maxLength) return text
159 return `${text.slice(0, maxLength)}\n...[内容已截断]`
160 }
161
162 async function persistHtmlEditorMessage(
163 ctx: Pick<IpcContext, 'db'>,
164 message: {
165 docId: string
166 role: 'user' | 'assistant'
167 content: string
168 intent?: string
169 plan?: HtmlEditorAiPlan | null
170 requiresConfirmation?: boolean
171 selectedElement?: HtmlEditorAiElementContext
172 }
173 ): Promise<void> {
174 try {
175 await ctx.db.createHtmlEditMessage({
176 id: nanoid(14),
177 docId: message.docId,
178 role: message.role,
179 content: clipText(message.content, MAX_USER_MESSAGE_LENGTH),
180 intent: message.intent || null,
181 planJson: message.plan ? JSON.stringify(message.plan) : null,
182 requiresConfirmation: message.requiresConfirmation === true,
183 selectedElement: message.selectedElement,
184 createdAt: Date.now()
185 })
186 } catch (error) {
187 log.warn('[html-editor:aiChat] persist message failed', {
188 docId: message.docId,
189 role: message.role,
190 message: error instanceof Error ? error.message : String(error)
191 })
192 }
193 }
194
195 function normalizeMessage(value: unknown): HtmlEditorAiMessage | null {
196 if (!value || typeof value !== 'object') return null
197 const record = value as Record<string, unknown>
198 const role = record.role === 'assistant' ? 'assistant' : record.role === 'user' ? 'user' : null
199 const content = clipText(record.content, MAX_HISTORY_MESSAGE_LENGTH)
200 return role && content ? { role, content } : null
201 }
202
203 function normalizeElement(value: unknown): HtmlEditorAiElementContext | undefined {
204 if (!value || typeof value !== 'object') return undefined
205 const record = value as Record<string, unknown>
206 const selector = clipText(record.selector, 2_000)
207 if (!selector) return undefined
208 return {
209 selector,
210 label: clipText(record.label, 500) || undefined,
211 elementTag: clipText(record.elementTag, 80) || undefined,
212 elementText: clipText(record.elementText, 2_000) || undefined,
213 html: clipText(record.html, MAX_ELEMENT_HTML_LENGTH) || undefined
214 }
215 }
216
217 function normalizePendingPlan(value: unknown): HtmlEditorAiPlan | undefined {
218 const parsed = htmlEditorAiPlanSchema.safeParse(value)
219 return parsed.success ? (parsed.data as HtmlEditorAiPlan) : undefined
220 }
221
222 function shouldIncludeConversationHistory(args: HtmlEditorAiPromptArgs): boolean {
223 if (!args.selectedElement || args.pendingPlan) return true
224 const normalized = args.userMessage.toLowerCase().replace(/\s+/g, '')
225 return /继续|刚才|上一条|上面|之前|这个方案|还是|然后|另外|同样|再改/.test(normalized)
226 }
227
228 function shouldIncludePageHtml(args: HtmlEditorAiPromptArgs): boolean {
229 if (!args.pageHtml) return false
230 if (!args.selectedElement) return true
231 const normalized = args.userMessage.toLowerCase().replace(/\s+/g, '')
232 return /整页|页面|文档|全局|整体|布局|结构|周围|旁边|其他|全部|整个/.test(normalized)
233 }
234
235 function isConfirmationRequest(userMessage: string, pendingPlan?: HtmlEditorAiPlan): boolean {
236 if (!pendingPlan) return false
237 const normalized = userMessage.toLowerCase().replace(/\s+/g, '')
238 return /确认|按这个|按方案|直接改|改吧|执行|应用|同意|没问题|可以改|好的改/.test(normalized)
239 }
240
241 function hasConcreteEditValue(normalizedMessage: string): boolean {
242 return /(?:改成|改为|换成|换为|设置为|设置成|设为|变成|变为|替换为|替换成|调整为|移动到|添加|加上|改造成)(?!$)(?!一下$)/.test(
243 normalizedMessage
244 )
245 }
246
247 export function isExplicitHtmlEditorEditRequest(
248 userMessage: string,
249 selectedElement?: HtmlEditorAiElementContext
250 ): boolean {
251 if (!selectedElement?.selector) return false
252 const normalized = userMessage.toLowerCase().replace(/\s+/g, '')
253 if (
254 /更?好看|更?现代|更?高级|更?专业|漂亮|美观|简洁|优化|美化|风格|设计感/.test(normalized) ||
255 /调整一下|改造一下|重新设计|改一下|处理一下/.test(normalized)
256 ) {
257 return false
258 }
259 if (/删除|移除|隐藏|显示/.test(normalized)) return true
260 return hasConcreteEditValue(normalized)
261 }
262
263 function isHtmlEditorChangeRequest(userMessage: string): boolean {
264 const normalized = userMessage.toLowerCase().replace(/\s+/g, '')
265 return /改|换|设置|删除|移除|隐藏|显示|添加|加上|移动|调整|优化|美化|改造|重新设计|替换|变成|设为/.test(
266 normalized
267 )
268 }
269
270 function hasHtmlEditorEdits(batch: HtmlEditorAiEditBatch): boolean {
271 return Object.values(batch).some((edits) => edits.length > 0)
272 }
273
274 function buildAppliedReply(locale: 'zh' | 'en', confirmed: boolean, warnings: string[]): string {
275 if (locale === 'en') {
276 return `${confirmed ? 'The confirmed HTML redesign has been applied.' : 'The HTML redesign has been applied.'}${warnings.length > 0 ? ` Warnings: ${warnings.join('; ')}` : ''}`
277 }
278 return `${confirmed ? '已按确认方案完成 HTML 改造。' : '已完成 HTML 改造。'}${warnings.length > 0 ? `提示:${warnings.join(';')}` : ''}`
279 }
280
281 function buildNoChangeReply(locale: 'zh' | 'en', warnings: string[]): string {
282 if (locale === 'en') {
283 return `No effective HTML change was produced, so the page and version history were left unchanged.${warnings.length > 0 ? ` Warnings: ${warnings.join('; ')}` : ''}`
284 }
285 return `没有产生可写入的 HTML 改动,页面和版本历史保持不变。${warnings.length > 0 ? ` 警告:${warnings.join(';')}` : ''}`
286 }
287
288 function buildHtmlEditorAiVersionMessage(
289 userMessage: string,
290 plan?: HtmlEditorAiPlan | null
291 ): string {
292 const detail = plan?.summary?.trim() || userMessage.trim() || '已应用改动'
293 return clipText(`AI 改造:${detail.replace(/\s+/g, ' ')}`, MAX_VERSION_MESSAGE_LENGTH)
294 }
295
296 function buildSelectionRequiredReply(locale: 'zh' | 'en'): string {
297 return locale === 'en'
298 ? 'Select an element on the canvas first, then I can apply the requested change to it.'
299 : '请先在画布中检选一个元素,再让我按你的要求改造它。'
300 }
301
302 function validateHtmlEditorAiEditTargets(
303 batch: HtmlEditorAiEditBatch,
304 selectedSelector?: string
305 ): void {
306 const selectors = [
307 ...batch.propertyEdits,
308 ...batch.textEdits,
309 ...batch.dragEdits,
310 ...batch.deletes,
311 ...batch.addElements.map((item) => ({ selector: item.parentSelector }))
312 ]
313 .map((item) => (typeof item.selector === 'string' ? item.selector.trim() : ''))
314 .filter(Boolean)
315 if (!selectedSelector && selectors.length > 0) {
316 throw new Error('AI 改造必须先检选一个元素')
317 }
318 if (selectedSelector && selectors.some((selector) => selector !== selectedSelector)) {
319 throw new Error('AI 改造只能应用到当前检选的元素')
320 }
321 }
322
323 function createHtmlEditorAiApplyTool(args: {
324 ctx: Pick<IpcContext, 'db' | 'resolveStoragePath'>
325 documentId: string
326 html?: string
327 selectedSelector?: string
328 canApply: boolean
329 batchOverride?: HtmlEditorAiEditBatch
330 getVersionMessage?: () => string
331 }): {
332 tool: StructuredToolInterface
333 getApplied: () => { html: string; warnings: string[]; changed: boolean } | null
334 } {
335 let applied: { html: string; warnings: string[]; changed: boolean } | null = null
336 const applyTool = tool(
337 async (input) => {
338 if (!args.canApply) {
339 return JSON.stringify({
340 status: 'confirmation_required',
341 message: '用户尚未确认,不能应用改动。'
342 })
343 }
344 if (applied) {
345 return JSON.stringify({ status: 'already_applied', warnings: applied.warnings })
346 }
347 const batch = args.batchOverride || (input as HtmlEditorAiEditBatch)
348 validateHtmlEditorAiEditTargets(batch, args.selectedSelector)
349 applied = await applyHtmlEditsForDocument(args.ctx, {
350 docId: args.documentId,
351 html: args.html,
352 batch,
353 message: args.getVersionMessage?.() || 'AI 改造'
354 })
355 return JSON.stringify({
356 status: applied.changed ? 'applied' : 'no_changes',
357 warnings: applied.warnings
358 })
359 },
360 {
361 name: 'apply_html_editor_edits',
362 description:
363 '在执行条件满足时,将结构化 HTML 编辑持久化到当前文档。明确改动请求可以直接执行;模糊改造请求必须先等待用户确认。只能修改当前检选元素。',
364 schema: htmlEditorAiEditBatchSchema
365 }
366 )
367 return { tool: applyTool as unknown as StructuredToolInterface, getApplied: () => applied }
368 }
369
370 export function buildHtmlEditorAiSystemPrompt(
371 locale: 'zh' | 'en' = 'zh',
372 options: { confirmed?: boolean; autoApply?: boolean; hasSelectedElement?: boolean } = {}
373 ): string {
374 const confirmed = options.confirmed === true
375 const autoApply = options.autoApply === true
376 const hasSelectedElement = options.hasSelectedElement !== false
377 return locale === 'en'
378 ? [
379 'You are the independent AI assistant for a local HTML editor.',
380 'Help the user select an element and assist with redesigning or improving it.',
381 "Answer in the user's language. Be concrete and concise.",
382 'You are running in a ReAct flow. You must call record_html_editor_plan once before your final response to identify intent and record the executable redesign plan.',
383 'There is no separate confirmation button in the UI. Treat a clear user message such as "yes", "confirm", or "apply this" as confirmation.',
384 'When changing className, submit the complete class list and preserve all existing classes except the explicitly requested replacement.',
385 !hasSelectedElement
386 ? 'No element is selected. You may analyze the page or guide selection, but never create executable edits, ask for confirmation, or call apply_html_editor_edits. The current document is /current.html in your workspace. When a request depends on page content, use the native read_file tool on /current.html with offset and limit, then read further sections only when needed. For an analysis request, record an inspect plan with empty edits.'
387 : confirmed
388 ? 'The user explicitly confirmed the pending plan. Call apply_html_editor_edits exactly once with the pending plan edits, then clearly report what was applied. Do not ask for confirmation again.'
389 : autoApply
390 ? 'The user gave a concrete edit request for the selected element. Record the executable plan, then stop tool use; the host will apply the plan immediately. Do not ask for confirmation or call apply_html_editor_edits.'
391 : 'When the user asks for a change, provide a concrete transformation plan with executable edits and ask whether to proceed. Do not apply changes at this stage.',
392 'Clearly separate proposed changes from changes that have actually been applied. Never claim that the document was changed unless the apply_html_editor_edits tool succeeded.'
393 ].join('\n')
394 : [
395 '你是本地 HTML 编辑器中的独立 AI 助手。',
396 '请帮助用户检选当前文档中的元素,并辅助改造它。',
397 '使用用户的语言回答,内容具体、简洁。',
398 '你运行在 ReAct 流程中,必须在最终回复前调用 record_html_editor_plan 识别意图,并记录可执行的改造 edits。',
399 '界面没有额外的确认按钮;用户在输入框明确回复“可以”“确认”或“按这个改”时,就视为确认。',
400 '修改 className 时必须提交完整类名列表;除用户明确要求替换的类名外,其他已有类名必须保留。',
401 !hasSelectedElement
402 ? '当前没有检选元素。你可以分析页面或引导用户检选,但绝不能生成可执行 edits、询问确认或调用 apply_html_editor_edits。当前文档位于工作区的 /current.html;只要问题依赖页面内容,就使用原生 read_file 工具并通过 offset、limit 分段读取,只在确有需要时继续读取后续内容。分析请求只记录 intent=inspect 且 edits 为空的方案。'
403 : confirmed
404 ? '用户已经明确确认了待执行方案。请严格调用一次 apply_html_editor_edits,使用待执行方案中的 edits,然后明确说明已应用的内容,不要再次询问确认。'
405 : autoApply
406 ? '用户对当前检选元素提出了明确的改造动作。记录可执行方案后立即停止工具调用,由宿主直接应用方案;不要询问确认,也不要调用 apply_html_editor_edits。'
407 : '当用户提出改造要求时,先给出包含可执行 edits 的具体方案并询问是否按此方案改造;当前阶段不要直接应用改动。',
408 '明确区分“建议改造内容”和“已经应用的改动”;只有 apply_html_editor_edits 工具成功后才能声称文档已修改。'
409 ].join('\n')
410 }
411
412 export function buildHtmlEditorAiMessages(args: HtmlEditorAiPromptArgs): HtmlEditorAiMessage[] {
413 const locale = args.locale === 'en' ? 'en' : 'zh'
414 const selectedElement = args.selectedElement
415 const history = shouldIncludeConversationHistory(args)
416 ? (args.recentMessages || [])
417 .map(normalizeMessage)
418 .filter((message): message is HtmlEditorAiMessage => Boolean(message))
419 .slice(-MAX_HISTORY_MESSAGES)
420 : []
421 const includePageHtml = shouldIncludePageHtml(args)
422
423 const context = [
424 locale === 'en' ? '[HTML editor context]' : '[HTML 编辑器上下文]',
425 `${locale === 'en' ? 'Document' : '文档'}: ${clipText(args.documentTitle, 500) || '(untitled)'}`,
426 includePageHtml
427 ? `${locale === 'en' ? 'Page HTML' : '页面 HTML'}:\n${clipText(args.pageHtml, MAX_PAGE_HTML_LENGTH)}`
428 : locale === 'en'
429 ? '[Page HTML omitted; the selected element context is sufficient for this request.]'
430 : '[已省略页面 HTML;当前请求只需要当前选中元素上下文。]',
431 args.pendingPlan
432 ? `${locale === 'en' ? '[Pending confirmed plan]' : '[待确认/待执行方案]'}\n${clipText(JSON.stringify(args.pendingPlan), 24_000)}`
433 : '',
434 selectedElement
435 ? [
436 locale === 'en' ? '[Selected element]' : '[当前选中元素]',
437 `selector: ${selectedElement.selector}`,
438 selectedElement.label ? `label: ${selectedElement.label}` : '',
439 selectedElement.elementTag ? `tag: <${selectedElement.elementTag}>` : '',
440 selectedElement.elementText ? `text: ${selectedElement.elementText}` : '',
441 selectedElement.html ? `outerHTML:\n${selectedElement.html}` : ''
442 ]
443 .filter(Boolean)
444 .join('\n')
445 : locale === 'en'
446 ? '[No element is selected. Ask the user to click an element in inspect mode when element context is needed.]'
447 : '[当前没有选中元素;需要元素上下文时,请提示用户先在检视模式中点击画布元素。]'
448 ].join('\n\n')
449
450 const userPrompt = `${context}\n\n${locale === 'en' ? '[User request]' : '[用户请求]'}\n${clipText(
451 args.userMessage,
452 MAX_USER_MESSAGE_LENGTH
453 )}`
454
455 return [...history, { role: 'user', content: userPrompt }]
456 }
457
458 function createHtmlEditorAiPlanTool(args: { autoApply: boolean; confirmed: boolean }): {
459 tool: StructuredToolInterface
460 getPlan: () => HtmlEditorAiPlan | null
461 } {
462 let plan: HtmlEditorAiPlan | null = null
463 const planTool = tool(
464 async (input) => {
465 plan = input as HtmlEditorAiPlan
466 return JSON.stringify({
467 status: 'plan_recorded',
468 message:
469 args.autoApply || args.confirmed
470 ? '方案已记录。宿主将直接应用这份方案,并在最终回复中说明已经应用的内容。'
471 : '方案已记录。向用户说明意图、改造步骤,并询问是否按此方案改造。'
472 })
473 },
474 {
475 name: 'record_html_editor_plan',
476 description:
477 '识别用户意图并记录 HTML 元素改造方案。每次请求必须调用一次。此工具只记录方案,不修改 HTML。',
478 schema: htmlEditorAiPlanSchema
479 }
480 )
481 return { tool: planTool as unknown as StructuredToolInterface, getPlan: () => plan }
482 }
483
484 function getObject(value: unknown): Record<string, unknown> | null {
485 return value && typeof value === 'object' && !Array.isArray(value)
486 ? (value as Record<string, unknown>)
487 : null
488 }
489
490 function isAssistantMessage(value: unknown): boolean {
491 const record = getObject(value)
492 if (!record) return false
493 const role = String(record.role || '').toLowerCase()
494 const type = String(record.type || '').toLowerCase()
495 const constructorName = String(
496 getObject(record.lc_kwargs)?.type ?? getObject(record.kwargs)?.type ?? ''
497 ).toLowerCase()
498 const isAssistant =
499 role === 'assistant' ||
500 type === 'ai' ||
501 type === 'assistant' ||
502 constructorName === 'ai' ||
503 constructorName === 'assistant'
504 const isToolOrHuman =
505 role === 'tool' ||
506 role === 'user' ||
507 role === 'system' ||
508 type === 'tool' ||
509 type === 'human' ||
510 type === 'system'
511 return isAssistant && !isToolOrHuman
512 }
513
514 function hasToolCalls(value: unknown): boolean {
515 const record = getObject(value)
516 if (!record) return false
517 const additional = getObject(record.additional_kwargs)
518 return [
519 record.tool_calls,
520 record.tool_call_chunks,
521 additional?.tool_calls,
522 additional?.tool_call_chunks
523 ].some((calls) => Array.isArray(calls) && calls.length > 0)
524 }
525
526 function extractAssistantTextsFromState(data: unknown): string[] {
527 const texts: string[] = []
528 const seen = new Set<object>()
529
530 const visit = (current: unknown): void => {
531 if (!current || typeof current !== 'object') return
532 if (seen.has(current as object)) return
533 seen.add(current as object)
534
535 if (Array.isArray(current)) {
536 current.forEach(visit)
537 return
538 }
539 if (isAssistantMessage(current) && !hasToolCalls(current)) {
540 const text = extractModelText(current).trim()
541 if (text) texts.push(text)
542 }
543 Object.values(current).forEach(visit)
544 }
545 visit(data)
546 return texts
547 }
548
549 async function collectHtmlEditorAgentReply(stream: AsyncIterable<unknown>): Promise<string> {
550 let reply = ''
551 let latestAssistantStateText = ''
552 const seenToolEvents = new Set<string>()
553 for await (const chunk of stream) {
554 if (!Array.isArray(chunk) || chunk.length < 3) continue
555 const mode = chunk[1] as string
556 const data = chunk[2]
557 if (mode === 'updates') {
558 logAgentToolEvents(data, seenToolEvents, { tag: 'html-editor:aiChat', source: 'updates' })
559 const assistantTexts = extractAssistantTextsFromState(data)
560 const longestText = assistantTexts.sort((a, b) => b.length - a.length)[0] || ''
561 if (longestText.length >= latestAssistantStateText.length) {
562 latestAssistantStateText = longestText
563 }
564 continue
565 }
566 if (mode !== 'messages' || !Array.isArray(data)) continue
567 logAgentToolEvents(data, seenToolEvents, { tag: 'html-editor:aiChat', source: 'messages' })
568 for (const message of data as Array<Record<string, unknown>>) {
569 if (!isAssistantMessage(message) || hasToolCalls(message)) continue
570 const text = extractModelText(message).trim()
571 if (text) reply += text
572 }
573 }
574 return latestAssistantStateText.trim() || reply.trim()
575 }
576
577 export function registerHtmlEditorAiHandlers(ctx: IpcContext): void {
578 ipcMain.handle('html-editor:aiChat', async (_event, payload: unknown) => {
579 const record =
580 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
581 const documentId = clipText(record.documentId, 200)
582 const userMessage = clipText(record.userMessage, MAX_USER_MESSAGE_LENGTH)
583 const selectedElement = normalizeElement(record.selectedElement)
584 if (!documentId) throw new Error('HTML 文档 ID 不能为空')
585 if (!userMessage) throw new Error('请输入 AI 请求')
586
587 const fallbackRecentMessages = Array.isArray(record.recentMessages) ? record.recentMessages : []
588 let recentMessages = fallbackRecentMessages
589 try {
590 const persistedMessages = await ctx.db.listHtmlEditMessages(documentId, MAX_HISTORY_MESSAGES)
591 if (persistedMessages.length > 0 || fallbackRecentMessages.length === 0) {
592 recentMessages = persistedMessages.map((message) => ({
593 role: message.role === 'assistant' ? ('assistant' as const) : ('user' as const),
594 content: message.content
595 }))
596 }
597 } catch (error) {
598 log.warn('[html-editor:aiChat] load message history failed', {
599 documentId,
600 message: error instanceof Error ? error.message : String(error)
601 })
602 }
603 await persistHtmlEditorMessage(ctx, {
604 docId: documentId,
605 role: 'user',
606 content: userMessage,
607 selectedElement
608 })
609
610 const locale = await readAppLocale(ctx)
611 const activeModel = await resolveModelConfigForTask(ctx, {
612 modelConfigId: typeof record.modelConfigId === 'string' ? record.modelConfigId : undefined,
613 purpose: 'html-editor:aiChat'
614 })
615 const modelTimeouts = await resolveGlobalModelTimeouts(ctx)
616 const pageHtml = typeof record.pageHtml === 'string' ? record.pageHtml : ''
617 const pendingPlan = normalizePendingPlan(record.pendingPlan)
618 const hasSelectedElement = Boolean(selectedElement?.selector)
619 const confirmed = isConfirmationRequest(userMessage, pendingPlan)
620 const autoApply = isExplicitHtmlEditorEditRequest(userMessage, selectedElement)
621 if (!hasSelectedElement && (confirmed || isHtmlEditorChangeRequest(userMessage))) {
622 const reply = buildSelectionRequiredReply(locale)
623 await persistHtmlEditorMessage(ctx, {
624 docId: documentId,
625 role: 'assistant',
626 content: reply,
627 selectedElement
628 })
629 return {
630 reply,
631 model: activeModel.name,
632 intent: 'other' as const,
633 plan: null,
634 requiresConfirmation: false,
635 applied: false,
636 warnings: []
637 }
638 }
639 if (confirmed && pendingPlan) {
640 validateHtmlEditorAiEditTargets(pendingPlan.edits, selectedElement?.selector)
641 const applied = await applyHtmlEditsForDocument(ctx, {
642 docId: documentId,
643 batch: pendingPlan.edits,
644 message: buildHtmlEditorAiVersionMessage(userMessage, pendingPlan)
645 })
646 const reply = applied.changed
647 ? buildAppliedReply(locale, true, applied.warnings)
648 : buildNoChangeReply(locale, applied.warnings)
649 await persistHtmlEditorMessage(ctx, {
650 docId: documentId,
651 role: 'assistant',
652 content: reply,
653 intent: pendingPlan.intent,
654 plan: pendingPlan,
655 requiresConfirmation: false,
656 selectedElement
657 })
658 log.info('[html-editor:aiChat] confirmation fast path', {
659 documentId,
660 warnings: applied.warnings.length
661 })
662 return {
663 reply,
664 model: activeModel.name,
665 intent: pendingPlan.intent,
666 plan: pendingPlan,
667 requiresConfirmation: false,
668 applied: applied.changed,
669 appliedHtml: applied.changed ? applied.html : undefined,
670 warnings: applied.warnings
671 }
672 }
673 const model = resolveModel(
674 activeModel.provider,
675 activeModel.apiKey,
676 activeModel.model,
677 activeModel.baseUrl,
678 0.35,
679 activeModel.maxTokens,
680 ctx.modelRuntime
681 )
682 const messages = buildHtmlEditorAiMessages({
683 documentTitle: typeof record.documentTitle === 'string' ? record.documentTitle : undefined,
684 pageHtml,
685 selectedElement,
686 recentMessages,
687 userMessage,
688 locale,
689 pendingPlan
690 })
691 const systemPrompt = buildHtmlEditorAiSystemPrompt(locale, {
692 confirmed,
693 autoApply,
694 hasSelectedElement
695 })
696
697 log.info('[html-editor:aiChat] start', {
698 documentId,
699 modelConfigId: activeModel.id,
700 model: activeModel.model,
701 hasSelectedElement: Boolean(record.selectedElement),
702 confirmed,
703 autoApply,
704 userMessageLength: userMessage.length
705 })
706
707 const planRecorder = createHtmlEditorAiPlanTool({ autoApply, confirmed })
708 const applyRecorder = createHtmlEditorAiApplyTool({
709 ctx,
710 documentId,
711 selectedSelector: selectedElement?.selector,
712 canApply: confirmed || autoApply,
713 batchOverride: confirmed ? pendingPlan?.edits : undefined,
714 getVersionMessage: () => buildHtmlEditorAiVersionMessage(userMessage, planRecorder.getPlan())
715 })
716 const documentWorkspace = await resolveHtmlEditorDocumentWorkspace(ctx, documentId)
717 const agent = createDeepAgent({
718 model,
719 backend: new FilesystemBackend({ rootDir: documentWorkspace, virtualMode: true }),
720 tools: [planRecorder.tool, applyRecorder.tool] as unknown as StructuredToolInterface[],
721 permissions: [
722 { operations: ['read'], paths: ['/**'] },
723 { operations: ['write'], paths: ['/**'], mode: 'deny' }
724 ],
725 systemPrompt
726 })
727 const stream = await agent.stream(
728 { messages },
729 {
730 streamMode: ['updates', 'messages'],
731 subgraphs: true,
732 signal: AbortSignal.timeout(resolveModelTimeoutMs(modelTimeouts.agent, 'agent'))
733 }
734 )
735 const streamedReply = await collectHtmlEditorAgentReply(stream as AsyncIterable<unknown>)
736 let applied = applyRecorder.getApplied()
737 const recordedPlan = planRecorder.getPlan()
738 const plan = hasSelectedElement
739 ? confirmed && pendingPlan
740 ? pendingPlan
741 : recordedPlan || pendingPlan || null
742 : null
743 if ((confirmed || autoApply) && !applied && plan) {
744 validateHtmlEditorAiEditTargets(plan.edits, selectedElement?.selector)
745 applied = await applyHtmlEditsForDocument(ctx, {
746 docId: documentId,
747 batch: plan.edits,
748 message: buildHtmlEditorAiVersionMessage(userMessage, plan)
749 })
750 }
751 if ((confirmed || autoApply) && !plan) {
752 throw new Error('AI 未生成可执行改动,请重试或把要改的内容描述得更具体')
753 }
754 const reply = applied
755 ? applied.changed
756 ? buildAppliedReply(locale, confirmed, applied.warnings)
757 : buildNoChangeReply(locale, applied.warnings)
758 : streamedReply
759 if (!reply) {
760 log.warn('[html-editor:aiChat] stream completed without assistant text', {
761 documentId,
762 modelConfigId: activeModel.id,
763 hasPlan: Boolean(plan)
764 })
765 throw new Error('AI 未返回有效内容,请检查模型协议和模型配置')
766 }
767 const requiresConfirmation = Boolean(
768 hasSelectedElement &&
769 !confirmed &&
770 !autoApply &&
771 plan &&
772 (hasHtmlEditorEdits(plan.edits) || !['inspect', 'other'].includes(plan.intent))
773 )
774
775 log.info('[html-editor:aiChat] complete', {
776 documentId,
777 modelConfigId: activeModel.id,
778 replyLength: reply.length,
779 intent: plan?.intent || 'unknown',
780 requiresConfirmation
781 })
782 await persistHtmlEditorMessage(ctx, {
783 docId: documentId,
784 role: 'assistant',
785 content: reply,
786 intent: plan?.intent,
787 plan,
788 requiresConfirmation,
789 selectedElement
790 })
791 return {
792 reply,
793 model: activeModel.name,
794 intent: plan?.intent || 'other',
795 plan,
796 requiresConfirmation,
797 applied: applied?.changed === true,
798 appliedHtml: applied?.changed ? applied.html : undefined,
799 warnings: applied?.warnings || []
800 }
801 })
802 }
803
803 lines TYPESCRIPT