Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: AI companion for diagrams, slides, and forms (#225)

scott 9721e697 eb857bfc

+1928 -7
+13
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.23.0] — 2026-04-04 9 + 10 + ### Added 11 + - AI companion chat panel in diagrams mode with shape/arrow actions (#355) 12 + - AI companion chat panel in slides mode with slide/text/shape actions (#356) 13 + - AI companion chat panel in forms mode with question add/modify/remove actions (#357) 14 + - Diagram AI actions: add shape, add arrow, modify shape, remove shape, add text 15 + - Slide AI actions: add slide, add text, add shape 16 + - Form AI actions: add question, modify question, remove question 17 + - AI can read diagram context (shapes, arrows, labels) and selected shapes 18 + - AI can read slide context (slide content, notes) and form context (questions, options) 19 + - 93 new tests covering all AI action executors, validation, parsing, and type guards 20 + 8 21 ## [0.22.4] — 2026-04-04 9 22 10 23 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.22.4", 3 + "version": "0.23.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+148
src/diagrams/ai-diagram-actions.ts
··· 1 + /** 2 + * AI Diagram Actions — executor for diagram-specific AI actions. 3 + * 4 + * Applies diagram_add_shape, diagram_add_arrow, diagram_modify_shape, 5 + * diagram_remove_shape, and diagram_add_text actions to the whiteboard state. 6 + */ 7 + 8 + import type { 9 + DiagramAddShapeAction, 10 + DiagramAddArrowAction, 11 + DiagramModifyShapeAction, 12 + DiagramRemoveShapeAction, 13 + DiagramAddTextAction, 14 + DiagramAction, 15 + } from '../lib/ai-actions.js'; 16 + import type { WhiteboardState, ShapeKind, ArrowEndpoint } from './whiteboard.js'; 17 + import { 18 + addShape, addArrow, removeShape, setShapeLabel, 19 + setShapeStyle, resizeShape, moveShape, 20 + } from './whiteboard.js'; 21 + 22 + export interface ActionResult { 23 + success: boolean; 24 + error?: string; 25 + } 26 + 27 + export interface DiagramActionDeps { 28 + getState: () => WhiteboardState; 29 + setState: (state: WhiteboardState) => void; 30 + render: () => void; 31 + pushHistory: () => void; 32 + } 33 + 34 + const VALID_KINDS = new Set<string>([ 35 + 'rectangle', 'ellipse', 'diamond', 'triangle', 'star', 36 + 'hexagon', 'cylinder', 'parallelogram', 'cloud', 'note', 'text', 37 + ]); 38 + 39 + const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/; 40 + function sanitizeColor(val: string | undefined): string | undefined { 41 + return val && HEX_COLOR_RE.test(val) ? val : undefined; 42 + } 43 + 44 + function findShapeByLabel(state: WhiteboardState, label: string): string | null { 45 + for (const [id, shape] of state.shapes) { 46 + if (shape.label === label) return id; 47 + } 48 + return null; 49 + } 50 + 51 + export function executeDiagramAction(action: DiagramAction, deps: DiagramActionDeps): ActionResult { 52 + let state = deps.getState(); 53 + 54 + switch (action.type) { 55 + case 'diagram_add_shape': { 56 + const kind = VALID_KINDS.has(action.kind) ? action.kind as ShapeKind : 'rectangle'; 57 + state = addShape(state, kind, action.x, action.y, action.w, action.h, action.label || ''); 58 + // Find the newly added shape (last entry in map) 59 + let newShapeId: string | null = null; 60 + for (const id of state.shapes.keys()) newShapeId = id; 61 + const fill = sanitizeColor(action.fill); 62 + const stroke = sanitizeColor(action.stroke); 63 + if (newShapeId && (fill || stroke)) { 64 + const style: Record<string, string> = {}; 65 + if (fill) style.fill = fill; 66 + if (stroke) style.stroke = stroke; 67 + state = setShapeStyle(state, [newShapeId], style); 68 + } 69 + deps.setState(state); 70 + deps.pushHistory(); 71 + deps.render(); 72 + return { success: true }; 73 + } 74 + 75 + case 'diagram_add_arrow': { 76 + const fromId = findShapeByLabel(state, action.fromLabel); 77 + const toId = findShapeByLabel(state, action.toLabel); 78 + if (!fromId) return { success: false, error: `Shape "${action.fromLabel}" not found` }; 79 + if (!toId) return { success: false, error: `Shape "${action.toLabel}" not found` }; 80 + 81 + const from: ArrowEndpoint = { shapeId: fromId, anchor: 'right' }; 82 + const to: ArrowEndpoint = { shapeId: toId, anchor: 'left' }; 83 + state = addArrow(state, from, to); 84 + deps.setState(state); 85 + deps.pushHistory(); 86 + deps.render(); 87 + return { success: true }; 88 + } 89 + 90 + case 'diagram_modify_shape': { 91 + const shapeId = findShapeByLabel(state, action.label); 92 + if (!shapeId) return { success: false, error: `Shape "${action.label}" not found` }; 93 + 94 + if (action.newLabel !== undefined) { 95 + state = setShapeLabel(state, shapeId, action.newLabel); 96 + } 97 + const mFill = sanitizeColor(action.fill); 98 + const mStroke = sanitizeColor(action.stroke); 99 + if (mFill || mStroke) { 100 + const style: Record<string, string> = {}; 101 + if (mFill) style.fill = mFill; 102 + if (mStroke) style.stroke = mStroke; 103 + state = setShapeStyle(state, [shapeId], style); 104 + } 105 + if (action.w !== undefined || action.h !== undefined) { 106 + const shape = state.shapes.get(shapeId)!; 107 + state = resizeShape(state, shapeId, action.w ?? shape.width, action.h ?? shape.height); 108 + } 109 + if (action.x !== undefined || action.y !== undefined) { 110 + const shape = state.shapes.get(shapeId)!; 111 + state = moveShape(state, shapeId, action.x ?? shape.x, action.y ?? shape.y); 112 + } 113 + deps.setState(state); 114 + deps.pushHistory(); 115 + deps.render(); 116 + return { success: true }; 117 + } 118 + 119 + case 'diagram_remove_shape': { 120 + const shapeId = findShapeByLabel(state, action.label); 121 + if (!shapeId) return { success: false, error: `Shape "${action.label}" not found` }; 122 + state = removeShape(state, shapeId); 123 + deps.setState(state); 124 + deps.pushHistory(); 125 + deps.render(); 126 + return { success: true }; 127 + } 128 + 129 + case 'diagram_add_text': { 130 + state = addShape(state, 'text' as ShapeKind, action.x, action.y, 200, 40, action.text); 131 + // Set font size if provided 132 + if (action.fontSize) { 133 + let newShapeId: string | null = null; 134 + for (const id of state.shapes.keys()) newShapeId = id; 135 + if (newShapeId) { 136 + const shape = state.shapes.get(newShapeId)!; 137 + const updated = new Map(state.shapes); 138 + updated.set(newShapeId, { ...shape, fontSize: action.fontSize }); 139 + state = { ...state, shapes: updated }; 140 + } 141 + } 142 + deps.setState(state); 143 + deps.pushHistory(); 144 + deps.render(); 145 + return { success: true }; 146 + } 147 + } 148 + }
+3
src/diagrams/index.html
··· 29 29 <!-- Export buttons --> 30 30 <button class="btn-icon btn-sm" id="btn-export-svg" title="Export SVG">SVG</button> 31 31 <button class="btn-icon btn-sm" id="btn-export-png" title="Export PNG">PNG</button> 32 + <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 33 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 34 + </button> 32 35 <span class="save-status" id="save-status"></span> 33 36 </div> 34 37
+147
src/diagrams/main.ts
··· 26 26 } from './whiteboard.js'; 27 27 import History from './history.js'; 28 28 import { exportToSVG, exportAndDownloadSVG, exportAndDownloadPNG } from './export.js'; 29 + import { 30 + createChatSidebar, createChatState, loadConfig, isConfigured, 31 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 32 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 33 + type ChatMessage, 34 + } from '../lib/ai-chat.js'; 35 + import { splitResponse, isDiagramAction } from '../lib/ai-actions.js'; 36 + import { executeDiagramAction } from './ai-diagram-actions.js'; 29 37 30 38 // --- DOM refs --- 31 39 const $ = (id: string) => document.getElementById(id)!; ··· 1929 1937 body: JSON.stringify({ name_encrypted: b64 }), 1930 1938 }); 1931 1939 }); 1940 + 1941 + // ── AI Chat Panel ──────────────────────────────────────────────────────── 1942 + 1943 + const chatUI = createChatSidebar(); 1944 + $('main-content').appendChild(chatUI.container); 1945 + 1946 + const chatState = createChatState(); 1947 + 1948 + const chatWiring = initChatWiring({ 1949 + chatUI, 1950 + chatState, 1951 + chatConfig: loadConfig(), 1952 + toggleBtn: $('btn-ai-chat'), 1953 + editorType: 'diagram', 1954 + onSend: sendChatMessage, 1955 + }); 1956 + 1957 + /** Build text summary of current diagram for AI context */ 1958 + function getDiagramContextText(): string { 1959 + const lines: string[] = []; 1960 + for (const [, shape] of state.shapes) { 1961 + const label = shape.label || '(unlabeled)'; 1962 + lines.push(`${shape.kind} "${label}" at (${Math.round(shape.x)},${Math.round(shape.y)}) size ${Math.round(shape.width)}x${Math.round(shape.height)}`); 1963 + } 1964 + for (const [, arrow] of state.arrows) { 1965 + const fromDesc = 'shapeId' in arrow.from 1966 + ? `"${state.shapes.get(arrow.from.shapeId)?.label || 'unknown'}"` 1967 + : `(${arrow.from.x},${arrow.from.y})`; 1968 + const toDesc = 'shapeId' in arrow.to 1969 + ? `"${state.shapes.get(arrow.to.shapeId)?.label || 'unknown'}"` 1970 + : `(${arrow.to.x},${arrow.to.y})`; 1971 + lines.push(`arrow from ${fromDesc} to ${toDesc}`); 1972 + } 1973 + return lines.join('\n'); 1974 + } 1975 + 1976 + async function sendChatMessage(): Promise<void> { 1977 + const text = chatUI.input.value.trim(); 1978 + if (!text || chatState.loading) return; 1979 + 1980 + const cfg = chatWiring.getConfig(); 1981 + if (!isConfigured(cfg)) { 1982 + chatUI.settingsPanel.style.display = ''; 1983 + chatUI.endpointInput.focus(); 1984 + return; 1985 + } 1986 + 1987 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 1988 + chatState.messages.push(userMsg); 1989 + appendMessage(chatUI.messageList, userMsg); 1990 + 1991 + chatUI.input.value = ''; 1992 + chatUI.input.style.height = ''; 1993 + chatUI.sendBtn.style.display = 'none'; 1994 + chatUI.stopBtn.style.display = ''; 1995 + chatState.loading = true; 1996 + chatState.error = null; 1997 + 1998 + const title = diagramTitle.value.trim() || 'Untitled Diagram'; 1999 + const includeContext = chatUI.contextToggle.checked; 2000 + const actionsEnabled = chatUI.actionsToggle.checked; 2001 + const contextText = includeContext ? getDiagramContextText() : ''; 2002 + 2003 + // Build selection context from selected shapes 2004 + let selectionContext: string | undefined; 2005 + if (selectedIds.size > 0) { 2006 + const selLines: string[] = []; 2007 + for (const id of selectedIds) { 2008 + const shape = state.shapes.get(id); 2009 + if (shape) selLines.push(`${shape.kind} "${shape.label || '(unlabeled)'}"`); 2010 + } 2011 + selectionContext = `Selected shapes: ${selLines.join(', ')}`; 2012 + } 2013 + 2014 + const systemPrompt = buildSystemMessage(title, contextText, { 2015 + editorType: 'diagram', 2016 + actionsEnabled, 2017 + selectionContext, 2018 + }); 2019 + 2020 + const diagramDeps = { 2021 + getState: () => state, 2022 + setState: (s: typeof state) => { state = s; syncToYjs(); }, 2023 + render, 2024 + pushHistory, 2025 + }; 2026 + 2027 + const abortController = new AbortController(); 2028 + chatState.abortController = abortController; 2029 + const bubble = appendStreamingBubble(chatUI.messageList); 2030 + let fullText = ''; 2031 + 2032 + await streamChat( 2033 + cfg, 2034 + chatState.messages, 2035 + systemPrompt, 2036 + { 2037 + onChunk(chunk) { 2038 + fullText += chunk; 2039 + bubble.update(renderMarkdown(fullText)); 2040 + }, 2041 + onDone(text) { 2042 + if (text) { 2043 + chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 2044 + 2045 + if (actionsEnabled) { 2046 + const { displayText, actions } = splitResponse(text); 2047 + if (actions.length > 0) { 2048 + bubble.update(renderMarkdown(displayText)); 2049 + for (const action of actions) { 2050 + if (!isDiagramAction(action)) continue; 2051 + appendActionCard(chatUI.messageList, action, { 2052 + onApply: (a) => { 2053 + const result = executeDiagramAction(a as Parameters<typeof executeDiagramAction>[0], diagramDeps); 2054 + if (!result.success && result.error) { 2055 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 2056 + } 2057 + }, 2058 + onDismiss: () => {}, 2059 + }); 2060 + } 2061 + } 2062 + } 2063 + } 2064 + }, 2065 + onError(err) { 2066 + chatState.error = err; 2067 + bubble.el.classList.add('ai-chat-bubble--error'); 2068 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 2069 + }, 2070 + }, 2071 + abortController.signal, 2072 + ); 2073 + 2074 + chatState.loading = false; 2075 + chatState.abortController = null; 2076 + chatUI.sendBtn.style.display = ''; 2077 + chatUI.stopBtn.style.display = 'none'; 2078 + } 1932 2079 1933 2080 // --- Initialize --- 1934 2081 async function init() {
+93
src/forms/ai-form-actions.ts
··· 1 + /** 2 + * AI Form Actions — executor for form builder AI actions. 3 + */ 4 + 5 + import type { 6 + FormAddQuestionAction, 7 + FormModifyQuestionAction, 8 + FormRemoveQuestionAction, 9 + FormAction, 10 + } from '../lib/ai-actions.js'; 11 + import type { FormSchema, QuestionType, QuestionOption } from './form-builder.js'; 12 + import { addQuestion, removeQuestion, updateQuestion } from './form-builder.js'; 13 + 14 + export interface ActionResult { 15 + success: boolean; 16 + error?: string; 17 + } 18 + 19 + export interface FormActionDeps { 20 + getForm: () => FormSchema; 21 + setForm: (form: FormSchema) => void; 22 + syncToYjs: () => void; 23 + render: () => void; 24 + } 25 + 26 + /** Map AI-facing type names to actual QuestionType values */ 27 + const TYPE_MAP: Record<string, QuestionType> = { 28 + short_text: 'short_text', 29 + long_text: 'long_text', 30 + multiple_choice: 'single_choice', 31 + checkbox: 'multiple_choice', 32 + dropdown: 'dropdown', 33 + number: 'number', 34 + date: 'date', 35 + email: 'email', 36 + rating: 'rating', 37 + single_choice: 'single_choice', 38 + scale: 'scale', 39 + }; 40 + 41 + function makeOptions(labels: string[]): QuestionOption[] { 42 + return labels.map((label, i) => ({ 43 + id: `opt-${Date.now()}-${i}`, 44 + label, 45 + })); 46 + } 47 + 48 + export function executeFormAction(action: FormAction, deps: FormActionDeps): ActionResult { 49 + let form = deps.getForm(); 50 + 51 + switch (action.type) { 52 + case 'form_add_question': { 53 + const qType = TYPE_MAP[action.questionType] || 'short_text'; 54 + const opts: { required?: boolean; options?: QuestionOption[] } = {}; 55 + if (action.required) opts.required = true; 56 + if (action.options && action.options.length > 0) { 57 + opts.options = makeOptions(action.options); 58 + } 59 + form = addQuestion(form, qType, action.title, opts); 60 + deps.setForm(form); 61 + deps.syncToYjs(); 62 + deps.render(); 63 + return { success: true }; 64 + } 65 + 66 + case 'form_modify_question': { 67 + // AI uses "title" but form uses "label" 68 + const q = form.questions.find(q => q.label === action.title); 69 + if (!q) return { success: false, error: `Question "${action.title}" not found` }; 70 + const updates: Record<string, unknown> = {}; 71 + if (action.newTitle !== undefined) updates.label = action.newTitle; 72 + if (action.required !== undefined) updates.required = action.required; 73 + if (action.options) { 74 + updates.options = makeOptions(action.options); 75 + } 76 + form = updateQuestion(form, q.id, updates); 77 + deps.setForm(form); 78 + deps.syncToYjs(); 79 + deps.render(); 80 + return { success: true }; 81 + } 82 + 83 + case 'form_remove_question': { 84 + const q = form.questions.find(q => q.label === action.title); 85 + if (!q) return { success: false, error: `Question "${action.title}" not found` }; 86 + form = removeQuestion(form, q.id); 87 + deps.setForm(form); 88 + deps.syncToYjs(); 89 + deps.render(); 90 + return { success: true }; 91 + } 92 + } 93 + }
+3
src/forms/index.html
··· 27 27 <input class="doc-title-input" id="form-title" type="text" value="Untitled Form" spellcheck="false"> 28 28 <span class="topbar-spacer"></span> 29 29 <span class="save-status" id="save-status"></span> 30 + <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 31 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 32 + </button> 30 33 </div> 31 34 32 35 <!-- Form builder -->
+129
src/forms/main.ts
··· 23 23 type Question, 24 24 } from './form-builder.js'; 25 25 import { 26 + createChatSidebar, createChatState, loadConfig, isConfigured, 27 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 28 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 29 + type ChatMessage, 30 + } from '../lib/ai-chat.js'; 31 + import { splitResponse, isFormAction } from '../lib/ai-actions.js'; 32 + import { executeFormAction } from './ai-form-actions.js'; 33 + import { 26 34 createConditionalState, 27 35 addRule, 28 36 getVisibleQuestions, ··· 451 459 loadFormFromYjs(); 452 460 if (mode === 'builder') renderBuilder(); 453 461 }); 462 + 463 + // ── AI Chat Panel ──────────────────────────────────────────────────────── 464 + 465 + const chatUI = createChatSidebar(); 466 + document.getElementById('main-content')!.appendChild(chatUI.container); 467 + 468 + const chatState = createChatState(); 469 + 470 + const chatWiring = initChatWiring({ 471 + chatUI, 472 + chatState, 473 + chatConfig: loadConfig(), 474 + toggleBtn: document.getElementById('btn-ai-chat')!, 475 + editorType: 'form', 476 + onSend: sendChatMessage, 477 + }); 478 + 479 + function getFormContextText(): string { 480 + const lines: string[] = []; 481 + lines.push(`Title: ${form.title}`); 482 + if (form.description) lines.push(`Description: ${form.description}`); 483 + form.questions.forEach((q, i) => { 484 + let line = `${i + 1}. [${q.type}] "${q.label}"`; 485 + if (q.required) line += ' (required)'; 486 + if (q.options.length > 0) line += ` — options: ${q.options.map(o => o.label).join(', ')}`; 487 + lines.push(line); 488 + }); 489 + return lines.join('\n'); 490 + } 491 + 492 + async function sendChatMessage(): Promise<void> { 493 + const text = chatUI.input.value.trim(); 494 + if (!text || chatState.loading) return; 495 + 496 + const cfg = chatWiring.getConfig(); 497 + if (!isConfigured(cfg)) { 498 + chatUI.settingsPanel.style.display = ''; 499 + chatUI.endpointInput.focus(); 500 + return; 501 + } 502 + 503 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 504 + chatState.messages.push(userMsg); 505 + appendMessage(chatUI.messageList, userMsg); 506 + 507 + chatUI.input.value = ''; 508 + chatUI.input.style.height = ''; 509 + chatUI.sendBtn.style.display = 'none'; 510 + chatUI.stopBtn.style.display = ''; 511 + chatState.loading = true; 512 + chatState.error = null; 513 + 514 + const title = titleInput.value.trim() || 'Untitled Form'; 515 + const includeContext = chatUI.contextToggle.checked; 516 + const actionsEnabled = chatUI.actionsToggle.checked; 517 + const contextText = includeContext ? getFormContextText() : ''; 518 + 519 + const systemPrompt = buildSystemMessage(title, contextText, { 520 + editorType: 'form', 521 + actionsEnabled, 522 + }); 523 + 524 + const formDeps = { 525 + getForm: () => form, 526 + setForm: (f: FormSchema) => { form = f; }, 527 + syncToYjs: syncFormToYjs, 528 + render: renderBuilder, 529 + }; 530 + 531 + const abortController = new AbortController(); 532 + chatState.abortController = abortController; 533 + const bubble = appendStreamingBubble(chatUI.messageList); 534 + let fullText = ''; 535 + 536 + await streamChat( 537 + cfg, 538 + chatState.messages, 539 + systemPrompt, 540 + { 541 + onChunk(chunk) { 542 + fullText += chunk; 543 + bubble.update(renderMarkdown(fullText)); 544 + }, 545 + onDone(doneText) { 546 + if (doneText) { 547 + chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 548 + 549 + if (actionsEnabled) { 550 + const { displayText, actions } = splitResponse(doneText); 551 + if (actions.length > 0) { 552 + bubble.update(renderMarkdown(displayText)); 553 + for (const action of actions) { 554 + if (!isFormAction(action)) continue; 555 + appendActionCard(chatUI.messageList, action, { 556 + onApply: (a) => { 557 + const result = executeFormAction(a as Parameters<typeof executeFormAction>[0], formDeps); 558 + if (!result.success && result.error) { 559 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 560 + } 561 + }, 562 + onDismiss: () => {}, 563 + }); 564 + } 565 + } 566 + } 567 + } 568 + }, 569 + onError(err) { 570 + chatState.error = err; 571 + bubble.el.classList.add('ai-chat-bubble--error'); 572 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 573 + }, 574 + }, 575 + abortController.signal, 576 + ); 577 + 578 + chatState.loading = false; 579 + chatState.abortController = null; 580 + chatUI.sendBtn.style.display = ''; 581 + chatUI.stopBtn.style.display = 'none'; 582 + } 454 583 455 584 // --- Initialize --- 456 585 async function init() {
+175 -2
src/lib/ai-actions.ts
··· 47 47 range: string; // e.g. "A1:B5" 48 48 } 49 49 50 + // Diagram actions 51 + export interface DiagramAddShapeAction { 52 + type: 'diagram_add_shape'; 53 + kind: string; 54 + x: number; 55 + y: number; 56 + w: number; 57 + h: number; 58 + label?: string; 59 + fill?: string; 60 + stroke?: string; 61 + } 62 + 63 + export interface DiagramAddArrowAction { 64 + type: 'diagram_add_arrow'; 65 + fromLabel: string; 66 + toLabel: string; 67 + } 68 + 69 + export interface DiagramModifyShapeAction { 70 + type: 'diagram_modify_shape'; 71 + label: string; 72 + newLabel?: string; 73 + fill?: string; 74 + stroke?: string; 75 + w?: number; 76 + h?: number; 77 + x?: number; 78 + y?: number; 79 + } 80 + 81 + export interface DiagramRemoveShapeAction { 82 + type: 'diagram_remove_shape'; 83 + label: string; 84 + } 85 + 86 + export interface DiagramAddTextAction { 87 + type: 'diagram_add_text'; 88 + x: number; 89 + y: number; 90 + text: string; 91 + fontSize?: number; 92 + } 93 + 94 + // Slide actions 95 + export interface SlideAddAction { 96 + type: 'slide_add'; 97 + layout?: string; 98 + } 99 + 100 + export interface SlideAddTextAction { 101 + type: 'slide_add_text'; 102 + x: number; 103 + y: number; 104 + w: number; 105 + h: number; 106 + text: string; 107 + fontSize?: number; 108 + } 109 + 110 + export interface SlideAddShapeAction { 111 + type: 'slide_add_shape'; 112 + element: string; 113 + x: number; 114 + y: number; 115 + w: number; 116 + h: number; 117 + fill?: string; 118 + } 119 + 120 + // Form actions 121 + export interface FormAddQuestionAction { 122 + type: 'form_add_question'; 123 + questionType: string; 124 + title: string; 125 + required?: boolean; 126 + options?: string[]; 127 + } 128 + 129 + export interface FormModifyQuestionAction { 130 + type: 'form_modify_question'; 131 + title: string; 132 + newTitle?: string; 133 + required?: boolean; 134 + options?: string[]; 135 + } 136 + 137 + export interface FormRemoveQuestionAction { 138 + type: 'form_remove_question'; 139 + title: string; 140 + } 141 + 50 142 export type DocAction = DocInsertAction | DocReplaceAction | DocSuggestInsertAction | DocSuggestReplaceAction; 51 143 export type SheetAction = SheetSetAction | SheetClearAction; 52 - export type AIAction = DocAction | SheetAction; 144 + export type DiagramAction = DiagramAddShapeAction | DiagramAddArrowAction | DiagramModifyShapeAction | DiagramRemoveShapeAction | DiagramAddTextAction; 145 + export type SlideAction = SlideAddAction | SlideAddTextAction | SlideAddShapeAction; 146 + export type FormAction = FormAddQuestionAction | FormModifyQuestionAction | FormRemoveQuestionAction; 147 + export type AIAction = DocAction | SheetAction | DiagramAction | SlideAction | FormAction; 53 148 54 149 // ── Validation ──────────────────────────────────────────────────────── 55 150 ··· 63 158 64 159 const DOC_ACTION_TYPES = new Set(['doc_insert', 'doc_replace', 'doc_suggest_insert', 'doc_suggest_replace']); 65 160 const SHEET_ACTION_TYPES = new Set(['sheet_set', 'sheet_clear']); 161 + const DIAGRAM_ACTION_TYPES = new Set(['diagram_add_shape', 'diagram_add_arrow', 'diagram_modify_shape', 'diagram_remove_shape', 'diagram_add_text']); 162 + const SLIDE_ACTION_TYPES = new Set(['slide_add', 'slide_add_text', 'slide_add_shape']); 163 + const FORM_ACTION_TYPES = new Set(['form_add_question', 'form_modify_question', 'form_remove_question']); 66 164 const VALID_POSITIONS = new Set(['cursor', 'start', 'end']); 67 165 68 166 export function validateAction(action: unknown): ValidationResult { ··· 76 174 return { valid: false, error: 'Action must have a string "type" field' }; 77 175 } 78 176 79 - if (!DOC_ACTION_TYPES.has(a.type) && !SHEET_ACTION_TYPES.has(a.type)) { 177 + if (!DOC_ACTION_TYPES.has(a.type) && !SHEET_ACTION_TYPES.has(a.type) && !DIAGRAM_ACTION_TYPES.has(a.type) && !SLIDE_ACTION_TYPES.has(a.type) && !FORM_ACTION_TYPES.has(a.type)) { 80 178 return { valid: false, error: `Unknown action type: ${a.type}` }; 81 179 } 82 180 ··· 121 219 return { valid: false, error: `sheet_clear requires a valid "range" (e.g. "A1:B5"), got: "${a.range}"` }; 122 220 } 123 221 break; 222 + 223 + case 'diagram_add_shape': 224 + if (typeof a.kind !== 'string') return { valid: false, error: 'diagram_add_shape requires "kind"' }; 225 + if (typeof a.x !== 'number' || typeof a.y !== 'number') return { valid: false, error: 'diagram_add_shape requires numeric x and y' }; 226 + if (typeof a.w !== 'number' || typeof a.h !== 'number') return { valid: false, error: 'diagram_add_shape requires numeric w and h' }; 227 + break; 228 + case 'diagram_add_arrow': 229 + if (typeof a.fromLabel !== 'string' || typeof a.toLabel !== 'string') return { valid: false, error: 'diagram_add_arrow requires "fromLabel" and "toLabel"' }; 230 + break; 231 + case 'diagram_modify_shape': 232 + if (typeof a.label !== 'string') return { valid: false, error: 'diagram_modify_shape requires "label"' }; 233 + break; 234 + case 'diagram_remove_shape': 235 + if (typeof a.label !== 'string') return { valid: false, error: 'diagram_remove_shape requires "label"' }; 236 + break; 237 + case 'diagram_add_text': 238 + if (typeof a.x !== 'number' || typeof a.y !== 'number') return { valid: false, error: 'diagram_add_text requires numeric x and y' }; 239 + if (typeof a.text !== 'string') return { valid: false, error: 'diagram_add_text requires "text"' }; 240 + break; 241 + case 'slide_add': 242 + break; 243 + case 'slide_add_text': 244 + if (typeof a.x !== 'number' || typeof a.y !== 'number') return { valid: false, error: 'slide_add_text requires numeric x and y' }; 245 + if (typeof a.w !== 'number' || typeof a.h !== 'number') return { valid: false, error: 'slide_add_text requires numeric w and h' }; 246 + if (typeof a.text !== 'string') return { valid: false, error: 'slide_add_text requires "text"' }; 247 + break; 248 + case 'slide_add_shape': 249 + if (typeof a.element !== 'string') return { valid: false, error: 'slide_add_shape requires "element"' }; 250 + if (typeof a.x !== 'number' || typeof a.y !== 'number') return { valid: false, error: 'slide_add_shape requires numeric x and y' }; 251 + if (typeof a.w !== 'number' || typeof a.h !== 'number') return { valid: false, error: 'slide_add_shape requires numeric w and h' }; 252 + break; 253 + case 'form_add_question': 254 + if (typeof a.questionType !== 'string') return { valid: false, error: 'form_add_question requires "questionType"' }; 255 + if (typeof a.title !== 'string') return { valid: false, error: 'form_add_question requires "title"' }; 256 + break; 257 + case 'form_modify_question': 258 + if (typeof a.title !== 'string') return { valid: false, error: 'form_modify_question requires "title"' }; 259 + break; 260 + case 'form_remove_question': 261 + if (typeof a.title !== 'string') return { valid: false, error: 'form_remove_question requires "title"' }; 262 + break; 124 263 } 125 264 126 265 return { valid: true }; ··· 202 341 return `Set ${action.cells.length} cell${action.cells.length > 1 ? 's' : ''}: ${action.cells.slice(0, 3).map((c) => c.ref).join(', ')}${action.cells.length > 3 ? '...' : ''}`; 203 342 case 'sheet_clear': 204 343 return `Clear range ${action.range}`; 344 + case 'diagram_add_shape': 345 + return `Add ${action.kind} shape${action.label ? ` "${action.label}"` : ''}`; 346 + case 'diagram_add_arrow': 347 + return `Add arrow from "${action.fromLabel}" to "${action.toLabel}"`; 348 + case 'diagram_modify_shape': 349 + return `Modify shape "${action.label}"`; 350 + case 'diagram_remove_shape': 351 + return `Remove shape "${action.label}"`; 352 + case 'diagram_add_text': 353 + return `Add text "${action.text.slice(0, 40)}${action.text.length > 40 ? '...' : ''}"`; 354 + case 'slide_add': 355 + return `Add ${action.layout || 'blank'} slide`; 356 + case 'slide_add_text': 357 + return `Add text "${action.text.slice(0, 40)}${action.text.length > 40 ? '...' : ''}"`; 358 + case 'slide_add_shape': 359 + return `Add ${action.element} shape`; 360 + case 'form_add_question': 361 + return `Add ${action.questionType} question: "${action.title.slice(0, 40)}${action.title.length > 40 ? '...' : ''}"`; 362 + case 'form_modify_question': 363 + return `Modify question "${action.title.slice(0, 40)}${action.title.length > 40 ? '...' : ''}"`; 364 + case 'form_remove_question': 365 + return `Remove question "${action.title}"`; 205 366 } 206 367 } 207 368 ··· 214 375 export function isSheetAction(action: AIAction): action is SheetAction { 215 376 return SHEET_ACTION_TYPES.has(action.type); 216 377 } 378 + 379 + export function isDiagramAction(action: AIAction): action is DiagramAction { 380 + return DIAGRAM_ACTION_TYPES.has(action.type); 381 + } 382 + 383 + export function isSlideAction(action: AIAction): action is SlideAction { 384 + return SLIDE_ACTION_TYPES.has(action.type); 385 + } 386 + 387 + export function isFormAction(action: AIAction): action is FormAction { 388 + return FORM_ACTION_TYPES.has(action.type); 389 + }
+87 -4
src/lib/ai-chat.ts
··· 87 87 88 88 // ── System prompt ────────────────────────────────────────────────────── 89 89 90 - export type EditorType = 'doc' | 'sheet'; 90 + export type EditorType = 'doc' | 'sheet' | 'diagram' | 'slide' | 'form'; 91 91 92 92 export interface SystemMessageOptions { 93 93 editorType?: EditorType; ··· 105 105 const descriptions: Record<EditorType, { role: string; label: string }> = { 106 106 doc: { role: 'a helpful writing assistant embedded in a document editor', label: 'document' }, 107 107 sheet: { role: 'a helpful data assistant embedded in a spreadsheet editor', label: 'spreadsheet' }, 108 + diagram: { role: 'a helpful diagramming assistant embedded in a whiteboard/diagram editor', label: 'diagram' }, 109 + slide: { role: 'a helpful presentation assistant embedded in a slide deck editor', label: 'presentation' }, 110 + form: { role: 'a helpful form-building assistant embedded in a form builder', label: 'form' }, 108 111 }; 109 112 const { role, label } = descriptions[editorType]; 110 113 const parts = [ ··· 167 170 ' {"type": "doc_suggest_replace", "search": "original text", "replace": "suggested replacement"}', 168 171 ' ```', 169 172 ); 170 - } else { 173 + } else if (editorType === 'sheet') { 171 174 lines.push( 172 175 'Available spreadsheet actions:', 173 176 '', ··· 179 182 '- **sheet_clear**: Clear a range of cells.', 180 183 ' ```action', 181 184 ' {"type": "sheet_clear", "range": "A1:B5"}', 185 + ' ```', 186 + ); 187 + } else if (editorType === 'diagram') { 188 + lines.push( 189 + 'Available diagram actions:', 190 + '', 191 + '- **diagram_add_shape**: Add a shape to the canvas.', 192 + ' ```action', 193 + ' {"type": "diagram_add_shape", "kind": "rectangle", "x": 100, "y": 100, "w": 160, "h": 80, "label": "My Shape", "fill": "#4A90D9", "stroke": "#2C5F8A"}', 194 + ' ```', 195 + ' Kind can be: rectangle, ellipse, diamond, triangle, star, hexagon, cylinder, parallelogram, cloud, note.', 196 + ' All position/size fields are in pixels. fill and stroke are hex colors. label is optional.', 197 + '', 198 + '- **diagram_add_arrow**: Add an arrow/line between two shapes.', 199 + ' ```action', 200 + ' {"type": "diagram_add_arrow", "fromLabel": "Shape A", "toLabel": "Shape B"}', 201 + ' ```', 202 + ' Reference shapes by their label text. The arrow connects nearest edges automatically.', 203 + '', 204 + '- **diagram_modify_shape**: Modify an existing shape by its label.', 205 + ' ```action', 206 + ' {"type": "diagram_modify_shape", "label": "My Shape", "newLabel": "Updated", "fill": "#FF6B6B", "w": 200, "h": 100}', 207 + ' ```', 208 + ' Only include fields you want to change.', 209 + '', 210 + '- **diagram_remove_shape**: Remove a shape by its label.', 211 + ' ```action', 212 + ' {"type": "diagram_remove_shape", "label": "My Shape"}', 213 + ' ```', 214 + '', 215 + '- **diagram_add_text**: Add a standalone text element.', 216 + ' ```action', 217 + ' {"type": "diagram_add_text", "x": 200, "y": 50, "text": "Title Text", "fontSize": 24}', 218 + ' ```', 219 + ); 220 + } else if (editorType === 'slide') { 221 + lines.push( 222 + 'Available presentation actions:', 223 + '', 224 + '- **slide_add**: Add a new slide.', 225 + ' ```action', 226 + ' {"type": "slide_add", "layout": "title"}', 227 + ' ```', 228 + ' Layout can be: blank, title, titleContent, twoColumn, section, image.', 229 + '', 230 + '- **slide_add_text**: Add text to the current slide.', 231 + ' ```action', 232 + ' {"type": "slide_add_text", "x": 100, "y": 100, "w": 800, "h": 60, "text": "Hello World", "fontSize": 32}', 233 + ' ```', 234 + '', 235 + '- **slide_add_shape**: Add a shape to the current slide.', 236 + ' ```action', 237 + ' {"type": "slide_add_shape", "element": "rectangle", "x": 100, "y": 200, "w": 200, "h": 100, "fill": "#4A90D9"}', 238 + ' ```', 239 + ); 240 + } else if (editorType === 'form') { 241 + lines.push( 242 + 'Available form actions:', 243 + '', 244 + '- **form_add_question**: Add a question to the form.', 245 + ' ```action', 246 + ' {"type": "form_add_question", "questionType": "short_text", "title": "What is your name?", "required": true}', 247 + ' ```', 248 + ' questionType can be: short_text, long_text, multiple_choice, checkbox, dropdown, number, date, email, rating.', 249 + ' For multiple_choice/checkbox/dropdown, include "options": ["Option A", "Option B"].', 250 + '', 251 + '- **form_modify_question**: Modify an existing question by its title.', 252 + ' ```action', 253 + ' {"type": "form_modify_question", "title": "What is your name?", "newTitle": "Full Name", "required": false}', 254 + ' ```', 255 + '', 256 + '- **form_remove_question**: Remove a question by title.', 257 + ' ```action', 258 + ' {"type": "form_remove_question", "title": "What is your name?"}', 182 259 ' ```', 183 260 ); 184 261 } ··· 867 944 }); 868 945 869 946 // Clear 870 - const label = editorType === 'sheet' ? 'spreadsheet' : 'document'; 871 - const contextWord = editorType === 'sheet' ? 'data' : 'content'; 947 + const editorLabels: Record<string, { label: string; contextWord: string }> = { 948 + doc: { label: 'document', contextWord: 'content' }, 949 + sheet: { label: 'spreadsheet', contextWord: 'data' }, 950 + diagram: { label: 'diagram', contextWord: 'shapes and arrows' }, 951 + slide: { label: 'presentation', contextWord: 'slides' }, 952 + form: { label: 'form', contextWord: 'questions' }, 953 + }; 954 + const { label, contextWord } = editorLabels[editorType] || editorLabels.doc; 872 955 chatUI.clearBtn.addEventListener('click', () => { 873 956 chatState.messages = []; 874 957 chatState.error = null;
+73
src/slides/ai-slide-actions.ts
··· 1 + /** 2 + * AI Slide Actions — executor for presentation AI actions. 3 + */ 4 + 5 + import type { 6 + SlideAddAction, 7 + SlideAddTextAction, 8 + SlideAddShapeAction, 9 + SlideAction, 10 + } from '../lib/ai-actions.js'; 11 + import type { DeckState, ElementType, ShapeType } from './canvas-engine.js'; 12 + import { addSlide, addElement, currentSlide } from './canvas-engine.js'; 13 + 14 + const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/; 15 + function sanitizeColor(val: string | undefined): string | undefined { 16 + return val && HEX_COLOR_RE.test(val) ? val : undefined; 17 + } 18 + 19 + export interface ActionResult { 20 + success: boolean; 21 + error?: string; 22 + } 23 + 24 + export interface SlideActionDeps { 25 + getState: () => DeckState; 26 + setState: (state: DeckState) => void; 27 + render: () => void; 28 + } 29 + 30 + export function executeSlideAction(action: SlideAction, deps: SlideActionDeps): ActionResult { 31 + let state = deps.getState(); 32 + 33 + switch (action.type) { 34 + case 'slide_add': { 35 + state = addSlide(state); 36 + deps.setState(state); 37 + deps.render(); 38 + return { success: true }; 39 + } 40 + 41 + case 'slide_add_text': { 42 + const fontSize = action.fontSize || 24; 43 + const style: Record<string, string> = { fontSize: `${fontSize}px` }; 44 + state = addElement(state, 'text', action.x, action.y, action.w, action.h, action.text, style); 45 + deps.setState(state); 46 + deps.render(); 47 + return { success: true }; 48 + } 49 + 50 + case 'slide_add_shape': { 51 + const shapeType = (action.element || 'rectangle') as ShapeType; 52 + const style: Record<string, string> = {}; 53 + const fill = sanitizeColor(action.fill); 54 + if (fill) style.fill = fill; 55 + state = addElement(state, 'shape', action.x, action.y, action.w, action.h, '', style); 56 + // Set shapeType immutably on the newly created element 57 + const slide = currentSlide(state); 58 + if (slide && slide.elements.length > 0) { 59 + const lastIdx = slide.elements.length - 1; 60 + const updatedElements = slide.elements.map((el, i) => 61 + i === lastIdx ? { ...el, shapeType } : el, 62 + ); 63 + const updatedSlides = state.slides.map((s, i) => 64 + i === state.currentSlide ? { ...s, elements: updatedElements } : s, 65 + ); 66 + state = { ...state, slides: updatedSlides }; 67 + } 68 + deps.setState(state); 69 + deps.render(); 70 + return { success: true }; 71 + } 72 + } 73 + }
+3
src/slides/index.html
··· 29 29 <span class="save-status" id="save-status"></span> 30 30 <button class="btn-secondary" id="btn-present" title="Present (F5)">&#9654; Present</button> 31 31 <button class="btn-secondary" id="btn-export" title="Export">Export</button> 32 + <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 33 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 34 + </button> 32 35 </div> 33 36 34 37 <main class="slides-main" id="main-content">
+128
src/slides/main.ts
··· 31 31 progressPercent, isOverTime, 32 32 } from './presenter-mode.js'; 33 33 import type { PresenterState } from './presenter-mode.js'; 34 + import { 35 + createChatSidebar, createChatState, loadConfig, isConfigured, 36 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 37 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 38 + type ChatMessage, 39 + } from '../lib/ai-chat.js'; 40 + import { splitResponse, isSlideAction } from '../lib/ai-actions.js'; 41 + import { executeSlideAction } from './ai-slide-actions.js'; 34 42 35 43 // --- DOM refs --- 36 44 const $ = (id: string) => document.getElementById(id)!; ··· 419 427 body: JSON.stringify({ name_encrypted: b64 }), 420 428 }); 421 429 }); 430 + 431 + // ── AI Chat Panel ──────────────────────────────────────────────────────── 432 + 433 + const chatUI = createChatSidebar(); 434 + $('main-content').appendChild(chatUI.container); 435 + 436 + const chatState = createChatState(); 437 + 438 + const chatWiring = initChatWiring({ 439 + chatUI, 440 + chatState, 441 + chatConfig: loadConfig(), 442 + toggleBtn: $('btn-ai-chat'), 443 + editorType: 'slide', 444 + onSend: sendChatMessage, 445 + }); 446 + 447 + function getSlideContextText(): string { 448 + const lines: string[] = []; 449 + deck.slides.forEach((slide, i) => { 450 + lines.push(`Slide ${i + 1}${i === deck.currentSlide ? ' (current)' : ''}:`); 451 + if (slide.notes) lines.push(` Notes: ${slide.notes}`); 452 + slide.elements.forEach(el => { 453 + if (el.content) lines.push(` ${el.type}: "${el.content}"`); 454 + else lines.push(` ${el.type} (${Math.round(el.width)}x${Math.round(el.height)})`); 455 + }); 456 + }); 457 + return lines.join('\n'); 458 + } 459 + 460 + async function sendChatMessage(): Promise<void> { 461 + const text = chatUI.input.value.trim(); 462 + if (!text || chatState.loading) return; 463 + 464 + const cfg = chatWiring.getConfig(); 465 + if (!isConfigured(cfg)) { 466 + chatUI.settingsPanel.style.display = ''; 467 + chatUI.endpointInput.focus(); 468 + return; 469 + } 470 + 471 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 472 + chatState.messages.push(userMsg); 473 + appendMessage(chatUI.messageList, userMsg); 474 + 475 + chatUI.input.value = ''; 476 + chatUI.input.style.height = ''; 477 + chatUI.sendBtn.style.display = 'none'; 478 + chatUI.stopBtn.style.display = ''; 479 + chatState.loading = true; 480 + chatState.error = null; 481 + 482 + const title = deckTitle.value.trim() || 'Untitled Presentation'; 483 + const includeContext = chatUI.contextToggle.checked; 484 + const actionsEnabled = chatUI.actionsToggle.checked; 485 + const contextText = includeContext ? getSlideContextText() : ''; 486 + 487 + const systemPrompt = buildSystemMessage(title, contextText, { 488 + editorType: 'slide', 489 + actionsEnabled, 490 + }); 491 + 492 + const slideDeps = { 493 + getState: () => deck, 494 + setState: (s: DeckState) => { deck = s; syncDeckToYjs(); }, 495 + render, 496 + }; 497 + 498 + const abortController = new AbortController(); 499 + chatState.abortController = abortController; 500 + const bubble = appendStreamingBubble(chatUI.messageList); 501 + let fullText = ''; 502 + 503 + await streamChat( 504 + cfg, 505 + chatState.messages, 506 + systemPrompt, 507 + { 508 + onChunk(chunk) { 509 + fullText += chunk; 510 + bubble.update(renderMarkdown(fullText)); 511 + }, 512 + onDone(doneText) { 513 + if (doneText) { 514 + chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 515 + 516 + if (actionsEnabled) { 517 + const { displayText, actions } = splitResponse(doneText); 518 + if (actions.length > 0) { 519 + bubble.update(renderMarkdown(displayText)); 520 + for (const action of actions) { 521 + if (!isSlideAction(action)) continue; 522 + appendActionCard(chatUI.messageList, action, { 523 + onApply: (a) => { 524 + const result = executeSlideAction(a as Parameters<typeof executeSlideAction>[0], slideDeps); 525 + if (!result.success && result.error) { 526 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 527 + } 528 + }, 529 + onDismiss: () => {}, 530 + }); 531 + } 532 + } 533 + } 534 + } 535 + }, 536 + onError(err) { 537 + chatState.error = err; 538 + bubble.el.classList.add('ai-chat-bubble--error'); 539 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 540 + }, 541 + }, 542 + abortController.signal, 543 + ); 544 + 545 + chatState.loading = false; 546 + chatState.abortController = null; 547 + chatUI.sendBtn.style.display = ''; 548 + chatUI.stopBtn.style.display = 'none'; 549 + } 422 550 423 551 // --- Initialize --- 424 552 async function init() {
+313
tests/ai-actions-extended.test.ts
··· 1 + /** 2 + * Tests for extended AI action types (diagram, slide, form). 3 + * Covers validation, parsing, description, and type guards. 4 + */ 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + validateAction, parseActions, splitResponse, stripActions, 8 + describeAction, isDiagramAction, isSlideAction, isFormAction, 9 + isDocAction, isSheetAction, 10 + } from '../src/lib/ai-actions.js'; 11 + import { buildSystemMessage, type EditorType } from '../src/lib/ai-chat.js'; 12 + 13 + describe('validateAction — diagram actions', () => { 14 + it('validates diagram_add_shape', () => { 15 + expect(validateAction({ type: 'diagram_add_shape', kind: 'rectangle', x: 0, y: 0, w: 100, h: 50 }).valid).toBe(true); 16 + }); 17 + 18 + it('rejects diagram_add_shape without kind', () => { 19 + expect(validateAction({ type: 'diagram_add_shape', x: 0, y: 0, w: 100, h: 50 }).valid).toBe(false); 20 + }); 21 + 22 + it('rejects diagram_add_shape with non-numeric coords', () => { 23 + expect(validateAction({ type: 'diagram_add_shape', kind: 'rectangle', x: 'a', y: 0, w: 100, h: 50 }).valid).toBe(false); 24 + }); 25 + 26 + it('rejects diagram_add_shape with non-numeric size', () => { 27 + expect(validateAction({ type: 'diagram_add_shape', kind: 'rectangle', x: 0, y: 0, w: 'big', h: 50 }).valid).toBe(false); 28 + }); 29 + 30 + it('validates diagram_add_arrow', () => { 31 + expect(validateAction({ type: 'diagram_add_arrow', fromLabel: 'A', toLabel: 'B' }).valid).toBe(true); 32 + }); 33 + 34 + it('rejects diagram_add_arrow without labels', () => { 35 + expect(validateAction({ type: 'diagram_add_arrow', fromLabel: 'A' }).valid).toBe(false); 36 + expect(validateAction({ type: 'diagram_add_arrow', toLabel: 'B' }).valid).toBe(false); 37 + }); 38 + 39 + it('validates diagram_modify_shape', () => { 40 + expect(validateAction({ type: 'diagram_modify_shape', label: 'Box' }).valid).toBe(true); 41 + }); 42 + 43 + it('rejects diagram_modify_shape without label', () => { 44 + expect(validateAction({ type: 'diagram_modify_shape' }).valid).toBe(false); 45 + }); 46 + 47 + it('validates diagram_remove_shape', () => { 48 + expect(validateAction({ type: 'diagram_remove_shape', label: 'X' }).valid).toBe(true); 49 + }); 50 + 51 + it('validates diagram_add_text', () => { 52 + expect(validateAction({ type: 'diagram_add_text', x: 10, y: 20, text: 'Hi' }).valid).toBe(true); 53 + }); 54 + 55 + it('rejects diagram_add_text without text', () => { 56 + expect(validateAction({ type: 'diagram_add_text', x: 10, y: 20 }).valid).toBe(false); 57 + }); 58 + }); 59 + 60 + describe('validateAction — slide actions', () => { 61 + it('validates slide_add', () => { 62 + expect(validateAction({ type: 'slide_add' }).valid).toBe(true); 63 + expect(validateAction({ type: 'slide_add', layout: 'title' }).valid).toBe(true); 64 + }); 65 + 66 + it('validates slide_add_text', () => { 67 + expect(validateAction({ type: 'slide_add_text', x: 0, y: 0, w: 800, h: 60, text: 'Hello' }).valid).toBe(true); 68 + }); 69 + 70 + it('rejects slide_add_text without text', () => { 71 + expect(validateAction({ type: 'slide_add_text', x: 0, y: 0, w: 100, h: 50 }).valid).toBe(false); 72 + }); 73 + 74 + it('rejects slide_add_text without w and h', () => { 75 + expect(validateAction({ type: 'slide_add_text', x: 0, y: 0, text: 'Hello' }).valid).toBe(false); 76 + }); 77 + 78 + it('validates slide_add_shape', () => { 79 + expect(validateAction({ type: 'slide_add_shape', element: 'rectangle', x: 0, y: 0, w: 100, h: 100 }).valid).toBe(true); 80 + }); 81 + 82 + it('rejects slide_add_shape without element', () => { 83 + expect(validateAction({ type: 'slide_add_shape', x: 0, y: 0, w: 100, h: 100 }).valid).toBe(false); 84 + }); 85 + 86 + it('rejects slide_add_shape without w and h', () => { 87 + expect(validateAction({ type: 'slide_add_shape', element: 'rectangle', x: 0, y: 0 }).valid).toBe(false); 88 + }); 89 + }); 90 + 91 + describe('validateAction — form actions', () => { 92 + it('validates form_add_question', () => { 93 + expect(validateAction({ type: 'form_add_question', questionType: 'short_text', title: 'Name?' }).valid).toBe(true); 94 + }); 95 + 96 + it('rejects form_add_question without title', () => { 97 + expect(validateAction({ type: 'form_add_question', questionType: 'short_text' }).valid).toBe(false); 98 + }); 99 + 100 + it('validates form_modify_question', () => { 101 + expect(validateAction({ type: 'form_modify_question', title: 'Old' }).valid).toBe(true); 102 + }); 103 + 104 + it('validates form_remove_question', () => { 105 + expect(validateAction({ type: 'form_remove_question', title: 'Q1' }).valid).toBe(true); 106 + }); 107 + 108 + it('rejects unknown action type', () => { 109 + expect(validateAction({ type: 'unknown_action' }).valid).toBe(false); 110 + }); 111 + }); 112 + 113 + describe('parseActions — extended types', () => { 114 + it('parses diagram action blocks', () => { 115 + const text = 'Here is a shape:\n```action\n{"type": "diagram_add_shape", "kind": "rectangle", "x": 100, "y": 100, "w": 160, "h": 80, "label": "Server"}\n```'; 116 + const { actions, errors } = parseActions(text); 117 + expect(errors.length).toBe(0); 118 + expect(actions.length).toBe(1); 119 + expect(actions[0].type).toBe('diagram_add_shape'); 120 + }); 121 + 122 + it('parses multiple mixed action types', () => { 123 + const text = [ 124 + '```action\n{"type": "diagram_add_shape", "kind": "ellipse", "x": 0, "y": 0, "w": 100, "h": 100}\n```', 125 + '```action\n{"type": "diagram_add_arrow", "fromLabel": "A", "toLabel": "B"}\n```', 126 + '```action\n{"type": "form_add_question", "questionType": "email", "title": "Email"}\n```', 127 + ].join('\n\n'); 128 + const { actions } = parseActions(text); 129 + expect(actions.length).toBe(3); 130 + }); 131 + 132 + it('strips diagram action blocks from display text', () => { 133 + const text = 'I added a shape:\n```action\n{"type": "diagram_add_shape", "kind": "rectangle", "x": 0, "y": 0, "w": 100, "h": 100}\n```\nDone!'; 134 + const display = stripActions(text); 135 + expect(display).toContain('I added a shape'); 136 + expect(display).toContain('Done!'); 137 + expect(display).not.toContain('diagram_add_shape'); 138 + }); 139 + }); 140 + 141 + describe('describeAction — extended types', () => { 142 + it('describes diagram_add_shape', () => { 143 + const desc = describeAction({ type: 'diagram_add_shape', kind: 'rectangle', x: 0, y: 0, w: 100, h: 50, label: 'Server' }); 144 + expect(desc).toContain('rectangle'); 145 + expect(desc).toContain('Server'); 146 + }); 147 + 148 + it('describes diagram_add_shape without label', () => { 149 + const desc = describeAction({ type: 'diagram_add_shape', kind: 'ellipse', x: 0, y: 0, w: 100, h: 100 }); 150 + expect(desc).toContain('ellipse'); 151 + }); 152 + 153 + it('describes diagram_add_arrow', () => { 154 + const desc = describeAction({ type: 'diagram_add_arrow', fromLabel: 'A', toLabel: 'B' }); 155 + expect(desc).toContain('A'); 156 + expect(desc).toContain('B'); 157 + }); 158 + 159 + it('describes diagram_modify_shape', () => { 160 + expect(describeAction({ type: 'diagram_modify_shape', label: 'X' })).toContain('X'); 161 + }); 162 + 163 + it('describes diagram_remove_shape', () => { 164 + expect(describeAction({ type: 'diagram_remove_shape', label: 'Y' })).toContain('Y'); 165 + }); 166 + 167 + it('describes diagram_add_text', () => { 168 + expect(describeAction({ type: 'diagram_add_text', x: 0, y: 0, text: 'Title' })).toContain('Title'); 169 + }); 170 + 171 + it('describes slide_add', () => { 172 + expect(describeAction({ type: 'slide_add' })).toContain('blank'); 173 + expect(describeAction({ type: 'slide_add', layout: 'title' })).toContain('title'); 174 + }); 175 + 176 + it('describes slide_add_text', () => { 177 + expect(describeAction({ type: 'slide_add_text', x: 0, y: 0, w: 100, h: 50, text: 'Hi' })).toContain('Hi'); 178 + }); 179 + 180 + it('describes slide_add_shape', () => { 181 + expect(describeAction({ type: 'slide_add_shape', element: 'ellipse', x: 0, y: 0, w: 100, h: 100 })).toContain('ellipse'); 182 + }); 183 + 184 + it('describes form_add_question', () => { 185 + const desc = describeAction({ type: 'form_add_question', questionType: 'email', title: 'Your email?' }); 186 + expect(desc).toContain('email'); 187 + expect(desc).toContain('Your email?'); 188 + }); 189 + 190 + it('describes form_modify_question', () => { 191 + expect(describeAction({ type: 'form_modify_question', title: 'Name' })).toContain('Name'); 192 + }); 193 + 194 + it('describes form_remove_question', () => { 195 + expect(describeAction({ type: 'form_remove_question', title: 'Q1' })).toContain('Q1'); 196 + }); 197 + 198 + it('truncates long text in descriptions', () => { 199 + const long = 'A'.repeat(100); 200 + const desc = describeAction({ type: 'diagram_add_text', x: 0, y: 0, text: long }); 201 + expect(desc.length).toBeLessThan(100); 202 + expect(desc).toContain('...'); 203 + }); 204 + }); 205 + 206 + describe('type guards — extended', () => { 207 + it('isDiagramAction returns true for diagram types', () => { 208 + expect(isDiagramAction({ type: 'diagram_add_shape', kind: 'rectangle', x: 0, y: 0, w: 100, h: 100 })).toBe(true); 209 + expect(isDiagramAction({ type: 'diagram_add_arrow', fromLabel: 'A', toLabel: 'B' })).toBe(true); 210 + expect(isDiagramAction({ type: 'diagram_modify_shape', label: 'X' })).toBe(true); 211 + expect(isDiagramAction({ type: 'diagram_remove_shape', label: 'X' })).toBe(true); 212 + expect(isDiagramAction({ type: 'diagram_add_text', x: 0, y: 0, text: 'Hi' })).toBe(true); 213 + }); 214 + 215 + it('isDiagramAction returns false for non-diagram types', () => { 216 + expect(isDiagramAction({ type: 'doc_insert', position: 'end', content: 'hi' })).toBe(false); 217 + expect(isDiagramAction({ type: 'sheet_set', cells: [{ ref: 'A1', value: 'x' }] })).toBe(false); 218 + expect(isDiagramAction({ type: 'form_add_question', questionType: 'short_text', title: 'x' })).toBe(false); 219 + }); 220 + 221 + it('isSlideAction returns true for slide types', () => { 222 + expect(isSlideAction({ type: 'slide_add' })).toBe(true); 223 + expect(isSlideAction({ type: 'slide_add_text', x: 0, y: 0, w: 100, h: 50, text: 'x' })).toBe(true); 224 + expect(isSlideAction({ type: 'slide_add_shape', element: 'rectangle', x: 0, y: 0, w: 100, h: 100 })).toBe(true); 225 + }); 226 + 227 + it('isSlideAction returns false for non-slide types', () => { 228 + expect(isSlideAction({ type: 'diagram_add_shape', kind: 'rectangle', x: 0, y: 0, w: 100, h: 100 })).toBe(false); 229 + }); 230 + 231 + it('isFormAction returns true for form types', () => { 232 + expect(isFormAction({ type: 'form_add_question', questionType: 'email', title: 'x' })).toBe(true); 233 + expect(isFormAction({ type: 'form_modify_question', title: 'x' })).toBe(true); 234 + expect(isFormAction({ type: 'form_remove_question', title: 'x' })).toBe(true); 235 + }); 236 + 237 + it('isFormAction returns false for non-form types', () => { 238 + expect(isFormAction({ type: 'doc_insert', position: 'end', content: 'hi' })).toBe(false); 239 + }); 240 + 241 + it('original guards still work', () => { 242 + expect(isDocAction({ type: 'doc_insert', position: 'end', content: 'hi' })).toBe(true); 243 + expect(isSheetAction({ type: 'sheet_set', cells: [{ ref: 'A1', value: 'x' }] })).toBe(true); 244 + }); 245 + }); 246 + 247 + describe('buildSystemMessage — new editor types', () => { 248 + const types: EditorType[] = ['diagram', 'slide', 'form']; 249 + 250 + for (const editorType of types) { 251 + it(`builds system message for ${editorType}`, () => { 252 + const msg = buildSystemMessage('Test', 'some context', { editorType }); 253 + expect(msg).toContain('Test'); 254 + expect(msg).toContain('some context'); 255 + }); 256 + 257 + it(`includes action instructions for ${editorType}`, () => { 258 + const msg = buildSystemMessage('Test', '', { editorType, actionsEnabled: true }); 259 + expect(msg).toContain('## Actions'); 260 + expect(msg).toContain('action'); 261 + }); 262 + } 263 + 264 + it('diagram prompt includes shape kinds', () => { 265 + const msg = buildSystemMessage('D', '', { editorType: 'diagram', actionsEnabled: true }); 266 + expect(msg).toContain('rectangle'); 267 + expect(msg).toContain('ellipse'); 268 + expect(msg).toContain('diagram_add_shape'); 269 + expect(msg).toContain('diagram_add_arrow'); 270 + expect(msg).toContain('diagram_modify_shape'); 271 + expect(msg).toContain('diagram_remove_shape'); 272 + expect(msg).toContain('diagram_add_text'); 273 + }); 274 + 275 + it('slide prompt includes slide actions', () => { 276 + const msg = buildSystemMessage('S', '', { editorType: 'slide', actionsEnabled: true }); 277 + expect(msg).toContain('slide_add'); 278 + expect(msg).toContain('slide_add_text'); 279 + expect(msg).toContain('slide_add_shape'); 280 + }); 281 + 282 + it('form prompt includes form actions', () => { 283 + const msg = buildSystemMessage('F', '', { editorType: 'form', actionsEnabled: true }); 284 + expect(msg).toContain('form_add_question'); 285 + expect(msg).toContain('form_modify_question'); 286 + expect(msg).toContain('form_remove_question'); 287 + expect(msg).toContain('short_text'); 288 + }); 289 + 290 + it('diagram prompt has correct role description', () => { 291 + const msg = buildSystemMessage('D', '', { editorType: 'diagram' }); 292 + expect(msg).toContain('diagramming assistant'); 293 + expect(msg).toContain('whiteboard'); 294 + }); 295 + 296 + it('slide prompt has correct role description', () => { 297 + const msg = buildSystemMessage('S', '', { editorType: 'slide' }); 298 + expect(msg).toContain('presentation assistant'); 299 + }); 300 + 301 + it('form prompt has correct role description', () => { 302 + const msg = buildSystemMessage('F', '', { editorType: 'form' }); 303 + expect(msg).toContain('form-building assistant'); 304 + }); 305 + 306 + it('selection context is included', () => { 307 + const msg = buildSystemMessage('D', '', { 308 + editorType: 'diagram', 309 + selectionContext: 'rectangle "Server"', 310 + }); 311 + expect(msg).toContain('rectangle "Server"'); 312 + }); 313 + });
+344
tests/ai-diagram-actions.test.ts
··· 1 + /** 2 + * Tests for AI diagram action executor. 3 + */ 4 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 5 + import { createWhiteboard, addShape, setShapeLabel } from '../src/diagrams/whiteboard.js'; 6 + import { executeDiagramAction } from '../src/diagrams/ai-diagram-actions.js'; 7 + import type { DiagramAction } from '../src/lib/ai-actions.js'; 8 + import type { WhiteboardState } from '../src/diagrams/whiteboard.js'; 9 + 10 + function makeDeps(initial?: WhiteboardState) { 11 + let state = initial ?? createWhiteboard(); 12 + const render = vi.fn(); 13 + const pushHistory = vi.fn(); 14 + return { 15 + getState: () => state, 16 + setState: (s: WhiteboardState) => { state = s; }, 17 + render, 18 + pushHistory, 19 + get state() { return state; }, 20 + }; 21 + } 22 + 23 + describe('executeDiagramAction', () => { 24 + describe('diagram_add_shape', () => { 25 + it('adds a rectangle with label', () => { 26 + const deps = makeDeps(); 27 + const action: DiagramAction = { 28 + type: 'diagram_add_shape', 29 + kind: 'rectangle', 30 + x: 100, y: 200, w: 160, h: 80, 31 + label: 'Server', 32 + }; 33 + const result = executeDiagramAction(action, deps); 34 + expect(result.success).toBe(true); 35 + expect(deps.state.shapes.size).toBe(1); 36 + const shape = [...deps.state.shapes.values()][0]; 37 + expect(shape.kind).toBe('rectangle'); 38 + expect(shape.label).toBe('Server'); 39 + expect(shape.width).toBe(160); 40 + expect(shape.height).toBe(80); 41 + expect(deps.render).toHaveBeenCalled(); 42 + expect(deps.pushHistory).toHaveBeenCalled(); 43 + }); 44 + 45 + it('adds a shape with fill and stroke', () => { 46 + const deps = makeDeps(); 47 + const action: DiagramAction = { 48 + type: 'diagram_add_shape', 49 + kind: 'ellipse', 50 + x: 50, y: 50, w: 120, h: 120, 51 + fill: '#FF0000', stroke: '#0000FF', 52 + }; 53 + const result = executeDiagramAction(action, deps); 54 + expect(result.success).toBe(true); 55 + const shape = [...deps.state.shapes.values()][0]; 56 + expect(shape.kind).toBe('ellipse'); 57 + expect(shape.style.fill).toBe('#FF0000'); 58 + expect(shape.style.stroke).toBe('#0000FF'); 59 + }); 60 + 61 + it('defaults invalid kind to rectangle', () => { 62 + const deps = makeDeps(); 63 + const action: DiagramAction = { 64 + type: 'diagram_add_shape', 65 + kind: 'invalid_shape', 66 + x: 0, y: 0, w: 100, h: 100, 67 + }; 68 + executeDiagramAction(action, deps); 69 + const shape = [...deps.state.shapes.values()][0]; 70 + expect(shape.kind).toBe('rectangle'); 71 + }); 72 + 73 + it('adds all valid shape kinds', () => { 74 + const kinds = ['rectangle', 'ellipse', 'diamond', 'triangle', 'star', 'hexagon', 'cylinder', 'parallelogram', 'cloud', 'note']; 75 + for (const kind of kinds) { 76 + const deps = makeDeps(); 77 + const action: DiagramAction = { 78 + type: 'diagram_add_shape', 79 + kind, 80 + x: 0, y: 0, w: 100, h: 100, 81 + }; 82 + const result = executeDiagramAction(action, deps); 83 + expect(result.success).toBe(true); 84 + expect([...deps.state.shapes.values()][0].kind).toBe(kind); 85 + } 86 + }); 87 + 88 + it('rejects invalid hex colors (sanitization)', () => { 89 + const deps = makeDeps(); 90 + const action: DiagramAction = { 91 + type: 'diagram_add_shape', 92 + kind: 'rectangle', 93 + x: 0, y: 0, w: 100, h: 100, 94 + fill: 'url(javascript:alert(1))', 95 + stroke: 'not-a-color', 96 + }; 97 + executeDiagramAction(action, deps); 98 + const shape = [...deps.state.shapes.values()][0]; 99 + // Invalid colors should be stripped, not applied 100 + expect(shape.style.fill).toBeUndefined(); 101 + expect(shape.style.stroke).toBeUndefined(); 102 + }); 103 + 104 + it('accepts valid hex colors', () => { 105 + const deps = makeDeps(); 106 + executeDiagramAction({ 107 + type: 'diagram_add_shape', 108 + kind: 'rectangle', 109 + x: 0, y: 0, w: 100, h: 100, 110 + fill: '#abc', 111 + stroke: '#112233', 112 + }, deps); 113 + const shape = [...deps.state.shapes.values()][0]; 114 + expect(shape.style.fill).toBe('#abc'); 115 + expect(shape.style.stroke).toBe('#112233'); 116 + }); 117 + 118 + it('adds shape without label', () => { 119 + const deps = makeDeps(); 120 + const action: DiagramAction = { 121 + type: 'diagram_add_shape', 122 + kind: 'rectangle', 123 + x: 0, y: 0, w: 100, h: 100, 124 + }; 125 + executeDiagramAction(action, deps); 126 + const shape = [...deps.state.shapes.values()][0]; 127 + expect(shape.label).toBe(''); 128 + }); 129 + }); 130 + 131 + describe('diagram_add_arrow', () => { 132 + it('connects two shapes by label', () => { 133 + let state = createWhiteboard(); 134 + state = addShape(state, 'rectangle', 0, 0, 100, 50, 'A'); 135 + state = addShape(state, 'rectangle', 300, 0, 100, 50, 'B'); 136 + const deps = makeDeps(state); 137 + 138 + const action: DiagramAction = { 139 + type: 'diagram_add_arrow', 140 + fromLabel: 'A', 141 + toLabel: 'B', 142 + }; 143 + const result = executeDiagramAction(action, deps); 144 + expect(result.success).toBe(true); 145 + expect(deps.state.arrows.size).toBe(1); 146 + const arrow = [...deps.state.arrows.values()][0]; 147 + expect('shapeId' in arrow.from).toBe(true); 148 + expect('shapeId' in arrow.to).toBe(true); 149 + }); 150 + 151 + it('fails when from shape not found', () => { 152 + let state = createWhiteboard(); 153 + state = addShape(state, 'rectangle', 0, 0, 100, 50, 'B'); 154 + const deps = makeDeps(state); 155 + 156 + const action: DiagramAction = { 157 + type: 'diagram_add_arrow', 158 + fromLabel: 'NonExistent', 159 + toLabel: 'B', 160 + }; 161 + const result = executeDiagramAction(action, deps); 162 + expect(result.success).toBe(false); 163 + expect(result.error).toContain('NonExistent'); 164 + }); 165 + 166 + it('fails when to shape not found', () => { 167 + let state = createWhiteboard(); 168 + state = addShape(state, 'rectangle', 0, 0, 100, 50, 'A'); 169 + const deps = makeDeps(state); 170 + 171 + const action: DiagramAction = { 172 + type: 'diagram_add_arrow', 173 + fromLabel: 'A', 174 + toLabel: 'Missing', 175 + }; 176 + const result = executeDiagramAction(action, deps); 177 + expect(result.success).toBe(false); 178 + expect(result.error).toContain('Missing'); 179 + }); 180 + }); 181 + 182 + describe('diagram_modify_shape', () => { 183 + it('renames a shape', () => { 184 + let state = createWhiteboard(); 185 + state = addShape(state, 'rectangle', 100, 100, 120, 80, 'OldName'); 186 + const deps = makeDeps(state); 187 + 188 + const action: DiagramAction = { 189 + type: 'diagram_modify_shape', 190 + label: 'OldName', 191 + newLabel: 'NewName', 192 + }; 193 + const result = executeDiagramAction(action, deps); 194 + expect(result.success).toBe(true); 195 + const shape = [...deps.state.shapes.values()][0]; 196 + expect(shape.label).toBe('NewName'); 197 + }); 198 + 199 + it('changes fill and stroke', () => { 200 + let state = createWhiteboard(); 201 + state = addShape(state, 'rectangle', 0, 0, 100, 100, 'Box'); 202 + const deps = makeDeps(state); 203 + 204 + const action: DiagramAction = { 205 + type: 'diagram_modify_shape', 206 + label: 'Box', 207 + fill: '#00FF00', 208 + stroke: '#FF00FF', 209 + }; 210 + const result = executeDiagramAction(action, deps); 211 + expect(result.success).toBe(true); 212 + const shape = [...deps.state.shapes.values()][0]; 213 + expect(shape.style.fill).toBe('#00FF00'); 214 + expect(shape.style.stroke).toBe('#FF00FF'); 215 + }); 216 + 217 + it('resizes a shape', () => { 218 + let state = createWhiteboard(); 219 + state = addShape(state, 'rectangle', 0, 0, 100, 50, 'Box'); 220 + const deps = makeDeps(state); 221 + 222 + const action: DiagramAction = { 223 + type: 'diagram_modify_shape', 224 + label: 'Box', 225 + w: 200, 226 + h: 150, 227 + }; 228 + executeDiagramAction(action, deps); 229 + const shape = [...deps.state.shapes.values()][0]; 230 + expect(shape.width).toBe(200); 231 + expect(shape.height).toBe(150); 232 + }); 233 + 234 + it('moves a shape', () => { 235 + let state = createWhiteboard(); 236 + state = addShape(state, 'rectangle', 100, 100, 100, 50, 'Box'); 237 + const deps = makeDeps(state); 238 + 239 + const action: DiagramAction = { 240 + type: 'diagram_modify_shape', 241 + label: 'Box', 242 + x: 300, 243 + y: 400, 244 + }; 245 + executeDiagramAction(action, deps); 246 + const shape = [...deps.state.shapes.values()][0]; 247 + // moveShape may snap to grid 248 + expect(shape.x).toBeGreaterThanOrEqual(280); 249 + expect(shape.y).toBeGreaterThanOrEqual(380); 250 + }); 251 + 252 + it('fails when shape not found', () => { 253 + const deps = makeDeps(); 254 + const action: DiagramAction = { 255 + type: 'diagram_modify_shape', 256 + label: 'Ghost', 257 + newLabel: 'X', 258 + }; 259 + const result = executeDiagramAction(action, deps); 260 + expect(result.success).toBe(false); 261 + expect(result.error).toContain('Ghost'); 262 + }); 263 + }); 264 + 265 + describe('diagram_remove_shape', () => { 266 + it('removes a shape by label', () => { 267 + let state = createWhiteboard(); 268 + state = addShape(state, 'rectangle', 0, 0, 100, 50, 'ToRemove'); 269 + state = addShape(state, 'rectangle', 200, 0, 100, 50, 'ToKeep'); 270 + const deps = makeDeps(state); 271 + 272 + const action: DiagramAction = { 273 + type: 'diagram_remove_shape', 274 + label: 'ToRemove', 275 + }; 276 + const result = executeDiagramAction(action, deps); 277 + expect(result.success).toBe(true); 278 + expect(deps.state.shapes.size).toBe(1); 279 + expect([...deps.state.shapes.values()][0].label).toBe('ToKeep'); 280 + }); 281 + 282 + it('fails when shape not found', () => { 283 + const deps = makeDeps(); 284 + const action: DiagramAction = { 285 + type: 'diagram_remove_shape', 286 + label: 'Nope', 287 + }; 288 + const result = executeDiagramAction(action, deps); 289 + expect(result.success).toBe(false); 290 + expect(result.error).toContain('Nope'); 291 + }); 292 + }); 293 + 294 + describe('diagram_add_text', () => { 295 + it('adds a text element', () => { 296 + const deps = makeDeps(); 297 + const action: DiagramAction = { 298 + type: 'diagram_add_text', 299 + x: 50, y: 50, 300 + text: 'Hello World', 301 + }; 302 + const result = executeDiagramAction(action, deps); 303 + expect(result.success).toBe(true); 304 + expect(deps.state.shapes.size).toBe(1); 305 + const shape = [...deps.state.shapes.values()][0]; 306 + expect(shape.kind).toBe('text'); 307 + expect(shape.label).toBe('Hello World'); 308 + }); 309 + 310 + it('sets custom font size', () => { 311 + const deps = makeDeps(); 312 + const action: DiagramAction = { 313 + type: 'diagram_add_text', 314 + x: 50, y: 50, 315 + text: 'Big Text', 316 + fontSize: 32, 317 + }; 318 + executeDiagramAction(action, deps); 319 + const shape = [...deps.state.shapes.values()][0]; 320 + expect(shape.fontSize).toBe(32); 321 + }); 322 + }); 323 + 324 + describe('state management', () => { 325 + it('calls setState, pushHistory, and render on every successful action', () => { 326 + const deps = makeDeps(); 327 + executeDiagramAction({ 328 + type: 'diagram_add_shape', kind: 'rectangle', 329 + x: 0, y: 0, w: 100, h: 100, 330 + }, deps); 331 + expect(deps.render).toHaveBeenCalledTimes(1); 332 + expect(deps.pushHistory).toHaveBeenCalledTimes(1); 333 + }); 334 + 335 + it('does not call render or pushHistory on failure', () => { 336 + const deps = makeDeps(); 337 + executeDiagramAction({ 338 + type: 'diagram_remove_shape', label: 'nope', 339 + }, deps); 340 + expect(deps.render).not.toHaveBeenCalled(); 341 + expect(deps.pushHistory).not.toHaveBeenCalled(); 342 + }); 343 + }); 344 + });
+181
tests/ai-form-actions.test.ts
··· 1 + /** 2 + * Tests for AI form action executor. 3 + */ 4 + import { describe, it, expect, vi } from 'vitest'; 5 + import { createForm, addQuestion } from '../src/forms/form-builder.js'; 6 + import { executeFormAction } from '../src/forms/ai-form-actions.js'; 7 + import type { FormAction } from '../src/lib/ai-actions.js'; 8 + import type { FormSchema } from '../src/forms/form-builder.js'; 9 + 10 + function makeDeps(initial?: FormSchema) { 11 + let form = initial ?? createForm('Test Form'); 12 + const syncToYjs = vi.fn(); 13 + const render = vi.fn(); 14 + return { 15 + getForm: () => form, 16 + setForm: (f: FormSchema) => { form = f; }, 17 + syncToYjs, 18 + render, 19 + get form() { return form; }, 20 + }; 21 + } 22 + 23 + describe('executeFormAction', () => { 24 + describe('form_add_question', () => { 25 + it('adds a short text question', () => { 26 + const deps = makeDeps(); 27 + const action: FormAction = { 28 + type: 'form_add_question', 29 + questionType: 'short_text', 30 + title: 'What is your name?', 31 + required: true, 32 + }; 33 + const result = executeFormAction(action, deps); 34 + expect(result.success).toBe(true); 35 + expect(deps.form.questions.length).toBe(1); 36 + expect(deps.form.questions[0].label).toBe('What is your name?'); 37 + expect(deps.form.questions[0].required).toBe(true); 38 + expect(deps.form.questions[0].type).toBe('short_text'); 39 + expect(deps.syncToYjs).toHaveBeenCalled(); 40 + expect(deps.render).toHaveBeenCalled(); 41 + }); 42 + 43 + it('adds a dropdown with options', () => { 44 + const deps = makeDeps(); 45 + const action: FormAction = { 46 + type: 'form_add_question', 47 + questionType: 'dropdown', 48 + title: 'Favorite color', 49 + options: ['Red', 'Blue', 'Green'], 50 + }; 51 + executeFormAction(action, deps); 52 + const q = deps.form.questions[0]; 53 + expect(q.type).toBe('dropdown'); 54 + expect(q.options.length).toBe(3); 55 + expect(q.options[0].label).toBe('Red'); 56 + expect(q.options[1].label).toBe('Blue'); 57 + expect(q.options[2].label).toBe('Green'); 58 + }); 59 + 60 + it('maps "multiple_choice" to single_choice (radio)', () => { 61 + const deps = makeDeps(); 62 + executeFormAction({ 63 + type: 'form_add_question', 64 + questionType: 'multiple_choice', 65 + title: 'Pick one', 66 + options: ['A', 'B'], 67 + }, deps); 68 + expect(deps.form.questions[0].type).toBe('single_choice'); 69 + }); 70 + 71 + it('maps "checkbox" to multiple_choice', () => { 72 + const deps = makeDeps(); 73 + executeFormAction({ 74 + type: 'form_add_question', 75 + questionType: 'checkbox', 76 + title: 'Pick many', 77 + options: ['A', 'B'], 78 + }, deps); 79 + expect(deps.form.questions[0].type).toBe('multiple_choice'); 80 + }); 81 + 82 + it('defaults unknown type to short_text', () => { 83 + const deps = makeDeps(); 84 + executeFormAction({ 85 + type: 'form_add_question', 86 + questionType: 'unknown_type', 87 + title: 'Q', 88 + }, deps); 89 + expect(deps.form.questions[0].type).toBe('short_text'); 90 + }); 91 + 92 + it('adds multiple questions sequentially', () => { 93 + const deps = makeDeps(); 94 + executeFormAction({ type: 'form_add_question', questionType: 'short_text', title: 'Q1' }, deps); 95 + executeFormAction({ type: 'form_add_question', questionType: 'email', title: 'Q2' }, deps); 96 + executeFormAction({ type: 'form_add_question', questionType: 'rating', title: 'Q3' }, deps); 97 + expect(deps.form.questions.length).toBe(3); 98 + }); 99 + }); 100 + 101 + describe('form_modify_question', () => { 102 + it('renames a question', () => { 103 + let form = createForm('Test'); 104 + form = addQuestion(form, 'short_text', 'Old Title'); 105 + const deps = makeDeps(form); 106 + 107 + const result = executeFormAction({ 108 + type: 'form_modify_question', 109 + title: 'Old Title', 110 + newTitle: 'New Title', 111 + }, deps); 112 + expect(result.success).toBe(true); 113 + expect(deps.form.questions[0].label).toBe('New Title'); 114 + }); 115 + 116 + it('updates required flag', () => { 117 + let form = createForm('Test'); 118 + form = addQuestion(form, 'email', 'Email'); 119 + const deps = makeDeps(form); 120 + 121 + executeFormAction({ 122 + type: 'form_modify_question', 123 + title: 'Email', 124 + required: true, 125 + }, deps); 126 + expect(deps.form.questions[0].required).toBe(true); 127 + }); 128 + 129 + it('updates options', () => { 130 + let form = createForm('Test'); 131 + form = addQuestion(form, 'dropdown', 'Color', { options: [{ id: 'x', label: 'Old' }] }); 132 + const deps = makeDeps(form); 133 + 134 + executeFormAction({ 135 + type: 'form_modify_question', 136 + title: 'Color', 137 + options: ['Red', 'Blue'], 138 + }, deps); 139 + expect(deps.form.questions[0].options.length).toBe(2); 140 + expect(deps.form.questions[0].options[0].label).toBe('Red'); 141 + }); 142 + 143 + it('fails when question not found', () => { 144 + const deps = makeDeps(); 145 + const result = executeFormAction({ 146 + type: 'form_modify_question', 147 + title: 'Ghost', 148 + newTitle: 'X', 149 + }, deps); 150 + expect(result.success).toBe(false); 151 + expect(result.error).toContain('Ghost'); 152 + }); 153 + }); 154 + 155 + describe('form_remove_question', () => { 156 + it('removes a question by title', () => { 157 + let form = createForm('Test'); 158 + form = addQuestion(form, 'short_text', 'Q1'); 159 + form = addQuestion(form, 'short_text', 'Q2'); 160 + const deps = makeDeps(form); 161 + 162 + const result = executeFormAction({ 163 + type: 'form_remove_question', 164 + title: 'Q1', 165 + }, deps); 166 + expect(result.success).toBe(true); 167 + expect(deps.form.questions.length).toBe(1); 168 + expect(deps.form.questions[0].label).toBe('Q2'); 169 + }); 170 + 171 + it('fails when question not found', () => { 172 + const deps = makeDeps(); 173 + const result = executeFormAction({ 174 + type: 'form_remove_question', 175 + title: 'Nope', 176 + }, deps); 177 + expect(result.success).toBe(false); 178 + expect(result.error).toContain('Nope'); 179 + }); 180 + }); 181 + });
+87
tests/ai-slide-actions.test.ts
··· 1 + /** 2 + * Tests for AI slide action executor. 3 + */ 4 + import { describe, it, expect, vi } from 'vitest'; 5 + import { createDeck, currentSlide } from '../src/slides/canvas-engine.js'; 6 + import { executeSlideAction } from '../src/slides/ai-slide-actions.js'; 7 + import type { SlideAction } from '../src/lib/ai-actions.js'; 8 + import type { DeckState } from '../src/slides/canvas-engine.js'; 9 + 10 + function makeDeps(initial?: DeckState) { 11 + let state = initial ?? createDeck(); 12 + const render = vi.fn(); 13 + return { 14 + getState: () => state, 15 + setState: (s: DeckState) => { state = s; }, 16 + render, 17 + get state() { return state; }, 18 + }; 19 + } 20 + 21 + describe('executeSlideAction', () => { 22 + describe('slide_add', () => { 23 + it('adds a new slide', () => { 24 + const deps = makeDeps(); 25 + expect(deps.state.slides.length).toBe(1); 26 + const result = executeSlideAction({ type: 'slide_add' }, deps); 27 + expect(result.success).toBe(true); 28 + expect(deps.state.slides.length).toBe(2); 29 + expect(deps.render).toHaveBeenCalled(); 30 + }); 31 + 32 + it('adds multiple slides', () => { 33 + const deps = makeDeps(); 34 + executeSlideAction({ type: 'slide_add' }, deps); 35 + executeSlideAction({ type: 'slide_add' }, deps); 36 + expect(deps.state.slides.length).toBe(3); 37 + }); 38 + }); 39 + 40 + describe('slide_add_text', () => { 41 + it('adds text element to current slide', () => { 42 + const deps = makeDeps(); 43 + const action: SlideAction = { 44 + type: 'slide_add_text', 45 + x: 100, y: 100, w: 400, h: 60, 46 + text: 'Hello Slides', 47 + }; 48 + const result = executeSlideAction(action, deps); 49 + expect(result.success).toBe(true); 50 + const slide = currentSlide(deps.state); 51 + expect(slide.elements.length).toBe(1); 52 + expect(slide.elements[0].content).toBe('Hello Slides'); 53 + expect(slide.elements[0].type).toBe('text'); 54 + }); 55 + 56 + it('applies fontSize via style', () => { 57 + const deps = makeDeps(); 58 + const action: SlideAction = { 59 + type: 'slide_add_text', 60 + x: 0, y: 0, w: 200, h: 40, 61 + text: 'Big', 62 + fontSize: 48, 63 + }; 64 + executeSlideAction(action, deps); 65 + const el = currentSlide(deps.state).elements[0]; 66 + expect(el.style.fontSize).toBe('48px'); 67 + }); 68 + }); 69 + 70 + describe('slide_add_shape', () => { 71 + it('adds a shape element', () => { 72 + const deps = makeDeps(); 73 + const action: SlideAction = { 74 + type: 'slide_add_shape', 75 + element: 'rectangle', 76 + x: 50, y: 50, w: 200, h: 100, 77 + fill: '#4A90D9', 78 + }; 79 + const result = executeSlideAction(action, deps); 80 + expect(result.success).toBe(true); 81 + const slide = currentSlide(deps.state); 82 + expect(slide.elements.length).toBe(1); 83 + expect(slide.elements[0].type).toBe('shape'); 84 + expect(slide.elements[0].style.fill).toBe('#4A90D9'); 85 + }); 86 + }); 87 + });