# 插件系统

QwenPaw 提供了插件系统，允许用户扩展 QwenPaw 的功能。

## 概述

插件系统支持以下扩展能力：

- **Provider 插件**：添加新的 LLM Provider 和模型
- **Middleware 插件**：注册 AgentScope `MiddlewareBase` 工厂，在 agent 推理循环中包裹 `on_acting` / `on_reasoning` 等钩子
- **Hook 插件**：在应用启动/关闭时执行自定义代码（app 生命周期级别，仅执行一次）
- **Command 插件**：注册自定义的 `/command` 魔法命令
- **HTTP API 插件**：通过 FastAPI `APIRouter` 在 `/api` 下暴露自定义 REST 接口
- **前端扩展插件**：在浏览器中运行的 JS 插件，共享宿主的 React / Ant Design 运行时，通过声明式 `window.QwenPaw.*` API 扩展界面——注册侧边栏菜单、页面路由、UI 插槽、聊天定制等，无需修改宿主代码
- **Channel 插件**：注册自定义消息频道（如 Slack、LINE）

## 插件管理

### 安装插件

从本地目录安装：

```bash
qwenpaw plugin install /path/to/plugin
```

从 URL 安装（支持 ZIP 文件）：

```bash
qwenpaw plugin install https://example.com/plugin.zip
```

强制重新安装：

```bash
qwenpaw plugin install /path/to/plugin --force
```

**注意**：插件操作只能在 QwenPaw 离线时执行。

### 列出已安装插件

```bash
qwenpaw plugin list
```

输出示例：

```
Installed Plugins:
==================

my-provider (v1.0.0)
  Custom LLM provider integration
  Author: Developer Name
  Path: /Users/user/.qwenpaw/plugins/my-provider
```

### 查看插件详情

```bash
qwenpaw plugin info <plugin-id>
```

### 卸载插件

```bash
qwenpaw plugin uninstall <plugin-id>
```

## 插件开发

### 后端插件

#### 基本结构

每个插件至少需要两个文件：

```
my-plugin/
├── plugin.json      # 插件清单（必需）
├── plugin.py        # 入口点（后端必需）
└── README.md        # 文档（推荐）
```

#### 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": {}
}
```

#### 清单字段说明

| 字段              | 类型            | 必填 | 说明                                                                                                                                              |
| ----------------- | --------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`              | `string`        | 是   | 插件唯一标识，同时作为安装目录名，不能包含路径分隔符。                                                                                            |
| `version`         | `string`        | 是   | 插件语义化版本号（例如 `1.0.0`）。                                                                                                                |
| `name`            | `string` 或对象 | 否   | 显示名称，缺省取 `id`。也可写成 `{"zh-CN": "...", "en-US": "..."}`，运行时按"英文优先"的顺序取第一个非空值。                                      |
| `type`            | `string`        | 否   | 取值之一：`tool`、`provider`、`hook`、`command`、`frontend`、`general`。省略时会按 `meta` / `entry` 推断（仅为兼容旧插件），新插件建议显式声明。  |
| `description`     | `string` 或对象 | 否   | 插件列表里的简短描述，支持本地化对象形式（同 `name`）。                                                                                           |
| `author`          | `string`        | 否   | 作者或组织名称。                                                                                                                                  |
| `entry.backend`   | `string`        | 否\* | 相对插件目录的 Python 入口文件路径，需在其中导出 `plugin`。                                                                                       |
| `entry.frontend`  | `string`        | 否\* | 已构建的前端 bundle 路径（如 `dist/index.js`）。                                                                                                  |
| `dependencies`    | `string[]`      | 否   | Python 依赖列表，安装时通过 pip / uv 自动安装。                                                                                                   |
| `qwenpaw_version` | `object`        | 否   | QwenPaw 版本约束（推荐）。包含 `min`（包含）和 `max`（不包含，可选）两个子字段，语义为 `>=min, <max`。省略 `max` 时默认取 `{major}.{minor+1}.0`。 |
| `min_version`     | `string`        | 否   | **遗留字段。** 需要的最低 QwenPaw 版本。当 `qwenpaw_version` 存在时被忽略，仅为兼容第三方旧插件保留。                                             |
| `max_version`     | `string`        | 否   | **遗留字段。** 不兼容的第一个 QwenPaw 版本（不包含）。配合 `min_version` 使用；省略时从 `min_version` 推导。                                      |
| `meta`            | `object`        | 否   | 自由元数据。前端 UI 与 `type` 推断都会读取（如 `meta.tools[]`、`meta.hook_type`、`meta.provider_id`）。                                           |
| `entry_point`     | `string`        | 否   | **遗留字段。** 等价于 `entry.backend`，仅为兼容老插件保留，新插件请使用 `entry.backend`。                                                         |

\* `entry.backend`、`entry.frontend`（或遗留 `entry_point`）至少需要提供其中之一。

#### `type` 取值

| 取值       | 适用场景                                             |
| ---------- | ---------------------------------------------------- |
| `tool`     | 注册一个或多个 Agent 工具（LLM 可调用的函数）。      |
| `provider` | 注册自定义 LLM 提供商 / 模型端点。                   |
| `hook`     | 在应用启动 / 关闭时执行代码（app 生命周期级别）。    |
| `command`  | 注册 `/slash` 控制命令。                             |
| `channel`  | 注册自定义消息频道。                                 |
| `frontend` | 提供前端 JS bundle，由 UI 动态加载。                 |
| `general`  | 兜底类型，用于组合型插件或不属于以上任何类别的插件。 |

