diff --git a/docs/PLUGIN_SPEC.md b/docs/PLUGIN_SPEC.md new file mode 100644 index 0000000..e10304e --- /dev/null +++ b/docs/PLUGIN_SPEC.md @@ -0,0 +1,759 @@ +# 🔌 Plugin Specification - Extension Development Guide + +## Philosophy: Everything is a Plugin + +**Core Principle:** If core features (Chat, Channels, Notes) are implemented as plugins, we prove that ANY feature can be a plugin. + +``` +Minimal Core (Go) Extensions (Any Language) +├── HTTP Router ├── Chat Engine (Python) +├── WebSocket Hub ├── Channels (Go) +├── Extension Manager ├── RAG Engine (Python) +├── Auth/Users ├── Workflows (Go + Python) +└── PostgreSQL └── Your Custom Plugin... +``` + +## Plugin Types + +### 1. Frontend Plugins (UI Only) +**Language:** JavaScript +**Runs:** In browser +**Capabilities:** UI modifications, client-side logic +**Modes:** Both managed and unmanaged + +### 2. Backend Plugins (Full Power) +**Language:** Python, Go, Node.js, Rust, etc. +**Runs:** As separate process +**Capabilities:** Tools, models, data processing, external APIs +**Modes:** Managed only + +## Frontend Plugin API + +### Plugin Structure + +``` +extensions/frontend/token-counter/ +├── manifest.json # Plugin metadata +├── main.js # Entry point +├── styles.css # Optional styles +└── README.md +``` + +### manifest.json + +```json +{ + "name": "token-counter", + "version": "1.0.0", + "author": "Your Name", + "description": "Display token count for messages", + "type": "frontend", + "entry": "main.js", + "permissions": [ + "message:read", + "ui:inject" + ], + "hooks": [ + "onMessageSend", + "onMessageReceive", + "onUIRender" + ] +} +``` + +### main.js Example + +```javascript +// Frontend plugin template +(function() { + 'use strict'; + + // Plugin initialization + window.ChatSwitchboard = window.ChatSwitchboard || {}; + window.ChatSwitchboard.plugins = window.ChatSwitchboard.plugins || []; + + const TokenCounter = { + name: 'token-counter', + version: '1.0.0', + + // Called when plugin loads + init: function() { + console.log('Token Counter plugin loaded'); + this.addUI(); + }, + + // Hook: Before message is sent + hooks: { + onMessageSend: function(message) { + const tokens = this.estimateTokens(message.content); + console.log(`Message has ~${tokens} tokens`); + + // Show warning if too long + if (tokens > 4000) { + window.ChatSwitchboard.showToast( + `⚠️ Long message: ${tokens} tokens`, + 'warning' + ); + } + + // Can modify message before sending + return message; + }, + + onMessageReceive: function(message) { + // Process incoming messages + return message; + }, + + onUIRender: function() { + // Inject UI elements + this.updateTokenDisplay(); + } + }, + + // Add token counter to UI + addUI: function() { + const inputArea = document.querySelector('.input-area'); + const counter = document.createElement('div'); + counter.id = 'token-counter'; + counter.className = 'plugin-token-counter'; + counter.textContent = '0 tokens'; + inputArea.appendChild(counter); + + // Update on input + const textarea = document.getElementById('messageInput'); + textarea.addEventListener('input', () => { + const tokens = this.estimateTokens(textarea.value); + counter.textContent = `${tokens} tokens`; + }); + }, + + // Token estimation (rough approximation) + estimateTokens: function(text) { + return Math.ceil(text.length / 4); + }, + + updateTokenDisplay: function() { + const counter = document.getElementById('token-counter'); + const input = document.getElementById('messageInput'); + if (counter && input) { + const tokens = this.estimateTokens(input.value); + counter.textContent = `${tokens} tokens`; + } + } + }; + + // Register plugin + window.ChatSwitchboard.plugins.push(TokenCounter); + + // Auto-init when DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => TokenCounter.init()); + } else { + TokenCounter.init(); + } +})(); +``` + +### Available Hooks + +```javascript +// Message lifecycle +onMessageSend(message) // Before sending to API +onMessageReceive(message) // After receiving from API +onMessageRender(element, message) // When rendering to DOM + +// UI lifecycle +onUIRender() // After UI updates +onChatSwitch(chatId) // When switching chats +onModelChange(model) // When model changes + +// Extension points +onToolCall(tool, params) // Before calling tool +onToolResult(tool, result) // After tool returns + +// Settings +onSettingsOpen() // Settings modal opened +onSettingsSave(settings) // Settings saved + +// Files +onFileUpload(file) // File uploaded +onFileSelect(file) // File selected for chat +``` + +### Plugin API Methods + +```javascript +// Toast notifications +ChatSwitchboard.showToast(message, type); // type: success, error, warning, info + +// Storage (scoped to plugin) +ChatSwitchboard.storage.set(key, value); +ChatSwitchboard.storage.get(key); +ChatSwitchboard.storage.remove(key); + +// UI manipulation +ChatSwitchboard.ui.addButton(location, config); +ChatSwitchboard.ui.addPanel(config); +ChatSwitchboard.ui.addMenuItem(config); + +// State access (read-only) +ChatSwitchboard.state.getCurrentChat(); +ChatSwitchboard.state.getCurrentModel(); +ChatSwitchboard.state.getSettings(); + +// Events +ChatSwitchboard.events.on(event, callback); +ChatSwitchboard.events.off(event, callback); +ChatSwitchboard.events.emit(event, data); +``` + +## Backend Plugin API + +### Plugin Structure + +``` +extensions/backend/web-search/ +├── extension.json # Manifest +├── main.py # Entry point (or main.go, server.js, etc) +├── requirements.txt # Dependencies (Python) +├── config.yaml # Configuration +└── README.md +``` + +### extension.json + +```json +{ + "name": "web-search", + "version": "1.0.0", + "author": "Chat Switchboard Team", + "description": "Search the web using DuckDuckGo API", + "type": "backend", + "runtime": "python", + "entry": "main.py", + "port": 9001, + "capabilities": ["tool"], + "dependencies": { + "python": ">=3.9", + "packages": ["fastapi", "uvicorn", "duckduckgo-search"] + }, + "tools": [ + { + "name": "search_web", + "description": "Search the web for information", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + }, + "max_results": { + "type": "integer", + "description": "Maximum results to return", + "default": 5 + } + }, + "required": ["query"] + }, + "returns": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "url": {"type": "string"}, + "snippet": {"type": "string"} + } + } + } + } + } + } + ], + "config": { + "max_requests_per_minute": 30, + "timeout_seconds": 10 + } +} +``` + +### Python Plugin Template + +```python +# extensions/backend/web-search/main.py +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from duckduckgo_search import DDGS +import uvicorn +import os + +app = FastAPI(title="Web Search Plugin") + +# Load config +PORT = int(os.getenv("PLUGIN_PORT", 9001)) +MAX_RESULTS = int(os.getenv("MAX_RESULTS", 5)) + +# Request/response models +class SearchRequest(BaseModel): + query: str + max_results: int = 5 + +class SearchResult(BaseModel): + title: str + url: str + snippet: str + +class SearchResponse(BaseModel): + results: list[SearchResult] + +# Tool endpoint +@app.post("/tools/search_web", response_model=SearchResponse) +async def search_web(request: SearchRequest): + """Search the web using DuckDuckGo""" + try: + ddgs = DDGS() + results = ddgs.text( + request.query, + max_results=min(request.max_results, MAX_RESULTS) + ) + + return SearchResponse( + results=[ + SearchResult( + title=r.get("title", ""), + url=r.get("href", ""), + snippet=r.get("body", "") + ) + for r in results + ] + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# Health check +@app.get("/health") +async def health(): + return {"status": "ok", "plugin": "web-search"} + +# Plugin info +@app.get("/info") +async def info(): + return { + "name": "web-search", + "version": "1.0.0", + "tools": ["search_web"] + } + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=PORT) +``` + +### Go Plugin Template + +```go +// extensions/backend/file-ops/main.go +package main + +import ( + "github.com/gin-gonic/gin" + "os" + "io/ioutil" +) + +type ReadFileRequest struct { + Path string `json:"path" binding:"required"` +} + +type ReadFileResponse struct { + Content string `json:"content"` + Size int64 `json:"size"` +} + +func main() { + r := gin.Default() + + // Tool endpoint + r.POST("/tools/read_file", func(c *gin.Context) { + var req ReadFileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + content, err := ioutil.ReadFile(req.Path) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + info, _ := os.Stat(req.Path) + + c.JSON(200, ReadFileResponse{ + Content: string(content), + Size: info.Size(), + }) + }) + + // Health check + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // Start server + port := os.Getenv("PLUGIN_PORT") + if port == "" { + port = "9002" + } + r.Run(":" + port) +} +``` + +## Extension Manager (Backend Core) + +### Go Extension Manager + +```go +// server/extensions/manager.go +package extensions + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "sync" +) + +type Extension struct { + Name string `json:"name"` + Version string `json:"version"` + Runtime string `json:"runtime"` + Entry string `json:"entry"` + Port int `json:"port"` + Tools []Tool `json:"tools"` + Process *os.Process + BaseURL string +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +type Manager struct { + extensions map[string]*Extension + tools map[string]*Tool + mu sync.RWMutex +} + +func NewManager() *Manager { + return &Manager{ + extensions: make(map[string]*Extension), + tools: make(map[string]*Tool), + } +} + +// Load all extensions from directory +func (m *Manager) LoadExtensions(dir string) error { + entries, err := ioutil.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + extPath := filepath.Join(dir, entry.Name()) + if err := m.LoadExtension(extPath); err != nil { + log.Printf("Failed to load extension %s: %v", entry.Name(), err) + } + } + + return nil +} + +// Load single extension +func (m *Manager) LoadExtension(path string) error { + // Read manifest + manifestPath := filepath.Join(path, "extension.json") + data, err := ioutil.ReadFile(manifestPath) + if err != nil { + return err + } + + var ext Extension + if err := json.Unmarshal(data, &ext); err != nil { + return err + } + + // Start extension process + if err := m.startExtension(&ext, path); err != nil { + return err + } + + // Register tools + for i := range ext.Tools { + ext.Tools[i].ExtensionName = ext.Name + m.tools[ext.Tools[i].Name] = &ext.Tools[i] + } + + m.mu.Lock() + m.extensions[ext.Name] = &ext + m.mu.Unlock() + + log.Printf("✅ Loaded extension: %s v%s", ext.Name, ext.Version) + return nil +} + +// Start extension process +func (m *Manager) startExtension(ext *Extension, path string) error { + var cmd *exec.Cmd + + switch ext.Runtime { + case "python": + cmd = exec.Command("python3", ext.Entry) + case "go": + cmd = exec.Command("go", "run", ext.Entry) + case "node": + cmd = exec.Command("node", ext.Entry) + default: + return fmt.Errorf("unsupported runtime: %s", ext.Runtime) + } + + cmd.Dir = path + cmd.Env = append(os.Environ(), fmt.Sprintf("PLUGIN_PORT=%d", ext.Port)) + + if err := cmd.Start(); err != nil { + return err + } + + ext.Process = cmd.Process + ext.BaseURL = fmt.Sprintf("http://localhost:%d", ext.Port) + + // Wait for extension to be ready + time.Sleep(2 * time.Second) + + return nil +} + +// Call tool +func (m *Manager) CallTool(name string, params map[string]interface{}) (interface{}, error) { + m.mu.RLock() + tool, exists := m.tools[name] + m.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("tool not found: %s", name) + } + + ext := m.extensions[tool.ExtensionName] + url := fmt.Sprintf("%s/tools/%s", ext.BaseURL, name) + + // HTTP POST to extension + body, _ := json.Marshal(params) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result interface{} + json.NewDecoder(resp.Body).Decode(&result) + return result, nil +} + +// List all tools +func (m *Manager) ListTools() []Tool { + m.mu.RLock() + defer m.mu.RUnlock() + + tools := make([]Tool, 0, len(m.tools)) + for _, tool := range m.tools { + tools = append(tools, *tool) + } + return tools +} + +// Shutdown all extensions +func (m *Manager) Shutdown() { + m.mu.Lock() + defer m.mu.Unlock() + + for _, ext := range m.extensions { + if ext.Process != nil { + ext.Process.Kill() + } + } +} +``` + +### Extension Discovery + +```go +// server/main.go +func main() { + // ... existing setup ... + + // Load extensions + extManager := extensions.NewManager() + if err := extManager.LoadExtensions("./extensions/backend"); err != nil { + log.Fatal("Failed to load extensions:", err) + } + defer extManager.Shutdown() + + // Expose extension tools via API + r.GET("/api/tools", func(c *gin.Context) { + tools := extManager.ListTools() + c.JSON(200, tools) + }) + + r.POST("/api/tools/:name", func(c *gin.Context) { + toolName := c.Param("name") + var params map[string]interface{} + c.BindJSON(¶ms) + + result, err := extManager.CallTool(toolName, params) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, result) + }) +} +``` + +## Plugin Development Workflow + +### 1. Create from Template + +```bash +# Frontend plugin +cp -r extensions/frontend/_template extensions/frontend/my-plugin +cd extensions/frontend/my-plugin +# Edit manifest.json, main.js + +# Backend plugin (Python) +cp -r extensions/backend/_template-python extensions/backend/my-plugin +cd extensions/backend/my-plugin +# Edit extension.json, main.py +pip install -r requirements.txt +``` + +### 2. Test Locally + +```bash +# Backend plugin +python main.py # Runs on port from extension.json + +# Test tool +curl -X POST http://localhost:9001/tools/my_tool \ + -H "Content-Type: application/json" \ + -d '{"param": "value"}' +``` + +### 3. Install in Chat Switchboard + +```bash +# Move to extensions directory +mv my-plugin ../chat-switchboard/extensions/backend/ + +# Restart backend +cd ../chat-switchboard +docker-compose restart backend +``` + +### 4. Publish to Marketplace + +```bash +# Package extension +tar -czf my-plugin-1.0.0.tar.gz my-plugin/ + +# Upload to marketplace +curl -X POST https://marketplace.chatswitch.io/api/publish \ + -F "file=@my-plugin-1.0.0.tar.gz" \ + -F "category=tools" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Security & Sandboxing + +### Backend Plugin Isolation + +```yaml +# docker-compose.yml - Run plugins in containers +services: + plugin-web-search: + build: ./extensions/backend/web-search + ports: + - "9001:9001" + environment: + - PLUGIN_PORT=9001 + networks: + - extension-network + restart: always +``` + +### Permission System + +```json +// Plugins declare required permissions +{ + "permissions": [ + "network:outbound", // Make external HTTP requests + "storage:read", // Read from DB + "storage:write", // Write to DB + "file:read", // Read files + "tool:call:*" // Call other tools + ] +} +``` + +## Official Plugin List + +### Bundled with Core + +1. **chat-engine** (Python) - LLM conversation handling +2. **channels** (Go) - Multi-user chat rooms +3. **rag-engine** (Python) - Vector search, embeddings +4. **workflows** (Go/Python) - Workflow orchestration +5. **notes** (Go) - Note management + +### Official Extensions + +6. **web-search** (Python) - DuckDuckGo search +7. **code-runner** (Python) - Sandboxed code execution +8. **image-gen** (Python) - DALL-E/Stable Diffusion +9. **file-ops** (Go) - File system operations +10. **calculator** (Python) - Math evaluation + +### Community Extensions (Example Ideas) + +- **slack-integration** - Post to Slack channels +- **github-integration** - Create issues, PRs +- **email-sender** - Send emails via SMTP +- **pdf-parser** - Extract text from PDFs +- **scraper** - Web scraping tool +- **translate** - Multi-language translation +- **tts** - Text-to-speech +- **stt** - Speech-to-text + +--- + +## Next Steps + +1. Read `docs/ARCHITECTURE.md` for system overview +2. Check `extensions/_template-python/` for starter code +3. Browse `extensions/backend/` for examples +4. Join developer Discord for help + +**Build something awesome! 🚀**