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