#### 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...")

        # 注册你的功能
        # api.register_provider(...)
        # api.register_startup_hook(...)
        # api.register_shutdown_hook(...)

        logger.info("✓ My plugin registered")


# Export plugin instance
plugin = MyPlugin()
```

### 前端插件

前端插件是运行在浏览器端的 JavaScript 扩展。与后端插件通过 Python `PluginApi` 注册能力不同，前端插件通过全局 `window.QwenPaw.*` API 声明式地扩展 Console 界面。

**加载生命周期：**

1. Console 启动，在 `window.QwenPaw` 上挂载 Host SDK（React、antd 等共享依赖）和注册 API（menu、route、slot、chat 等命名空间）
2. Console 请求 `/frontend_plugin` 获取已启用的前端插件列表
3. 逐一下载各插件的 JS bundle，通过 Blob URL 动态导入执行
4. 插件代码执行，调用 `window.QwenPaw.*` 注册菜单、路由、聊天定制等 UI 扩展
5. 注册立即生效——菜单出现在侧边栏、路由可导航、聊天区域呈现定制内容

插件无需声明使用了哪些扩展点；系统通过 `pluginId` 自动追踪所有注册。卸载或禁用插件时，通过 `dispose()` 或 `chat.disposeAll(pluginId)` 清理全部注册。

**设计特点：**

| 特点              | 说明                                                                             |
| ----------------- | -------------------------------------------------------------------------------- |
| **共享运行时**    | React、ReactDOM、Ant Design 由宿主提供，插件无需打包，避免版本冲突和体积膨胀     |
| **声明式注册**    | 三个核心动词：`set`（设置 / 合并属性）、`render`（替换渲染）、`add`（追加项目）  |
| **pluginId 隔离** | 所有注册方法以 `pluginId` 为第一参数，系统据此追踪来源、检测冲突、支持按插件清理 |
| **可撤销**        | 每个注册返回 `{ dispose() }` 对象，调用即撤销，支持热重载和插件卸载              |
| **国际化**        | 文本字段支持 `Localized<T>` 类型——传入 `(locale) => string` 函数按语言返回不同值 |

**扩展点一览：**

| 命名空间                          | 能力                            | 典型用途                                        |
| --------------------------------- | ------------------------------- | ----------------------------------------------- |
| `host`                            | 共享依赖、React Hooks、认证请求 | 获取 React / antd、读取主题和语言、调用后端 API |
| `menu`                            | 侧边栏菜单项                    | 添加导航入口                                    |
| `route`                           | 页面路由                        | 注册新页面、包装已有页面                        |
| `slot`                            | 通用 UI 插槽                    | 向 Header / Sidebar 等预设位置注入内容          |
| `chat.welcome`                    | 欢迎界面                        | 自定义问候语、推荐提示词                        |
| `chat.theme`                      | 聊天主题色                      | 更换主色调                                      |
| `chat.leftHeader` / `rightHeader` | 聊天头部                        | 设置品牌 Logo、添加操作按钮                     |
| `chat.sender`                     | 输入框                          | 自定义 placeholder、输入建议                    |
| `chat.actions` / `requestActions` | 消息操作按钮                    | 在消息下方添加自定义操作                        |
| `chat.requestPayload`             | 外发聊天请求体                  | 请求发送到后端前追加或改写自定义字段            |
| `chat.request` / `response`       | 消息气泡                        | 在消息前后追加内容或完全替换渲染                |
| `chat.toolRender`                 | 工具调用渲染                    | 自定义工具结果展示（如天气卡片）                |
| `chat.card`                       | 自定义卡片                      | 注册新的卡片类型                                |
| `audit`                           | 审计与调试                      | 查看所有扩展注册记录                            |

#### 基本结构

```
my-plugin/
├── plugin.json      # 插件清单（必需）
├── src/
│   └── index.tsx    # 入口点，调用 window.QwenPaw.* API
├── package.json     # 依赖声明
├── tsconfig.json    # TypeScript 配置
└── vite.config.ts   # 构建配置
```

#### 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

插件入口文件在加载时执行，通过 `window.QwenPaw.*` API 注册扩展：

```tsx
const { React, antd } = window.QwenPaw.host;
const pluginId = "my-plugin";

// 调用 window.QwenPaw.* API 注册菜单、路由、聊天定制等
// 详见下方「前端扩展 API」
```

#### 构建工具链

**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"` 将 JSX 编译为 `React.createElement`，使用宿主提供的 `React`；`external` 避免打包 React，使用应用已加载的版本。

#### 构建和安装

```bash
npm install && npm run build
cp -r . ~/.qwenpaw/plugins/my-plugin/
qwenpaw app
```

可将 `console/src/plugins/types/qwenpaw.d.ts` 复制到插件项目中作为 `qwenpaw-host.d.ts`，获得完整的类型提示。

## 前端扩展 API

