Files
myfirst-addon/main/index.js
logikonline 2f99c6fb0b feat: add AI conversation capabilities to demo addon
Implements two AI integration methods:
- aiChat: simple text-based AI conversation
- aiStructuredOutput: schema-based AI responses using tools

Adds ai:conversation permission and AnalyzeCode tool definition to addon.json with rocket icon. Both methods communicate with AIHost service on localhost:5678.
2026-01-24 17:51:55 -05:00

810 lines
24 KiB
JavaScript

/**
* My First Addon - Main Process Module
*
* This file runs in Electron's main process and handles:
* - Addon lifecycle (initialize, activate, deactivate, dispose)
* - Settings management
* - IPC communication with renderer process
* - Method invocations from GitCaddy
* - Event handling
*
* The main process has full Node.js access and can:
* - Read/write files
* - Make network requests
* - Spawn child processes
* - Access system APIs
*/
// Import shared types (optional - for code organization)
const { DEFAULT_SETTINGS } = require('../shared/types');
/**
* Main Addon Class
*
* GitCaddy will instantiate this class and call its lifecycle methods.
* The class must be the default export.
*/
class MyFirstAddon {
constructor() {
// Context provided by GitCaddy (set in initialize)
this.context = null;
// Local settings cache
this.settings = { ...DEFAULT_SETTINGS };
// Track disposables for cleanup
this.disposables = [];
// Track if addon is active
this.isActive = false;
}
// ============================================================
// LIFECYCLE METHODS
// These are called by GitCaddy at specific points
// ============================================================
/**
* Initialize the addon.
*
* Called once when the addon is first loaded.
* Use this to set up initial state and register event listeners.
*
* @param {IAddonMainContext} context - Context provided by GitCaddy
*/
async initialize(context) {
this.context = context;
// Log that we're initializing
context.log.info('MyFirstAddon initializing...');
// Load settings from storage
await this.loadSettings();
// Log loaded settings (be careful not to log sensitive data!)
context.log.debug('Settings loaded:', {
enableFeature: this.settings.enableFeature,
mode: this.settings.mode,
maxItems: this.settings.maxItems,
// Don't log apiKey!
});
// Register for settings changes
const settingsDisposable = context.settings.onDidChange((key, value) => {
this.onSettingChanged(key, value);
});
this.disposables.push(settingsDisposable);
// Register for repository events
// Note: Use the specific event methods, not a generic 'on' method
const repoDisposable = context.events.onRepositorySelected((repo) => {
this.onRepositoryChanged(repo);
});
this.disposables.push(repoDisposable);
// Register for commit events
const commitDisposable = context.events.onCommitCreated((commit) => {
this.onCommitCreated(commit);
});
this.disposables.push(commitDisposable);
context.log.info('MyFirstAddon initialized successfully');
}
/**
* Activate the addon.
*
* Called when the addon is enabled/activated.
* Start any background processes or services here.
*/
async activate() {
this.context?.log.info('MyFirstAddon activating...');
this.isActive = true;
// Example: Start a periodic task
// this.startPeriodicTask();
this.context?.log.info('MyFirstAddon activated');
return true; // Must return true to indicate successful activation
}
/**
* Deactivate the addon.
*
* Called when the addon is disabled.
* Stop background processes but don't fully clean up.
*/
async deactivate() {
this.context?.log.info('MyFirstAddon deactivating...');
this.isActive = false;
// Example: Stop periodic tasks
// this.stopPeriodicTask();
this.context?.log.info('MyFirstAddon deactivated');
}
/**
* Dispose of the addon.
*
* Called when the addon is being unloaded.
* Clean up all resources, event listeners, etc.
*/
dispose() {
this.context?.log.info('MyFirstAddon disposing...');
// Dispose all registered disposables
for (const disposable of this.disposables) {
disposable.dispose();
}
this.disposables = [];
// Clear references
this.context = null;
this.isActive = false;
console.log('MyFirstAddon disposed');
}
// ============================================================
// INVOKE METHOD
// GitCaddy calls this to invoke addon functionality
// ============================================================
/**
* Handle method invocations from GitCaddy.
*
* This is the main entry point for GitCaddy to call your addon's functionality.
* Methods can be invoked from:
* - Toolbar buttons (onClick.action = "invoke-method")
* - Menu items
* - Context menus
* - Other addons
* - The renderer process
*
* @param {string} method - Method name to invoke
* @param {unknown[]} args - Arguments passed to the method
* @returns {Promise<unknown>} - Result of the method
*/
async invoke(method, args) {
this.context?.log.debug(`Invoke called: ${method}`, args);
switch (method) {
// ---- Settings Methods ----
case 'getSettings':
return this.getSettings();
case 'updateSettings':
return this.updateSettings(args[0]);
// ---- Badge Methods ----
case 'getBadgeContent':
return this.getBadgeContent();
// ---- Action Methods ----
case 'quickAction':
return this.quickAction(args[0], args[1]);
case 'menuAction':
return this.menuAction();
case 'processFiles':
return this.processFiles(args[0]);
case 'analyzeCommit':
return this.analyzeCommit(args[0]);
// ---- Demo Methods ----
case 'getDemoData':
return this.getDemoData();
case 'performAction':
return this.performAction(args[0]);
// ---- Dialog Methods ----
case 'saveFileDemo':
return this.saveFileDemo(args[0], args[1]);
case 'openFileDemo':
return this.openFileDemo();
// ---- Host Methods ----
// These call the .NET host process
case 'callHelloWorld':
return this.callHelloWorld(args[0]);
// ---- Capability Methods ----
// These are called by GitCaddy for registered capabilities
case 'generateCommitMessage':
return this.generateCommitMessage(args[0]);
// ---- AI Methods ----
// These demonstrate the AI conversation API
case 'aiChat':
return this.aiChat(args[0]);
case 'aiStructuredOutput':
return this.aiStructuredOutput(args[0], args[1]);
default:
throw new Error(`Unknown method: ${method}`);
}
}
// ============================================================
// LICENSE METHODS
// Optional: Implement if your addon has licensing
// ============================================================
/**
* Get the license status for this addon.
*
* GitCaddy calls this method to check if the addon is licensed.
* If not implemented, the addon is assumed to be licensed (free addon).
*
* @returns {{ valid: boolean, message?: string }}
*/
getLicenseStatus() {
// For free/open-source addons, always return valid
return { valid: true };
// For commercial addons, you would check license here:
// return {
// valid: this.isLicenseValid,
// message: this.isLicenseValid ? undefined : 'License expired'
// };
}
// ============================================================
// SETTINGS METHODS
// ============================================================
/**
* Load settings from GitCaddy's settings store.
*/
async loadSettings() {
if (!this.context) return;
const stored = this.context.settings.getAll();
this.settings = {
enableFeature: stored.enableFeature ?? DEFAULT_SETTINGS.enableFeature,
apiKey: stored.apiKey ?? DEFAULT_SETTINGS.apiKey,
mode: stored.mode ?? DEFAULT_SETTINGS.mode,
maxItems: stored.maxItems ?? DEFAULT_SETTINGS.maxItems,
};
}
/**
* Handle setting changes.
*/
onSettingChanged(key, value) {
this.context?.log.debug(`Setting changed: ${key} =`, value);
if (key in this.settings) {
this.settings[key] = value;
}
// React to specific setting changes
if (key === 'mode') {
this.context?.log.info(`Mode changed to: ${value}`);
}
}
/**
* Get current settings (for UI).
*/
getSettings() {
return {
...this.settings,
// Don't expose the actual API key, just whether it's set
hasApiKey: !!this.settings.apiKey,
apiKey: undefined,
};
}
/**
* Update settings.
*/
async updateSettings(updates) {
if (!this.context) return;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
await this.context.settings.set(key, value);
}
}
return { success: true };
}
// ============================================================
// BADGE METHODS
// ============================================================
/**
* Get badge content for toolbar button.
*
* Called by GitCaddy to update the badge on your toolbar button.
* Return null/undefined to hide the badge.
*
* @returns {string|number|null} Badge content
*/
getBadgeContent() {
// Example: Return a count or status indicator
if (!this.settings.enableFeature) {
return null; // Hide badge when feature is disabled
}
// Return a number, string, or null
return '3'; // Example: "3 items"
}
// ============================================================
// ACTION METHODS
// ============================================================
/**
* Quick action triggered by toolbar button.
*/
async quickAction(arg1, arg2) {
this.context?.log.info('Quick action triggered!', { arg1, arg2 });
// Show a notification
this.context?.ipc.send('show-notification', {
title: 'Quick Action',
body: `Action executed with args: ${arg1}, ${arg2}`,
});
return { success: true };
}
/**
* Menu action triggered from Repository menu.
*/
async menuAction() {
this.context?.log.info('Menu action triggered!');
// Example: Get the current repository
const repo = this.context?.appState?.getCurrentRepository();
if (repo) {
this.context?.log.info('Current repository:', repo.path);
}
return { success: true };
}
/**
* Process selected files from context menu.
*/
async processFiles(contextData) {
const files = contextData?.files || [];
this.context?.log.info('Processing files:', files);
// Show notification to demonstrate the context menu is working
const fileCount = files.length;
const fileList = files.map(f => f.path).join(', ');
this.context?.ipc.send('show-notification', {
title: 'Process with MyFirst Addon',
body: `Processing ${fileCount} file(s): ${fileList}`,
});
// Example file processing
const results = [];
for (const file of files) {
results.push({
path: file.path,
status: file.status,
processed: true,
});
}
return { files: results };
}
/**
* Analyze a commit from context menu.
*/
async analyzeCommit(contextData) {
const commit = contextData?.commit;
this.context?.log.info('Analyzing commit:', commit?.sha);
// Show notification to demonstrate the context menu is working
this.context?.ipc.send('show-notification', {
title: 'Analyze with MyFirst Addon',
body: `Analyzing commit: ${commit?.sha?.substring(0, 7) || 'unknown'}\n${commit?.summary || ''}`,
});
return {
sha: commit?.sha,
summary: commit?.summary,
analysis: 'This is a demo analysis result',
};
}
// ============================================================
// DEMO METHODS (for the demo view)
// ============================================================
/**
* Get demo data for the view.
*/
getDemoData() {
return {
addonId: this.context?.addonId,
addonPath: this.context?.addonPath,
isActive: this.isActive,
settings: this.getSettings(),
features: [
'Toolbar buttons',
'Menu items',
'Context menus',
'Custom views',
'Settings storage',
'IPC communication',
'Event handling',
'Native file dialogs',
'AI chat (simple)',
'AI structured output (tools)',
],
hasDialogs: !!this.context?.dialogs,
};
}
/**
* Perform a demo action.
*/
async performAction(actionType) {
this.context?.log.info(`Performing action: ${actionType}`);
switch (actionType) {
case 'notify':
this.context?.ipc.send('show-notification', {
title: 'Hello from Addon!',
body: 'This notification was triggered by the addon.',
});
return { message: 'Notification sent!' };
case 'log':
this.context?.log.info('This is an info log from the demo');
this.context?.log.warn('This is a warning log from the demo');
this.context?.log.debug('This is a debug log from the demo');
return { message: 'Check the logs!' };
case 'settings':
return this.getSettings();
default:
return { message: `Unknown action: ${actionType}` };
}
}
// ============================================================
// CAPABILITY METHODS
// ============================================================
/**
* Generate commit message (if you registered 'commit-message-generation' capability).
*
* GitCaddy will call this when the user requests a commit message.
*/
async generateCommitMessage(context) {
this.context?.log.info('Generating commit message...');
// Example: Simple commit message based on files
const fileCount = context.files?.length || 0;
const summary = `chore: update ${fileCount} file(s)`;
return {
summary,
description: 'This is a demo commit message from My First Addon.',
};
}
// ============================================================
// DIALOG METHODS
// These methods demonstrate the native file dialogs API
// Requires "ui:dialogs" permission in addon.json
// ============================================================
/**
* Save content to a file using a native Save dialog.
*
* Demonstrates context.dialogs.saveFile() which shows a native
* file picker and saves the content to the selected location.
*
* @param {string} content - Content to save
* @param {string} [filename] - Suggested filename
* @returns {Promise<{success: boolean, filePath?: string, error?: string}>}
*/
async saveFileDemo(content, filename) {
// Check if dialogs API is available
if (!this.context?.dialogs) {
this.context?.log.warn('Dialogs API not available - missing ui:dialogs permission?');
return { success: false, error: 'Dialogs not available' };
}
try {
// Show native save dialog and write file
const filePath = await this.context.dialogs.saveFile(content || 'Hello from addon!', {
defaultPath: filename || 'addon-export.txt',
filters: [
{ name: 'Text Files', extensions: ['txt'] },
{ name: 'All Files', extensions: ['*'] }
],
title: 'Save File Demo'
});
if (filePath) {
this.context?.log.info(`File saved to: ${filePath}`);
this.context?.ipc.send('show-notification', {
title: 'File Saved',
body: `Saved to: ${filePath}`,
});
return { success: true, filePath };
} else {
// User canceled
return { success: false, canceled: true };
}
} catch (error) {
this.context?.log.error('Failed to save file:', error);
return { success: false, error: error.message };
}
}
/**
* Open a file using a native Open dialog.
*
* Demonstrates context.dialogs.showOpenDialog() which shows a native
* file picker and returns the selected file path(s).
*
* @returns {Promise<{success: boolean, filePaths?: string[], error?: string}>}
*/
async openFileDemo() {
// Check if dialogs API is available
if (!this.context?.dialogs) {
this.context?.log.warn('Dialogs API not available - missing ui:dialogs permission?');
return { success: false, error: 'Dialogs not available' };
}
try {
// Show native open dialog
const result = await this.context.dialogs.showOpenDialog({
filters: [
{ name: 'Text Files', extensions: ['txt', 'md', 'json'] },
{ name: 'All Files', extensions: ['*'] }
],
title: 'Open File Demo',
multiSelections: false
});
if (!result.canceled && result.filePaths.length > 0) {
this.context?.log.info(`File selected: ${result.filePaths[0]}`);
this.context?.ipc.send('show-notification', {
title: 'File Selected',
body: `Selected: ${result.filePaths[0]}`,
});
return { success: true, filePaths: result.filePaths };
} else {
// User canceled
return { success: false, canceled: true };
}
} catch (error) {
this.context?.log.error('Failed to open file:', error);
return { success: false, error: error.message };
}
}
// ============================================================
// HOST METHODS
// These methods communicate with the .NET host process
// ============================================================
/**
* Call the Hello World endpoint on the .NET host.
*
* Demonstrates how to communicate with a native host process.
*
* @param {string} [name] - Optional name to greet
* @returns {Promise<{message: string, timestamp: string}>}
*/
async callHelloWorld(name) {
if (!this.context?.hostPort) {
this.context?.log.warn('Host port not available - host may not be running');
return { error: 'Host not available', message: null };
}
try {
const endpoint = name
? `http://127.0.0.1:${this.context.hostPort}/hello/${encodeURIComponent(name)}`
: `http://127.0.0.1:${this.context.hostPort}/hello`;
this.context?.log.info(`Calling host endpoint: ${endpoint}`);
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
this.context?.log.info('Host response:', result);
return result;
} catch (error) {
this.context?.log.error('Failed to call host:', error);
return { error: error.message, message: null };
}
}
// ============================================================
// AI METHODS
// These demonstrate how to use GitCaddy's AI conversation API
// Requires "ai:conversation" permission in addon.json
// ============================================================
/**
* Simple AI chat - send a message and get a text response.
*
* This calls the AIHost service running on localhost:5678.
* The AIHost uses the user's configured AI provider (Claude, OpenAI, etc.)
*
* @param {string} prompt - The user's message
* @returns {Promise<{success: boolean, content?: string, error?: string}>}
*/
async aiChat(prompt) {
const AI_HOST_PORT = 5678;
try {
this.context?.log.info('Sending AI chat request...');
const response = await fetch(`http://127.0.0.1:${AI_HOST_PORT}/ai/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [
{ role: 'user', content: prompt }
]
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
this.context?.log.info('AI chat response received');
return result;
} catch (error) {
this.context?.log.error('AI chat failed:', error);
return { success: false, error: error.message };
}
}
/**
* AI structured output - get a response matching a defined schema.
*
* This uses AI tools/functions to get structured JSON output.
* The tool must be defined in addon.json under contributes.aiTools.
*
* @param {string} prompt - The prompt describing what to analyze
* @param {string} toolName - Name of the tool defined in addon.json (e.g., "AnalyzeCode")
* @returns {Promise<{success: boolean, data?: object, error?: string, fallbackText?: string}>}
*/
async aiStructuredOutput(prompt, toolName) {
const AI_HOST_PORT = 5678;
try {
this.context?.log.info(`Sending AI structured output request using tool: ${toolName}`);
// Find the tool definition from our manifest
const tool = this.context?.manifest?.contributes?.aiTools?.find(t => t.name === toolName);
if (!tool) {
return { success: false, error: `Tool '${toolName}' not found in addon manifest` };
}
const response = await fetch(`http://127.0.0.1:${AI_HOST_PORT}/ai/addon/structured-output`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
addonId: this.context?.addonId,
prompt: prompt,
tool: tool,
systemPrompt: 'You are a helpful code analysis assistant. Analyze the provided code and respond using the tool.',
temperature: 0.3,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
this.context?.log.info('AI structured output received:', result.success ? 'success' : 'failed');
return result;
} catch (error) {
this.context?.log.error('AI structured output failed:', error);
return { success: false, error: error.message };
}
}
// ============================================================
// EVENT HANDLERS
// ============================================================
/**
* Handle repository changes.
*/
onRepositoryChanged(repo) {
this.context?.log.debug('Repository changed:', repo?.path);
// Example: Refresh badge when repo changes
// The badge will be refreshed automatically by GitCaddy
}
/**
* Handle new commits.
*/
onCommitCreated(commit) {
this.context?.log.debug('New commit created:', commit?.sha);
}
}
// ============================================================
// CONTEXT INTERFACE (for reference)
// ============================================================
/**
* @typedef {Object} IAddonMainContext
* @property {string} addonId - Unique addon identifier
* @property {string} addonPath - Path to addon directory
* @property {Object} manifest - Parsed addon.json
* @property {IAddonLogger} log - Logger instance
* @property {IAddonSettings} settings - Settings storage
* @property {IAddonEvents} events - Event emitter
* @property {IAddonIPC} ipc - IPC communication
* @property {number} [hostPort] - Port of native host (if configured)
* @property {IAppStateProxy} [appState] - Access to app state
* @property {IAddonDialogs} [dialogs] - Native file dialogs (requires ui:dialogs permission)
*/
/**
* @typedef {Object} IAddonDialogs
* @property {function} showSaveDialog - Show native save dialog
* @property {function} showOpenDialog - Show native open dialog
* @property {function} saveFile - Show save dialog and write content to file
*/
/**
* @typedef {Object} IAddonLogger
* @property {function} debug - Log debug message
* @property {function} info - Log info message
* @property {function} warn - Log warning message
* @property {function} error - Log error message
*/
/**
* @typedef {Object} IAddonSettings
* @property {function} get - Get a setting value
* @property {function} set - Set a setting value
* @property {function} getAll - Get all settings
* @property {function} onDidChange - Register for setting changes
*/
/**
* @typedef {Object} IAddonEvents
* @property {function} onAppReady - Called when app is ready
* @property {function} onAppWillQuit - Called before app quits
* @property {function} onRepositorySelected - Called when repository changes
* @property {function} onFilesChanged - Called when files change
* @property {function} onCommitCreated - Called when a commit is created
* @property {function} emit - Emit a custom event
*/
// Export the addon class as default
module.exports = MyFirstAddon;
module.exports.default = MyFirstAddon;