返回 oh-my-ppt
html-editor-ai.test.ts
根目录 / tests / unit / html-editor / html-editor-ai.test.ts
1 import { beforeEach, describe, expect, it, vi } from 'vitest'
2
3 const state = vi.hoisted(() => {
4 const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>()
5 const defaultAgentStream = async () =>
6 (async function* () {
7 yield ['', 'messages', [{ lc_kwargs: { type: 'ai' }, content: 'AI reply', tool_calls: [] }]]
8 })()
9 const agentStream = vi.fn(defaultAgentStream)
10 return {
11 handlers,
12 agentStream,
13 defaultAgentStream,
14 agentConfig: null as {
15 tools?: Array<{ invoke: (input: unknown) => Promise<unknown> }>
16 systemPrompt?: string
17 backend?: unknown
18 permissions?: unknown
19 } | null,
20 ipcMain: {
21 handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
22 handlers.set(channel, handler)
23 })
24 },
25 log: { info: vi.fn(), warn: vi.fn() },
26 db: {
27 listHtmlEditMessages: vi.fn(async () => []),
28 createHtmlEditMessage: vi.fn(async () => {})
29 },
30 resolveModel: vi.fn(() => ({ invoke: vi.fn() })),
31 applyHtmlEditsForDocument: vi.fn(async () => ({
32 html: '<main>updated</main>',
33 warnings: [],
34 changed: true
35 })),
36 resolveHtmlEditorDocumentWorkspace: vi.fn(async () => '/workspace/html-editor/doc-1'),
37 filesystemBackendOptions: null as { rootDir?: string; virtualMode?: boolean } | null,
38 createDeepAgent: vi.fn((config: typeof state.agentConfig) => {
39 state.agentConfig = config
40 return { stream: state.agentStream }
41 }),
42 resolveModelConfigForTask: vi.fn(async () => ({
43 id: 'model-1',
44 name: 'Test model',
45 provider: 'openai',
46 apiKey: 'key',
47 model: 'test-model',
48 baseUrl: '',
49 maxTokens: 1024
50 })),
51 resolveGlobalModelTimeouts: vi.fn(async () => ({ agent: 1000 })),
52 resolveModelTimeoutMs: vi.fn(() => 1000),
53 readAppLocale: vi.fn(async () => 'zh'),
54 extractModelText: vi.fn(() => 'AI reply')
55 }
56 })
57
58 vi.mock('electron', () => ({ ipcMain: state.ipcMain }))
59 vi.mock('electron-log/main.js', () => ({ default: state.log }))
60 vi.mock('../../../src/main/agent-runtime/model', () => ({
61 resolveModel: state.resolveModel,
62 extractModelText: state.extractModelText
63 }))
64 vi.mock('deepagents', () => ({
65 createDeepAgent: state.createDeepAgent,
66 FilesystemBackend: class {
67 constructor(options: { rootDir?: string; virtualMode?: boolean }) {
68 state.filesystemBackendOptions = options
69 }
70 }
71 }))
72 vi.mock('../../../src/main/config/model-config-utils', () => ({
73 resolveGlobalModelTimeouts: state.resolveGlobalModelTimeouts,
74 resolveModelConfigForTask: state.resolveModelConfigForTask
75 }))
76 vi.mock('../../../src/main/config/locale-utils', () => ({
77 readAppLocale: state.readAppLocale
78 }))
79 vi.mock('../../../src/main/html-editor/html-editor-handlers', () => ({
80 applyHtmlEditsForDocument: state.applyHtmlEditsForDocument,
81 resolveHtmlEditorDocumentWorkspace: state.resolveHtmlEditorDocumentWorkspace
82 }))
83 vi.mock('@shared/model-timeout', () => ({
84 resolveModelTimeoutMs: state.resolveModelTimeoutMs
85 }))
86
87 describe('html-editor AI IPC', () => {
88 beforeEach(() => {
89 vi.resetModules()
90 state.handlers.clear()
91 state.ipcMain.handle.mockClear()
92 state.agentStream.mockReset()
93 state.agentStream.mockImplementation(state.defaultAgentStream)
94 state.resolveModel.mockClear()
95 state.applyHtmlEditsForDocument.mockClear()
96 state.resolveHtmlEditorDocumentWorkspace.mockClear()
97 state.filesystemBackendOptions = null
98 state.createDeepAgent.mockClear()
99 state.agentConfig = null
100 state.resolveModelConfigForTask.mockClear()
101 state.resolveGlobalModelTimeouts.mockClear()
102 state.resolveModelTimeoutMs.mockClear()
103 state.readAppLocale.mockClear()
104 state.extractModelText.mockClear()
105 state.db.listHtmlEditMessages.mockClear()
106 state.db.createHtmlEditMessage.mockClear()
107 })
108
109 it('registers an independent chat handler with selected HTML context', async () => {
110 const {
111 buildHtmlEditorAiMessages,
112 buildHtmlEditorAiSystemPrompt,
113 isExplicitHtmlEditorEditRequest,
114 registerHtmlEditorAiHandlers
115 } = await import('../../../src/main/html-editor/html-editor-ai-handlers')
116 registerHtmlEditorAiHandlers({ db: state.db } as never)
117
118 const handler = state.handlers.get('html-editor:aiChat')
119 expect(handler).toBeDefined()
120
121 const messages = buildHtmlEditorAiMessages({
122 documentTitle: 'Demo',
123 pageHtml: '<main><p>Hello</p></main>',
124 selectedElement: {
125 selector: 'body[data-page-id="doc-1"] p',
126 elementTag: 'p',
127 elementText: 'Hello',
128 html: '<p style="color:red">Hello</p>'
129 },
130 recentMessages: [{ role: 'assistant', content: 'Previous answer' }],
131 userMessage: '把这个元素改造成绿色卡片'
132 })
133 expect(buildHtmlEditorAiSystemPrompt()).toContain('record_html_editor_plan')
134 expect(buildHtmlEditorAiSystemPrompt('zh', { hasSelectedElement: false })).toContain(
135 '绝不能生成可执行 edits'
136 )
137 expect(
138 isExplicitHtmlEditorEditRequest('把这个改为蓝色', {
139 selector: 'body[data-page-id="doc-1"] p'
140 })
141 ).toBe(true)
142 expect(
143 isExplicitHtmlEditorEditRequest('把这个改得更现代', {
144 selector: 'body[data-page-id="doc-1"] p'
145 })
146 ).toBe(false)
147 expect(messages[0]?.role).toBe('user')
148 expect(messages).toHaveLength(1)
149 expect(messages).not.toEqual(
150 expect.arrayContaining([expect.objectContaining({ role: 'system' })])
151 )
152 expect(messages[0]?.content).toContain('color:red')
153 expect(messages[0]?.content).toContain('把这个元素改造成绿色卡片')
154 expect(messages[0]?.content).toContain('已省略页面 HTML')
155
156 const continuedMessages = buildHtmlEditorAiMessages({
157 selectedElement: {
158 selector: 'body[data-page-id="doc-1"] p',
159 html: '<p>Hello</p>'
160 },
161 recentMessages: [{ role: 'assistant', content: '上一条方案' }],
162 userMessage: '继续按上面的方案改'
163 })
164 expect(continuedMessages[0]).toEqual({ role: 'assistant', content: '上一条方案' })
165
166 const pageAnalysisMessages = buildHtmlEditorAiMessages({
167 pageHtml: '<main><h1>页面标题</h1></main>',
168 userMessage: '这个页面布局怎么样'
169 })
170 expect(pageAnalysisMessages[0]?.content).toContain('<h1>页面标题</h1>')
171
172 const directPlan = {
173 intent: 'style',
174 target: 'body[data-page-id="doc-1"] p',
175 summary: '将元素颜色改为蓝色。',
176 changes: ['将元素文字颜色改为蓝色。'],
177 confirmationQuestion: '是否按此方案改造?',
178 edits: {
179 propertyEdits: [
180 {
181 selector: 'body[data-page-id="doc-1"] p',
182 patch: { style: { color: '#3b82f6' } }
183 }
184 ],
185 textEdits: [],
186 dragEdits: [],
187 deletes: [],
188 addElements: []
189 }
190 }
191 state.agentStream.mockImplementation(async () => {
192 const tools = state.agentConfig?.tools || []
193 await tools[0]?.invoke(directPlan)
194 return state.defaultAgentStream()
195 })
196
197 const result = await handler?.(
198 {},
199 {
200 documentId: 'doc-1',
201 documentTitle: 'Demo',
202 pageHtml: '',
203 selectedElement: {
204 selector: 'body[data-page-id="doc-1"] p',
205 elementTag: 'p',
206 elementText: 'Hello',
207 html: '<p style="color:red">Hello</p>'
208 },
209 recentMessages: [{ role: 'assistant', content: 'Previous answer' }],
210 userMessage: '把这个改为蓝色'
211 }
212 )
213
214 expect(state.resolveModelConfigForTask).toHaveBeenCalledWith(expect.anything(), {
215 modelConfigId: undefined,
216 purpose: 'html-editor:aiChat'
217 })
218 expect(state.resolveModel).toHaveBeenCalledWith(
219 'openai',
220 'key',
221 'test-model',
222 '',
223 0.35,
224 1024,
225 undefined
226 )
227 expect(state.createDeepAgent).toHaveBeenCalledWith(
228 expect.objectContaining({
229 systemPrompt: expect.stringContaining('record_html_editor_plan'),
230 tools: expect.any(Array),
231 permissions: [
232 { operations: ['read'], paths: ['/**'] },
233 { operations: ['write'], paths: ['/**'], mode: 'deny' }
234 ]
235 })
236 )
237 expect(state.agentStream).toHaveBeenCalledWith(
238 expect.objectContaining({
239 messages: expect.not.arrayContaining([expect.objectContaining({ role: 'system' })])
240 }),
241 expect.objectContaining({ signal: expect.any(AbortSignal) })
242 )
243 expect(result).toMatchObject({
244 reply: '已完成 HTML 改造。',
245 intent: 'style',
246 applied: true,
247 plan: directPlan,
248 requiresConfirmation: false
249 })
250 const [, applyArgs] = state.applyHtmlEditsForDocument.mock.calls[0] || []
251 expect(applyArgs).not.toHaveProperty('html')
252 expect(applyArgs).toMatchObject({
253 message: 'AI 改造:将元素颜色改为蓝色。'
254 })
255 expect(state.db.createHtmlEditMessage).toHaveBeenCalledTimes(2)
256 expect(state.db.createHtmlEditMessage).toHaveBeenCalledWith(
257 expect.objectContaining({
258 role: 'user',
259 selectedElement: expect.objectContaining({ selector: 'body[data-page-id="doc-1"] p' })
260 })
261 )
262 })
263
264 it('asks for confirmation for a vague redesign request', async () => {
265 const { registerHtmlEditorAiHandlers } =
266 await import('../../../src/main/html-editor/html-editor-ai-handlers')
267 registerHtmlEditorAiHandlers({ db: state.db } as never)
268 const handler = state.handlers.get('html-editor:aiChat')
269 const plan = {
270 intent: 'redesign',
271 target: 'body[data-page-id="doc-1"] p',
272 summary: '让这个元素更现代。',
273 changes: ['调整配色和圆角,使视觉更现代。'],
274 confirmationQuestion: '是否按此方案改造?',
275 edits: {
276 propertyEdits: [
277 {
278 selector: 'body[data-page-id="doc-1"] p',
279 patch: { style: { backgroundColor: '#eef6ff' } }
280 }
281 ],
282 textEdits: [],
283 dragEdits: [],
284 deletes: [],
285 addElements: []
286 }
287 }
288 state.agentStream.mockImplementation(async () => {
289 const tools = state.agentConfig?.tools || []
290 await tools[0]?.invoke(plan)
291 return state.defaultAgentStream()
292 })
293
294 const result = await handler?.(
295 {},
296 {
297 documentId: 'doc-1',
298 pageHtml: '<main><p>Hello</p></main>',
299 selectedElement: {
300 selector: 'body[data-page-id="doc-1"] p',
301 html: '<p>Hello</p>'
302 },
303 userMessage: '把这个改得更现代'
304 }
305 )
306
307 expect(state.applyHtmlEditsForDocument).not.toHaveBeenCalled()
308 expect(result).toMatchObject({
309 intent: 'redesign',
310 plan,
311 requiresConfirmation: true,
312 applied: false
313 })
314 expect(state.db.createHtmlEditMessage).toHaveBeenCalledTimes(2)
315 })
316
317 it('requires a selected element before accepting a modification request', async () => {
318 const { registerHtmlEditorAiHandlers } =
319 await import('../../../src/main/html-editor/html-editor-ai-handlers')
320 registerHtmlEditorAiHandlers({ db: state.db } as never)
321 const handler = state.handlers.get('html-editor:aiChat')
322
323 const result = await handler?.(
324 {},
325 {
326 documentId: 'doc-1',
327 pageHtml: '<main><p>Hello</p></main>',
328 userMessage: '改成红色'
329 }
330 )
331
332 expect(state.resolveModel).not.toHaveBeenCalled()
333 expect(state.applyHtmlEditsForDocument).not.toHaveBeenCalled()
334 expect(result).toMatchObject({
335 reply: '请先在画布中检选一个元素,再让我按你的要求改造它。',
336 plan: null,
337 requiresConfirmation: false,
338 applied: false
339 })
340 expect(state.db.createHtmlEditMessage).toHaveBeenCalledTimes(2)
341 })
342
343 it('gives whole-page analysis a read-only document workspace', async () => {
344 const { registerHtmlEditorAiHandlers } =
345 await import('../../../src/main/html-editor/html-editor-ai-handlers')
346 registerHtmlEditorAiHandlers({ db: state.db } as never)
347 const handler = state.handlers.get('html-editor:aiChat')
348
349 const result = await handler?.(
350 {},
351 {
352 documentId: 'doc-1',
353 pageHtml: '',
354 userMessage: '这个页面布局怎么样'
355 }
356 )
357
358 expect(state.resolveHtmlEditorDocumentWorkspace).toHaveBeenCalledWith(
359 expect.anything(),
360 'doc-1'
361 )
362 expect(state.filesystemBackendOptions).toEqual({
363 rootDir: '/workspace/html-editor/doc-1',
364 virtualMode: true
365 })
366 expect(state.applyHtmlEditsForDocument).not.toHaveBeenCalled()
367 expect(state.agentConfig?.tools).toHaveLength(2)
368 expect(state.agentConfig?.systemPrompt).toContain('read_file')
369 expect(state.agentConfig?.systemPrompt).toContain('/current.html')
370 expect(state.agentConfig?.permissions).toEqual([
371 { operations: ['read'], paths: ['/**'] },
372 { operations: ['write'], paths: ['/**'], mode: 'deny' }
373 ])
374 expect(result).toMatchObject({ applied: false, requiresConfirmation: false, plan: null })
375 })
376
377 it('applies the pending plan after an explicit confirmation', async () => {
378 const { registerHtmlEditorAiHandlers } =
379 await import('../../../src/main/html-editor/html-editor-ai-handlers')
380 registerHtmlEditorAiHandlers({ db: state.db } as never)
381 const handler = state.handlers.get('html-editor:aiChat')
382 const pendingPlan = {
383 intent: 'style',
384 target: 'body[data-page-id="doc-1"] p',
385 summary: '将文字颜色替换为红色。',
386 changes: ['将 text-gray-800 替换为 text-red-500。'],
387 confirmationQuestion: '是否按此方案改造?',
388 edits: {
389 propertyEdits: [
390 {
391 selector: 'body[data-page-id="doc-1"] p',
392 patch: { attrs: { className: 'text-red-500' } }
393 }
394 ],
395 textEdits: [],
396 dragEdits: [],
397 deletes: [],
398 addElements: []
399 }
400 }
401
402 const result = await handler?.(
403 {},
404 {
405 documentId: 'doc-1',
406 pageHtml: '<main><p class="text-gray-800">Hello</p></main>',
407 selectedElement: {
408 selector: 'body[data-page-id="doc-1"] p',
409 html: '<p class="text-gray-800">Hello</p>'
410 },
411 pendingPlan,
412 recentMessages: [{ role: 'assistant', content: '方案已记录' }],
413 userMessage: '可以,就按这个吧'
414 }
415 )
416
417 expect(state.applyHtmlEditsForDocument).toHaveBeenCalledWith(
418 expect.anything(),
419 expect.objectContaining({
420 docId: 'doc-1',
421 message: 'AI 改造:将文字颜色替换为红色。',
422 batch: pendingPlan.edits
423 })
424 )
425 expect(result).toMatchObject({
426 applied: true,
427 appliedHtml: '<main>updated</main>',
428 requiresConfirmation: false
429 })
430 expect(state.db.createHtmlEditMessage).toHaveBeenCalledTimes(2)
431 })
432
433 it('does not report an AI version when the requested edit makes no HTML change', async () => {
434 const { registerHtmlEditorAiHandlers } =
435 await import('../../../src/main/html-editor/html-editor-ai-handlers')
436 registerHtmlEditorAiHandlers({ db: state.db } as never)
437 const handler = state.handlers.get('html-editor:aiChat')
438 const plan = {
439 intent: 'style',
440 target: 'body[data-page-id="doc-1"] p',
441 summary: '将元素颜色改为蓝色。',
442 changes: ['将文字颜色改为蓝色。'],
443 confirmationQuestion: '是否按此方案改造?',
444 edits: {
445 propertyEdits: [
446 {
447 selector: 'body[data-page-id="doc-1"] p',
448 patch: { style: { color: '#3b82f6' } }
449 }
450 ],
451 textEdits: [],
452 dragEdits: [],
453 deletes: [],
454 addElements: []
455 }
456 }
457 state.applyHtmlEditsForDocument.mockResolvedValueOnce({
458 html: '<main><p style="color:#3b82f6">Hello</p></main>',
459 warnings: [],
460 changed: false
461 })
462 state.agentStream.mockImplementation(async () => {
463 const tools = state.agentConfig?.tools || []
464 await tools[0]?.invoke(plan)
465 return state.defaultAgentStream()
466 })
467
468 const result = await handler?.(
469 {},
470 {
471 documentId: 'doc-1',
472 selectedElement: {
473 selector: 'body[data-page-id="doc-1"] p',
474 html: '<p style="color:#3b82f6">Hello</p>'
475 },
476 userMessage: '把这个改为蓝色'
477 }
478 )
479
480 expect(result).toMatchObject({
481 applied: false,
482 appliedHtml: undefined,
483 reply: expect.stringContaining('没有产生可写入的 HTML 改动')
484 })
485 expect(state.applyHtmlEditsForDocument).toHaveBeenCalledWith(
486 expect.anything(),
487 expect.objectContaining({ message: 'AI 改造:将元素颜色改为蓝色。' })
488 )
489 })
490 })
491
491 lines TYPESCRIPT