前端插件通过 `window.QwenPaw.*` API 扩展 Console 界面，无需修改宿主代码。所有注册方法第一个参数是 `pluginId`，每个注册返回 `{ dispose() }` 对象用于撤销。

### Host SDK — `window.QwenPaw.host`

宿主共享依赖，插件无需打包这些库：

```ts
host.React                        // React 库
host.ReactDOM                     // ReactDOM 库
host.antd                         // Ant Design 组件库
host.antdIcons                    // Ant Design 图标库
host.apiBaseUrl                   // API 基础 URL
host.getApiUrl(path: string)      // 拼接完整 API URL
host.getApiToken(): string | null // 获取当前认证 Token
```

**React Hooks（在 React 组件内使用）：**

```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
```

**命令式获取（可在任意位置调用）：**

```ts
const agentId = window.QwenPaw.host.getSelectedAgentId();
const sessionId = window.QwenPaw.host.getCurrentSessionId();
```

**认证代理请求（自动注入 Authorization 和 X-Agent-Id 请求头）：**

```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();
```

### 侧边栏菜单 — `window.QwenPaw.menu`

| 方法       | 签名                                     | 说明             |
| ---------- | ---------------------------------------- | ---------------- |
| `add`      | `(pluginId, item \| item[]): Disposable` | 添加菜单项       |
| `replace`  | `(pluginId, targetId, item): Disposable` | 替换已有菜单项   |
| `remove`   | `(targetId): void`                       | 移除菜单项       |
| `snapshot` | `(location?): MenuItem[]`                | 获取当前菜单快照 |

**MenuItem 参数：**

```ts
{
  id: string;                    // 全局唯一，如 "my-plugin.foo"
  label: string | (() => ReactNode);
  icon?: ReactComponent | ReactNode;
  route?: string;                // 点击时导航到的路由 id
  parentId?: string;             // 挂在哪个分组下
  location?: "primary.agentScoped" | "primary.settings" | "userMenu";
  before?: string;               // 排在某个 id 之前
  after?: string;                // 排在某个 id 之后
  order?: number;                // 数值越小越靠前
  visible?: () => boolean;       // 动态控制显隐
  isGroup?: boolean;             // 作为分组标题
  divider?: boolean;             // 渲染为水平分割线
}
```

### 页面路由 — `window.QwenPaw.route`

| 方法      | 签名                                          | 说明                     |
| --------- | --------------------------------------------- | ------------------------ |
| `add`     | `(pluginId, route \| route[]): Disposable`    | 注册新路由               |
| `replace` | `(pluginId, targetId, component): Disposable` | 替换已有路由的组件       |
| `wrap`    | `(pluginId, targetId, wrapper): Disposable`   | 包装已有路由（洋葱模式） |
| `remove`  | `(targetId): void`                            | 移除路由                 |

**Route 参数：**

```ts
{
  id: string; // 全局唯一，如 "my-plugin.home"
  path: string; // URL 路径，支持 react-router 模式
  component: React.ComponentType; // 页面组件
}
```

**wrap 示例（为已有页面加顶部 banner）：**

```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>
  );
});
```

### 通用 UI 插槽 — `window.QwenPaw.slot`

| 方法       | 签名                                          | 说明                                          |
| ---------- | --------------------------------------------- | --------------------------------------------- |
| `fill`     | `(pluginId, name, render, opts?): Disposable` | 向插槽追加内容（可多个共存）                  |
| `replace`  | `(pluginId, name, render, opts?): Disposable` | 替换插槽内容（最后注册的生效，屏蔽所有 fill） |
| `snapshot` | `(): SlotInfo[]`                              | 获取所有已注册的插槽信息                      |

**内置插槽：**

| 插槽名              | 类型    | UI 位置                        |
| ------------------- | ------- | ------------------------------ |
| `header.logo`       | replace | 顶部导航栏最左侧               |
| `header.left`       | fill    | 顶部导航栏左区（Logo 右边）    |
| `header.right`      | fill    | 顶部导航栏右区（设置按钮左边） |
| `sider.top`         | fill    | 侧边栏顶部（Agent 选择器下方） |
| `sider.bottom`      | fill    | 侧边栏底部（菜单下方）         |
| `content.statusBar` | fill    | 主内容区顶部                   |
| `overlay.global`    | fill    | 全局覆盖层                     |

**示例：**

```tsx
// 替换 Header Logo
window.QwenPaw.slot.replace("my-plugin", "header.logo", (defaultLogo) => {
  return <img src="https://example.com/logo.svg" style={{ height: 24 }} />;
});
```

### 聊天欢迎界面 — `chat.welcome`

```tsx
window.QwenPaw.chat.welcome.set("my-plugin", {
  greeting: (locale) => (locale.startsWith("zh") ? "你好！" : "Hello!"),
  description: "I specialize in data analysis.",
  avatar: "https://example.com/avatar.png",
  nick: "My Bot",
  prompts: [
    { label: "分析数据", value: "请分析上传的数据集" },
    { label: "生成图表", value: "根据数据创建柱状图" },
  ],
});

// 或完全替换欢迎界面
window.QwenPaw.chat.welcome.render("my-plugin", (props) => {
  return <div>Custom Welcome</div>;
});
```

### 聊天主题 — `chat.theme`

