760 lines
18 KiB
Markdown
760 lines
18 KiB
Markdown
# 🔌 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! 🚀**
|