From 1fec76eda12b91ef99fada23d882fc8554b059a9 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 13 Mar 2026 21:09:13 +0800 Subject: [PATCH] feat: add platform client documentation and examples - Introduced platform client documentation in `docs/v4/clients/platform.md` detailing methods for sending messages, images, and managing group members. - Added example plugins for LLM chat and database functionalities in `docs/v4/examples/README.md`, `docs/v4/examples/llm-chat/README.md`, and `docs/v4/examples/database/README.md`. - Enhanced quickstart guide with links to new documentation and example plugins. - Implemented runtime contract tests to ensure compatibility of public capabilities and hooks. --- docs/v4/api-reference.md | 467 +++++++++++++++++++++++ docs/v4/clients/db.md | 370 ++++++++++++++++++ docs/v4/clients/llm.md | 283 ++++++++++++++ docs/v4/clients/memory.md | 309 +++++++++++++++ docs/v4/clients/platform.md | 320 ++++++++++++++++ docs/v4/examples/README.md | 55 +++ docs/v4/examples/database/README.md | 478 ++++++++++++++++++++++++ docs/v4/examples/llm-chat/README.md | 333 +++++++++++++++++ docs/v4/quickstart.md | 16 +- tests_v4/test_compatibility_contract.py | 10 + tests_v4/test_runtime_contracts.py | 72 ++++ 11 files changed, 2712 insertions(+), 1 deletion(-) create mode 100644 docs/v4/api-reference.md create mode 100644 docs/v4/clients/db.md create mode 100644 docs/v4/clients/llm.md create mode 100644 docs/v4/clients/memory.md create mode 100644 docs/v4/clients/platform.md create mode 100644 docs/v4/examples/README.md create mode 100644 docs/v4/examples/database/README.md create mode 100644 docs/v4/examples/llm-chat/README.md create mode 100644 tests_v4/test_runtime_contracts.py diff --git a/docs/v4/api-reference.md b/docs/v4/api-reference.md new file mode 100644 index 000000000..5797ec6c0 --- /dev/null +++ b/docs/v4/api-reference.md @@ -0,0 +1,467 @@ +# AstrBot SDK v4 API 参考 + +本文档提供 AstrBot SDK v4 的完整 API 参考。 + +## 目录 + +- [核心概念](#核心概念) +- [顶层 API](#顶层-api) +- [装饰器](#装饰器) +- [Context 上下文](#context-上下文) +- [MessageEvent 消息事件](#messageevent-消息事件) +- [客户端 API](#客户端-api) +- [错误处理](#错误处理) +- [测试工具](#测试工具) + +--- + +## 核心概念 + +AstrBot SDK v4 采用**协议优先**的设计,插件与宿主通过显式协议消息交互: + +``` +┌─────────────────┐ +│ 插件代码 │ +├─────────────────┤ +│ Context │ ← 运行时上下文 +│ ├─ llm │ ← LLM 客户端 +│ ├─ memory │ ← 记忆客户端 +│ ├─ db │ ← 数据库客户端 +│ └─ platform │ ← 平台客户端 +├─────────────────┤ +│ CapabilityProxy│ ← 能力代理 +├─────────────────┤ +│ Peer │ ← 对等节点通信 +└─────────────────┘ +``` + +--- + +## 顶层 API + +从 `astrbot_sdk` 直接导入的推荐入口: + +```python +from astrbot_sdk import ( + Star, # 插件基类 + Context, # 运行时上下文 + MessageEvent, # 消息事件 + AstrBotError, # 错误类型 + on_command, # 命令装饰器 + on_message, # 消息装饰器 + on_event, # 事件装饰器 + on_schedule, # 定时任务装饰器 + provide_capability, # 能力提供装饰器 + require_admin, # 管理员权限装饰器 +) +``` + +--- + +## 装饰器 + +### @on_command + +注册命令处理器。 + +```python +@on_command( + command: str, # 命令名称 + *, + aliases: list[str] | None = None, # 命令别名 + description: str | None = None, # 命令描述 +) +``` + +**示例**: + +```python +@on_command("hello", aliases=["hi"], description="发送问候") +async def hello(self, event: MessageEvent, ctx: Context): + await event.reply("Hello!") +``` + +### @on_message + +注册消息处理器,支持正则匹配或关键词匹配。 + +```python +@on_message( + *, + regex: str | None = None, # 正则表达式 + keywords: list[str] | None = None, # 关键词列表 + platforms: list[str] | None = None, # 平台过滤 +) +``` + +**示例**: + +```python +@on_message(regex=r"^ping$") +async def ping(self, event: MessageEvent): + await event.reply("pong") + +@on_message(keywords=["帮助", "help"]) +async def help_handler(self, event: MessageEvent): + await event.reply("这是帮助信息...") +``` + +### @on_event + +注册事件处理器。 + +```python +@on_event(event_type: str) # 事件类型 +``` + +**常见事件类型**: +- `"message"` - 消息事件 +- `"group_join"` - 群加入事件 +- `"group_leave"` - 群退出事件 +- `"friend_add"` - 好友添加事件 + +**示例**: + +```python +@on_event("group_join") +async def on_group_join(self, event: MessageEvent, ctx: Context): + await ctx.platform.send(event.session_id, "欢迎加入群组!") +``` + +### @on_schedule + +注册定时任务。 + +```python +@on_schedule( + *, + cron: str | None = None, # Cron 表达式 + interval_seconds: int | None = None, # 间隔秒数 +) +``` + +**示例**: + +```python +# 每 60 秒执行一次 +@on_schedule(interval_seconds=60) +async def heartbeat(self, ctx: Context): + await ctx.db.set("last_heartbeat", {"time": "now"}) + +# 使用 cron 表达式(每天 9 点) +@on_schedule(cron="0 9 * * *") +async def daily_greeting(self, ctx: Context): + pass +``` + +### @require_admin + +要求管理员权限才能执行。 + +```python +@require_admin +@on_command("admin") +async def admin_only(self, event: MessageEvent): + await event.reply("管理员命令已执行") +``` + +### @provide_capability + +声明插件对外暴露的能力。 + +```python +@provide_capability( + name: str, # 能力名称 + *, + description: str, # 能力描述 + input_schema: dict | None = None, # 输入 JSON Schema + output_schema: dict | None = None, # 输出 JSON Schema + supports_stream: bool = False, # 是否支持流式 + cancelable: bool = False, # 是否可取消 +) +``` + +**示例**: + +```python +@provide_capability( + "demo.echo", + description="回显输入文本", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + output_schema={ + "type": "object", + "properties": {"echo": {"type": "string"}}, + }, +) +async def echo_capability(self, payload: dict, ctx: Context, cancel_token): + return {"echo": payload.get("text", "")} +``` + +--- + +## Context 上下文 + +运行时上下文,提供所有能力客户端。 + +```python +class Context: + llm: LLMClient # LLM 客户端 + memory: MemoryClient # 记忆客户端 + db: DBClient # 数据库客户端 + platform: PlatformClient # 平台客户端 + plugin_id: str # 插件 ID + logger: Logger # 日志器 + cancel_token: CancelToken # 取消令牌 +``` + +### CancelToken + +取消信号,用于处理中断请求。 + +```python +class CancelToken: + @property + def cancelled(self) -> bool # 是否已取消 + + def cancel(self) -> None # 发送取消信号 + + async def wait(self) -> None # 等待取消 + + def raise_if_cancelled(self) -> None # 如果已取消则抛出异常 +``` + +**示例**: + +```python +async def long_task(self, ctx: Context): + for i in range(100): + ctx.cancel_token.raise_if_cancelled() # 检查取消信号 + await asyncio.sleep(1) +``` + +--- + +## MessageEvent 消息事件 + +消息事件对象,包含消息信息和操作方法。 + +```python +class MessageEvent: + text: str # 消息文本 + user_id: str | None # 用户 ID + session_id: str # 会话 ID + group_id: str | None # 群组 ID(私聊为 None) + platform: str # 平台名称 + raw: dict # 原始消息数据 +``` + +### 方法 + +#### event.reply() + +回复消息。 + +```python +async def reply(self, text: str) -> None +``` + +**示例**: + +```python +await event.reply("收到您的消息!") +``` + +#### event.plain_result() + +创建纯文本结果。 + +```python +def plain_result(self, text: str) -> MessageEventResult +``` + +**示例**: + +```python +return event.plain_result("处理完成") +``` + +#### event.to_payload() + +转换为字典格式。 + +```python +def to_payload(self) -> dict[str, Any] +``` + +#### event.session_ref + +获取结构化会话引用。 + +```python +@property +def session_ref(self) -> SessionRef | None +``` + +--- + +## 客户端 API + +### LLMClient + +[详细文档](clients/llm.md) + +```python +# 简单对话 +reply = await ctx.llm.chat("你好") + +# 带历史对话 +reply = await ctx.llm.chat("继续", history=[ + {"role": "user", "content": "你好"}, + {"role": "assistant", "content": "你好!"}, +]) + +# 流式对话 +async for chunk in ctx.llm.stream_chat("讲个故事"): + print(chunk, end="") +``` + +### DBClient + +[详细文档](clients/db.md) + +```python +# 读写数据 +await ctx.db.set("user:1", {"name": "张三"}) +data = await ctx.db.get("user:1") + +# 前缀查询 +keys = await ctx.db.list("user:") + +# 批量操作 +await ctx.db.set_many({"a": 1, "b": 2}) +values = await ctx.db.get_many(["a", "b"]) +``` + +### MemoryClient + +[详细文档](clients/memory.md) + +```python +# 保存记忆 +await ctx.memory.save("user_pref", {"theme": "dark"}) + +# 语义搜索 +results = await ctx.memory.search("用户偏好") + +# 精确获取 +pref = await ctx.memory.get("user_pref") +``` + +### PlatformClient + +[详细文档](clients/platform.md) + +```python +# 发送消息 +await ctx.platform.send(event.session_id, "你好") + +# 发送图片 +await ctx.platform.send_image(event.session_id, "https://example.com/img.png") + +# 获取群成员 +members = await ctx.platform.get_members(event.session_id) +``` + +--- + +## 错误处理 + +### AstrBotError + +统一的错误类型。 + +```python +class AstrBotError(Exception): + code: str # 错误码 + message: str # 错误消息 + hint: str # 解决建议 + retryable: bool # 是否可重试 +``` + +### 错误码 + +| 错误码 | 说明 | 可重试 | +|--------|------|--------| +| `llm_not_configured` | LLM 未配置 | 否 | +| `capability_not_found` | 能力未找到 | 否 | +| `permission_denied` | 权限不足 | 否 | +| `invalid_input` | 输入无效 | 否 | +| `cancelled` | 操作已取消 | 否 | +| `capability_timeout` | 能力调用超时 | 是 | +| `network_error` | 网络错误 | 是 | + +**示例**: + +```python +from astrbot_sdk import AstrBotError + +try: + result = await ctx.llm.chat("hello") +except AstrBotError as e: + print(f"[{e.code}] {e.message}") + if e.hint: + print(f"建议: {e.hint}") +``` + +--- + +## 测试工具 + +### MockContext + +用于单元测试的模拟上下文。 + +```python +from astrbot_sdk.testing import MockContext, MockMessageEvent + +ctx = MockContext(plugin_id="test") +event = MockMessageEvent(text="hello", context=ctx) + +# 模拟 LLM 响应 +ctx.llm.mock_response("你好!") + +# 断言发送内容 +await event.reply("测试") +ctx.platform.assert_sent("测试") +``` + +### PluginHarness + +完整的插件测试工具。 + +```python +from astrbot_sdk.testing import PluginHarness, LocalRuntimeConfig + +harness = PluginHarness( + LocalRuntimeConfig(plugin_dir=Path("my-plugin")) +) + +async with harness: + records = await harness.dispatch_text("hello") + assert any(r.text for r in records) +``` + +--- + +## 更多资源 + +- [快速开始](quickstart.md) +- [LLM 客户端文档](clients/llm.md) +- [数据库客户端文档](clients/db.md) +- [平台客户端文档](clients/platform.md) +- [记忆客户端文档](clients/memory.md) +- [架构设计](../../ARCHITECTURE.md) diff --git a/docs/v4/clients/db.md b/docs/v4/clients/db.md new file mode 100644 index 000000000..9a83f295b --- /dev/null +++ b/docs/v4/clients/db.md @@ -0,0 +1,370 @@ +# 数据库客户端 + +数据库客户端提供键值存储能力,用于持久化插件数据。 + +## 概述 + +```python +from astrbot_sdk import Context + +# 通过 Context 访问 +ctx.db # DBClient 实例 +``` + +特点: +- 数据永久存储,除非显式删除 +- 支持任意 JSON 数据类型 +- 支持前缀查询 +- 支持批量读写 +- 支持变更订阅 + +--- + +## 方法 + +### get() + +获取指定键的值。 + +```python +async def get(self, key: str) -> Any | None +``` + +**参数**: +- `key: str` - 数据键名 + +**返回**:`Any | None` - 存储的值,不存在则返回 `None` + +**示例**: + +```python +# 获取数据 +data = await ctx.db.get("user_settings") +if data: + print(data["theme"]) + +# 获取不存在的键 +value = await ctx.db.get("nonexistent") # None +``` + +--- + +### set() + +设置键值对。 + +```python +async def set(self, key: str, value: Any) -> None +``` + +**参数**: +- `key: str` - 数据键名 +- `value: Any` - 要存储的 JSON 值 + +**示例**: + +```python +# 存储字典 +await ctx.db.set("user_settings", { + "theme": "dark", + "lang": "zh", + "notifications": True +}) + +# 存储列表 +await ctx.db.set("history", ["msg1", "msg2", "msg3"]) + +# 存储简单值 +await ctx.db.set("greeted", True) +await ctx.db.set("count", 42) +``` + +--- + +### delete() + +删除指定键的数据。 + +```python +async def delete(self, key: str) -> None +``` + +**示例**: + +```python +await ctx.db.delete("user_settings") +await ctx.db.delete("temp_data") +``` + +--- + +### list() + +列出匹配前缀的所有键。 + +```python +async def list(self, prefix: str | None = None) -> list[str] +``` + +**参数**: +- `prefix: str | None` - 键前缀过滤,`None` 表示列出所有键 + +**返回**:`list[str]` - 匹配的键名列表 + +**示例**: + +```python +# 列出所有键 +all_keys = await ctx.db.list() +# ["settings", "user:1", "user:2", "temp"] + +# 列出前缀为 "user:" 的键 +user_keys = await ctx.db.list("user:") +# ["user:1", "user:2"] + +# 使用前缀组织数据 +await ctx.db.set("user:1", {"name": "张三"}) +await ctx.db.set("user:2", {"name": "李四"}) +await ctx.db.set("config:theme", "dark") + +user_keys = await ctx.db.list("user:") # ["user:1", "user:2"] +config_keys = await ctx.db.list("config:") # ["config:theme"] +``` + +--- + +### get_many() + +批量获取多个键的值。 + +```python +async def get_many(self, keys: Sequence[str]) -> dict[str, Any | None] +``` + +**参数**: +- `keys: Sequence[str]` - 要读取的键列表 + +**返回**:`dict[str, Any | None]` - 键值对字典,不存在的键值为 `None` + +**示例**: + +```python +# 批量读取 +values = await ctx.db.get_many(["user:1", "user:2", "user:3"]) + +for key, value in values.items(): + if value is None: + print(f"{key} 不存在") + else: + print(f"{key}: {value['name']}") + +# 处理部分缺失的情况 +values = await ctx.db.get_many(["a", "b", "c"]) +# {"a": {"data": 1}, "b": None, "c": {"data": 3}} +``` + +--- + +### set_many() + +批量写入多个键值对。 + +```python +async def set_many( + self, + items: Mapping[str, Any] | Sequence[tuple[str, Any]] +) -> None +``` + +**参数**: +- `items` - 键值对集合(字典或二元组列表) + +**示例**: + +```python +# 使用字典 +await ctx.db.set_many({ + "user:1": {"name": "张三", "age": 25}, + "user:2": {"name": "李四", "age": 30}, + "user:3": {"name": "王五", "age": 28} +}) + +# 使用二元组列表 +await ctx.db.set_many([ + ("counter:page_views", 100), + ("counter:unique_visitors", 42) +]) +``` + +--- + +### watch() + +订阅 KV 变更事件(流式)。 + +```python +def watch(self, prefix: str | None = None) -> AsyncIterator[dict[str, Any]] +``` + +**参数**: +- `prefix: str | None` - 键前缀过滤,`None` 表示订阅所有键 + +**返回**:`AsyncIterator[dict]` - 变更事件流 + +**事件格式**: +```python +{ + "op": "set" | "delete", # 操作类型 + "key": str, # 变更的键 + "value": Any | None # 新值(delete 时为 None) +} +``` + +**示例**: + +```python +# 订阅所有变更 +async for event in ctx.db.watch(): + if event["op"] == "set": + print(f"设置 {event['key']} = {event['value']}") + else: + print(f"删除 {event['key']}") + +# 只订阅特定前缀 +async for event in ctx.db.watch("user:"): + print(f"用户数据变更: {event['key']}") +``` + +--- + +## 使用场景 + +### 场景 1:用户设置存储 + +```python +@on_command("settheme") +async def set_theme(self, event: MessageEvent, ctx: Context): + theme = event.text.split()[-1] + user_id = event.user_id + + # 读取现有设置 + settings = await ctx.db.get(f"settings:{user_id}") or {} + settings["theme"] = theme + + # 保存设置 + await ctx.db.set(f"settings:{user_id}", settings) + await event.reply(f"已将主题设置为 {theme}") + +@on_command("mytheme") +async def get_theme(self, event: MessageEvent, ctx: Context): + settings = await ctx.db.get(f"settings:{event.user_id}") or {} + theme = settings.get("theme", "默认") + await event.reply(f"当前主题: {theme}") +``` + +### 场景 2:计数器 + +```python +@on_command("count") +async def count(self, event: MessageEvent, ctx: Context): + key = f"counter:{event.user_id}" + + # 读取并增加计数 + count = await ctx.db.get(key) or 0 + count += 1 + await ctx.db.set(key, count) + + await event.reply(f"您已使用此命令 {count} 次") +``` + +### 场景 3:批量用户管理 + +```python +@on_command("listusers") +async def list_users(self, event: MessageEvent, ctx: Context): + # 列出所有用户键 + user_keys = await ctx.db.list("user:") + + if not user_keys: + await event.reply("暂无用户数据") + return + + # 批量获取用户数据 + users = await ctx.db.get_many(user_keys) + + lines = ["用户列表:"] + for key, data in users.items(): + if data: + lines.append(f"- {data.get('name', '未知')}") + + await event.reply("\n".join(lines)) +``` + +### 场景 4:缓存层 + +```python +async def get_user_info(self, user_id: str, ctx: Context): + # 先查缓存 + cache_key = f"cache:user:{user_id}" + cached = await ctx.db.get(cache_key) + if cached: + return cached + + # 模拟从外部获取数据 + data = await self._fetch_from_api(user_id) + + # 写入缓存 + await ctx.db.set(cache_key, data) + return data +``` + +--- + +## 最佳实践 + +### 1. 使用前缀组织数据 + +```python +# 推荐:使用有意义的键前缀 +"settings:{user_id}" # 用户设置 +"cache:{type}:{id}" # 缓存数据 +"counter:{name}" # 计数器 +"temp:{session_id}" # 临时数据 + +# 避免:无组织的键名 +"data" +"info" +"temp" +``` + +### 2. 处理空值 + +```python +# 使用 or 提供默认值 +data = await ctx.db.get("key") or {} +count = await ctx.db.get("counter") or 0 + +# 或显式检查 +data = await ctx.db.get("key") +if data is None: + data = self._get_default() +``` + +### 3. 批量操作减少调用 + +```python +# 不好:多次单独调用 +for key, value in items: + await ctx.db.set(key, value) + +# 好:批量写入 +await ctx.db.set_many(items) +``` + +--- + +## 相关文档 + +- [API 参考](../api-reference.md) +- [Memory 客户端](memory.md) - 语义搜索存储 +- [示例:数据库插件](../examples/database/) diff --git a/docs/v4/clients/llm.md b/docs/v4/clients/llm.md new file mode 100644 index 000000000..c1df3b62c --- /dev/null +++ b/docs/v4/clients/llm.md @@ -0,0 +1,283 @@ +# LLM 客户端 + +LLM 客户端提供与大语言模型交互的能力。 + +## 概述 + +```python +from astrbot_sdk import Context + +# 通过 Context 访问 +ctx.llm # LLMClient 实例 +``` + +LLM 客户端支持三种调用模式: +- `chat()` - 简单对话,返回文本 +- `chat_raw()` - 完整响应,包含 usage 和 tool_calls +- `stream_chat()` - 流式对话,逐块返回 + +--- + +## 方法 + +### chat() + +发送聊天请求并返回文本响应。 + +```python +async def chat( + self, + prompt: str, + *, + system: str | None = None, + history: Sequence[ChatHistoryItem] | None = None, + model: str | None = None, + temperature: float | None = None, + **kwargs: Any, +) -> str +``` + +**参数**: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `prompt` | `str` | 用户输入的提示文本 | +| `system` | `str \| None` | 系统提示词 | +| `history` | `list \| None` | 对话历史 | +| `model` | `str \| None` | 指定模型名称 | +| `temperature` | `float \| None` | 生成温度 (0-1) | +| `**kwargs` | `Any` | 额外参数 | + +**返回**:`str` - 生成的文本内容 + +**示例**: + +```python +# 简单对话 +reply = await ctx.llm.chat("你好") +print(reply) # "你好!有什么可以帮助你的?" + +# 带系统提示词 +reply = await ctx.llm.chat( + "介绍一下自己", + system="你是一个友好的助手,用简洁的语言回答" +) + +# 带历史对话 +history = [ + ChatMessage(role="user", content="我叫小明"), + ChatMessage(role="assistant", content="你好小明!"), +] +reply = await ctx.llm.chat("你记得我的名字吗?", history=history) + +# 控制生成温度 +reply = await ctx.llm.chat("写一首诗", temperature=0.8) +``` + +--- + +### chat_raw() + +发送聊天请求并返回完整响应。 + +```python +async def chat_raw( + self, + prompt: str, + **kwargs: Any, +) -> LLMResponse +``` + +**返回**:`LLMResponse` - 完整响应对象 + +```python +class LLMResponse: + text: str # 生成的文本 + usage: dict | None # Token 使用统计 + finish_reason: str | None # 结束原因 + tool_calls: list[dict] # 工具调用列表 +``` + +**示例**: + +```python +response = await ctx.llm.chat_raw( + "写一首关于春天的诗", + temperature=0.7 +) + +print(f"生成文本: {response.text}") +print(f"Token 使用: {response.usage}") +# {'input_tokens': 15, 'output_tokens': 120} + +print(f"结束原因: {response.finish_reason}") +# "stop" + +if response.tool_calls: + for tool in response.tool_calls: + print(f"工具调用: {tool['name']}") +``` + +--- + +### stream_chat() + +流式聊天,逐块返回响应文本。 + +```python +async def stream_chat( + self, + prompt: str, + *, + system: str | None = None, + history: Sequence[ChatHistoryItem] | None = None, + model: str | None = None, + temperature: float | None = None, + **kwargs: Any, +) -> AsyncGenerator[str, None] +``` + +**返回**:`AsyncGenerator[str, None]` - 文本块迭代器 + +**示例**: + +```python +# 实时输出生成内容 +async for chunk in ctx.llm.stream_chat("讲一个短故事"): + print(chunk, end="", flush=True) +print() # 换行 + +# 收集完整响应 +chunks = [] +async for chunk in ctx.llm.stream_chat("写一首诗"): + chunks.append(chunk) +full_text = "".join(chunks) +``` + +--- + +## ChatMessage + +对话消息模型,用于构建历史。 + +```python +from astrbot_sdk.clients.llm import ChatMessage + +message = ChatMessage( + role="user", # "user", "assistant", "system" + content="消息内容" +) +``` + +**示例**: + +```python +from astrbot_sdk.clients.llm import ChatMessage + +history = [ + ChatMessage(role="user", content="你好"), + ChatMessage(role="assistant", content="你好!"), + ChatMessage(role="user", content="今天天气怎么样?"), +] + +reply = await ctx.llm.chat("继续聊", history=history) +``` + +--- + +## 使用场景 + +### 场景 1:智能问答 + +```python +@on_command("ask") +async def ask(self, event: MessageEvent, ctx: Context): + question = event.text.removeprefix("/ask").strip() + if not question: + await event.reply("请输入问题,如:/ask 什么是人工智能?") + return + + reply = await ctx.llm.chat(question) + await event.reply(reply) +``` + +### 场景 2:流式回复 + +```python +@on_command("chat") +async def chat(self, event: MessageEvent, ctx: Context): + prompt = event.text.removeprefix("/chat").strip() + + # 流式回复,实时显示 + reply_text = "" + async for chunk in ctx.llm.stream_chat(prompt): + reply_text += chunk + # 可以选择实时更新消息或最后一次性发送 + pass + + await event.reply(reply_text) +``` + +### 场景 3:带上下文的对话 + +```python +@on_command("continue") +async def continue_chat(self, event: MessageEvent, ctx: Context): + # 从数据库加载历史 + history = await ctx.db.get("chat_history") or [] + + # 添加当前消息 + prompt = event.text.removeprefix("/continue").strip() + reply = await ctx.llm.chat(prompt, history=history) + + # 保存更新后的历史 + history.append({"role": "user", "content": prompt}) + history.append({"role": "assistant", "content": reply}) + await ctx.db.set("chat_history", history[-10:]) # 保留最近 10 条 + + await event.reply(reply) +``` + +### 场景 4:指定模型和参数 + +```python +@on_command("creative") +async def creative(self, event: MessageEvent, ctx: Context): + prompt = event.text.removeprefix("/creative").strip() + + # 使用更高的温度增加创造性 + reply = await ctx.llm.chat( + prompt, + temperature=0.9, + system="你是一个富有创意的作家" + ) + await event.reply(reply) +``` + +--- + +## 注意事项 + +1. **Token 限制**:注意对话历史不要过长,可能会超出模型上下文限制 +2. **错误处理**:LLM 调用可能失败,建议添加错误处理 +3. **超时**:长文本生成可能需要较长时间 + +```python +from astrbot_sdk import AstrBotError + +try: + reply = await ctx.llm.chat("hello") +except AstrBotError as e: + if e.code == "llm_not_configured": + await event.reply("LLM 未配置,请联系管理员") + else: + await event.reply(f"LLM 调用失败: {e.message}") +``` + +--- + +## 相关文档 + +- [API 参考](../api-reference.md) +- [快速开始](../quickstart.md) +- [示例:LLM 对话插件](../examples/llm-chat/) diff --git a/docs/v4/clients/memory.md b/docs/v4/clients/memory.md new file mode 100644 index 000000000..ce9354fc7 --- /dev/null +++ b/docs/v4/clients/memory.md @@ -0,0 +1,309 @@ +# 记忆客户端 + +记忆客户端提供 AI 记忆存储能力,支持语义搜索。 + +## 概述 + +```python +from astrbot_sdk import Context + +# 通过 Context 访问 +ctx.memory # MemoryClient 实例 +``` + +### Memory vs DB 的区别 + +| 特性 | DBClient | MemoryClient | +|------|----------|--------------| +| 存储方式 | 键值存储 | 语义向量存储 | +| 检索方式 | 精确匹配 | 语义搜索 | +| 适用场景 | 配置、计数器、简单数据 | AI 上下文、用户偏好、对话记忆 | + +**选择建议**: +- 需要精确键查找 → 使用 `db` +- 需要语义搜索 → 使用 `memory` + +--- + +## 方法 + +### save() + +保存记忆项。 + +```python +async def save( + self, + key: str, + value: dict[str, Any] | None = None, + **extra: Any, +) -> None +``` + +**参数**: +- `key: str` - 记忆项的唯一标识键 +- `value: dict | None` - 要存储的数据字典 +- `**extra: Any` - 额外的键值对 + +**示例**: + +```python +# 保存用户偏好 +await ctx.memory.save("user_pref", { + "theme": "dark", + "language": "zh", + "interests": ["游戏", "音乐"] +}) + +# 使用关键字参数 +await ctx.memory.save( + "note:1", + None, + content="重要笔记", + tags=["work", "urgent"], + created_at="2024-01-01" +) + +# 保存对话摘要 +await ctx.memory.save("conversation:session_123", { + "summary": "用户询问了天气,推荐了晴天出行", + "topics": ["天气", "出行"], + "sentiment": "positive" +}) +``` + +--- + +### get() + +精确获取单个记忆项。 + +```python +async def get(self, key: str) -> dict[str, Any] | None +``` + +**参数**: +- `key: str` - 记忆项的唯一键 + +**返回**:`dict | None` - 记忆内容,不存在则返回 `None` + +**示例**: + +```python +# 获取用户偏好 +pref = await ctx.memory.get("user_pref") +if pref: + print(f"用户偏好主题: {pref.get('theme')}") + print(f"用户兴趣: {pref.get('interests')}") +``` + +--- + +### search() + +语义搜索记忆项。 + +```python +async def search(self, query: str) -> list[dict[str, Any]] +``` + +**参数**: +- `query: str` - 搜索查询文本 + +**返回**:`list[dict]` - 匹配的记忆项列表,按相关度排序 + +**示例**: + +```python +# 搜索用户偏好相关记忆 +results = await ctx.memory.search("用户喜欢什么颜色") +for item in results: + print(f"键: {item['key']}") + print(f"内容: {item['content']}") + print(f"相关度: {item.get('score', 0)}") + print("---") + +# 搜索对话历史 +results = await ctx.memory.search("之前讨论过天气吗") +if results: + await event.reply("是的,我们之前讨论过天气话题") +``` + +--- + +### delete() + +删除记忆项。 + +```python +async def delete(self, key: str) -> None +``` + +**示例**: + +```python +# 删除过期记忆 +await ctx.memory.delete("old_note") + +# 删除用户数据 +await ctx.memory.delete(f"user_data:{user_id}") +``` + +--- + +## 使用场景 + +### 场景 1:用户偏好记忆 + +```python +@on_command("remember") +async def remember_preference(self, event: MessageEvent, ctx: Context): + """记住用户偏好""" + preference = event.text.removeprefix("/remember").strip() + + # 保存偏好 + key = f"pref:{event.user_id}" + prefs = await ctx.memory.get(key) or {"items": []} + prefs["items"].append(preference) + await ctx.memory.save(key, prefs) + + await event.reply(f"已记住:{preference}") + +@on_command("what_do_i_like") +async def recall_preference(self, event: MessageEvent, ctx: Context): + """回忆用户偏好""" + query = "用户偏好 喜欢" + results = await ctx.memory.search(query) + + if results: + lines = ["您之前告诉过我:"] + for item in results[:3]: + lines.append(f"- {item.get('content', '未知')}") + await event.reply("\n".join(lines)) + else: + await event.reply("我还没有记住您的偏好") +``` + +### 场景 2:对话上下文记忆 + +```python +@on_message(keywords=["我"]) +async def track_context(self, event: MessageEvent, ctx: Context): + """跟踪用户提到的个人信息""" + # 保存到记忆 + await ctx.memory.save( + f"user_info:{event.user_id}:{event.session_id}", + { + "message": event.text, + "timestamp": "2024-01-01", + "type": "personal_info" + } + ) + +@on_command("recall") +async def recall_context(self, event: MessageEvent, ctx: Context): + """回忆对话内容""" + query = event.text.removeprefix("/recall").strip() or "用户说过什么" + + results = await ctx.memory.search(query) + if results: + await event.reply(f"您之前提到:{results[0].get('message', '未知')}") + else: + await event.reply("我没有找到相关记忆") +``` + +### 场景 3:智能推荐 + +```python +@on_command("recommend") +async def recommend(self, event: MessageEvent, ctx: Context): + """基于记忆的智能推荐""" + # 搜索用户兴趣相关的记忆 + interests = await ctx.memory.search(f"{event.user_id} 兴趣 爱好") + + if not interests: + await event.reply("告诉我您的兴趣,我可以给您推荐内容!") + return + + # 基于兴趣生成推荐 + interest_text = ", ".join( + item.get("content", "") + for item in interests[:3] + ) + + prompt = f"用户喜欢 {interest_text},推荐一些相关内容" + recommendation = await ctx.llm.chat(prompt) + await event.reply(recommendation) +``` + +--- + +## 最佳实践 + +### 1. 使用结构化键名 + +```python +# 推荐:有层次结构的键名 +"user:{user_id}:preferences" +"user:{user_id}:history:{session_id}" +"conversation:{session_id}:summary" + +# 避免:无组织的键名 +"data" +"info" +"temp" +``` + +### 2. 为搜索优化内容 + +```python +# 好:包含可搜索的描述性文本 +await ctx.memory.save("user_pref", { + "description": "用户喜欢玩游戏和听音乐", + "interests": ["游戏", "音乐"], + "level": "advanced" +}) + +# 不好:过于抽象,难以语义搜索 +await ctx.memory.save("user_pref", { + "a": ["x", "y"], + "b": 2 +}) +``` + +### 3. 结合 DB 和 Memory + +```python +# DB:存储精确配置 +await ctx.db.set("config:theme", "dark") + +# Memory:存储语义可搜索的内容 +await ctx.memory.save("user_interests", { + "description": "用户对游戏开发感兴趣", + "tags": ["游戏", "开发", "Unity"] +}) +``` + +--- + +## 注意事项 + +1. **值必须是字典**:`memory.save()` 的 value 参数必须是 `dict` 类型 + +```python +# 正确 +await ctx.memory.save("key", {"value": 123}) + +# 错误 +await ctx.memory.save("key", 123) # TypeError +``` + +2. **语义搜索依赖宿主实现**:搜索质量取决于宿主的向量存储配置 + +--- + +## 相关文档 + +- [API 参考](../api-reference.md) +- [DB 客户端](db.md) - 精确键值存储 +- [LLM 客户端](llm.md) - 结合 AI 能力 diff --git a/docs/v4/clients/platform.md b/docs/v4/clients/platform.md new file mode 100644 index 000000000..a5b086564 --- /dev/null +++ b/docs/v4/clients/platform.md @@ -0,0 +1,320 @@ +# 平台客户端 + +平台客户端提供向聊天平台发送消息和获取信息的能力。 + +## 概述 + +```python +from astrbot_sdk import Context + +# 通过 Context 访问 +ctx.platform # PlatformClient 实例 +``` + +支持的平台能力: +- `send()` - 发送文本消息 +- `send_image()` - 发送图片 +- `send_chain()` - 发送富消息链 +- `get_members()` - 获取群成员 + +--- + +## 方法 + +### send() + +发送文本消息。 + +```python +async def send( + self, + session: str | SessionRef, + text: str +) -> dict[str, Any] +``` + +**参数**: +- `session: str | SessionRef` - 目标会话标识 +- `text: str` - 要发送的文本内容 + +**返回**:`dict` - 发送结果,可能包含消息 ID 等 + +**示例**: + +```python +# 发送到当前会话 +await ctx.platform.send(event.session_id, "收到您的消息!") + +# 发送到指定用户(需要知道 session_id) +await ctx.platform.send("qq:bot:123456", "私信消息") + +# 使用 event.target +if event.target: + await ctx.platform.send(event.target, "回复到引用的消息来源") +``` + +--- + +### send_image() + +发送图片消息。 + +```python +async def send_image( + self, + session: str | SessionRef, + image_url: str +) -> dict[str, Any] +``` + +**参数**: +- `session: str | SessionRef` - 目标会话标识 +- `image_url: str` - 图片 URL 或本地文件路径 + +**返回**:`dict` - 发送结果 + +**示例**: + +```python +# 发送网络图片 +await ctx.platform.send_image( + event.session_id, + "https://example.com/image.png" +) + +# 发送本地图片 +await ctx.platform.send_image( + event.session_id, + "/path/to/local/image.jpg" +) +``` + +--- + +### send_chain() + +发送富消息链。 + +```python +async def send_chain( + self, + session: str | SessionRef, + chain: list[dict[str, Any]] +) -> dict[str, Any] +``` + +**参数**: +- `session: str | SessionRef` - 目标会话标识 +- `chain: list[dict]` - 消息组件数组 + +**返回**:`dict` - 发送结果 + +**消息组件格式**: + +```python +# 纯文本 +{"type": "Plain", "text": "文本内容"} + +# 图片 +{"type": "Image", "file": "https://example.com/img.png"} + +# @某人 +{"type": "At", "user_id": "123456"} + +# 表情 +{"type": "Face", "id": "123"} +``` + +**示例**: + +```python +# 发送混合内容 +await ctx.platform.send_chain(event.session_id, [ + {"type": "Plain", "text": "你好!"}, + {"type": "Image", "file": "https://example.com/welcome.png"}, + {"type": "Plain", "text": "欢迎加入群组"} +]) + +# @用户并发送消息 +await ctx.platform.send_chain(event.session_id, [ + {"type": "At", "user_id": event.user_id}, + {"type": "Plain", "text": " 这是一条通知消息"} +]) +``` + +--- + +### get_members() + +获取群组成员列表。 + +```python +async def get_members( + self, + session: str | SessionRef +) -> list[dict[str, Any]] +``` + +**参数**: +- `session: str | SessionRef` - 群组会话标识 + +**返回**:`list[dict]` - 成员信息列表 + +**成员信息格式**: +```python +{ + "user_id": str, # 用户 ID + "nickname": str, # 昵称 + "role": str, # 角色: "owner", "admin", "member" +} +``` + +**示例**: + +```python +@on_command("members") +async def list_members(self, event: MessageEvent, ctx: Context): + # 仅群聊有效 + if not event.group_id: + await event.reply("此命令仅在群聊中可用") + return + + members = await ctx.platform.get_members(event.session_id) + + lines = [f"群成员 ({len(members)} 人):"] + for member in members[:10]: # 只显示前 10 个 + role = f"[{member.get('role', 'member')}]" + name = member.get('nickname', member.get('user_id', '未知')) + lines.append(f" {role} {name}") + + if len(members) > 10: + lines.append(f" ... 还有 {len(members) - 10} 人") + + await event.reply("\n".join(lines)) +``` + +--- + +## SessionRef + +结构化会话引用,用于精确指定消息目标。 + +```python +from astrbot_sdk.protocol.descriptors import SessionRef + +ref = SessionRef( + platform="qq", # 平台名称 + instance="bot1", # 实例标识 + user_id="123456", # 用户 ID + group_id="654321", # 群组 ID(可选) +) +``` + +--- + +## 使用场景 + +### 场景 1:自动回复 + +```python +@on_message(keywords=["hello", "hi"]) +async def auto_reply(self, event: MessageEvent, ctx: Context): + await ctx.platform.send(event.session_id, "你好!我是机器人") +``` + +### 场景 2:命令响应 + +```python +@on_command("status") +async def status(self, event: MessageEvent, ctx: Context): + # 发送状态信息 + await ctx.platform.send(event.session_id, "系统状态:正常运行") + + # 发送状态图片 + await ctx.platform.send_image( + event.session_id, + "https://example.com/status.png" + ) +``` + +### 场景 3:群管理 + +```python +@on_command("admin") +@require_admin +async def admin_cmd(self, event: MessageEvent, ctx: Context): + if not event.group_id: + await event.reply("此命令仅在群聊中可用") + return + + # 获取成员列表 + members = await ctx.platform.get_members(event.session_id) + + # 统计 + admins = [m for m in members if m.get('role') in ('owner', 'admin')] + await event.reply(f"群管理员数量: {len(admins)}") +``` + +### 场景 4:富消息回复 + +```python +@on_command("card") +async def send_card(self, event: MessageEvent, ctx: Context): + # 发送复杂的富消息 + await ctx.platform.send_chain(event.session_id, [ + {"type": "Plain", "text": "📊 统计报告\n\n"}, + {"type": "Plain", "text": "用户数: 1000\n"}, + {"type": "Plain", "text": "消息数: 50000\n"}, + {"type": "Image", "file": "https://example.com/chart.png"}, + {"type": "Plain", "text": "\n— 来自 AstrBot"}, + ]) +``` + +--- + +## 注意事项 + +### 1. 私聊 vs 群聊 + +```python +if event.group_id: + # 群聊消息 + await ctx.platform.send(event.session_id, "群消息") +else: + # 私聊消息 + await ctx.platform.send(event.session_id, "私聊消息") +``` + +### 2. 发送频率 + +避免频繁发送消息,部分平台有频率限制: + +```python +import asyncio + +for msg in messages: + await ctx.platform.send(event.session_id, msg) + await asyncio.sleep(1) # 间隔 1 秒 +``` + +### 3. 错误处理 + +```python +from astrbot_sdk import AstrBotError + +try: + await ctx.platform.send(event.session_id, "消息") +except AstrBotError as e: + if e.code == "permission_denied": + print("没有发送权限") + else: + print(f"发送失败: {e.message}") +``` + +--- + +## 相关文档 + +- [API 参考](../api-reference.md) +- [MessageEvent 消息事件](../api-reference.md#messageevent-消息事件) +- [快速开始](../quickstart.md) diff --git a/docs/v4/examples/README.md b/docs/v4/examples/README.md new file mode 100644 index 000000000..667ffe78b --- /dev/null +++ b/docs/v4/examples/README.md @@ -0,0 +1,55 @@ +# 示例插件索引 + +这里收集了 AstrBot SDK v4 的示例插件,帮助你快速学习各种功能的用法。 + +## 示例列表 + +### [LLM 对话插件](llm-chat/) + +演示如何使用 LLM 客户端: + +- 简单对话 +- 流式对话 +- 带历史记录的对话 +- 模型和参数控制 + +```python +# 简单对话 +reply = await ctx.llm.chat("你好") + +# 流式对话 +async for chunk in ctx.llm.stream_chat("讲个故事"): + print(chunk) +``` + +### [数据库插件](database/) + +演示如何使用数据库客户端: + +- 用户设置存储 +- 计数器 +- 待办事项 +- 批量操作 + +```python +# 存储数据 +await ctx.db.set("user:1", {"name": "张三"}) + +# 读取数据 +data = await ctx.db.get("user:1") + +# 批量操作 +await ctx.db.set_many({"a": 1, "b": 2}) +``` + +--- + +## 更多示例 + +如果你想贡献更多示例,请提交 PR 到 [astrbot-sdk 仓库](https://github.com/Soulter/astrbot-sdk)。 + +## 相关文档 + +- [快速开始](../quickstart.md) +- [API 参考](../api-reference.md) +- [客户端文档](../clients/) diff --git a/docs/v4/examples/database/README.md b/docs/v4/examples/database/README.md new file mode 100644 index 000000000..cd453dba3 --- /dev/null +++ b/docs/v4/examples/database/README.md @@ -0,0 +1,478 @@ +# 数据库插件示例 + +本示例演示如何使用数据库客户端存储和管理插件数据。 + +## 完整代码 + +### plugin.yaml + +```yaml +name: database_demo +display_name: 数据库演示 +desc: 演示数据库客户端的各种用法 +author: your-name +version: 1.0.0 +runtime: + python: "3.12" +components: + - class: main:DatabasePlugin +``` + +### main.py + +```python +"""数据库插件示例。 + +功能演示: +- 用户设置存储 +- 计数器 +- 批量操作 +- 数据查询 +""" + +from __future__ import annotations + +from astrbot_sdk import Context, MessageEvent, Star, on_command + + +class DatabasePlugin(Star): + """数据库演示插件。""" + + # ==================== 用户设置 ==================== + + @on_command("set", description="设置用户配置") + async def set_config(self, event: MessageEvent, ctx: Context) -> None: + """设置用户配置项。""" + args = event.text.removeprefix("/set").strip().split(maxsplit=1) + + if len(args) < 2: + await event.reply("用法: /set <键名> <值>") + return + + key, value = args + user_id = event.user_id or "unknown" + + # 获取现有配置 + config_key = f"user_config:{user_id}" + config = await ctx.db.get(config_key) or {} + + # 更新配置 + config[key] = value + await ctx.db.set(config_key, config) + + await event.reply(f"已设置 {key} = {value}") + + @on_command("get", description="获取用户配置") + async def get_config(self, event: MessageEvent, ctx: Context) -> None: + """获取用户配置项。""" + key = event.text.removeprefix("/get").strip() + + if not key: + await event.reply("用法: /get <键名>") + return + + user_id = event.user_id or "unknown" + config_key = f"user_config:{user_id}" + + config = await ctx.db.get(config_key) or {} + + if key in config: + await event.reply(f"{key} = {config[key]}") + else: + await event.reply(f"未找到配置项: {key}") + + @on_command("config", description="显示所有配置") + async def show_config(self, event: MessageEvent, ctx: Context) -> None: + """显示用户的所有配置。""" + user_id = event.user_id or "unknown" + config_key = f"user_config:{user_id}" + + config = await ctx.db.get(config_key) + + if not config: + await event.reply("您还没有设置任何配置") + return + + lines = ["📋 您的配置:"] + for key, value in config.items(): + lines.append(f" {key} = {value}") + + await event.reply("\n".join(lines)) + + # ==================== 计数器 ==================== + + @on_command("count", description="计数器 +1") + async def increment_counter(self, event: MessageEvent, ctx: Context) -> None: + """计数器增加。""" + user_id = event.user_id or "unknown" + key = f"counter:{user_id}" + + # 读取并增加 + count = await ctx.db.get(key) or 0 + count += 1 + await ctx.db.set(key, count) + + await event.reply(f"计数器: {count}") + + @on_command("reset", description="重置计数器") + async def reset_counter(self, event: MessageEvent, ctx: Context) -> None: + """重置计数器。""" + user_id = event.user_id or "unknown" + key = f"counter:{user_id}" + + await ctx.db.delete(key) + await event.reply("计数器已重置") + + # ==================== 待办事项 ==================== + + @on_command("todo", description="添加待办事项") + async def add_todo(self, event: MessageEvent, ctx: Context) -> None: + """添加待办事项。""" + content = event.text.removeprefix("/todo").strip() + + if not content: + await event.reply("用法: /todo <事项内容>") + return + + user_id = event.user_id or "unknown" + + # 获取现有待办列表 + todo_key = f"todos:{user_id}" + todos = await ctx.db.get(todo_key) or [] + + # 添加新事项 + todos.append({ + "id": len(todos) + 1, + "content": content, + "done": False + }) + await ctx.db.set(todo_key, todos) + + await event.reply(f"已添加待办事项 #{len(todos)}") + + @on_command("todos", description="显示待办列表") + async def show_todos(self, event: MessageEvent, ctx: Context) -> None: + """显示待办列表。""" + user_id = event.user_id or "unknown" + todo_key = f"todos:{user_id}" + + todos = await ctx.db.get(todo_key) or [] + + if not todos: + await event.reply("待办列表为空") + return + + lines = ["📝 待办事项:"] + for todo in todos: + status = "✅" if todo.get("done") else "⬜" + lines.append(f" {status} #{todo['id']} {todo['content']}") + + await event.reply("\n".join(lines)) + + @on_command("done", description="标记待办完成") + async def complete_todo(self, event: MessageEvent, ctx: Context) -> None: + """标记待办事项完成。""" + arg = event.text.removeprefix("/done").strip() + + if not arg: + await event.reply("用法: /done <序号>") + return + + try: + todo_id = int(arg) + except ValueError: + await event.reply("序号必须是数字") + return + + user_id = event.user_id or "unknown" + todo_key = f"todos:{user_id}" + + todos = await ctx.db.get(todo_key) or [] + + for todo in todos: + if todo.get("id") == todo_id: + todo["done"] = True + await ctx.db.set(todo_key, todos) + await event.reply(f"已完成 #{todo_id}") + return + + await event.reply(f"未找到待办事项 #{todo_id}") + + # ==================== 批量操作 ==================== + + @on_command("batch_set", description="批量设置测试数据") + async def batch_set(self, event: MessageEvent, ctx: Context) -> None: + """批量写入数据演示。""" + user_id = event.user_id or "unknown" + + # 批量写入 + items = { + f"test:{user_id}:a": {"value": 1, "desc": "第一项"}, + f"test:{user_id}:b": {"value": 2, "desc": "第二项"}, + f"test:{user_id}:c": {"value": 3, "desc": "第三项"}, + } + + await ctx.db.set_many(items) + await event.reply(f"已批量写入 {len(items)} 条数据") + + @on_command("batch_get", description="批量读取测试数据") + async def batch_get(self, event: MessageEvent, ctx: Context) -> None: + """批量读取数据演示。""" + user_id = event.user_id or "unknown" + + # 批量读取 + keys = [f"test:{user_id}:a", f"test:{user_id}:b", f"test:{user_id}:c"] + values = await ctx.db.get_many(keys) + + lines = ["📦 批量读取结果:"] + for key, value in values.items(): + if value: + lines.append(f" {key}: {value.get('value')} - {value.get('desc')}") + else: + lines.append(f" {key}: 不存在") + + await event.reply("\n".join(lines)) + + # ==================== 数据管理 ==================== + + @on_command("keys", description="列出所有键") + async def list_keys(self, event: MessageEvent, ctx: Context) -> None: + """列出用户的所有数据键。""" + user_id = event.user_id or "unknown" + prefix = f"{user_id}:" + + keys = await ctx.db.list(prefix) + + if not keys: + await event.reply("没有找到数据") + return + + lines = [f"🔑 数据键 ({len(keys)} 个):"] + for key in keys[:10]: + lines.append(f" {key}") + + if len(keys) > 10: + lines.append(f" ... 还有 {len(keys) - 10} 个") + + await event.reply("\n".join(lines)) + + @on_command("clear", description="清除所有数据") + async def clear_all(self, event: MessageEvent, ctx: Context) -> None: + """清除用户的所有数据。""" + user_id = event.user_id or "unknown" + + # 列出并删除所有键 + keys = await ctx.db.list(f"{user_id}:") + + for key in keys: + await ctx.db.delete(key) + + await event.reply(f"已清除 {len(keys)} 条数据") +``` + +### requirements.txt + +``` +# 无额外依赖 +``` + +## 功能说明 + +### 用户设置 + +```bash +# 设置配置 +用户: /set theme dark +机器人: 已设置 theme = dark + +用户: /set lang zh +机器人: 已设置 lang = zh + +# 获取配置 +用户: /get theme +机器人: theme = dark + +# 显示所有配置 +用户: /config +机器人: +📋 您的配置: + theme = dark + lang = zh +``` + +### 计数器 + +```bash +用户: /count +机器人: 计数器: 1 + +用户: /count +机器人: 计数器: 2 + +用户: /reset +机器人: 计数器已重置 +``` + +### 待办事项 + +```bash +用户: /todo 买菜 +机器人: 已添加待办事项 #1 + +用户: /todo 写作业 +机器人: 已添加待办事项 #2 + +用户: /todos +机器人: +📝 待办事项: + ⬜ #1 买菜 + ⬜ #2 写作业 + +用户: /done 1 +机器人: 已完成 #1 + +用户: /todos +机器人: +📝 待办事项: + ✅ #1 买菜 + ⬜ #2 写作业 +``` + +### 批量操作 + +```bash +用户: /batch_set +机器人: 已批量写入 3 条数据 + +用户: /batch_get +机器人: +📦 批量读取结果: + test:user1:a: 1 - 第一项 + test:user1:b: 2 - 第二项 + test:user1:c: 3 - 第三项 +``` + +## 测试代码 + +### tests/test_plugin.py + +```python +import pytest +from astrbot_sdk.testing import MockContext, MockMessageEvent + + +class TestDatabasePlugin: + """数据库插件测试。""" + + @pytest.mark.asyncio + async def test_set_and_get_config(self): + """测试配置存取。""" + from main import DatabasePlugin + + plugin = DatabasePlugin() + ctx = MockContext(plugin_id="test") + + # 设置配置 + event = MockMessageEvent(text="/set theme dark", context=ctx, user_id="user1") + await plugin.set_config(event, ctx) + + # 获取配置 + event2 = MockMessageEvent(text="/get theme", context=ctx, user_id="user1") + await plugin.get_config(event2, ctx) + + assert "dark" in event2.replies[-1] + + @pytest.mark.asyncio + async def test_counter(self): + """测试计数器。""" + from main import DatabasePlugin + + plugin = DatabasePlugin() + ctx = MockContext(plugin_id="test") + + # 第一次计数 + event1 = MockMessageEvent(text="/count", context=ctx, user_id="user1") + await plugin.increment_counter(event1, ctx) + assert "1" in event1.replies[-1] + + # 第二次计数 + event2 = MockMessageEvent(text="/count", context=ctx, user_id="user1") + await plugin.increment_counter(event2, ctx) + assert "2" in event2.replies[-1] + + @pytest.mark.asyncio + async def test_todos(self): + """测试待办事项。""" + from main import DatabasePlugin + + plugin = DatabasePlugin() + ctx = MockContext(plugin_id="test") + + # 添加待办 + event1 = MockMessageEvent(text="/todo 测试事项", context=ctx, user_id="user1") + await plugin.add_todo(event1, ctx) + + # 显示待办 + event2 = MockMessageEvent(text="/todos", context=ctx, user_id="user1") + await plugin.show_todos(event2, ctx) + + assert "测试事项" in event2.replies[-1] + + # 完成待办 + event3 = MockMessageEvent(text="/done 1", context=ctx, user_id="user1") + await plugin.complete_todo(event3, ctx) + + @pytest.mark.asyncio + async def test_batch_operations(self): + """测试批量操作。""" + from main import DatabasePlugin + + plugin = DatabasePlugin() + ctx = MockContext(plugin_id="test") + + # 批量写入 + event1 = MockMessageEvent(text="/batch_set", context=ctx, user_id="user1") + await plugin.batch_set(event1, ctx) + assert "3" in event1.replies[-1] + + # 验证数据 + assert await ctx.router.db.get("test:user1:a") is not None + assert await ctx.router.db.get("test:user1:b") is not None + assert await ctx.router.db.get("test:user1:c") is not None +``` + +## 最佳实践 + +### 1. 使用有意义的键前缀 + +```python +# 推荐 +"user_config:{user_id}" # 用户配置 +"todos:{user_id}" # 待办事项 +"counter:{user_id}" # 计数器 +"cache:{type}:{id}" # 缓存数据 +"temp:{session_id}" # 临时数据 +``` + +### 2. 处理空值 + +```python +# 使用 or 提供默认值 +config = await ctx.db.get(key) or {} +count = await ctx.db.get(key) or 0 +todos = await ctx.db.get(key) or [] +``` + +### 3. 限制数据大小 + +```python +# 只保留最近 N 条记录 +history = history[-100:] # 最多 100 条 +await ctx.db.set(key, history) +``` + +## 相关文档 + +- [DB 客户端文档](../clients/db.md) +- [API 参考](../api-reference.md) +- [快速开始](../quickstart.md) diff --git a/docs/v4/examples/llm-chat/README.md b/docs/v4/examples/llm-chat/README.md new file mode 100644 index 000000000..7e8584ec0 --- /dev/null +++ b/docs/v4/examples/llm-chat/README.md @@ -0,0 +1,333 @@ +# LLM 对话插件示例 + +本示例演示如何创建一个功能完整的 AI 对话插件。 + +## 完整代码 + +### plugin.yaml + +```yaml +name: llm_chat_demo +display_name: LLM 对话演示 +desc: 一个支持上下文对话的 AI 聊天插件 +author: your-name +version: 1.0.0 +runtime: + python: "3.12" +components: + - class: main:LLMChatPlugin +``` + +### main.py + +```python +"""LLM 对话插件示例。 + +功能演示: +- 简单对话 +- 流式对话 +- 带历史记录的对话 +- 模型和参数控制 +""" + +from __future__ import annotations + +from astrbot_sdk import Context, MessageEvent, Star, on_command +from astrbot_sdk.clients.llm import ChatMessage + + +class LLMChatPlugin(Star): + """LLM 对话插件。""" + + @on_command("chat", description="与 AI 对话") + async def chat(self, event: MessageEvent, ctx: Context) -> None: + """简单对话示例。""" + prompt = event.text.removeprefix("/chat").strip() + + if not prompt: + await event.reply("用法: /chat <问题>") + return + + # 调用 LLM + reply = await ctx.llm.chat(prompt) + await event.reply(reply) + + @on_command("stream", description="流式对话") + async def stream_chat(self, event: MessageEvent, ctx: Context) -> None: + """流式对话示例。""" + prompt = event.text.removeprefix("/stream").strip() + + if not prompt: + await event.reply("用法: /stream <问题>") + return + + # 收集流式响应 + chunks = [] + async for chunk in ctx.llm.stream_chat(prompt): + chunks.append(chunk) + + # 发送完整响应 + full_response = "".join(chunks) + await event.reply(full_response) + + @on_command("creative", description="创造性写作") + async def creative_chat(self, event: MessageEvent, ctx: Context) -> None: + """使用更高温度的创造性对话。""" + prompt = event.text.removeprefix("/creative").strip() + + if not prompt: + await event.reply("用法: /creative <主题>") + return + + # 使用更高的温度增加创造性 + reply = await ctx.llm.chat( + prompt, + temperature=0.9, + system="你是一个富有创意的作家,善于用生动的语言创作内容" + ) + await event.reply(reply) + + @on_command("ask", description="带历史的对话") + async def ask_with_history(self, event: MessageEvent, ctx: Context) -> None: + """带对话历史的聊天。""" + prompt = event.text.removeprefix("/ask").strip() + + if not prompt: + await event.reply("用法: /ask <问题>") + return + + user_id = event.user_id or "unknown" + history_key = f"chat_history:{user_id}" + + # 加载历史记录 + history_data = await ctx.db.get(history_key) or [] + history = [ + ChatMessage(role=item["role"], content=item["content"]) + for item in history_data + ] + + # 调用 LLM + reply = await ctx.llm.chat(prompt, history=history) + + # 保存历史 + history_data.append({"role": "user", "content": prompt}) + history_data.append({"role": "assistant", "content": reply}) + + # 只保留最近 10 轮对话 + if len(history_data) > 20: + history_data = history_data[-20:] + + await ctx.db.set(history_key, history_data) + + await event.reply(reply) + + @on_command("clear", description="清除对话历史") + async def clear_history(self, event: MessageEvent, ctx: Context) -> None: + """清除用户的对话历史。""" + user_id = event.user_id or "unknown" + history_key = f"chat_history:{user_id}" + + await ctx.db.delete(history_key) + await event.reply("对话历史已清除") + + @on_command("raw", description="获取完整响应信息") + async def raw_chat(self, event: MessageEvent, ctx: Context) -> None: + """获取 LLM 的完整响应。""" + prompt = event.text.removeprefix("/raw").strip() + + if not prompt: + await event.reply("用法: /raw <问题>") + return + + # 获取完整响应 + response = await ctx.llm.chat_raw(prompt) + + # 构建响应信息 + lines = [ + f"📝 响应: {response.text}", + f"", + f"📊 Token 使用:", + f" - 输入: {response.usage.get('input_tokens', 'N/A') if response.usage else 'N/A'}", + f" - 输出: {response.usage.get('output_tokens', 'N/A') if response.usage else 'N/A'}", + f"", + f"🏁 结束原因: {response.finish_reason or 'N/A'}", + ] + + if response.tool_calls: + lines.append(f"🔧 工具调用: {len(response.tool_calls)} 个") + + await event.reply("\n".join(lines)) +``` + +### requirements.txt + +``` +# 无额外依赖 +``` + +## 功能说明 + +### 1. 简单对话 (`/chat`) + +```bash +用户: /chat 你好 +机器人: 你好!有什么可以帮助你的? +``` + +### 2. 流式对话 (`/stream`) + +```bash +用户: /stream 讲一个短故事 +机器人: [流式输出的故事内容...] +``` + +### 3. 创造性写作 (`/creative`) + +```bash +用户: /creative 写一首关于春天的诗 +机器人: [生成的诗歌...] +``` + +### 4. 带历史的对话 (`/ask`) + +```bash +用户: /ask 我叫小明 +机器人: 你好小明! + +用户: /ask 你记得我的名字吗 +机器人: 当然记得,你叫小明! +``` + +### 5. 完整响应信息 (`/raw`) + +```bash +用户: /raw hello +机器人: +📝 响应: Hello! How can I help you today? + +📊 Token 使用: + - 输入: 5 + - 输出: 12 + +🏁 结束原因: stop +``` + +## 本地测试 + +```bash +# 创建插件目录 +astrbot-sdk init llm-chat-demo + +# 复制上述代码到对应文件 + +# 本地运行 +astrbot-sdk dev --local --plugin-dir llm-chat-demo --interactive + +# 在交互模式中测试 +> /chat 你好 +> /creative 写一首诗 +``` + +## 测试代码 + +### tests/test_plugin.py + +```python +import pytest +from pathlib import Path + +from astrbot_sdk.testing import ( + MockContext, + MockMessageEvent, + PluginHarness, + LocalRuntimeConfig, +) + + +class TestLLMChatPlugin: + """LLM 对话插件测试。""" + + @pytest.mark.asyncio + async def test_simple_chat(self): + """测试简单对话。""" + from main import LLMChatPlugin + + plugin = LLMChatPlugin() + ctx = MockContext(plugin_id="test") + event = MockMessageEvent(text="/chat 你好", context=ctx) + + # 模拟 LLM 响应 + ctx.llm.mock_response("你好!有什么可以帮助你的?") + + await plugin.chat(event, ctx) + + # 验证回复 + assert "你好" in event.replies[0] + ctx.platform.assert_sent("你好!有什么可以帮助你的?") + + @pytest.mark.asyncio + async def test_creative_chat(self): + """测试创造性对话。""" + from main import LLMChatPlugin + + plugin = LLMChatPlugin() + ctx = MockContext(plugin_id="test") + event = MockMessageEvent(text="/creative 写一首诗", context=ctx) + + ctx.llm.mock_response("春风吹绿柳枝头...") + + await plugin.creative_chat(event, ctx) + + assert len(event.replies) == 1 + + @pytest.mark.asyncio + async def test_chat_with_history(self): + """测试带历史的对话。""" + from main import LLMChatPlugin + + plugin = LLMChatPlugin() + ctx = MockContext(plugin_id="test") + + # 第一次对话 + event1 = MockMessageEvent(text="/ask 我叫小明", context=ctx, user_id="user1") + ctx.llm.mock_response("你好小明!") + await plugin.ask_with_history(event1, ctx) + + # 验证历史被保存 + history = await ctx.db.get("chat_history:user1") + assert history is not None + assert len(history) == 2 + + # 第二次对话 + ctx.llm.mock_response("你叫小明") + event2 = MockMessageEvent(text="/ask 我叫什么", context=ctx, user_id="user1") + await plugin.ask_with_history(event2, ctx) + + @pytest.mark.asyncio + async def test_full_harness(self): + """使用完整 harness 测试。""" + plugin_dir = Path(__file__).parent.parent + + harness = PluginHarness( + LocalRuntimeConfig(plugin_dir=plugin_dir) + ) + + async with harness: + harness.router.enqueue_llm_response("测试响应") + records = await harness.dispatch_text("chat 测试") + + assert any("测试响应" in (r.text or "") for r in records) +``` + +## 扩展建议 + +1. **添加更多系统提示词**:支持用户选择不同的 AI 人设 +2. **支持图片输入**:使用 `image_urls` 参数 +3. **工具调用**:结合 `tool_calls` 实现功能扩展 +4. **多模型支持**:让用户选择不同的模型 + +## 相关文档 + +- [LLM 客户端文档](../clients/llm.md) +- [API 参考](../api-reference.md) +- [快速开始](../quickstart.md) diff --git a/docs/v4/quickstart.md b/docs/v4/quickstart.md index 62c118860..d1047f455 100644 --- a/docs/v4/quickstart.md +++ b/docs/v4/quickstart.md @@ -140,10 +140,24 @@ async def test_plugin_directory(): assert any(item.text for item in records) ``` -## 7. 当前边界 +## 7. 更多文档 + +- [API 参考](api-reference.md) - 完整的 API 文档 +- [LLM 客户端](clients/llm.md) - 大语言模型调用 +- [数据库客户端](clients/db.md) - 数据持久化存储 +- [平台客户端](clients/platform.md) - 消息发送与群管理 +- [记忆客户端](clients/memory.md) - 语义搜索存储 + +### 示例插件 + +- [LLM 对话插件](examples/llm-chat/) - AI 对话功能演示 +- [数据库插件](examples/database/) - 数据存储功能演示 + +## 8. 当前边界 当前 quickstart 对应的是已经存在的能力,不包含这些后续项: +TODO: 这些功能正在开发中: - `ctx.http` / `ctx.cache` / `ctx.storage` / `ctx.i18n` - 完整宿主调度下的 schedule 执行器 diff --git a/tests_v4/test_compatibility_contract.py b/tests_v4/test_compatibility_contract.py index f6fbc3426..c2ba6d1d7 100644 --- a/tests_v4/test_compatibility_contract.py +++ b/tests_v4/test_compatibility_contract.py @@ -9,6 +9,8 @@ import pytest LEVEL_ONE_MODULES = [ "astrbot.api", "astrbot.api.all", + "astrbot.api.components", + "astrbot.api.components.command", "astrbot.api.message_components", "astrbot.api.event", "astrbot.api.event.filter", @@ -25,6 +27,10 @@ LEVEL_TWO_MODULES = [ "astrbot.core.message", "astrbot.core.message.components", "astrbot.core.message.message_event_result", + "astrbot.core.agent", + "astrbot.core.agent.message", + "astrbot.core.db", + "astrbot.core.db.po", "astrbot.core.platform", "astrbot.core.platform.astr_message_event", "astrbot.core.platform.astrbot_message", @@ -32,7 +38,11 @@ LEVEL_TWO_MODULES = [ "astrbot.core.platform.platform_metadata", "astrbot.core.platform.register", "astrbot.core.platform.sources.aiocqhttp", + "astrbot.core.provider", + "astrbot.core.provider.entities", + "astrbot.core.provider.provider", "astrbot.core.utils", + "astrbot.core.utils.astrbot_path", "astrbot.core.utils.session_waiter", ] diff --git a/tests_v4/test_runtime_contracts.py b/tests_v4/test_runtime_contracts.py new file mode 100644 index 000000000..603550c76 --- /dev/null +++ b/tests_v4/test_runtime_contracts.py @@ -0,0 +1,72 @@ +"""Contract guards for the current public runtime/compat surface.""" + +from __future__ import annotations + +from importlib import import_module + +from astrbot_sdk.api.event import filter as compat_filter_namespace +from astrbot_sdk.protocol.descriptors import BUILTIN_CAPABILITY_SCHEMAS +from astrbot_sdk.runtime.capability_router import CapabilityRouter + +EXPECTED_PUBLIC_BUILTIN_CAPABILITIES = { + "llm.chat": False, + "llm.chat_raw": False, + "llm.stream_chat": True, + "memory.search": False, + "memory.save": False, + "memory.get": False, + "memory.delete": False, + "db.get": False, + "db.set": False, + "db.delete": False, + "db.list": False, + "db.get_many": False, + "db.set_many": False, + "db.watch": True, + "platform.send": False, + "platform.send_image": False, + "platform.send_chain": False, + "platform.get_members": False, +} +EXPECTED_CANCELABLE_CAPABILITIES = {"llm.stream_chat", "db.watch"} +EXPECTED_PUBLIC_COMPAT_HOOKS = { + "after_message_sent", + "on_astrbot_loaded", + "on_platform_loaded", + "on_decorating_result", + "on_llm_request", + "on_llm_response", + "on_waiting_llm_request", + "on_using_llm_tool", + "on_llm_tool_respond", + "on_plugin_error", + "on_plugin_loaded", + "on_plugin_unloaded", +} + + +def test_builtin_capability_schema_registry_matches_public_contract(): + """协议层公开的内建 capability 集合必须保持稳定。""" + assert set(BUILTIN_CAPABILITY_SCHEMAS) == set(EXPECTED_PUBLIC_BUILTIN_CAPABILITIES) + + +def test_capability_router_descriptors_match_public_contract(): + """Runtime 层内建 capability 的名字、stream 和 cancel 语义必须对齐契约。""" + descriptors = {item.name: item for item in CapabilityRouter().descriptors()} + + assert set(descriptors) == set(EXPECTED_PUBLIC_BUILTIN_CAPABILITIES) + assert { + name: descriptor.supports_stream for name, descriptor in descriptors.items() + } == EXPECTED_PUBLIC_BUILTIN_CAPABILITIES + assert { + name for name, descriptor in descriptors.items() if descriptor.cancelable + } == EXPECTED_CANCELABLE_CAPABILITIES + + +def test_public_compat_hook_factories_remain_available(): + """兼容 hook 名称必须同时保留模块级和 namespace 级入口。""" + compat_filter_module = import_module("astrbot_sdk.api.event.filter") + + for name in EXPECTED_PUBLIC_COMPAT_HOOKS: + assert callable(getattr(compat_filter_module, name)) + assert callable(getattr(compat_filter_namespace, name))