```ts
window.QwenPaw.chat.theme.set("my-plugin", {
  colorPrimary: "#1890ff",
});
```

### 聊天头部 — `chat.leftHeader` / `chat.rightHeader`

```tsx
// 设置左上角标题
window.QwenPaw.chat.leftHeader.set("my-plugin", {
  title: "My Brand",
  logo: <img src="logo.svg" style={{ height: 20 }} />,
});

// 在右上角添加按钮
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 },
);
```

### 输入框 — `chat.sender`

```ts
// 自定义 placeholder
window.QwenPaw.chat.sender.set("my-plugin", {
  placeholder: "Ask me anything...",
  disclaimer: "Responses may not be accurate.",
});

// 添加输入建议
window.QwenPaw.chat.sender.addSuggestion("my-plugin", {
  id: "my-plugin.suggestions",
  items: [
    { label: "/analyze", value: "analyze" },
    { label: "/visualize", value: "visualize" },
  ],
});
```

### 消息操作按钮 — `chat.actions` / `chat.requestActions`

```tsx
// AI 回复消息下方添加操作按钮
window.QwenPaw.chat.actions.add("my-plugin", {
  id: "my-plugin.star",
  icon: <span>⭐</span>,
  onClick: ({ data }) => console.log("Starred:", data),
});

// 用户消息下方添加操作按钮
window.QwenPaw.chat.requestActions.add("my-plugin", {
  id: "my-plugin.edit",
  icon: <span>✏️</span>,
  onClick: ({ data }) => console.log("Edit:", data),
});
```

### 请求体转换 — `chat.requestPayload`

使用 `chat.requestPayload.add` 可以在 Console 将聊天请求发送到后端前改写 `requestBody`。多个转换函数会按 `order` 从小到大执行，入参包含当前 `payload`、解析后的 `sessionId` 和 `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 },
);
```

转换函数返回新对象时会替换当前请求体；返回 `undefined` 时保持请求体不变。建议使用全局唯一的 `id`，方便审计和卸载时清理。

### 消息气泡自定义 — `chat.request` / `chat.response`

```tsx
// 设置默认 AI 回复的头像和昵称
// 当前会复用 welcome.avatar / welcome.nick，因为默认 ResponseCard 读取这两个字段
window.QwenPaw.chat.response.set("my-plugin", {
  avatar: "https://example.com/bot-avatar.png",
  nick: "My Bot",
});

// 在用户消息前方追加内容
window.QwenPaw.chat.request.prepend("my-plugin", ({ data }) => {
  return <div style={{ fontSize: 10, color: "#999" }}>User</div>;
});

// 在最新 AI 回复下方追加信息条
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>
  );
});

// 完全替换用户消息渲染（可调用 fallback() 保留默认渲染）
window.QwenPaw.chat.request.render("my-plugin", ({ data, fallback }) => {
  return (
    <div style={{ border: "1px dashed #ccc", borderRadius: 8, padding: 4 }}>
      {fallback()}
    </div>
  );
});
```

### 工具调用渲染 — `chat.toolRender`

```tsx
// 注册自定义工具结果渲染组件（props 包含 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>
  );
});
```

### 自定义卡片 — `chat.card`

```ts
window.QwenPaw.chat.card("my-plugin", "my-card", MyCardComponent);
```

### 审计与调试

```ts
// 查看扩展注册记录
console.table(window.QwenPaw.audit.overrides());

// 清理插件的所有 Chat 扩展注册
window.QwenPaw.chat.disposeAll("my-plugin");
```

### 国际化支持

所有支持 `Localized<T>` 类型的字段可传入函数，按语言返回不同值：

```ts
window.QwenPaw.chat.welcome.set("my-plugin", {
  greeting: (locale) => (locale.startsWith("zh") ? "你好！" : "Hello!"),
});
```

### 常见错误

| 错误                              | 原因                                 | 解决                                        |
| --------------------------------- | ------------------------------------ | ------------------------------------------- |
| `e.item.render is not a function` | render/prepend/append 传了非函数     | 确保传入 React 组件或返回 ReactNode 的函数  |
| `duplicate id`                    | 两次 `add` 使用了相同 id             | 使用全局唯一 id（推荐 `pluginId.xxx` 格式） |
| Hook 在组件外调用                 | `useTheme()` 等在非 React 上下文使用 | 改用 `getSelectedAgentId()` 等命令式 API    |

## 使用示例

### 示例 1：添加自定义 Provider

假设你想接入一个企业内部的 LLM 服务。

#### 1. 创建插件目录

```bash
mkdir my-llm-provider
cd my-llm-provider
```

#### 2. 创建 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. 创建 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]:
        """获取默认模型列表。"""
        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. 创建 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...")

        # 从同一目录加载 provider 模块
        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. 安装和使用

```bash
# 安装插件
qwenpaw plugin install my-llm-provider

# 启动 QwenPaw
qwenpaw app

# 在 Web UI 中配置 API Key
# 然后就可以使用新的 Provider 了
```

### 示例 2：添加启动钩子

假设你想在 QwenPaw 启动时初始化一个监控服务。

#### 1. 创建插件

```bash
mkdir monitoring-hook
cd monitoring-hook
```

