diff --git a/src/main/engine/ImportAnalysisEngine.ts b/src/main/engine/ImportAnalysisEngine.ts index 4858a15..157fe06 100644 --- a/src/main/engine/ImportAnalysisEngine.ts +++ b/src/main/engine/ImportAnalysisEngine.ts @@ -38,12 +38,14 @@ export interface AnalyzedCategory { name: string; slug: string; existsInProject: boolean; + mappedTo?: string; // When set, indicates this item should be mapped to the given name on import } export interface AnalyzedTag { name: string; slug: string; existsInProject: boolean; + mappedTo?: string; // When set, indicates this item should be mapped to the given name on import } export interface ImportAnalysisReport { diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 3f981b6..a1ec2c1 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -1147,6 +1147,149 @@ export class OpenCodeManager { return errorMsg; } + /** + * Analyze taxonomy items (tags and categories) and suggest mappings + * This is a one-shot request without conversation context + */ + async analyzeTaxonomy( + categories: Array<{ name: string; slug: string; existsInProject: boolean }>, + tags: Array<{ name: string; slug: string; existsInProject: boolean }>, + modelId: string + ): Promise<{ + success: boolean; + categoryMappings?: Record; + tagMappings?: Record; + error?: string; + }> { + if (!this.apiKey) { + return { success: false, error: 'API key not set' }; + } + + const provider = this.detectProvider(modelId); + + // Build the prompt for taxonomy analysis + const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name); + const newCategories = categories.filter(c => !c.existsInProject).map(c => c.name); + const existingTags = tags.filter(t => t.existsInProject).map(t => t.name); + const newTags = tags.filter(t => !t.existsInProject).map(t => t.name); + + const systemPrompt = `You are an expert at analyzing and consolidating taxonomy terms (tags and categories) for a blog import system. + +Your task is to analyze imported categories and tags and suggest mappings to reduce duplicates and inconsistencies. + +RULES: +1. Only suggest mappings for items that are genuinely similar or duplicates +2. Consider variations like: singular/plural, different casing, synonyms, abbreviations, hyphenation +3. Map to existing items when there's a clear match +4. Map multiple similar new items to a single canonical form +5. Only suggest mappings where it makes sense - not every item needs a mapping + +RESPONSE FORMAT: +You MUST respond with valid JSON only, no other text. Use this exact structure: +{ + "categoryMappings": { "Source Category": "Target Category", ... }, + "tagMappings": { "Source Tag": "Target Tag", ... } +} + +If there are no sensible mappings to suggest, return empty objects.`; + + const userPrompt = `Analyze these taxonomy items from a WordPress import and suggest mappings: + +EXISTING CATEGORIES IN PROJECT: +${existingCategories.length > 0 ? existingCategories.join(', ') : '(none)'} + +NEW CATEGORIES FROM IMPORT: +${newCategories.length > 0 ? newCategories.join(', ') : '(none)'} + +EXISTING TAGS IN PROJECT: +${existingTags.length > 0 ? existingTags.join(', ') : '(none)'} + +NEW TAGS FROM IMPORT: +${newTags.length > 0 ? newTags.join(', ') : '(none)'} + +Suggest mappings to consolidate similar items. Response must be valid JSON only.`; + + try { + let responseText = ''; + + if (provider === 'anthropic') { + const body = { + model: modelId, + max_tokens: 4096, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }; + + const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + }); + + if (response.statusCode !== 200) { + console.error('[OpenCodeManager] Taxonomy analysis failed:', response.body); + return { success: false, error: `API request failed: ${response.statusCode}` }; + } + + const data = JSON.parse(response.body); + // Extract text from Anthropic response + for (const block of data.content || []) { + if (block.type === 'text') { + responseText += block.text; + } + } + } else { + // OpenAI-compatible + const body = { + model: modelId, + max_tokens: 4096, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }; + + const response = await this.httpRequest(ZEN_OPENAI_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (response.statusCode !== 200) { + console.error('[OpenCodeManager] Taxonomy analysis failed:', response.body); + return { success: false, error: `API request failed: ${response.statusCode}` }; + } + + const data = JSON.parse(response.body); + responseText = data.choices?.[0]?.message?.content || ''; + } + + // Parse the JSON response + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + console.error('[OpenCodeManager] No JSON found in response:', responseText); + return { success: false, error: 'Invalid response format from AI' }; + } + + const result = JSON.parse(jsonMatch[0]); + return { + success: true, + categoryMappings: result.categoryMappings || {}, + tagMappings: result.tagMappings || {}, + }; + } catch (error) { + console.error('[OpenCodeManager] Error analyzing taxonomy:', error); + return { success: false, error: (error as Error).message }; + } + } + private httpRequest( urlStr: string, options: { diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 4cdec7b..5c8573d 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -317,6 +317,19 @@ export function registerChatHandlers(): void { return { success: false, error: (error as Error).message }; } }); + + // ============ Taxonomy Analysis ============ + + // Analyze taxonomy items (tags/categories) and suggest mappings + ipcMain.handle('chat:analyzeTaxonomy', async (_, categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => { + try { + const manager = getOpenCodeManager(); + return await manager.analyzeTaxonomy(categories, tags, modelId); + } catch (error) { + console.error('[Chat IPC] Error analyzing taxonomy:', error); + return { success: false, error: (error as Error).message }; + } + }); } /** diff --git a/src/main/preload.ts b/src/main/preload.ts index 046427c..85f3160 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -199,6 +199,9 @@ contextBridge.exposeInMainWorld('electronAPI', { clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId), setConversationModel: (conversationId: string, modelId: string) => ipcRenderer.invoke('chat:setConversationModel', conversationId, modelId), + // Taxonomy Analysis + analyzeTaxonomy: (categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => ipcRenderer.invoke('chat:analyzeTaxonomy', categories, tags, modelId), + // Event listeners for streaming/progress onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => { const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data); diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css index 84f8fc1..81b6c1e 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css @@ -382,6 +382,213 @@ color: #75beff; } +/* Mapped taxonomy items - green background */ +.import-taxonomy-pill.mapped { + background: rgba(115, 201, 145, 0.25); + color: #73c991; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.import-taxonomy-pill .pill-arrow { + opacity: 0.6; + font-size: 10px; +} + +.import-taxonomy-pill .pill-mapped-to { + font-weight: 600; +} + +.import-taxonomy-pill .pill-clear-btn { + background: none; + border: none; + color: inherit; + opacity: 0.5; + cursor: pointer; + padding: 0 2px; + font-size: 14px; + line-height: 1; + margin-left: 2px; +} + +.import-taxonomy-pill .pill-clear-btn:hover { + opacity: 1; +} + +/* Editable taxonomy pills */ +.import-taxonomy-pill:not(.editing):not(.exists):not(.mapped) { + cursor: pointer; +} + +.import-taxonomy-pill:not(.editing):hover { + filter: brightness(1.1); +} + +.import-taxonomy-pill.editing { + background: var(--vscode-input-background); + padding: 0 4px 0 8px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.import-taxonomy-pill.editing .pill-name { + color: var(--vscode-foreground); +} + +.import-taxonomy-pill .pill-edit-input { + background: transparent; + border: none; + color: #73c991; + font-size: 11px; + padding: 2px 4px; + width: 100px; + outline: none; +} + +.import-taxonomy-pill .pill-edit-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +/* Pill edit container for autocomplete */ +.pill-edit-container { + position: relative; + display: inline-block; +} + +.pill-suggestions-dropdown { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + min-width: 150px; + max-width: 250px; + max-height: 200px; + overflow-y: auto; + background: var(--vscode-dropdown-background, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + z-index: 1000; +} + +.pill-suggestion-item { + display: block; + width: 100%; + padding: 6px 10px; + font-size: 11px; + text-align: left; + background: none; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pill-suggestion-item:hover, +.pill-suggestion-item.selected { + background: var(--vscode-list-hoverBackground); +} + +.pill-suggestion-item.selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +/* Taxonomy analyze row */ +.taxonomy-analyze-row { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.06)); +} + +.taxonomy-analyze-dropdown { + position: relative; +} + +.taxonomy-analyze-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + border: none; + border-radius: 4px; + cursor: pointer; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + white-space: nowrap; +} + +.taxonomy-analyze-btn:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground); +} + +.taxonomy-analyze-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.taxonomy-analyze-btn .import-spinner.small { + width: 12px; + height: 12px; + border-width: 1.5px; +} + +.taxonomy-model-dropdown { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: var(--vscode-dropdown-background, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + z-index: 100; + min-width: 180px; + max-height: 300px; + overflow-y: auto; +} + +.taxonomy-model-option { + display: block; + width: 100%; + padding: 8px 12px; + font-size: 12px; + text-align: left; + background: none; + border: none; + color: var(--vscode-foreground); + cursor: pointer; +} + +.taxonomy-model-option:hover { + background: var(--vscode-list-hoverBackground); +} + +.taxonomy-analyze-hint { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.taxonomy-mapped-count { + font-size: 10px; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; + background: rgba(115, 201, 145, 0.2); + color: #73c991; + margin-left: 8px; +} + /* Empty state */ .import-empty-state { display: flex; diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index 69233a2..a8abf17 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -1,4 +1,5 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; +import type { ChatModel } from '../../types/electron'; import './ImportAnalysisView.css'; interface AnalysisReport { @@ -50,6 +51,7 @@ interface TaxonomyItem { name: string; slug: string; existsInProject: boolean; + mappedTo?: string; // When set, indicates this item should be mapped to the given name on import } interface ImportAnalysisViewProps { @@ -66,6 +68,62 @@ export const ImportAnalysisView: React.FC = ({ definiti const [expandedSections, setExpandedSections] = useState>({}); const nameInputRef = useRef(null); + // Save the current report to the definition + const persistReport = useCallback(async (updatedReport: AnalysisReport) => { + await window.electronAPI?.importDefinitions.update(definitionId, { + lastAnalysisResult: JSON.stringify(updatedReport), + }); + }, [definitionId]); + + // Handler for updating taxonomy mappings + const handleTaxonomyMappingsUpdated = useCallback(async ( + categoryMappings: Record, + tagMappings: Record + ) => { + if (!report) return; + + const updatedReport: AnalysisReport = { + ...report, + categories: report.categories.map(cat => ({ + ...cat, + mappedTo: categoryMappings[cat.name] || cat.mappedTo, + })), + tags: report.tags.map(tag => ({ + ...tag, + mappedTo: tagMappings[tag.name] || tag.mappedTo, + })), + }; + + setReport(updatedReport); + await persistReport(updatedReport); + }, [report, persistReport]); + + // Handler for updating a single item mapping + const handleSingleMappingUpdated = useCallback(async ( + type: 'category' | 'tag', + itemName: string, + mappedTo: string | undefined + ) => { + if (!report) return; + + const updatedReport: AnalysisReport = { + ...report, + categories: type === 'category' + ? report.categories.map(cat => + cat.name === itemName ? { ...cat, mappedTo } : cat + ) + : report.categories, + tags: type === 'tag' + ? report.tags.map(tag => + tag.name === itemName ? { ...tag, mappedTo } : tag + ) + : report.tags, + }; + + setReport(updatedReport); + await persistReport(updatedReport); + }, [report, persistReport]); + // Load definition on mount useEffect(() => { const load = async () => { @@ -247,6 +305,8 @@ export const ImportAnalysisView: React.FC = ({ definiti tags={report.tags} expanded={expandedSections['taxonomy'] ?? false} onToggle={() => toggleSection('taxonomy')} + onMappingsAnalyzed={handleTaxonomyMappingsUpdated} + onMappingUpdated={handleSingleMappingUpdated} /> )} @@ -453,43 +513,343 @@ const TaxonomySection: React.FC<{ tags: TaxonomyItem[]; expanded: boolean; onToggle: () => void; -}> = ({ categories, tags, expanded, onToggle }) => ( -
-

- - Categories & Tags -

- {expanded && ( - <> - {categories.length > 0 && ( -
-
- Categories + onMappingsAnalyzed: (categoryMappings: Record, tagMappings: Record) => void; + onMappingUpdated: (type: 'category' | 'tag', itemName: string, mappedTo: string | undefined) => void; +}> = ({ categories, tags, expanded, onToggle, onMappingsAnalyzed, onMappingUpdated }) => { + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [showModelSelector, setShowModelSelector] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + const [editingItem, setEditingItem] = useState<{ type: 'category' | 'tag'; name: string } | null>(null); + const [editValue, setEditValue] = useState(''); + const [existingTags, setExistingTags] = useState([]); + const [existingCategories, setExistingCategories] = useState([]); + const modelSelectorRef = useRef(null); + + // Load available models and existing taxonomy on mount + useEffect(() => { + const loadData = async () => { + const [modelsResult, tagsResult, categoriesResult] = await Promise.all([ + window.electronAPI?.chat.getAvailableModels(), + window.electronAPI?.tags.getAll(), + window.electronAPI?.meta.getCategories(), + ]); + if (modelsResult?.models) { + setAvailableModels(modelsResult.models); + } + if (tagsResult) { + setExistingTags(tagsResult.map(t => t.name)); + } + if (categoriesResult) { + setExistingCategories(categoriesResult); + } + }; + loadData(); + }, []); + + // Close model selector when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (modelSelectorRef.current && !modelSelectorRef.current.contains(e.target as Node)) { + setShowModelSelector(false); + } + }; + if (showModelSelector) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showModelSelector]); + + const handleAnalyze = async (modelId: string) => { + setShowModelSelector(false); + setIsAnalyzing(true); + + try { + const result = await window.electronAPI?.chat.analyzeTaxonomy(categories, tags, modelId); + if (result?.success) { + onMappingsAnalyzed(result.categoryMappings || {}, result.tagMappings || {}); + } else { + console.error('Taxonomy analysis failed:', result?.error); + } + } catch (error) { + console.error('Taxonomy analysis error:', error); + } finally { + setIsAnalyzing(false); + } + }; + + const handleStartEdit = (type: 'category' | 'tag', item: TaxonomyItem) => { + setEditingItem({ type, name: item.name }); + setEditValue(item.mappedTo || ''); + }; + + const handleSaveEdit = (directValue?: string) => { + if (editingItem) { + const valueToSave = directValue !== undefined ? directValue : editValue; + onMappingUpdated(editingItem.type, editingItem.name, valueToSave.trim() || undefined); + setEditingItem(null); + setEditValue(''); + } + }; + + const handleClearMapping = (type: 'category' | 'tag', name: string) => { + onMappingUpdated(type, name, undefined); + }; + + // Build suggestions list: existing project items + import items (deduplicated) + const categorySuggestions = [...new Set([...existingCategories, ...categories.map(c => c.name)])].sort(); + const tagSuggestions = [...new Set([...existingTags, ...tags.map(t => t.name)])].sort(); + + const mappedCategoriesCount = categories.filter(c => c.mappedTo).length; + const mappedTagsCount = tags.filter(t => t.mappedTo).length; + + return ( +
+

+ + Categories & Tags + {(mappedCategoriesCount > 0 || mappedTagsCount > 0) && ( + + {mappedCategoriesCount + mappedTagsCount} mapped + + )} +

+ {expanded && ( + <> +
+
+ + {showModelSelector && ( +
+ {availableModels.map(model => ( + + ))} +
+ )}
-
- {categories.map((cat, idx) => ( - - {cat.name} - + + AI will suggest mappings to consolidate similar tags and categories + +
+ + {categories.length > 0 && ( +
+
+ Categories +
+
+ {categories.map((cat, idx) => ( + handleStartEdit('category', cat)} + onSaveEdit={handleSaveEdit} + onCancelEdit={() => setEditingItem(null)} + onClearMapping={() => handleClearMapping('category', cat.name)} + /> + ))} +
+
+ )} + {tags.length > 0 && ( +
+
+ Tags +
+
+ {tags.map((tag, idx) => ( + handleStartEdit('tag', tag)} + onSaveEdit={handleSaveEdit} + onCancelEdit={() => setEditingItem(null)} + onClearMapping={() => handleClearMapping('tag', tag.name)} + /> + ))} +
+
+ )} + + )} +
+ ); +}; + +const TaxonomyPill: React.FC<{ + item: TaxonomyItem; + type: 'category' | 'tag'; + isEditing: boolean; + editValue: string; + suggestions: string[]; + onEditValueChange: (value: string) => void; + onStartEdit: () => void; + onSaveEdit: (directValue?: string) => void; + onCancelEdit: () => void; + onClearMapping: () => void; +}> = ({ item, type: _type, isEditing, editValue, suggestions, onEditValueChange, onStartEdit, onSaveEdit, onCancelEdit, onClearMapping }) => { + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [showDropdown, setShowDropdown] = useState(false); + + // Filter suggestions based on input, exclude current item name + const filteredSuggestions = suggestions.filter(s => + s.toLowerCase() !== item.name.toLowerCase() && + s.toLowerCase().includes(editValue.toLowerCase()) + ); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + setShowDropdown(true); + setSelectedIndex(-1); + } + }, [isEditing]); + + // Reset selection when filtered results change + useEffect(() => { + setSelectedIndex(-1); + }, [editValue]); + + const handleSelectSuggestion = (suggestion: string) => { + setShowDropdown(false); + onSaveEdit(suggestion); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(prev => + prev < filteredSuggestions.length - 1 ? prev + 1 : prev + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < filteredSuggestions.length) { + handleSelectSuggestion(filteredSuggestions[selectedIndex]); + } else { + onSaveEdit(); + } + } else if (e.key === 'Escape') { + onCancelEdit(); + } else if (e.key === 'Tab') { + // Allow tab to close dropdown and save + setShowDropdown(false); + } + }; + + const handleBlur = (e: React.FocusEvent) => { + // Check if focus is moving to the dropdown + if (dropdownRef.current?.contains(e.relatedTarget as Node)) { + return; + } + // Small delay to allow click on dropdown item + setTimeout(() => { + setShowDropdown(false); + onSaveEdit(); + }, 150); + }; + + if (isEditing) { + return ( + + {item.name} + +
+ { + onEditValueChange(e.target.value); + setShowDropdown(true); + }} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + onFocus={() => setShowDropdown(true)} + placeholder="Map to..." + /> + {showDropdown && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.slice(0, 10).map((suggestion, idx) => ( + ))}
-
- )} - {tags.length > 0 && ( -
-
- Tags -
-
- {tags.map((tag, idx) => ( - - {tag.name} - - ))} -
-
- )} - - )} -
-); + )} +
+ + ); + } + + const hasMaping = !!item.mappedTo; + const className = `import-taxonomy-pill ${item.existsInProject ? 'exists' : 'new-tax'} ${hasMaping ? 'mapped' : ''}`; + + return ( + + {item.name} + {hasMaping && ( + <> + + {item.mappedTo} + + + )} + + ); +}; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 9180e8f..c715481 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -432,6 +432,9 @@ export interface ElectronAPI { clearMessages: (conversationId: string) => Promise; setConversationModel: (conversationId: string, modelId: string) => Promise; + // Taxonomy Analysis + analyzeTaxonomy: (categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => Promise<{ success: boolean; categoryMappings?: Record; tagMappings?: Record; error?: string }>; + // Event listeners for streaming/progress onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void; onToolCall: (callback: (data: ChatToolCall) => void) => () => void; diff --git a/tests/engine/ProjectEngine.test.ts b/tests/engine/ProjectEngine.test.ts index 7c5050e..1180bc9 100644 --- a/tests/engine/ProjectEngine.test.ts +++ b/tests/engine/ProjectEngine.test.ts @@ -75,6 +75,7 @@ vi.mock('fs/promises', () => ({ unlink: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), readdir: vi.fn(async () => []), + rm: vi.fn(async () => {}), stat: vi.fn(async () => ({ isFile: () => false, isDirectory: () => true,