Files
Pine e2244a3cae feat: PineAgentsWeb 官网前端应用(Vite+React SPA)
- src/ 页面与组件、public/ 静态资源
- 工程配置:vite/tsconfig/package 依赖
2026-08-23 22:31:31 +08:00

2097 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <plugin-id>
```
### Uninstall Plugin
```bash
qwenpaw plugin uninstall <plugin-id>
```
## 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, <max`. When `max` is omitted, defaults to `{major}.{minor+1}.0`. |
| `min_version` | `string` | no | **Legacy.** Minimum QwenPaw version required. Ignored when `qwenpaw_version` is present. Retained only for backward compatibility with third-party plugins. |
| `max_version` | `string` | no | **Legacy.** First incompatible QwenPaw version (exclusive). Used with `min_version`; when omitted, derived from `min_version`. |
| `meta` | `object` | no | Free-form plugin metadata. Used by the UI and by `type` inference (e.g. `meta.tools[]`, `meta.hook_type`, `meta.provider_id`). |
| `entry_point` | `string` | no | **Legacy.** Equivalent to `entry.backend`. Still accepted for backwards compatibility with older plugins; new plugins should use `entry.backend`. |
\* At least one of `entry.backend` / `entry.frontend` (or legacy `entry_point`) must be provided.
#### `type` values
| Value | When to use |
| ---------- | ---------------------------------------------------------------------- |
| `tool` | Registers one or more agent tools (functions the LLM can call). |
| `provider` | Registers a custom LLM provider / model endpoint. |
| `hook` | Runs code during application startup or shutdown (app lifespan level). |
| `command` | Registers one or more `/slash` control commands. |
| `channel` | Registers a custom messaging channel. |
| `frontend` | Ships a frontend JS bundle loaded dynamically by the UI. |
| `general` | Fallback for plugins that combine multiple capabilities or don't fit. |
#### plugin.py
```python
# -*- coding: utf-8 -*-
"""My Plugin Entry Point."""
from qwenpaw.plugins.api import PluginApi
import logging
logger = logging.getLogger(__name__)
class MyPlugin:
"""My Plugin."""
def register(self, api: PluginApi):
"""Register plugin capabilities.
Args:
api: PluginApi instance
"""
logger.info("Registering my plugin...")
# Register your capabilities
# api.register_provider(...)
# api.register_startup_hook(...)
# api.register_shutdown_hook(...)
logger.info("✓ My plugin registered")
# Export plugin instance
plugin = MyPlugin()
```
### Frontend Plugins
Frontend plugins are JavaScript extensions that run in the browser. Unlike backend plugins that register capabilities via the Python `PluginApi`, frontend plugins declaratively extend the Console UI through the global `window.QwenPaw.*` API.
**Loading lifecycle:**
1. Console starts up and mounts the Host SDK (React, antd, and other shared dependencies) and registration APIs (menu, route, slot, chat, and other namespaces) on `window.QwenPaw`
2. Console fetches the enabled frontend plugin list from `/frontend_plugin`
3. Downloads each plugin's JS bundle and executes it via Blob URL dynamic import
4. Plugin code runs and calls `window.QwenPaw.*` to register menus, routes, chat customizations, and other UI extensions
5. Registrations take effect immediately — menus appear in the sidebar, routes become navigable, chat areas show customized content
Plugins don't need to declare which extension points they use; the system automatically tracks all registrations via `pluginId`. When a plugin is uninstalled or disabled, all registrations are cleaned up via `dispose()` or `chat.disposeAll(pluginId)`.
**Design characteristics:**
| Feature | Description |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Shared runtime** | React, ReactDOM, and Ant Design are provided by the host — plugins don't bundle them, avoiding version conflicts and bloat |
| **Declarative registration** | Three core verbs: `set` (set / merge properties), `render` (replace rendering), `add` (append items) |
| **pluginId isolation** | Every registration method takes `pluginId` as the first argument — the system uses it to track origins, detect conflicts, and support per-plugin cleanup |
| **Revocable** | Every registration returns a `{ dispose() }` object — call it to undo the registration, enabling hot-reload and clean uninstall |
| **Internationalization** | Text fields support the `Localized<T>` 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 () => (
<div>
<div style={{ background: "#fff3cd", padding: 8, textAlign: "center" }}>
Beta Feature
</div>
<Inner />
</div>
);
});
```
### 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 <img src="https://example.com/logo.svg" style={{ height: 24 }} />;
});
```
### 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 <div>Custom Welcome</div>;
});
```
### 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: <img src="logo.svg" style={{ height: 20 }} />,
});
// Add a button to the right header
window.QwenPaw.chat.rightHeader.add(
"my-plugin",
<button
onClick={() => alert("Plugin action!")}
style={{ border: "none", background: "none", cursor: "pointer" }}
>
My Button
</button>,
{ 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: <span></span>,
onClick: ({ data }) => console.log("Starred:", data),
});
// Add action button below user messages
window.QwenPaw.chat.requestActions.add("my-plugin", {
id: "my-plugin.edit",
icon: <span></span>,
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 <div style={{ fontSize: 10, color: "#999" }}>User</div>;
});
// Append an info bar below the latest AI response
window.QwenPaw.chat.response.append("my-plugin", ({ data, isLast }) => {
if (!isLast) return null;
return (
<div
style={{
background: "#e3f2fd",
padding: "4px 8px",
borderRadius: 4,
fontSize: 12,
}}
>
Powered by My Plugin
</div>
);
});
// Fully replace user message rendering (call fallback() to keep defaults)
window.QwenPaw.chat.request.render("my-plugin", ({ data, fallback }) => {
return (
<div style={{ border: "1px dashed #ccc", borderRadius: 8, padding: 4 }}>
{fallback()}
</div>
);
});
```
### 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 (
<div style={{ padding: 12, border: "1px solid #e8e8e8", borderRadius: 8 }}>
{data.city}: {data.temperature}°C
</div>
);
});
```
### 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<T>` 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 (
<Card
style={{
maxWidth: 480,
margin: "40px auto",
background: theme === "dark" ? "#1f1f1f" : "#fff",
}}
>
<Typography.Title level={2}>Welcome to QwenPaw</Typography.Title>
<Typography.Paragraph>Plugin system is working!</Typography.Paragraph>
</Card>
);
};
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 (
<Card
title="Weather Info"
size="small"
style={{ marginTop: 8, maxWidth: 400 }}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="City">{data.city}</Descriptions.Item>
<Descriptions.Item label="Temperature">
{data.temperature}°C
</Descriptions.Item>
<Descriptions.Item label="Weather">{data.weather}</Descriptions.Item>
</Descriptions>
</Card>
);
});
```
### 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:<plugin_id>` 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 <plugin-id>
```
### 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`.