#### 2. 创建 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. 创建 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 ===")

                # 初始化你的监控服务
                # 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,
                )

        # 注册启动钩子（priority=0 表示最高优先级）
        api.register_startup_hook(
            hook_name="monitoring_init",
            callback=startup_hook,
            priority=0,
        )

        logger.info("✓ Monitoring hook registered")


# Export plugin instance
plugin = MonitoringHookPlugin()
```

#### 4. 安装

```bash
qwenpaw plugin install monitoring-hook
qwenpaw app
```

### 示例 3：添加自定义命令

假设你想添加一个 `/status` 命令来查看系统状态。

#### 1. 创建插件

```bash
mkdir status-command
cd status-command
```

#### 2. 创建 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. 创建 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. 安装和使用

```bash
qwenpaw plugin install status-command
qwenpaw app

# 使用命令
/status
```

### 示例 4：添加自定义前端页面

向侧边栏添加一个欢迎页面。构建工具链文件（`package.json`、`tsconfig.json`、`vite.config.ts`）参考上方「前端插件 > 构建工具链」。

**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>插件系统运行正常！</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
```

### 示例 5：自定义工具调用渲染

自定义 Agent 工具调用结果的展示方式。项目结构同示例 4，仅 `src/index.tsx` 不同。

**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="天气信息" size="small" style={{ marginTop: 8, maxWidth: 400 }}>
      <Descriptions column={1} size="small">
        <Descriptions.Item label="城市">{data.city}</Descriptions.Item>
        <Descriptions.Item label="温度">{data.temperature}°C</Descriptions.Item>
        <Descriptions.Item label="天气">{data.weather}</Descriptions.Item>
      </Descriptions>
    </Card>
  );
});
```

### 示例 6：自定义聊天欢迎界面

定制对话页面的欢迎语、描述和推荐提示词。项目结构同示例 4，仅 `src/index.tsx` 不同。

**src/index.tsx**：

```tsx
const pluginId = "custom-greeting-plugin";

window.QwenPaw.chat.welcome.set(pluginId, {
  greeting: (locale) =>
    locale.startsWith("zh")
      ? "你好！我是定制版 QwenPaw"
      : "Hello! I'm customized QwenPaw",
  description: "这是一个定制化的聊天助手",
  prompts: [
    { label: "分析代码", value: "帮我分析这段代码" },
    { label: "单元测试", value: "写一个单元测试" },
    { label: "优化逻辑", value: "优化这段逻辑" },
  ],
});
```

### 示例 7：暴露 FastAPI 接口

后端插件可以通过注册 `fastapi.APIRouter` 暴露自己的 HTTP 接口。路由会挂载在
`/api` 加上你指定的前缀下，与 QwenPaw 核心 API 使用同一个 FastAPI 应用，因此
共享 CORS、鉴权等设置，并会出现在 `/openapi.json` 与 `/docs` 中。

下面示例增加一个简单的 `/api/pets` 接口：列出宠物，并支持新增。

#### 1. 创建插件目录

```bash
mkdir pet-api-plugin && cd pet-api-plugin
```

#### 2. 创建 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. 创建 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. 安装并试用

```bash
qwenpaw plugin install pet-api-plugin
```

启动 QwenPaw 后，可在终端用 `curl` 测试（端口请按你本地实际为准，例如 `8088`）：

```bash
# 列出全部宠物
curl http://127.0.0.1:8088/api/pets

# 按 id 查询
curl http://127.0.0.1:8088/api/pets/1

# 新增宠物（POST 到集合路径 /api/pets）
curl -X POST http://127.0.0.1:8088/api/pets \
  -H "Content-Type: application/json" \
  -d '{"name": "Luna", "species": "rabbit"}'
```

**说明：**

- `prefix` 必须以 `/` 开头，且不能仅为 `/`，应使用有语义的片段（如 `/pets`）。完整路径恒为 `/api` + 你的 `prefix`。
- 每个前缀只能被一个插件占用；重复注册相同前缀会抛出 `ValueError`。
- `tags` 可选；省略时路由在 OpenAPI 中会默认打上 `plugin:<插件 id>` 标签。
- 插件卸载或禁用时会自动卸载对应路由。

### 示例 8：Tracing Middleware（工具调用追踪）

本示例展示如何注册一个 `on_acting` middleware，当设置环境变量 `QWENPAW_TRACE` 时记录每次 tool call 的名称、参数和执行耗时。

**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()
```

**要点：**

- **条件激活**：工厂函数检测环境变量 `QWENPAW_TRACE`，仅设置时启用
- **`priority=50`**：比默认优先级更高（数值更小 = 更靠外层），确保 tracing 包裹其他 middleware
- **`on_acting` 钩子**：在 tool call 执行前/后测量耗时
- 完整源码参见 `plugins/middleware-demo/tracing-middleware/tracing_plugin.py`

---

### 示例 9：Thinking Log Middleware（推理过程日志）

本示例展示如何注册一个 `on_reasoning` middleware，捕获并打印模型的思维链。

