# Plugin System QwenPaw provides a plugin system that allows users to extend QwenPaw's functionality. ## Overview The plugin system supports the following extension capabilities: - **Provider Plugins**: Add new LLM providers and models - **Middleware Plugins**: Register AgentScope `MiddlewareBase` factories to wrap `on_acting` / `on_reasoning` hooks in the agent reasoning loop - **Hook Plugins**: Execute custom code during application startup/shutdown (app lifespan level, runs once) - **Command Plugins**: Register custom `/command` magic commands - **HTTP API Plugins**: Expose custom REST endpoints under `/api` via a FastAPI `APIRouter` - **Frontend Extension Plugins**: Browser-side JS plugins that share the host's React / Ant Design runtime and declaratively extend the UI via `window.QwenPaw.*` API — register sidebar menus, page routes, UI slots, chat customizations, and more without modifying host code - **Channel Plugins**: Register custom messaging channels (e.g. Slack, LINE) ## Plugin Management ### Install Plugin Install from local directory: ```bash qwenpaw plugin install /path/to/plugin ``` Install from URL (supports ZIP files): ```bash qwenpaw plugin install https://example.com/plugin.zip ``` Force reinstall: ```bash qwenpaw plugin install /path/to/plugin --force ``` **Note**: Plugin operations can only be performed when QwenPaw is offline. ### List Installed Plugins ```bash qwenpaw plugin list ``` Example output: ``` Installed Plugins: ================== my-provider (v1.0.0) Custom LLM provider integration Author: Developer Name Path: /Users/user/.qwenpaw/plugins/my-provider ``` ### View Plugin Details ```bash qwenpaw plugin info ``` ### Uninstall Plugin ```bash qwenpaw plugin uninstall ``` ## Plugin Development ### Backend Plugins #### Basic Structure Each plugin requires at least two files: ``` my-plugin/ ├── plugin.json # Plugin manifest (required) ├── plugin.py # Entry point (required) └── README.md # Documentation (recommended) ``` #### plugin.json ```json { "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "type": "general", "description": "Plugin description", "author": "Your Name", "entry": { "backend": "plugin.py" }, "dependencies": [], "qwenpaw_version": { "min": "1.0.0", "max": "2.1.0" }, "meta": {} } ``` #### Manifest Field Reference | Field | Type | Required | Description | | ----------------- | ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `string` | yes | Unique plugin identifier. Used as the install directory name; must not contain path separators. | | `version` | `string` | yes | Semantic version of the plugin (e.g. `1.0.0`). | | `name` | `string` \| object | no | Display name. Defaults to `id`. May also be `{"zh-CN": "...", "en-US": "..."}`; the first non-empty localised value is used (English preferred). | | `type` | `string` | no | One of `tool`, `provider`, `hook`, `command`, `frontend`, `general`. When omitted, the type is inferred from `meta` / `entry` (legacy plugins). Prefer setting explicitly. | | `description` | `string` \| object | no | Short description shown in the plugin list. Localised form is accepted (see `name`). | | `author` | `string` | no | Author or organisation name. | | `entry.backend` | `string` | no\* | Path (relative to plugin dir) of the Python entry file that exports `plugin`. | | `entry.frontend` | `string` | no\* | Path of the built frontend bundle (e.g. `dist/index.js`). | | `dependencies` | `string[]` | no | Python package requirements installed via pip/uv at install time. | | `qwenpaw_version` | `object` | no | QwenPaw version constraint (recommended). Contains `min` (inclusive) and `max` (exclusive, optional) sub-fields. Semantics: `>=min, ` type — pass a `(locale) => string` function to return different values per language | **Extension points at a glance:** | Namespace | Capability | Typical use | | --------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------- | | `host` | Shared dependencies, React Hooks, authenticated fetch | Access React / antd, read theme and locale, call backend APIs | | `menu` | Sidebar menu items | Add navigation entries | | `route` | Page routes | Register new pages, wrap existing pages | | `slot` | General UI slots | Inject content into Header / Sidebar and other preset positions | | `chat.welcome` | Welcome screen | Customize greeting, suggested prompts | | `chat.theme` | Chat theme color | Change the primary color | | `chat.leftHeader` / `rightHeader` | Chat header | Set brand logo, add action buttons | | `chat.sender` | Input box | Custom placeholder, input suggestions | | `chat.actions` / `requestActions` | Message action buttons | Add custom actions below messages | | `chat.requestPayload` | Outgoing chat request payload | Add custom fields before the request is sent to the backend | | `chat.request` / `response` | Message bubbles | Prepend/append content or fully replace rendering | | `chat.toolRender` | Tool-call rendering | Custom tool result display (e.g. weather card) | | `chat.card` | Custom cards | Register new card types | | `audit` | Audit & debugging | View all extension registration records | #### Basic Structure ``` my-plugin/ ├── plugin.json # Plugin manifest (required) ├── src/ │ └── index.tsx # Entry point, calls window.QwenPaw.* APIs ├── package.json # Dependencies ├── tsconfig.json # TypeScript config └── vite.config.ts # Build config ``` #### plugin.json ```json { "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "type": "frontend", "author": "Your Name", "entry": { "frontend": "dist/index.js" } } ``` #### src/index.tsx The plugin entry file executes on load and registers extensions via `window.QwenPaw.*` API: ```tsx const { React, antd } = window.QwenPaw.host; const pluginId = "my-plugin"; // Call window.QwenPaw.* APIs to register menus, routes, chat customizations, etc. // See "Frontend Extension API" below for details ``` #### Build Toolchain **package.json**: ```json { "name": "my-plugin", "version": "1.0.0", "scripts": { "build": "vite build" }, "devDependencies": { "vite": "^5.0.0", "typescript": "^5.0.0", "@vitejs/plugin-react": "^4.0.0" } } ``` **tsconfig.json**: ```json { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react", "strict": false, "skipLibCheck": true } } ``` **vite.config.ts**: ```ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react({ jsxRuntime: "classic" })], build: { lib: { entry: "src/index.tsx", formats: ["es"], fileName: () => "index.js", }, rollupOptions: { external: ["react", "react-dom"] }, }, }); ``` `jsxRuntime: "classic"` compiles JSX to `React.createElement`, using the host-provided `React`; `external` avoids bundling React, using the version already loaded by the application. #### Build and Install ```bash npm install && npm run build cp -r . ~/.qwenpaw/plugins/my-plugin/ qwenpaw app ``` You can copy `console/src/plugins/types/qwenpaw.d.ts` into your plugin project as `qwenpaw-host.d.ts` for full type hints. ## Frontend Extension API Frontend plugins extend the Console UI through the `window.QwenPaw.*` API without modifying host code. All registration methods take `pluginId` as the first argument, and every registration returns a `{ dispose() }` object for revocation. ### Host SDK — `window.QwenPaw.host` Shared dependencies — plugins do not need to bundle these libraries: ```ts host.React // React library host.ReactDOM // ReactDOM library host.antd // Ant Design component library host.antdIcons // Ant Design icons library host.apiBaseUrl // API base URL host.getApiUrl(path: string) // Build full API URL host.getApiToken(): string | null // Get current auth token ``` **React Hooks (use inside React components):** ```ts const theme = window.QwenPaw.host.useTheme(); // "light" | "dark" const locale = window.QwenPaw.host.useLocale(); // "zh" | "en" const agent = window.QwenPaw.host.useSelectedAgent(); // { id: string } const session = window.QwenPaw.host.useCurrentSession(); // { id: string } | null ``` **Imperative getters (can be called anywhere):** ```ts const agentId = window.QwenPaw.host.getSelectedAgentId(); const sessionId = window.QwenPaw.host.getCurrentSessionId(); ``` **Authenticated fetch (automatically injects Authorization and X-Agent-Id headers):** ```ts const resp = await window.QwenPaw.host.fetch("/api/v1/my-endpoint", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: "test" }), }); const data = await resp.json(); ``` ### Sidebar Menu — `window.QwenPaw.menu` | Method | Signature | Description | | ---------- | ---------------------------------------- | ------------------------------------ | | `add` | `(pluginId, item \| item[]): Disposable` | Add menu items | | `replace` | `(pluginId, targetId, item): Disposable` | Replace an existing menu item | | `remove` | `(targetId): void` | Remove a menu item | | `snapshot` | `(location?): MenuItem[]` | Get a snapshot of current menu items | **MenuItem Parameters:** ```ts { id: string; // Globally unique, e.g. "my-plugin.foo" label: string | (() => ReactNode); icon?: ReactComponent | ReactNode; route?: string; // Route id to navigate to on click parentId?: string; // Parent group to attach to location?: "primary.agentScoped" | "primary.settings" | "userMenu"; before?: string; // Position before a specific id after?: string; // Position after a specific id order?: number; // Lower values appear first visible?: () => boolean; // Dynamic visibility control isGroup?: boolean; // Render as group header divider?: boolean; // Render as horizontal divider } ``` ### Page Routes — `window.QwenPaw.route` | Method | Signature | Description | | --------- | --------------------------------------------- | -------------------------------------- | | `add` | `(pluginId, route \| route[]): Disposable` | Register new routes | | `replace` | `(pluginId, targetId, component): Disposable` | Replace an existing route's component | | `wrap` | `(pluginId, targetId, wrapper): Disposable` | Wrap an existing route (onion pattern) | | `remove` | `(targetId): void` | Remove a route | **Route parameters:** ```ts { id: string; // Globally unique, e.g. "my-plugin.home" path: string; // URL path, supports react-router patterns component: React.ComponentType; // Page component } ``` **Wrap example (add a top banner to an existing page):** ```tsx window.QwenPaw.route.wrap("my-plugin", "core.chat", (Inner) => { return () => (
Beta Feature
); }); ``` ### General UI Slots — `window.QwenPaw.slot` | Method | Signature | Description | | ---------- | --------------------------------------------- | ------------------------------------------------------- | | `fill` | `(pluginId, name, render, opts?): Disposable` | Append content to a slot (multiple can coexist) | | `replace` | `(pluginId, name, render, opts?): Disposable` | Replace slot content (latest wins, overrides all fills) | | `snapshot` | `(): SlotInfo[]` | Get all registered slot information | **Built-in Slots:** | Slot Name | Type | UI Location | | ------------------- | ------- | ----------------------------------------- | | `header.logo` | replace | Top navbar, leftmost | | `header.left` | fill | Top navbar, left area (right of logo) | | `header.right` | fill | Top navbar, right area (left of settings) | | `sider.top` | fill | Sidebar top (below agent selector) | | `sider.bottom` | fill | Sidebar bottom (below menu) | | `content.statusBar` | fill | Main content area top | | `overlay.global` | fill | Global overlay | **Example:** ```tsx // Replace Header Logo window.QwenPaw.slot.replace("my-plugin", "header.logo", (defaultLogo) => { return ; }); ``` ### Chat Welcome Screen — `chat.welcome` ```tsx window.QwenPaw.chat.welcome.set("my-plugin", { greeting: (locale) => (locale.startsWith("zh") ? "Hello!" : "Hello!"), description: "I specialize in data analysis.", avatar: "https://example.com/avatar.png", nick: "My Bot", prompts: [ { label: "Analyze data", value: "Please analyze the uploaded dataset" }, { label: "Create chart", value: "Create a bar chart from the data" }, ], }); // Or fully replace the welcome screen window.QwenPaw.chat.welcome.render("my-plugin", (props) => { return
Custom Welcome
; }); ``` ### Chat Theme — `chat.theme` ```ts window.QwenPaw.chat.theme.set("my-plugin", { colorPrimary: "#1890ff", }); ``` ### Chat Header — `chat.leftHeader` / `chat.rightHeader` ```tsx // Set the left header title window.QwenPaw.chat.leftHeader.set("my-plugin", { title: "My Brand", logo: , }); // Add a button to the right header window.QwenPaw.chat.rightHeader.add( "my-plugin", , { id: "my-plugin.btn", order: 10 }, ); ``` ### Input Box — `chat.sender` ```ts // Custom placeholder window.QwenPaw.chat.sender.set("my-plugin", { placeholder: "Ask me anything...", disclaimer: "Responses may not be accurate.", }); // Add input suggestions window.QwenPaw.chat.sender.addSuggestion("my-plugin", { id: "my-plugin.suggestions", items: [ { label: "/analyze", value: "analyze" }, { label: "/visualize", value: "visualize" }, ], }); ``` ### Message Action Buttons — `chat.actions` / `chat.requestActions` ```tsx // Add action button below AI responses window.QwenPaw.chat.actions.add("my-plugin", { id: "my-plugin.star", icon: , onClick: ({ data }) => console.log("Starred:", data), }); // Add action button below user messages window.QwenPaw.chat.requestActions.add("my-plugin", { id: "my-plugin.edit", icon: ✏️, onClick: ({ data }) => console.log("Edit:", data), }); ``` ### Request Payload Transform — `chat.requestPayload` Use `chat.requestPayload.add` to modify the outgoing chat request body before the Console sends it to the backend. Transforms run in ascending `order` and receive the current payload plus the resolved `sessionId` and `selectedAgent`. ```ts window.QwenPaw.chat.requestPayload.add( "my-plugin", ({ payload, sessionId, selectedAgent }) => ({ ...payload, request_context: { session_id: sessionId, agent_id: selectedAgent, datasource_id: "ds-123", }, }), { id: "my-plugin.request-context", order: 10 }, ); ``` The transform may return a new object to replace the payload. Returning `undefined` leaves the payload unchanged. Use a globally unique `id` so the registration can be audited and disposed cleanly. ### Message Bubble Customization — `chat.request` / `chat.response` ```tsx // Set the default assistant response avatar and nickname // This currently reuses welcome.avatar / welcome.nick because the default ResponseCard reads those fields window.QwenPaw.chat.response.set("my-plugin", { avatar: "https://example.com/bot-avatar.png", nick: "My Bot", }); // Prepend content before user messages window.QwenPaw.chat.request.prepend("my-plugin", ({ data }) => { return
User
; }); // Append an info bar below the latest AI response window.QwenPaw.chat.response.append("my-plugin", ({ data, isLast }) => { if (!isLast) return null; return (
Powered by My Plugin
); }); // Fully replace user message rendering (call fallback() to keep defaults) window.QwenPaw.chat.request.render("my-plugin", ({ data, fallback }) => { return (
{fallback()}
); }); ``` ### Tool-Call Rendering — `chat.toolRender` ```tsx // Register a custom tool result renderer (props include result, sessionId, messageId) window.QwenPaw.chat.toolRender("my-plugin", "get_weather", ({ result }) => { const data = typeof result === "string" ? JSON.parse(result) : result; return (
{data.city}: {data.temperature}°C
); }); ``` ### Custom Cards — `chat.card` ```ts window.QwenPaw.chat.card("my-plugin", "my-card", MyCardComponent); ``` ### Audit & Debugging ```ts // View extension registration records console.table(window.QwenPaw.audit.overrides()); // Remove all Chat extension registrations for a plugin window.QwenPaw.chat.disposeAll("my-plugin"); ``` ### Internationalization All fields that support the `Localized` type accept a function that returns different values per locale: ```ts window.QwenPaw.chat.welcome.set("my-plugin", { greeting: (locale) => (locale.startsWith("zh") ? "Hello!" : "Hello!"), }); ``` ### Common Errors | Error | Cause | Solution | | --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------- | | `e.item.render is not a function` | render/prepend/append received a non-function | Ensure you pass a React component or a function returning ReactNode | | `duplicate id` | Two `add` calls used the same id | Use globally unique ids (recommended format: `pluginId.xxx`) | | Hook called outside component | `useTheme()` etc. used outside React context | Use imperative APIs like `getSelectedAgentId()` instead | ## Usage Examples ### Example 1: Add Custom Provider Let's say you want to connect to an enterprise internal LLM service. #### 1. Create Plugin Directory ```bash mkdir my-llm-provider cd my-llm-provider ``` #### 2. Create plugin.json ```json { "id": "my-llm-provider", "name": "My LLM Provider", "version": "1.0.0", "type": "provider", "description": "Custom LLM provider for enterprise", "author": "Your Name", "entry": { "backend": "plugin.py" }, "dependencies": ["httpx>=0.24.0"], "qwenpaw_version": { "min": "1.0.0", "max": "2.1.0" }, "meta": { "api_key_url": "https://example.com/get-api-key", "api_key_hint": "Get your API key from example.com" } } ``` #### 3. Create provider.py ```python # -*- coding: utf-8 -*- """My LLM Provider Implementation.""" from qwenpaw.providers.openai_provider import OpenAIProvider from qwenpaw.providers.provider import ModelInfo from typing import List class MyLLMProvider(OpenAIProvider): """My custom LLM provider (OpenAI-compatible).""" def __init__(self, **kwargs): """Initialize provider.""" super().__init__(**kwargs) @classmethod def get_default_models(cls) -> List[ModelInfo]: """Get default models.""" return [ ModelInfo( id="my-model-v1", name="My Model V1", supports_multimodal=False, supports_image=False, supports_video=False, ), ModelInfo( id="my-model-v2", name="My Model V2", supports_multimodal=True, supports_image=True, supports_video=False, ), ] ``` #### 4. Create plugin.py ```python # -*- coding: utf-8 -*- """My LLM Provider Plugin Entry Point.""" import importlib.util import logging import os from qwenpaw.plugins.api import PluginApi logger = logging.getLogger(__name__) class MyLLMProviderPlugin: """My LLM Provider Plugin.""" def register(self, api: PluginApi): """Register the provider. Args: api: PluginApi instance """ logger.info("Registering My LLM Provider...") # Load provider module from same directory plugin_dir = os.path.dirname(os.path.abspath(__file__)) provider_path = os.path.join(plugin_dir, "provider.py") spec = importlib.util.spec_from_file_location( "my_provider", provider_path ) provider_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(provider_module) MyLLMProvider = provider_module.MyLLMProvider # Register provider api.register_provider( provider_id="my-llm", provider_class=MyLLMProvider, label="My LLM", base_url="https://api.example.com/v1", ) logger.info("✓ My LLM Provider registered") # Export plugin instance plugin = MyLLMProviderPlugin() ``` #### 5. Install and Use ```bash # Install plugin qwenpaw plugin install my-llm-provider # Start QwenPaw qwenpaw app ``` ### Example 2: Add Startup Hook Let's say you want to initialize a monitoring service when QwenPaw starts. #### 1. Create Plugin ```bash mkdir monitoring-hook cd monitoring-hook ``` #### 2. Create plugin.json ```json { "id": "monitoring-hook", "name": "Monitoring Hook", "version": "1.0.0", "type": "hook", "description": "Initialize monitoring service at startup", "author": "Your Name", "entry": { "backend": "plugin.py" }, "dependencies": [], "qwenpaw_version": { "min": "1.0.0", "max": "2.1.0" } } ``` #### 3. Create plugin.py ```python # -*- coding: utf-8 -*- """Monitoring Hook Plugin Entry Point.""" from qwenpaw.plugins.api import PluginApi import logging logger = logging.getLogger(__name__) class MonitoringHookPlugin: """Monitoring Hook Plugin.""" def register(self, api: PluginApi): """Register the monitoring hook. Args: api: PluginApi instance """ logger.info("Registering monitoring hook...") def startup_hook(): """Startup hook to initialize monitoring.""" try: logger.info("=== Monitoring Service Initialization ===") # Initialize your monitoring service # from my_monitoring import init_monitoring # init_monitoring(app_name="QwenPaw") logger.info("✓ Monitoring initialized successfully") except Exception as e: logger.error( f"Failed to initialize monitoring: {e}", exc_info=True, ) # Register startup hook (priority=0 means highest priority) api.register_startup_hook( hook_name="monitoring_init", callback=startup_hook, priority=0, ) logger.info("✓ Monitoring hook registered") # Export plugin instance plugin = MonitoringHookPlugin() ``` #### 4. Install ```bash qwenpaw plugin install monitoring-hook qwenpaw app ``` ### Example 3: Add Custom Command Let's say you want to add a `/status` command to check system status. #### 1. Create Plugin ```bash mkdir status-command cd status-command ``` #### 2. Create plugin.json ```json { "id": "status-command", "name": "Status Command", "version": "1.0.0", "type": "command", "description": "Custom status command", "author": "Your Name", "entry": { "backend": "plugin.py" }, "dependencies": [], "qwenpaw_version": { "min": "1.0.0", "max": "2.1.0" } } ``` #### 3. Create plugin.py ```python # -*- coding: utf-8 -*- """Status Command Plugin Entry Point.""" import logging from qwenpaw.plugins.api import PluginApi logger = logging.getLogger(__name__) class StatusCommandPlugin: """Status Command Plugin.""" def register(self, api: PluginApi): """Register the status command.""" from qwenpaw.runtime.commands.control.base import ( BaseControlCommandHandler, ) class StatusCommandHandler(BaseControlCommandHandler): command_name = "status" help_text = "Check system status" async def handle(self, ctx, args: str): from agentscope.message import Msg return Msg( name="system", role="assistant", content="System is running normally.", ) api.register_control_command( handler=StatusCommandHandler(), priority_level=10, ) logger.info("✓ Status command registered: /status") # Export plugin instance plugin = StatusCommandPlugin() ``` #### 4. Install and Use ```bash qwenpaw plugin install status-command qwenpaw app # Use the command /status ``` ### Example 4: Add a Custom Frontend Page Add a welcome page to the sidebar. Build toolchain files (`package.json`, `tsconfig.json`, `vite.config.ts`) follow the "Frontend Plugins > Build Toolchain" section above. **plugin.json**: ```json { "id": "welcome-plugin", "name": "Welcome Plugin", "version": "1.0.0", "type": "frontend", "description": "Welcome page plugin", "author": "Your Name", "entry": { "frontend": "dist/index.js" } } ``` **src/index.tsx**: ```tsx const { React, antd } = window.QwenPaw.host; const { Typography, Card } = antd; const pluginId = "welcome-plugin"; const WelcomePage = () => { const theme = window.QwenPaw.host.useTheme(); return ( Welcome to QwenPaw Plugin system is working! ); }; window.QwenPaw.menu.add(pluginId, { id: "welcome-plugin.home", label: "Welcome", icon: "spark-home-line", route: "welcome-plugin.home", }); window.QwenPaw.route.add(pluginId, { id: "welcome-plugin.home", path: "/welcome-plugin/home", component: WelcomePage, }); ``` ```bash npm install && npm run build cp -r . ~/.qwenpaw/plugins/welcome-plugin/ qwenpaw app ``` ### Example 5: Custom Tool-Call Renderer Customize how Agent tool-call results are displayed. Project structure follows Example 4, only `src/index.tsx` differs. **src/index.tsx**: ```tsx const { React, antd } = window.QwenPaw.host; const { Card, Descriptions } = antd; const pluginId = "tool-render-plugin"; window.QwenPaw.chat.toolRender(pluginId, "get_weather", ({ result }) => { const data = typeof result === "string" ? JSON.parse(result) : result; return ( {data.city} {data.temperature}°C {data.weather} ); }); ``` ### Example 6: Customize Chat Welcome Customize the chat page greeting, description, and suggested prompts. Project structure follows Example 4, only `src/index.tsx` differs. **src/index.tsx**: ```tsx const pluginId = "custom-greeting-plugin"; window.QwenPaw.chat.welcome.set(pluginId, { greeting: (locale) => locale.startsWith("zh") ? "Hello! I'm customized QwenPaw" : "Hello! I'm customized QwenPaw", description: "This is a customized chat assistant", prompts: [ { label: "Analyze code", value: "Help me analyze this code" }, { label: "Unit test", value: "Write a unit test" }, { label: "Optimize", value: "Optimize this logic" }, ], }); ``` ### Example 7: Expose a FastAPI Endpoint Backend plugins can expose their own HTTP endpoints by registering a `fastapi.APIRouter`. The router is mounted under `/api` + your prefix and is served by the same FastAPI app as QwenPaw's core API, so it shares CORS settings, the auth layer, and is included in `/openapi.json` / `/docs`. In this example we add a small `/api/pets` endpoint that returns a list of pets and lets the user add new ones. #### 1. Create plugin directory ```bash mkdir pet-api-plugin && cd pet-api-plugin ``` #### 2. Create plugin.json ```json { "id": "pet-api-plugin", "name": "Pet API Plugin", "version": "1.0.0", "type": "general", "description": "Expose a small REST API under /api/pets", "author": "Your Name", "entry": { "backend": "plugin.py" }, "dependencies": [], "qwenpaw_version": { "min": "1.1.5", "max": "2.1.0" } } ``` #### 3. Create plugin.py ```python # -*- coding: utf-8 -*- """Pet API Plugin Entry Point.""" import logging from typing import List from fastapi import APIRouter, HTTPException from pydantic import BaseModel from qwenpaw.plugins.api import PluginApi logger = logging.getLogger(__name__) class Pet(BaseModel): """Pet model.""" id: int name: str species: str class PetCreate(BaseModel): """Pet creation payload.""" name: str species: str _PETS: List[Pet] = [ Pet(id=1, name="Mochi", species="cat"), Pet(id=2, name="Bao", species="dog"), ] def build_router() -> APIRouter: """Build the plugin's APIRouter. Routes are mounted under ``/api`` + the prefix passed to ``register_http_router``. With ``prefix="/pets"`` the handlers below are served at ``/api/pets`` and ``/api/pets/{pet_id}``. """ router = APIRouter() @router.get("", response_model=List[Pet]) def list_pets() -> List[Pet]: """Return all pets.""" return list(_PETS) @router.get("/{pet_id}", response_model=Pet) def get_pet(pet_id: int) -> Pet: """Return a single pet by id.""" for pet in _PETS: if pet.id == pet_id: return pet raise HTTPException(status_code=404, detail="Pet not found") @router.post("", response_model=Pet, status_code=201) def create_pet(payload: PetCreate) -> Pet: """Create a new pet.""" new_id = (max((p.id for p in _PETS), default=0)) + 1 pet = Pet(id=new_id, name=payload.name, species=payload.species) _PETS.append(pet) return pet return router class PetApiPlugin: """Pet API Plugin.""" def register(self, api: PluginApi): """Register the HTTP router. Args: api: PluginApi instance """ logger.info("Registering Pet API plugin...") api.register_http_router( build_router(), prefix="/pets", tags=["pets"], ) logger.info("✓ Pet API registered at /api/pets") # Export plugin instance plugin = PetApiPlugin() ``` #### 4. Install and try it out ```bash qwenpaw plugin install pet-api-plugin ``` Once QwenPaw is running: ```bash # List pets curl http://127.0.0.1:8088/api/pets # Get one pet curl http://127.0.0.1:8088/api/pets/1 # Create a pet curl -X POST http://127.0.0.1:8088/api/pets \ -H "Content-Type: application/json" \ -d '{"name": "Luna", "species": "rabbit"}' ``` **Notes:** - `prefix` must start with `/` and must not be just `/` — use a descriptive segment such as `/pets`. The full URL is always `/api` + your prefix. - Each prefix can only be claimed by one plugin. Registering the same prefix twice raises `ValueError`. - `tags` is optional; when omitted, routes are tagged `plugin:` automatically for OpenAPI grouping. - Routes are unmounted automatically when the plugin is uninstalled or disabled. ### Example 8: Tracing Middleware (Tool Call Tracing) This example demonstrates how to register an `on_acting` middleware that logs every tool call with timing information when the `QWENPAW_TRACE` environment variable is set. **plugin.json:** ```json { "id": "middleware-demo-tracing", "name": "Tracing Middleware Demo", "version": "1.0.0", "description": "Demo: logs tool calls with execution timing to a trace file", "author": "QwenPaw Team", "type": "general", "entry": { "backend": "tracing_plugin.py" }, "dependencies": [], "qwenpaw_version": { "min": "1.0.0", "max": "2.1.0" } } ``` **tracing_plugin.py:** ```python import os import time from pathlib import Path from typing import Any, AsyncGenerator, Callable from agentscope.middleware import MiddlewareBase from qwenpaw.plugins.api import PluginApi class TracingMiddleware(MiddlewareBase): """Logs tool call name, input, and execution duration.""" def __init__(self, trace_file: Path) -> None: self._trace_file = trace_file self._trace_file.parent.mkdir(parents=True, exist_ok=True) async def on_acting( self, agent: Any, input_kwargs: dict[str, Any], next_handler: Callable[..., AsyncGenerator[Any, None]], ) -> AsyncGenerator[Any, None]: tool_call = input_kwargs["tool_call"] tool_name = getattr(tool_call, "name", str(tool_call)) tool_input = getattr(tool_call, "input", "") start = time.perf_counter() try: async for item in next_handler(): yield item finally: elapsed_ms = (time.perf_counter() - start) * 1000 line = f"[{time.strftime('%H:%M:%S')}] {tool_name}({tool_input[:100]}) — {elapsed_ms:.1f}ms\n" with open(self._trace_file, "a", encoding="utf-8") as f: f.write(line) def _tracing_factory(ctx: Any, agent_config: Any) -> TracingMiddleware | None: """Create TracingMiddleware when QWENPAW_TRACE env var is set.""" if not os.environ.get("QWENPAW_TRACE"): return None workspace_dir = getattr(ctx, "workspace_dir", None) if workspace_dir is None: return None trace_file = Path(workspace_dir) / ".qwenpaw" / "trace.log" return TracingMiddleware(trace_file=trace_file) class TracingPlugin: def register(self, api: PluginApi) -> None: api.register_middleware(_tracing_factory, priority=50) plugin = TracingPlugin() ``` **Key points:** - **Conditional activation**: The factory checks the `QWENPAW_TRACE` environment variable and only activates when set - **`priority=50`**: Higher priority (lower number = outermost in onion), ensuring tracing wraps other middlewares - **`on_acting` hook**: Measures execution time before/after tool calls - Full source: `plugins/middleware-demo/tracing-middleware/tracing_plugin.py` --- ### Example 9: Thinking Log Middleware (Reasoning Process Logger) This example demonstrates how to register an `on_reasoning` middleware that captures and prints the model's chain-of-thought. **plugin.json:** ```json { "id": "middleware-demo-thinking-log", "name": "Thinking Log Middleware Demo", "version": "1.0.0", "description": "Demo: prints model reasoning steps to stdout", "author": "QwenPaw Team", "type": "general", "entry": { "backend": "thinking_log_plugin.py" }, "dependencies": [], "qwenpaw_version": { "min": "1.0.0", "max": "2.1.0" } } ``` **thinking_log_plugin.py:** ```python import sys from typing import Any, AsyncGenerator, Callable from agentscope.middleware import MiddlewareBase from agentscope.event import ThinkingBlockDeltaEvent, TextBlockDeltaEvent from qwenpaw.plugins.api import PluginApi class ThinkingLogMiddleware(MiddlewareBase): """Prints reasoning stream events to stdout.""" async def on_reasoning( self, agent: Any, input_kwargs: dict[str, Any], next_handler: Callable[..., AsyncGenerator[Any, None]], ) -> AsyncGenerator[Any, None]: async for item in next_handler(): if isinstance(item, ThinkingBlockDeltaEvent): print(f"[THINKING] {item.delta}", end="", file=sys.stdout, flush=True) elif isinstance(item, TextBlockDeltaEvent): print(f"[TEXT] {item.delta}", end="", file=sys.stdout, flush=True) yield item def _thinking_log_factory(ctx: Any, agent_config: Any) -> ThinkingLogMiddleware: """Always create the middleware (unconditional activation).""" return ThinkingLogMiddleware() class ThinkingLogPlugin: def register(self, api: PluginApi) -> None: api.register_middleware(_thinking_log_factory, priority=80) plugin = ThinkingLogPlugin() ``` **Key points:** - **Unconditional activation**: The factory always returns an instance, applied to every request - **`on_reasoning` hook**: Captures streaming events during the model's reasoning phase (`ThinkingBlockDeltaEvent` for chain-of-thought, `TextBlockDeltaEvent` for text responses) - **Real-time printing**: Each delta event is printed immediately while being yielded downstream — does not block streaming - Full source: `plugins/middleware-demo/thinking-log-middleware/thinking_log_plugin.py` --- ### Example 10: Register a Custom Channel Channel plugins let you add new messaging platforms to QwenPaw. The channel appears in the Console UI alongside built-in channels (DingTalk, Telegram, etc.) and can be configured, enabled, and disabled the same way. #### 1. Create Plugin Directory ```bash mkdir sample-channel-plugin && cd sample-channel-plugin ``` #### 2. Create plugin.json ```json { "id": "sample-channel", "name": "Sample Channel", "version": "1.0.0", "type": "channel", "description": "Sample messaging channel integration for QwenPaw", "author": "Your Name", "entry": { "backend": "plugin.py" }, "dependencies": ["sample-sdk>=1.0.0"], "qwenpaw_version": { "min": "1.1.5", "max": "2.1.0" } } ``` #### 3. Create channel.py — BaseChannel subclass Your channel class must implement the `BaseChannel` contract. The key methods are: - **`from_config(cls, process, config, ...)`** — classmethod that creates an instance from saved configuration. This is how `ChannelManager` instantiates your channel at startup. - **`start()` / `stop()`** — lifecycle hooks called when the channel is enabled/disabled. - **`send(to_handle, text, meta)`** — send a message to a user/session. ```python # -*- coding: utf-8 -*- """Sample channel implementation.""" import logging from pathlib import Path from typing import Optional from qwenpaw.app.channels.base import ( BaseChannel, OnReplySent, ProcessHandler, ) from qwenpaw.app.channels.renderer import ChannelDisplayConfig logger = logging.getLogger(__name__) class SampleChannel(BaseChannel): """Sample messaging channel.""" channel = "sample" # unique key, must match config key def __init__( self, process: ProcessHandler, enabled: bool = True, bot_token: str = "", signing_secret: str = "", bot_prefix: str = "", on_reply_sent: OnReplySent = None, display_config: ChannelDisplayConfig | None = None, **kwargs, ): super().__init__( process, on_reply_sent=on_reply_sent, display_config=display_config, ) self.enabled = enabled self.bot_prefix = bot_prefix self.bot_token = bot_token self.signing_secret = signing_secret @classmethod def from_config( cls, process: ProcessHandler, config, on_reply_sent: OnReplySent = None, display_config: ChannelDisplayConfig | None = None, workspace_dir: Optional[Path] = None, ) -> "SampleChannel": """Create from config. Note: for plugin channels, ``config`` is a ``types.SimpleNamespace`` object (not a dict). Use ``getattr(config, "field", default)`` to read fields safely. """ return cls( process=process, enabled=getattr(config, "enabled", False), bot_token=getattr(config, "bot_token", ""), signing_secret=getattr(config, "signing_secret", ""), bot_prefix=getattr(config, "bot_prefix", ""), on_reply_sent=on_reply_sent, display_config=display_config or ChannelDisplayConfig.from_config(config), ) async def start(self): """Start the sample event listener.""" logger.info("Sample channel starting (token=%s...)", self.bot_token[:8]) # Start your platform's API client here async def stop(self): """Stop the sample event listener.""" logger.info("Sample channel stopping") async def send(self, to_handle: str, text: str, meta=None): """Send a message to a sample user or channel.""" logger.info("Sending to sample %s: %s", to_handle, text[:50]) # Use sample-sdk to post messages ``` > **Important: `config` parameter type** — For plugin channels, the > `config` passed to `from_config()` is a `types.SimpleNamespace` object > (not a dict or Pydantic model). The framework merges > `BaseChannelConfig` defaults with the user's saved config before > passing it. Always use `getattr(config, "field", default)` to read > fields safely. #### 4. Create plugin.py — Plugin entry point ```python # -*- coding: utf-8 -*- """Sample Channel Plugin Entry Point.""" import logging from qwenpaw.plugins.api import PluginApi logger = logging.getLogger(__name__) class SampleChannelPlugin: """Sample Channel Plugin.""" def register(self, api: PluginApi): """Register the sample channel.""" from .channel import SampleChannel api.register_channel( channel_class=SampleChannel, label="Sample", description="Sample messaging channel integration", icon="https://example.com/sample-icon.png", # optional card icon (http/https only) doc_url={ # optional doc link, plain string or localized dict (http/https only) "zh": "https://example.com/docs?lang=zh", "en": "https://example.com/docs?lang=en", }, config_fields=[ { "name": "bot_token", "label": "Bot Token", "type": "password", "required": True, "placeholder": "your-bot-token-here", "help": "Bot access token", }, { "name": "signing_secret", "label": "Signing Secret", "type": "password", "required": True, "help": "Signing secret for request verification", }, { "name": "streaming_enabled", "label": { "zh-CN": "流式输出", "en-US": "Streaming Output", }, "type": "switch", "required": False, "default": False, }, ], ) logger.info("✓ Sample channel registered") plugin = SampleChannelPlugin() ``` #### 5. Install and Use ```bash qwenpaw plugin install sample-channel-plugin qwenpaw app ``` After starting, go to **Control → Channels** in the Console. The sample channel card will appear alongside built-in channels. Click it to fill in credentials and enable it. #### 6. Adding Webhook Endpoints (Optional) If your channel needs to receive HTTP callbacks (e.g. your platform's events API), register a FastAPI router in the same plugin: ```python from fastapi import APIRouter def register(self, api: PluginApi): from .channel import SampleChannel api.register_channel(channel_class=SampleChannel, ...) # Mount webhook endpoint at /api/sample/events router = APIRouter() @router.post("/events") async def sample_events(request): body = await request.json() # Handle event verification and messages return {"ok": True} api.register_http_router(router, prefix="/sample", tags=["sample"]) ``` **Key points:** - The `channel_class` must be a `BaseChannel` subclass with a `channel` class attribute (the unique key). - **You must implement `from_config`** — this is how `ChannelManager` creates your channel at startup. The `config` parameter is a `SimpleNamespace`, not a dict. - `config_fields` defines the form fields shown in the Console settings drawer. Supported types: `text`, `password`, `number`, `switch`, `select`. - The `label`, `help`, and `placeholder` of each field accept either a plain string or a localized dict. Dict keys support **both long codes (e.g. `zh-CN`, `en-US`) and short codes (e.g. `zh`, `en`), which can be mixed freely**. The value is resolved with fallback in order (exact locale → short code → short-code prefix match → English → Chinese → first non-empty value) so a missing locale never renders blank. - `icon` (optional) is a custom icon URL for the channel card. Only `http`/`https` URLs are supported; other values are ignored and fall back to the default icon. - `doc_url` (optional) is a documentation link for the channel. It can be a plain string or a localized dict (e.g. `{"zh": "...", "en": "..."}`, same long/short code rules as `label`). Only `http`/`https` URLs are supported; the Console shows a "Doc" button in the settings drawer header that opens the link for the current language, and hides it when the value is invalid or missing. - Plugin channels share the same enable/disable, access control, and `bot_prefix` features as built-in channels. - If a plugin channel key conflicts with a built-in key, the built-in takes precedence and the plugin channel is skipped with a warning. - For webhook-based channels, combine `register_channel` with `register_http_router` in the same plugin. ## Dependency Management ### Using requirements.txt If your plugin requires additional Python packages, create `requirements.txt`: ``` httpx>=0.24.0 pydantic>=2.0.0 ``` Dependencies will be automatically installed when the plugin is installed. ### Using Custom PyPI Index ``` --index-url https://custom-pypi.example.com/simple my-package>=1.0.0 ``` ## Best Practices ### 1. Naming Conventions - **Plugin ID**: Use lowercase letters and hyphens, e.g., `my-plugin` - **Version**: Follow semantic versioning (1.0.0, 1.1.0, 2.0.0) ### 2. Error Handling Hook callbacks should handle errors gracefully to avoid blocking application startup: ```python def startup_hook(): try: # Your initialization code pass except Exception as e: logger.error(f"Initialization failed: {e}", exc_info=True) # Don't raise, let the application continue ``` ### 3. Logging Use Python logging to record plugin behavior: ```python import logging logger = logging.getLogger(__name__) logger.info("Plugin loaded") logger.debug("Debug information") logger.error("Error occurred", exc_info=True) ``` ### 4. Documentation Provide clear README.md documentation including: - Feature description - Installation steps - Usage examples - Configuration instructions - Troubleshooting ## Priority System ### Hook Priority Hooks are executed in priority order: - **Lower priority values execute earlier** - Priority 0 = Highest priority (executes first) - Priority 100 = Default priority - Priority 200 = Low priority (executes last) **Example**: ```python # Executes first api.register_startup_hook("early", callback, priority=0) # Default order api.register_startup_hook("normal", callback, priority=100) # Executes last api.register_startup_hook("late", callback, priority=200) ``` ## Troubleshooting ### Plugin Not Loading 1. Check if plugin is installed: ```bash qwenpaw plugin list ``` 2. View QwenPaw logs: ```bash tail -f ~/.qwenpaw/logs/qwenpaw.log | grep -i plugin ``` 3. Verify plugin manifest format: ```bash qwenpaw plugin info ``` ### Dependency Installation Failed 1. Check `requirements.txt` format 2. Manually test dependency installation: ```bash pip install -r /path/to/plugin/requirements.txt ``` 3. Reinstall plugin with `--force` flag ### Provider Not Showing 1. Confirm plugin is installed and restart QwenPaw 2. Check the model management page in Web UI 3. Review provider registration info in logs ### Command Not Responding 1. Confirm plugin is installed 2. Check if the command handler was registered successfully in logs 3. Verify the command name matches (e.g. `/status`) ## Security Considerations 1. **Only install trusted plugins**: Plugin code executes in the QwenPaw process 2. **Check dependencies**: Ensure plugin dependencies come from trusted sources 3. **Review code**: Review plugin source code before installation 4. **Hot-loading awareness**: The current version supports hot-installing/uninstalling plugins via API while the app is running. Be mindful of state consistency during hot-loading ## PluginApi Reference ### register_provider Register a custom LLM provider. ```python api.register_provider( provider_id: str, # Unique provider identifier (required) provider_class: Type, # Provider class (required) label: str = "", # Display name (optional, defaults to provider_id) base_url: str = "", # API base URL (optional) **metadata, # Additional keyword args (chat_model, require_api_key, etc.) ) ``` ### register_startup_hook Register a startup hook. ```python api.register_startup_hook( hook_name: str, # Hook name callback: Callable, # Callback function priority: int = 100, # Priority (lower = earlier) ) ``` ### register_shutdown_hook Register a shutdown hook. ```python api.register_shutdown_hook( hook_name: str, # Hook name callback: Callable, # Callback function priority: int = 100, # Priority (lower = earlier) ) ``` ### register_http_router Mount a `fastapi.APIRouter` under `/api` + _prefix_. ```python api.register_http_router( router: APIRouter, # fastapi.APIRouter instance *, prefix: str, # Path under /api, e.g. "/pets" tags: Optional[List[str]] = None, # OpenAPI tags (optional) ) ``` See [Example 7](#example-7-expose-a-fastapi-endpoint) for a full walkthrough. ### register_control_command Register a custom `/slash` control command. ```python api.register_control_command( handler: BaseControlCommandHandler, # Command handler instance priority_level: int = 10, # Command priority (default: 10) ) ``` The handler must inherit from `qwenpaw.runtime.commands.control.base.BaseControlCommandHandler` and implement `command_name`, `help_text`, and `async handle(self, ctx, args)`. ### register_tool Register a tool function into the Agent's toolkit. ```python api.register_tool( tool_name: str, # Unique tool function name tool_func: Callable, # The tool callable to register description: str = "", # Human-readable description shown in the UI icon: str = "🔧", # Display icon (emoji string) enabled: bool = False, # Whether the tool is enabled by default ) ``` ### register_uninstall_hook Register a hook that runs only when the plugin is explicitly uninstalled. ```python api.register_uninstall_hook( hook_name: str, # Hook name callback: Callable, # Callback function priority: int = 100, # Priority (lower = earlier) ) ``` ### register_workspace_created_hook Register a hook that fires when a new workspace is created. ```python api.register_workspace_created_hook( hook_name: str, # Hook name callback: Callable, # Callback: (workspace_info: dict) -> None priority: int = 100, # Priority (lower = earlier) ) ``` ### get_tool_config / set_tool_config Get or save per-agent tool configuration. ```python config = api.get_tool_config(tool_name: str, agent_id: str) # Returns dict api.set_tool_config(tool_name: str, agent_id: str, config: dict) ``` ### register_middleware Register an AgentScope `MiddlewareBase` factory. ```python api.register_middleware( middleware_factory: Callable, # Factory function *, priority: int = 100, # Priority (lower = outermost) ) ``` Factory signature: `(ctx: HookContext, agent_config: AgentProfileConfig) -> MiddlewareBase | None` - `ctx` contains request-level context such as `session_id`, `agent_id`, `workspace_dir` - Returning `None` means this middleware is skipped for the current request - Lower `priority` values place the middleware further out in the onion model (executed first) The factory is called during `AgentBuilder.build()` for each request. The returned middleware instance is inserted into the agent's middleware chain. See [Example 8](#example-8-tracing-middleware) and [Example 9](#example-9-thinking-log-middleware) above for full walkthroughs. ## Advanced Features ### Modifying Agent Behavior To intercept or enhance agent request processing, use one of these approaches: - **Enhance the agent reasoning loop**: use `register_middleware` to inject AgentScope middlewares (`on_acting` / `on_reasoning` hooks) - **Intercept specific commands**: use `register_control_command` to register a custom command handler - **Inject logic into the request lifecycle**: use `HookRegistry` (8-phase hooks) The current request flow is `Runtime.run()` → `AgentBuilder.build()` → `AgentExecutor.run()`. ### Custom Commands In 2.0, the recommended way to add custom `/slash` commands is via `api.register_control_command()`. This replaces the old monkey patching approach: ```python from qwenpaw.runtime.commands.control.base import BaseControlCommandHandler class MyCommandHandler(BaseControlCommandHandler): command_name = "mycommand" help_text = "Description of my command" async def handle(self, ctx, args: str): from agentscope.message import Msg return Msg( name="system", role="assistant", content="Command result here.", ) api.register_control_command( handler=MyCommandHandler(), priority_level=10, ) ``` ### Access Runtime Information Access runtime information through `api.runtime`: ```python def my_hook(): # Access provider manager provider_manager = api.runtime.provider_manager # Get all providers providers = provider_manager.list_provider_info() ``` ## Plugin Packaging Package your plugin as a ZIP file for distribution: ```bash cd /path/to/plugins zip -r my-plugin-1.0.0.zip my-plugin/ ``` Users can install via URL: ```bash qwenpaw plugin install https://example.com/my-plugin-1.0.0.zip ``` ## FAQ ### Q: What QwenPaw APIs can plugins access? A: Plugins access core functionality through `PluginApi`, including: - Provider registration - Middleware registration (`register_middleware`) - Hook registration - Custom command registration (`register_control_command`) - HTTP router registration (`register_http_router`) - Runtime helpers (provider_manager, etc.) ### Q: Can plugins modify QwenPaw's core behavior? A: Yes, through `register_middleware` (inject AgentScope middlewares), `register_control_command`, `register_tool`, runtime hooks, and other PluginApi methods. Use with caution to avoid breaking core functionality. ### Q: Will plugins conflict with each other? A: If multiple plugins register the same provider_id or command_name, the later one will override the earlier one. Use unique IDs. ## Example Plugins ### GPT Image 2 Tool Plugin A tool plugin that adds OpenAI's GPT Image 2 image generation capability to QwenPaw agents. **Requirements:** - Minimum QwenPaw version: `1.1.5` **Installation:** ```bash # Clone the QwenPaw repository (if not already cloned) git clone https://github.com/agentscope-ai/QwenPaw.git cd QwenPaw # Install the plugin qwenpaw plugin install plugins/tool/gpt-image2 ``` **Configuration:** 1. After installation, restart QwenPaw 2. Go to Agent Settings → Tools 3. Find "generate_image_gpt" tool 4. Click "Configure" and enter your OpenAI API Key 5. Enable the tool **Usage:** Once configured, agents can generate images by calling the tool: ``` User: Please generate an image of a cute cat playing in a garden Agent: [Calls generate_image_gpt tool] [Returns generated image] ``` **Features:** - Supports multiple image sizes: 1024x1024, 1024x1792, 1792x1024 - Quality options: low, medium, high, auto - Automatic API key validation - Per-agent configuration (each agent can have its own API key) For more details, see `plugins/tool/gpt-image2/README.md`.