**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()
```

**要点：**

- **无条件激活**：工厂始终返回实例，适用于所有请求
- **`on_reasoning` 钩子**：在模型推理阶段捕获流式事件（`ThinkingBlockDeltaEvent` 为思维链，`TextBlockDeltaEvent` 为文本响应）
- **实时打印**：每收到一个 delta 事件即打印，同时 yield 给下游，不阻塞流式响应
- 完整源码参见 `plugins/middleware-demo/thinking-log-middleware/thinking_log_plugin.py`

---

### 示例 10：注册自定义消息频道

Channel 插件可以为 QwenPaw 添加新的消息平台。注册后的频道会在控制台 UI 中与内置
频道（钉钉、Telegram 等）一起显示，支持同样的启用/禁用和配置方式。

#### 1. 创建插件目录

```bash
mkdir sample-channel-plugin && cd sample-channel-plugin
```

#### 2. 创建 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. 创建 channel.py — BaseChannel 子类

Channel 类必须实现 `BaseChannel` 的契约，核心方法包括：

- **`from_config(cls, process, config, ...)`** — 类方法，从保存的配置创建实例。
  `ChannelManager` 启动时通过它实例化你的频道。
- **`start()` / `stop()`** — 生命周期钩子，频道启用/禁用时调用。
- **`send(to_handle, text, meta)`** — 向用户/会话发送消息。

```python
# -*- coding: utf-8 -*-
"""Sample 频道实现。"""

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 消息频道。"""

    channel = "sample"  # 唯一 key，必须与 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":
        """从配置创建实例。

        注意：插件频道的 ``config`` 是 ``types.SimpleNamespace`` 对象
        （不是 dict），请使用 ``getattr(config, "field", default)``
        安全读取字段。
        """
        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):
        """启动 Sample 事件监听。"""
        logger.info("Sample channel starting (token=%s...)", self.bot_token[:8])
        # 在此启动你的平台 API 客户端

    async def stop(self):
        """停止 Sample 事件监听。"""
        logger.info("Sample channel stopping")

    async def send(self, to_handle: str, text: str, meta=None):
        """向 Sample 用户或频道发送消息。"""
        logger.info("Sending to sample %s: %s", to_handle, text[:50])
        # 使用 sample-sdk 发送消息
```

> **重要：`config` 参数类型** — 插件频道的 `from_config()` 收到的 `config`
> 是 `types.SimpleNamespace` 对象（不是 dict 或 Pydantic model）。框架会将
> `BaseChannelConfig` 的默认值与用户保存的配置合并后传入。请始终使用
> `getattr(config, "field", default)` 安全读取字段。

#### 4. 创建 plugin.py — 插件入口

```python
# -*- coding: utf-8 -*-
"""Sample Channel 插件入口。"""

import logging
from qwenpaw.plugins.api import PluginApi

logger = logging.getLogger(__name__)


class SampleChannelPlugin:
    """Sample Channel 插件。"""

    def register(self, api: PluginApi):
        """注册 Sample 频道。"""
        from .channel import SampleChannel

        api.register_channel(
            channel_class=SampleChannel,
            label="Sample",
            description="Sample messaging channel integration",
            icon="https://example.com/sample-icon.png",  # 可选：卡片图标（仅 http/https）
            doc_url={  # 可选：文档链接，支持纯字符串或本地化字典（仅 http/https）
                "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. 安装和使用

```bash
qwenpaw plugin install sample-channel-plugin
qwenpaw app
```

启动后，在控制台的 **Control → Channels** 中可以看到 Sample 频道卡片，点击即可
填写凭证并启用。

#### 6. 添加 Webhook 端点（可选）

如果你的频道需要接收 HTTP 回调（如你的平台事件 API），可以在同一个插件中
注册 FastAPI 路由：

```python
from fastapi import APIRouter

def register(self, api: PluginApi):
    from .channel import SampleChannel

    api.register_channel(channel_class=SampleChannel, ...)

    # 在 /api/sample/events 挂载 webhook 端点
    router = APIRouter()

    @router.post("/events")
    async def sample_events(request):
        body = await request.json()
        # 处理事件验证和消息
        return {"ok": True}

    api.register_http_router(router, prefix="/sample", tags=["sample"])
```

**要点：**

- `channel_class` 必须是 `BaseChannel` 的子类，且需要有 `channel` 类属性（唯一
  key）。
- **必须实现 `from_config`** — `ChannelManager` 启动时通过它创建频道实例。
  `config` 参数是 `SimpleNamespace`，不是 dict。
- `config_fields` 定义控制台设置面板中显示的表单字段，支持类型：`text`、
  `password`、`number`、`switch`、`select`。
- 每个字段的 `label`、`help`、`placeholder` 既可以是纯字符串，也可以是
  本地化字典。字典的键**同时支持长编码（如 `zh-CN`、`en-US`）和短编码
  （如 `zh`、`en`），两者可混用**。取值按优先级回退（当前语言精确码 →
  短码 → 短码前缀匹配 → 英文 → 中文 → 字典首个非空值），确保缺失某语言
  时不会显示为空白。
- `icon`（可选）为频道卡片自定义图标 URL，仅支持 `http`/`https` 链接；其他值
  会被忽略并回退到默认图标。
- `doc_url`（可选）为频道文档链接，可以是纯字符串，也可以是本地化字典
  （如 `{"zh": "...", "en": "..."}`，键的长短码规则同 `label`）。仅支持
  `http`/`https` 链接；控制台设置面板标题栏会显示一个 “Doc” 按钮，点击按当前
  语言跳转，值非法或缺失时不显示按钮。
- 插件频道与内置频道共享启用/禁用、访问控制、`bot_prefix` 等功能。
- 如果插件频道 key 与内置频道冲突，内置频道优先，插件频道会被跳过并打印警告。
- 对于基于 webhook 的频道，可在同一个插件中组合 `register_channel` 和
  `register_http_router`。

## 依赖管理

### 使用 requirements.txt

如果插件需要额外的 Python 包，创建 `requirements.txt`：

```
httpx>=0.24.0
pydantic>=2.0.0
```

插件安装时会自动安装依赖。

### 使用自定义 PyPI 源

```
--index-url https://custom-pypi.example.com/simple
my-package>=1.0.0
```

## 最佳实践

### 1. 命名规范

- **插件 ID**：使用小写字母和连字符，如 `my-plugin`
- **版本号**：遵循语义化版本（1.0.0, 1.1.0, 2.0.0）

### 2. 错误处理

钩子回调应该优雅处理错误，避免阻塞应用启动：

```python
def startup_hook():
    try:
        # 你的初始化代码
        pass
    except Exception as e:
        logger.error(f"Initialization failed: {e}", exc_info=True)
        # 不要 raise，让应用继续启动
```

### 3. 日志记录

使用 Python logging 记录插件行为：

```python
import logging

logger = logging.getLogger(__name__)

logger.info("Plugin loaded")
logger.debug("Debug information")
logger.error("Error occurred", exc_info=True)
```

### 4. 文档

提供清晰的 README.md 文档，包括：

- 功能说明
- 安装步骤
- 使用示例
- 配置说明
- 故障排查

## 优先级系统

### Hook 优先级

钩子按优先级顺序执行：

- **优先级值越低，执行越早**
- Priority 0 = 最高优先级（最先执行）
- Priority 100 = 默认优先级
- Priority 200 = 低优先级（最后执行）

**示例**：

```python
# 最先执行
api.register_startup_hook("early", callback, priority=0)

# 默认顺序
api.register_startup_hook("normal", callback, priority=100)

# 最后执行
api.register_startup_hook("late", callback, priority=200)
```

## 故障排查

### 插件未加载

1. 检查插件是否已安装：

   ```bash
   qwenpaw plugin list
   ```

2. 查看 QwenPaw 日志：

   ```bash
   tail -f ~/.qwenpaw/logs/qwenpaw.log | grep -i plugin
   ```

3. 验证插件清单格式：
   ```bash
   qwenpaw plugin info <plugin-id>
   ```

### 依赖安装失败

1. 检查 `requirements.txt` 格式
2. 手动安装依赖测试：
   ```bash
   pip install -r /path/to/plugin/requirements.txt
   ```
3. 使用 `--force` 重新安装插件

### Provider 未显示

1. 确认插件已安装并重启 QwenPaw
2. 检查 Web UI 的模型管理页面
3. 查看日志中的 provider 注册信息

### 命令未响应

1. 确认插件已安装
2. 检查日志中命令处理器是否注册成功
3. 确认命令名称是否匹配（如 `/status`）

## 安全注意事项

1. **只安装可信插件**：插件代码会在 QwenPaw 进程中执行
2. **检查依赖**：确保插件依赖来自可信源
3. **审查代码**：安装前审查插件源代码
4. **热加载注意**：当前版本支持运行中通过 API 热安装/热卸载插件，无需重启。请注意热加载时的状态一致性

## PluginApi 参考

### register_provider

注册自定义 LLM Provider。

```python
api.register_provider(
    provider_id: str,              # Provider 唯一标识符（必填）
    provider_class: Type,          # Provider 类（必填）
    label: str = "",               # 显示名称（可选，默认为 provider_id）
    base_url: str = "",            # API base URL（可选）
    **metadata,                    # 额外关键字参数（chat_model, require_api_key 等）
)
```

### register_startup_hook

注册启动钩子。

```python
api.register_startup_hook(
    hook_name: str,      # 钩子名称
    callback: Callable,  # 回调函数
    priority: int = 100, # 优先级（越低越早执行）
)
```

### register_shutdown_hook

注册关闭钩子。

```python
api.register_shutdown_hook(
    hook_name: str,      # 钩子名称
    callback: Callable,  # 回调函数
    priority: int = 100, # 优先级（越低越早执行）
)
```

### register_http_router

将 `fastapi.APIRouter` 挂载到 `/api` + _prefix_ 下。

```python
api.register_http_router(
    router: APIRouter,             # fastapi.APIRouter 实例
    *,
    prefix: str,                   # /api 下的路径，例如 "/pets"
    tags: Optional[List[str]] = None,  # OpenAPI 标签（可选）
)
```

完整步骤见上文「示例 7：暴露 FastAPI 接口」。

### register_control_command

注册自定义 `/slash` 控制命令。

```python
api.register_control_command(
    handler: BaseControlCommandHandler,  # 命令处理器实例
    priority_level: int = 10,            # 命令优先级（默认: 10）
)
```

handler 必须继承 `qwenpaw.runtime.commands.control.base.BaseControlCommandHandler`，并实现 `command_name`、`help_text` 和 `async handle(self, ctx, args)` 方法。

### register_tool

将工具函数注册到 Agent 的工具集中。

```python
api.register_tool(
    tool_name: str,          # 工具函数的唯一名称
    tool_func: Callable,     # 要注册的工具函数
    description: str = "",   # UI 中显示的描述
    icon: str = "🔧",        # 显示图标（emoji 字符串）
    enabled: bool = False,   # 是否默认启用
)
```

### register_uninstall_hook

注册卸载钩子，仅在插件被显式卸载时执行。

```python
api.register_uninstall_hook(
    hook_name: str,      # 钩子名称
    callback: Callable,  # 回调函数
    priority: int = 100, # 优先级（越低越早执行）
)
```

### register_workspace_created_hook

注册 workspace 创建时触发的钩子。

```python
api.register_workspace_created_hook(
    hook_name: str,      # 钩子名称
    callback: Callable,  # 回调函数: (workspace_info: dict) -> None
    priority: int = 100, # 优先级（越低越早执行）
)
```

### get_tool_config / set_tool_config

获取或保存每个 Agent 的工具配置。

```python
config = api.get_tool_config(tool_name: str, agent_id: str)  # 返回 dict
api.set_tool_config(tool_name: str, agent_id: str, config: dict)
```

### register_middleware

注册 AgentScope `MiddlewareBase` 工厂。

```python
api.register_middleware(
    middleware_factory: Callable,   # 工厂函数
    *,
    priority: int = 100,           # 优先级（越低越靠外层）
)
```

工厂函数签名：`(ctx: HookContext, agent_config: AgentProfileConfig) -> MiddlewareBase | None`

- `ctx` 包含 `session_id`、`agent_id`、`workspace_dir` 等请求级上下文
- 返回 `None` 表示本次请求跳过该 middleware
- `priority` 越小越先进入洋葱模型（即越靠外层）

工厂在每次请求的 `AgentBuilder.build()` 阶段被调用，返回的 middleware 实例将被插入到 agent 的中间件链中。

完整步骤见上文「示例 8」和「示例 9」。

## 高级功能

### 修改 Agent 行为

如需拦截或增强 agent 的请求处理，推荐以下方式：

- **增强 agent 推理循环**：使用 `register_middleware` 注册 AgentScope middleware（`on_acting` / `on_reasoning` 钩子）
- **拦截特定命令**：使用 `register_control_command` 注册自定义命令处理器
- **在请求生命周期中注入逻辑**：使用 `HookRegistry`（8 阶段 hook）

当前请求流程为 `Runtime.run()` → `AgentBuilder.build()` → `AgentExecutor.run()`。

### 访问运行时信息

通过 `api.runtime` 访问运行时信息：

```python
def my_hook():
    # 访问 provider manager
    provider_manager = api.runtime.provider_manager

    # 获取所有 providers
    providers = provider_manager.list_provider_info()
```

## 插件打包

将插件打包为 ZIP 文件以便分发：

```bash
cd /path/to/plugins
zip -r my-plugin-1.0.0.zip my-plugin/
```

用户可以通过 URL 安装：

```bash
qwenpaw plugin install https://example.com/my-plugin-1.0.0.zip
```

## 常见问题

### Q: 插件可以访问哪些 QwenPaw API？

A: 插件通过 `PluginApi` 访问核心功能，包括：

- Provider 注册
- Middleware 注册（`register_middleware`）
- Hook 注册
- 自定义命令注册（`register_control_command`）
- HTTP 路由注册（`register_http_router`）
- Runtime helpers（provider_manager 等）

### Q: 插件可以修改 QwenPaw 的核心行为吗？

A: 可以，通过 `register_middleware`（注入 AgentScope middleware）、`register_control_command`、`register_tool`、runtime hooks 和其他 PluginApi 方法。请谨慎使用，确保不会破坏核心功能。

### Q: 插件之间会冲突吗？

A: 如果多个插件注册相同的 provider_id 或 command_name，后注册的会覆盖先注册的。建议使用唯一的 ID。

## 示例插件

### GPT Image 2 工具插件

一个为 QwenPaw agents 添加 OpenAI GPT Image 2 图片生成能力的工具插件。

**系统要求：**

- QwenPaw 最低版本：`1.1.5`

**安装方法：**

```bash
# 克隆 QwenPaw 仓库（如果尚未克隆）
git clone https://github.com/agentscope-ai/QwenPaw.git
cd QwenPaw

# 安装插件
qwenpaw plugin install plugins/tool/gpt-image2
```

**配置步骤：**

1. 安装完成后，重启 QwenPaw
2. 进入 Agent 设置 → 工具管理
3. 找到 "generate_image_gpt" 工具
4. 点击"配置"按钮，输入你的 OpenAI API Key
5. 启用该工具

**使用方法：**

配置完成后，agent 可以通过调用工具来生成图片：

```
用户: 请生成一张可爱的小猫在花园里玩耍的图片
Agent: [调用 generate_image_gpt 工具]
       [返回生成的图片]
```

**功能特性：**

- 支持多种图片尺寸：1024x1024, 1024x1792, 1792x1024
- 质量选项：low, medium, high, auto
- 自动验证 API Key
- Per-agent 配置（每个 agent 可以使用不同的 API Key）

更多详情请参考 `plugins/tool/gpt-image2/README.md`。
