refactor(satori-bot): implement dispatcher-centric action registry (#1029)
This commit is contained in:
@@ -1,120 +1,76 @@
|
||||
# AIRI Satori Bot
|
||||
|
||||
一个基于 Satori 协议的 AI 聊天机器人,可以通过 Koishi 连接到多个聊天平台(QQ、Telegram、Discord、飞书等)。
|
||||
> **⚠️ Disclaimer**: This is a submodule of **AIRI**. The `core` part of this satori bot is merely a **temporary solution**. We will eventually delete it and integrate with **AIRI's Core** once the main framework is stable.
|
||||
|
||||
## 架构说明
|
||||
A **STANDALONE**, event-driven AI agent built on the [Satori Protocol](https://satori.chat/). It connects to multiple chat platforms (QQ, Telegram, Discord, Lark) via a Koishi bridge, featuring an autonomous thought loop.
|
||||
|
||||
本项目采用**独立架构**,参考了 Telegram Bot 的实现模式
|
||||
## 🏗 Architecture & Internals (Provisional)
|
||||
|
||||
## 前置要求
|
||||
**Important**: This module currently implements a self-contained "Mini-Core" (`src/core/`) to operate independently. This is **NOT** the final architecture of AIRI.
|
||||
|
||||
1. **Koishi 实例**:需要一个运行中的 Koishi 实例,并启用 Satori 服务
|
||||
2. **LLM API**:OpenAI API 或兼容的 API(如 Ollama、vLLM 等)
|
||||
3. **Node.js**: >= 18.0.0
|
||||
4. **pnpm**: >= 8.0.0
|
||||
* **Temporary Logic**: The Event Loop, Scheduler, and Planner logic located in `src/core/` are placeholders. They simulate the behavior of the future AIRI Core.
|
||||
* **Retained Components**: The **Dispatcher** and **Database** will be retained. They will be exposed as **tool-like modules** to the AIRI Core for action execution and state persistence.
|
||||
* **Future Migration**: Once the main AIRI Core is ready, the `src/core/` directory (specifically the loop/planning logic) will be removed. This module will then be refactored to strictly function as an **Adapter** (Satori Protocol handling) and **Capability Provider** (Actions), delegating the cognitive loop to the main AIRI process.
|
||||
|
||||
## 安装
|
||||
For the current standalone version, please refer to these documents:
|
||||
|
||||
* **[HANDLER.md](./docs/HANDLER.md)**: Explains the **current** Event-to-Action Flow (Queue -> Scheduler -> LLM).
|
||||
* **[PERSISTENCE.md](./docs/PERSISTENCE.md)**: Details the **current** Memory-First state management strategy specific to this temporary core.
|
||||
|
||||
**Key Code Paths:**
|
||||
* **Loop & Logic (Temporary)**: `src/core/`
|
||||
* **Adapter (Permanent)**: `src/adapter/satori/`
|
||||
* **Capabilities (Permanent)**: `src/capabilities/`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* **Node.js** >= 18.0.0
|
||||
* **pnpm** >= 8.0.0
|
||||
* **Koishi Instance**: Running the `server-satori` plugin.
|
||||
* **LLM Provider**: OpenAI compatible API (Ollama, vLLM, DeepSeek, etc.).
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
# 在项目根目录
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
复制 `.env` 文件并修改配置:
|
||||
2. **Configure Environment**
|
||||
Copy the example config and edit it:
|
||||
|
||||
```bash
|
||||
# 在 services/satori-bot 目录
|
||||
cp .env .env.local
|
||||
```
|
||||
|
||||
编辑 `.env.local`:
|
||||
**Key Variables:**
|
||||
|
||||
```env
|
||||
# Satori Configuration
|
||||
SATORI_WS_URL=ws://localhost:5140/satori/v1/events
|
||||
SATORI_API_BASE_URL=http://localhost:5140/satori/v1
|
||||
SATORI_TOKEN=your_satori_token_here
|
||||
SATORI_TOKEN= # Optional: Leave empty if auth is disabled in Koishi
|
||||
|
||||
# LLM Configuration
|
||||
# LLM (OpenAI Compatible)
|
||||
LLM_API_KEY=your_api_key_here
|
||||
LLM_API_BASE_URL=https://api.openai.com/v1
|
||||
LLM_MODEL=gpt-4
|
||||
LLM_RESPONSE_LANGUAGE=简体中文
|
||||
LLM_RESPONSE_LANGUAGE=English
|
||||
LLM_OLLAMA_DISABLE_THINK=false
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
#### Satori 配置
|
||||
|
||||
- `SATORI_WS_URL`: Satori WebSocket 地址(Koishi 默认:`ws://localhost:5140/satori/v1/events`)
|
||||
- `SATORI_API_BASE_URL`: Satori HTTP API 地址(Koishi 默认:`http://localhost:5140/satori/v1`)
|
||||
- `SATORI_TOKEN`: Satori 认证令牌(在 Koishi 配置中获取,如果为空 请留空,如:`SATORI_TOKEN=`)
|
||||
|
||||
**重要**: Koishi 的 Satori 服务默认路由是 `/satori/v1`,因此完整的 API 路径会自动拼接,例如:
|
||||
- 发送消息: `http://localhost:5140/satori/v1/message.create`
|
||||
- 获取消息: `http://localhost:5140/satori/v1/message.get`
|
||||
|
||||
#### LLM 配置
|
||||
|
||||
- `LLM_API_KEY`: LLM API 密钥
|
||||
- `LLM_API_BASE_URL`: LLM API 地址
|
||||
- `LLM_MODEL`: 使用的模型名称
|
||||
- `LLM_RESPONSE_LANGUAGE`: 回复语言(默认:简体中文)
|
||||
- `LLM_OLLAMA_DISABLE_THINK`: 是否禁用 Ollama 的思考模式
|
||||
|
||||
## 使用
|
||||
|
||||
### 开发模式
|
||||
3. **Run**
|
||||
|
||||
```bash
|
||||
# 在项目根目录
|
||||
# Development (Hot-reload)
|
||||
pnpm --filter @proj-airi/satori-bot dev
|
||||
```
|
||||
|
||||
### 生产模式
|
||||
|
||||
```bash
|
||||
# 在项目根目录
|
||||
# Production
|
||||
pnpm --filter @proj-airi/satori-bot start
|
||||
```
|
||||
|
||||
### 类型检查
|
||||
## Key Locations
|
||||
|
||||
```bash
|
||||
# 在项目根目录
|
||||
pnpm --filter @proj-airi/satori-bot typecheck
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 如何配置 Koishi?
|
||||
|
||||
在 Koishi 中启用 `server-satori`,配置项保持默认即可,无需改动。
|
||||
|
||||
### 2. 如何自定义 AI 人格?
|
||||
|
||||
可以编辑以下文件:
|
||||
|
||||
- `services\satori-bot\src\prompts\personality-v1.velin.md`
|
||||
- `services\satori-bot\src\prompts\system-action-gen-v1.velin.md`
|
||||
|
||||
### 3. 数据库文件在哪里?
|
||||
|
||||
`services/satori-bot/data/db.json`
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [AIRI 项目](https://github.com/moeru-ai/airi)
|
||||
- [Satori 协议文档](https://satori.chat/)
|
||||
- [Koishi 文档](https://koishi.chat/)
|
||||
* **Persona & System Prompts**: `src/core/planner/prompts/*.velin.md`
|
||||
* **Database (JSON)**: `data/db.json` (See *PERSISTENCE.md* for limitations)
|
||||
* **Action Logic**: `src/capabilities/actions/`
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
### **JSON Field Documentation**
|
||||
|
||||
JSON Field Documentation of `SatoriEvent`
|
||||
|
||||
* **Root Level**
|
||||
|
||||
| Path | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `self_id` | String | The unique ID (QQ number) of the bot receiving the event. |
|
||||
| `platform` | String | The platform name (e.g., `onebot`). |
|
||||
| `timestamp` | Number | Unix timestamp (ms) when the event was created. |
|
||||
| `type` | String | The general event category (e.g., `message-created`). |
|
||||
| `subtype` | String | The sub-category of the event (e.g., `group`). |
|
||||
| `subsubtype` | String | Further classification (e.g., `group`). |
|
||||
| `id` | Integer | Internal sequence ID for the event processing. |
|
||||
| `sn` | Integer | Serial number for the event. |
|
||||
|
||||
* **Message Object**
|
||||
|
||||
| Path | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `message` | Object | Container for standardized message details. |
|
||||
| `message.message_id` | String | Unique identifier for this specific message. |
|
||||
| `message.content` | String | The plain text content of the message. |
|
||||
|
||||
* **User & Member**
|
||||
|
||||
| Path | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `user` | Object | Standardized information about the message sender. |
|
||||
| `user.id` | String | Unique ID of the sender. |
|
||||
| `user.name` | String | Display name of the sender. |
|
||||
| `user.avatar` | String | URL to the sender's avatar image. |
|
||||
| `member` | Object | Context-specific member info (e.g., group membership). |
|
||||
| `member.nick` | String | The user's nickname/card in this specific group . |
|
||||
| `member.roles` | Array | List of roles assigned to the user (e.g., `member`). |
|
||||
|
||||
* **Context (Guild/Group)**
|
||||
|
||||
| Path | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `guild` | Object | Information about the guild/group. |
|
||||
| `guild.id` | String | Unique ID of the group/guild. |
|
||||
| `channel` | Object | Information about the channel (often same as guild in QQ groups). |
|
||||
| `channel.type` | Integer | Channel type classification. |
|
||||
|
||||
* **Bot Instance (`login`)**
|
||||
|
||||
| Path | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `login` | Object | Information about the bot instance processing this event. |
|
||||
| `login.user` | Object | The bot's own user details (Name, Avatar, ID). |
|
||||
| `login.status` | Integer | Connection status (1 = Online). |
|
||||
| `login.features` | Array | List of supported API features (e.g., `message.create`). |
|
||||
| `login.adapter` | String | The adapter protocol being used (`onebot`). |
|
||||
|
||||
### [Optional] raw data from adapter
|
||||
|
||||
e.g. onebot
|
||||
|
||||
* **OneBot Data (`_data`)** *Raw payload from the OneBot adapter*
|
||||
|
||||
| Path | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `_data.message_type` | String | Type of message (e.g., `group`, `private`). |
|
||||
| `_data.sub_type` | String | Subtype (e.g., `normal`, `anonymous`). |
|
||||
| `_data.message_id` | Integer | Message ID as an integer (OneBot standard). |
|
||||
| `_data.real_id` | Integer | The real message ID from the protocol. |
|
||||
| `_data.sender` | Object | Sender details specific to OneBot format. |
|
||||
| `_data.sender.card` | String | The sender's group card/nickname. |
|
||||
| `_data.raw_message` | String | The unformatted raw string of the message. |
|
||||
| `_data.message` | Array | Array of message segments (Text, Image, Face, etc.). |
|
||||
| `_data.group_id` | Integer | The numeric ID of the group. |
|
||||
| `_data.group_name` | String | The name of the group. |
|
||||
|
||||
* **Protocol Raw Data (`_data.raw`)** *Internal low-level protocol data (NTQQ/Lagrange)*
|
||||
|
||||
| Path | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `_data.raw.msgId` | String | Protocol-level message ID. |
|
||||
| `_data.raw.msgSeq` | String | Message sequence number. |
|
||||
| `_data.raw.elements` | Array | Detailed rich media elements (text, faces, images). |
|
||||
| `_data.raw.senderUin` | String | Sender's User Internal Number (QQ). |
|
||||
| `_data.raw.peerUin` | String | Receiver/Group User Internal Number. |
|
||||
@@ -0,0 +1,79 @@
|
||||
# Core Message Flow Architecture
|
||||
|
||||
This document outlines the complete lifecycle of a message within the Satori Bot, from the initial WebSocket event to the LLM's decision-making process.
|
||||
|
||||
## 1. Architectural Overview
|
||||
|
||||
The bot operates on a **Event-Driven + Autonomous Loop** hybrid model:
|
||||
* **Event Layer**: Handles raw WebSocket signals, deduplication, and queuing.
|
||||
* **Scheduler Layer**: Consumes the queue, updates the internal "Unread Pool" state, and triggers channel-specific processing loops.
|
||||
* **Planner Layer**: The LLM acts as an Agent that observes the "Unread Pool" and "History Actions" state to decide whether to `read_unread_messages` (observe) or `send_message` (act).
|
||||
|
||||
---
|
||||
|
||||
## 2. Detailed Data Flow
|
||||
|
||||
### Phase 1: Ingress
|
||||
**Location:** `src/adapter/satori/client.ts` → `src/core/loop/queue.ts`
|
||||
|
||||
1. **WebSocket Reception**: The `SatoriClient` receives a raw JSON signal and parses it into a `SatoriEvent`.
|
||||
2. **Event Listener**: The `setupMessageEventHandler` (in `queue.ts`) listens for `message-created` events.
|
||||
3. **Deduplication**: The system checks the `processedIds` set (key: `channelId-messageId`) to prevent double-processing.
|
||||
4. **Enqueuing**:
|
||||
* The raw `event` is wrapped into a `{ event, status: 'ready' }` object.
|
||||
* It is pushed into `botContext.eventQueue`.
|
||||
* **Key Data**: `event.message.content`, `event.user.id`, `event.channel.id`.
|
||||
|
||||
### Phase 2: Consumption & Anchoring
|
||||
**Location:** `src/core/loop/scheduler.ts` (Function: `onMessageArrival`)
|
||||
|
||||
When the system processing lock is free, it consumes events from the `eventQueue`:
|
||||
|
||||
1. **Context Initialization**:
|
||||
* Extracts `channelId`.
|
||||
* Calls `ensureChatContext` (in `src/core/session/context.ts`) to load or create the in-memory `ChatContext` for that channel.
|
||||
* **Anchor**: The `event.channel.id` is the primary key for all context.
|
||||
2. **Filtering**:
|
||||
* Checks `selfId`. If the sender is the bot itself, the event is discarded (not counted as unread) to prevent infinite loops.
|
||||
3. **State Update (The "Unread Pool")**:
|
||||
* The event is pushed into `botContext.unreadEvents[channelId]`.
|
||||
* *Note:* This step does **not** just store the message; it marks the event as a "pending observation object".
|
||||
4. **Loop Trigger**:
|
||||
* Immediately calls `loopIterationForChannel`, waking up the Agent Loop for this specific channel.
|
||||
|
||||
### Phase 3: Reasoning (LLM)
|
||||
**Location:** `src/core/loop/scheduler.ts` → `src/core/planner/llm-client.ts`
|
||||
|
||||
The LLM is prompted not to "reply to this text," but to "decide the next action based on state."
|
||||
|
||||
1. **Context Construction (`imagineAnAction`)**:
|
||||
* **System**: Injects `system-action-gen-v1` (Tool Definitions) and `personality-v1` (Persona).
|
||||
* **Short-term Memory**: Injects `chatContext.messages` (Recent conversation turns).
|
||||
* **Global State (Crucial)**: The prompt explicitly states: *"You have X unread events."* and lists the contents of `botContext.unreadEvents`.
|
||||
* **Incoming Injection**: If there is an incoming message stream, it is injected as an `Incoming events` block at the end of the prompt.
|
||||
* **Action History**: Injects `chatContext.actions` to show the results of previous attempts (e.g., "Last action: read_messages, Result: Success").
|
||||
2. **Generation**:
|
||||
* The LLM outputs a strictly formatted JSON Action, e.g., `{"action": "read_unread_messages", "channelId": "..."}`.
|
||||
|
||||
### Phase 4: Dispatch & Execution
|
||||
**Location:** `src/core/dispatcher.ts` → `src/capabilities/registry.ts`
|
||||
|
||||
The system looks up the corresponding Handler in `globalRegistry` based on the JSON Action:
|
||||
|
||||
* **Case: `read_unread_messages`** (`src/capabilities/actions/read-messages.ts`)
|
||||
* **Logic**: Retrieves all backlog events from `botContext.unreadEvents` for the specified channel.
|
||||
* **Formatting**: Converts them into a single text block (e.g., `[User]: Content`).
|
||||
* **Result**: Stores this text in the `Action Result`.
|
||||
* **State Change**: Clears the `unreadEvents` for that channel. In the **next Tick**, the LLM will see this text in its History Actions and generate a reply.
|
||||
|
||||
* **Case: `send_message`** (`src/capabilities/actions/send-message.ts`)
|
||||
* **Safety Check**: Checks `unreadEvents` again. If new messages arrived during generation, it might abort the send to prioritize reading.
|
||||
* **Execution**: Calls `satoriClient.sendMessage`.
|
||||
* **Recording**: Persists the response to the DB and the in-memory `messages` array.
|
||||
|
||||
### Phase 5: Loop Continuation
|
||||
**Location:** `src/core/loop/scheduler.ts` (`handleLoopStep`)
|
||||
|
||||
* `dispatchAction` returns an `ActionResult` containing a `shouldContinue` flag.
|
||||
* If `shouldContinue` is true (e.g., usually true after reading messages, as a reply is expected), the scheduler waits for `LOOP_CONTINUE_DELAY_MS` (default 2.5s) and then recursively calls `handleLoopStep`.
|
||||
* **Termination**: The loop stops only when the LLM selects the `continue` action (Wait/Stop) or the `break` action.
|
||||
@@ -0,0 +1,46 @@
|
||||
## **Architecture Status Report: Memory & Persistence**
|
||||
|
||||
**Date:** February 9, 2026 (Refactored)
|
||||
**Component:** State Management Layer
|
||||
|
||||
### **1. Memory Architecture (RAM)**
|
||||
|
||||
The bot utilizes a **Memory-First** strategy, where the active state is fully resident in the Node.js heap.
|
||||
|
||||
* **Storage Mechanism**: All chat contexts are stored in a native `Map<string, ChatContext>` within the `BotContext` object (`src/core/types.ts`).
|
||||
* **Lifecycle Management**:
|
||||
* **Creation**: Contexts are lazy-loaded via `ensureChatContext` in `src/core/session/context.ts` upon receiving a message.
|
||||
* **Retention**: There is currently **no garbage collection (GC)** mechanism. Once a channel is loaded, its context remains in memory indefinitely until the process terminates.
|
||||
* **Context Trimming**:
|
||||
* Executed within `handleLoopStep` in `src/core/loop/scheduler.ts`.
|
||||
* Individual channels enforce a strict limit on history length (Default: 20 messages, 50 actions) to prevent single-channel bloat.
|
||||
* **Risk**: The architecture is susceptible to memory leaks (OOM) as the number of unique channels increases over time.
|
||||
|
||||
### **2. Persistence Architecture (Disk)**
|
||||
|
||||
The bot uses a file-based logging system primarily for archival purposes and basic metadata recovery upon restart, rather than for active state management.
|
||||
|
||||
* **Technology**: `lowdb` with a JSON file adapter.
|
||||
* **Location**: `src/lib/db.ts` -> `data/db.json`.
|
||||
* **Data Structure**:
|
||||
* `channels`: Stores metadata like Channel ID, Platform, and SelfID.
|
||||
* `messages`: A global, flattened array of messages.
|
||||
* **Write Strategy**: **Synchronous full-file serialization**. Every new message triggers a complete rewrite of the JSON file to disk.
|
||||
* **Retention Policy**: A global hard limit of **1000 messages** is enforced. When the limit is reached, the oldest messages are discarded regardless of which channel they belong to.
|
||||
* **Recovery Logic**: Upon restart, `ensureChatContext` queries `db.channels` to restore the channel's `platform` and `selfId`, but it **does not** load historical messages into the in-memory context.
|
||||
|
||||
### **3. State Consistency**
|
||||
|
||||
There is a significant desynchronization between the ephemeral memory state and the persistent disk state.
|
||||
|
||||
* **In-Memory State (Rich)**: Contains the full "Chain of Thought" (System prompts, reasoning steps, `AbortController` handles, pending Promises, `Action History`).
|
||||
* **On-Disk State (Flat)**: Contains only raw user content and final bot responses.
|
||||
* **Impact**: A process restart results in a **Hard Context Reset**. The bot loses all active "trains of thought" and task states, falling back to a state driven solely by new incoming messages.
|
||||
|
||||
### **4. Future Roadmap (WIP)**
|
||||
|
||||
We are planning to implement a "Small Memory" storage scheme to improve robustness, featuring:
|
||||
|
||||
1. **Indiscriminate Event Storage**: Storing all events without preemptive filtering.
|
||||
2. **Event Activation Query**: Triggering queries based on specific event activation signals.
|
||||
3. **Dynamic Context Filtering**: Reconstructing same-group contexts via query-time filtering rather than pre-computed buckets.
|
||||
@@ -0,0 +1,54 @@
|
||||
### **Prompt Architecture: Context-Injected Action Loop**
|
||||
|
||||
The bot implements a **"State-Aware Agentic Loop"** rather than a simple Chat-QA structure. The prompt is constructed dynamically at every tick of the loop.
|
||||
|
||||
#### **1. Static Layer (System Definitions)**
|
||||
|
||||
* **Source:** `src/core/planner/prompts/*.velin.md`
|
||||
* **Loader:** `src/core/planner/prompts/index.ts`
|
||||
* **Role:** Defines the "Soul" and "Rules".
|
||||
* **Components:**
|
||||
* **Protocol Definition (`system-action-gen-v1`):** Hardcodes the JSON schema for available tools (`send_message`, `read_unread_messages`, `sleep`) and logic flow (e.g., "Must check unread messages after sending").
|
||||
* **Persona (`personality-v1`):** Defines the character "AIRI" (tone, brevity, naturalness).
|
||||
|
||||
#### **2. History Layer (Short-term Memory)**
|
||||
|
||||
* **Source:** In-memory `messages` array (`ChatContext`).
|
||||
* **Role:** Provides conversational continuity.
|
||||
* **Mechanism:** A sliding window of the last ~20 messages (User/Assistant turns) is injected directly after the system prompt.
|
||||
|
||||
#### **3. Dynamic State Layer (Sensory Injection)**
|
||||
|
||||
* **Source:** `src/core/planner/llm-client.ts` (Runtime generated)
|
||||
* **Role:** Provides "Situational Awareness" and "Grounding".
|
||||
* **Mechanism:** A synthesized **User Message** is appended at the very end of the context window, forcing the LLM to focus on the immediate reality. It contains:
|
||||
* **Incoming Stream**: Raw content of new messages arriving *now* (passed from `scheduler`).
|
||||
* **Action History**: Results of the *immediately preceding* tool executions (e.g., "Action: send_message, Result: Success").
|
||||
* **Environment**: Current server time.
|
||||
* **Global State**: A summary of unread message counts across all channels (`unreadEvents`).
|
||||
* **Trigger**: The final instruction: *"Based on the context... Respond with the action... in JSON only."*
|
||||
|
||||
---
|
||||
|
||||
### **Data Flow Summary**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Static Markdown] -->|Velin Render| B(System Message)
|
||||
C[Chat History] -->|Sliding Window| D(Context Body)
|
||||
E[Runtime State] -->|Unread/Time/Results| F(State Injection)
|
||||
|
||||
B --> G[Final Prompt]
|
||||
D --> G
|
||||
F --> G
|
||||
|
||||
G -->|LLM API| H{Decision}
|
||||
H -->|JSON| I[Action Dispatcher]
|
||||
|
||||
```
|
||||
|
||||
### **Key Characteristics**
|
||||
|
||||
1. **JSON Enforcement**: The bot does not use native "Function Calling" APIs (like OpenAI Tools). It relies on **Prompt Engineering** to force the model to output raw JSON, which is then parsed by `best-effort-json-parser`.
|
||||
2. **Stateless Logic**: The prompt explicitly tells the LLM "You have X unread messages" in every turn, making the LLM the sole decision-maker for flow control (Reading vs. Replying vs. Sleeping).
|
||||
3. **Observation-Reflection**: The prompt includes `History actions`, allowing the LLM to "see" the result of its previous attempt (e.g., if a read action returned empty, it knows to stop).
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { BotContext, ChatContext, ReadUnreadMessagesAction } from '../types/bot'
|
||||
import type { SatoriMessage } from '../types/satori'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
export async function readUnreadMessages(
|
||||
botContext: BotContext,
|
||||
chatContext: ChatContext,
|
||||
action: ReadUnreadMessagesAction,
|
||||
): Promise<{ result: string } | undefined> {
|
||||
const logger = useLogg('readUnreadMessages').useGlobalConfig()
|
||||
|
||||
if (Object.keys(botContext.unreadMessages).length === 0) {
|
||||
logger.log('No unread messages - clearing unread messages')
|
||||
botContext.unreadMessages = {}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!action.channelId) {
|
||||
logger.warn('No channel ID provided - clearing all unread messages')
|
||||
return undefined
|
||||
}
|
||||
|
||||
let unreadMessagesForThisChannel: SatoriMessage[] | undefined = botContext.unreadMessages[action.channelId]
|
||||
|
||||
if (!Array.isArray(unreadMessagesForThisChannel)) {
|
||||
logger.log('Unread messages for channel is not an array - converting to array')
|
||||
unreadMessagesForThisChannel = []
|
||||
}
|
||||
|
||||
if (unreadMessagesForThisChannel.length === 0) {
|
||||
logger.log('No unread messages for channel - deleting')
|
||||
delete botContext.unreadMessages[action.channelId]
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Format messages for LLM context
|
||||
const formattedMessages = unreadMessagesForThisChannel.map((msg) => {
|
||||
const userName = msg.user?.name || msg.user?.id || 'Unknown'
|
||||
const content = msg.content || '[No content]'
|
||||
return `[${userName}]: ${content}`
|
||||
}).join('\n')
|
||||
|
||||
// Clear the unread messages for this channel
|
||||
delete botContext.unreadMessages[action.channelId]
|
||||
|
||||
logger.log(`Read ${unreadMessagesForThisChannel.length} unread messages from channel ${action.channelId}`)
|
||||
|
||||
return {
|
||||
result: `AIRI System: Read ${unreadMessagesForThisChannel.length} unread messages from channel ${action.channelId}:\n${formattedMessages}`,
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { SatoriClient } from '../client/satori-client'
|
||||
import type { BotContext, ChatContext } from '../types/bot'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
import { recordMessage } from '../db'
|
||||
|
||||
export async function sendMessage(
|
||||
botContext: BotContext,
|
||||
chatContext: ChatContext,
|
||||
satoriClient: SatoriClient,
|
||||
content: string,
|
||||
channelId: string,
|
||||
_abortController: AbortController,
|
||||
) {
|
||||
const logger = useLogg('sendMessage').useGlobalConfig()
|
||||
|
||||
try {
|
||||
// Check if we should abort due to new messages
|
||||
if (botContext.unreadMessages[channelId] && botContext.unreadMessages[channelId].length > 0) {
|
||||
logger.log(`Not sending message to ${channelId} - new messages arrived`)
|
||||
return
|
||||
}
|
||||
|
||||
// Send the message
|
||||
logger.withField('channelId', channelId).withField('content', content).log('Sending message')
|
||||
|
||||
await satoriClient.sendMessage(chatContext.platform, chatContext.selfId, channelId, content)
|
||||
|
||||
// Record the message in database
|
||||
await recordMessage(channelId, 'bot', 'AIRI', content)
|
||||
|
||||
// Add to chat context as assistant message
|
||||
chatContext.messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
})
|
||||
|
||||
logger.log('Message sent successfully')
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
logger.log('Message sending was aborted')
|
||||
return
|
||||
}
|
||||
|
||||
logger.withError(err as Error).log('Failed to send message')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { SatoriMessageCreateRequest, SatoriMessageCreateResponse } from '../types/satori'
|
||||
import type { SatoriMessageCreateRequest, SatoriMessageCreateResponse } from './types'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
+5
-3
@@ -1,11 +1,11 @@
|
||||
import type { SatoriEvent, SatoriIdentifyBody, SatoriReadyBody, SatoriSignal } from '../types/satori'
|
||||
import type { SatoriEvent, SatoriIdentifyBody, SatoriReadyBody, SatoriSignal } from './types'
|
||||
|
||||
import WebSocket from 'ws'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
import { SatoriOpcode } from '../types/satori'
|
||||
import { SatoriAPI } from './satori-api'
|
||||
import { SatoriAPI } from './api'
|
||||
import { SatoriOpcode } from './types'
|
||||
|
||||
const log = useLogg('SatoriClient')
|
||||
|
||||
@@ -213,6 +213,8 @@ export class SatoriClient {
|
||||
// Public API for sending messages
|
||||
async sendMessage(platform: string, selfId: string, channelId: string, content: string): Promise<void> {
|
||||
const key = `${platform}:${selfId}`
|
||||
log.debug(`sendMessage called - platform: "${platform}", selfId: "${selfId}", key: "${key}"`)
|
||||
log.debug(`Available API clients: ${Array.from(this.apiClients.keys()).join(', ')}`)
|
||||
const api = this.apiClients.get(key)
|
||||
|
||||
if (!api) {
|
||||
+1
@@ -78,6 +78,7 @@ export interface SatoriGuildRole {
|
||||
export interface SatoriMessage {
|
||||
id: string
|
||||
content: string
|
||||
platform?: string
|
||||
channel?: SatoriChannel
|
||||
guild?: SatoriGuild
|
||||
member?: SatoriGuildMember
|
||||
@@ -1,363 +0,0 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { SatoriClient } from '../client/satori-client'
|
||||
import type { Action, BotContext, ChatContext } from '../types/bot'
|
||||
import type { SatoriMessage } from '../types/satori'
|
||||
|
||||
import { readUnreadMessages } from '../actions/read-unread-messages'
|
||||
import { sendMessage } from '../actions/send-message'
|
||||
import { listChannels, recordChannel, recordMessage } from '../db'
|
||||
import { imagineAnAction } from '../llm/actions'
|
||||
|
||||
async function dispatchAction(
|
||||
ctx: BotContext,
|
||||
satoriClient: SatoriClient,
|
||||
action: Action,
|
||||
abortController: AbortController,
|
||||
chatCtx?: ChatContext,
|
||||
): Promise<(() => Promise<any>) | undefined> {
|
||||
// If action generation failed, don't proceed
|
||||
if (!action || !action.action) {
|
||||
ctx.logger.withField('action', action).log('No valid action returned.')
|
||||
if (chatCtx) {
|
||||
chatCtx.messages.push({
|
||||
role: 'user',
|
||||
content: 'AIRI System: No valid action returned.',
|
||||
})
|
||||
return () => handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (action.action) {
|
||||
case 'list_channels': {
|
||||
if (chatCtx) {
|
||||
const channels = await listChannels()
|
||||
const channelList = channels.map(c => `ID:${c.id}, Name:${c.name}, Platform:${c.platform}`).join('\n')
|
||||
chatCtx.actions.push({
|
||||
action,
|
||||
result: `AIRI System: List of channels:\n${channelList}`,
|
||||
})
|
||||
}
|
||||
return () => handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
}
|
||||
|
||||
case 'send_message': {
|
||||
const chatCtx = await ensureChatContext(ctx, action.channelId)
|
||||
chatCtx.actions.push({
|
||||
action,
|
||||
result: `AIRI System: Sending message to channel ${action.channelId}: ${action.content}`,
|
||||
})
|
||||
await sendMessage(ctx, chatCtx, satoriClient, action.content, action.channelId, abortController)
|
||||
return () => handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
}
|
||||
|
||||
case 'read_unread_messages': {
|
||||
const chatCtx = await ensureChatContext(ctx, action.channelId)
|
||||
const res = await readUnreadMessages(ctx, chatCtx, action)
|
||||
if (res?.result) {
|
||||
ctx.logger.log('Messages read')
|
||||
chatCtx.actions.push({ action, result: res.result })
|
||||
return () => handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
case 'continue':
|
||||
if (chatCtx) {
|
||||
chatCtx.actions.push({
|
||||
action,
|
||||
result: 'AIRI System: Acknowledged, will now continue until next tick.',
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
|
||||
case 'break':
|
||||
if (chatCtx) {
|
||||
chatCtx.messages = []
|
||||
chatCtx.actions = []
|
||||
chatCtx.actions.push({
|
||||
action,
|
||||
result: 'AIRI System: Acknowledged, will now break, and clear out all existing memories, messages, actions.',
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
|
||||
case 'sleep':
|
||||
await new Promise(resolve => setTimeout(resolve, 30 * 1000))
|
||||
if (chatCtx) {
|
||||
chatCtx.actions.push({
|
||||
action,
|
||||
result: 'AIRI System: Sleeping for 30 seconds as requested...',
|
||||
})
|
||||
}
|
||||
return () => handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
|
||||
default:
|
||||
if (chatCtx) {
|
||||
chatCtx.messages.push({
|
||||
role: 'user',
|
||||
content: `AIRI System: The action ${(action as any).action} hasn't been implemented yet by developer.`,
|
||||
})
|
||||
}
|
||||
return () => handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoopStep(
|
||||
ctx: BotContext,
|
||||
satoriClient: SatoriClient,
|
||||
chatCtx: ChatContext,
|
||||
incomingMessage?: SatoriMessage,
|
||||
): Promise<(() => Promise<any>) | undefined> {
|
||||
ctx.currentProcessingStartTime = Date.now()
|
||||
|
||||
if (chatCtx?.currentAbortController) {
|
||||
chatCtx.currentAbortController.abort()
|
||||
}
|
||||
|
||||
const currentController = new AbortController()
|
||||
if (chatCtx) {
|
||||
chatCtx.currentAbortController = currentController
|
||||
|
||||
// Track message processing state
|
||||
if (chatCtx.channelId && !ctx.lastInteractedChannelIds.includes(chatCtx.channelId)) {
|
||||
ctx.lastInteractedChannelIds.push(chatCtx.channelId)
|
||||
}
|
||||
if (ctx.lastInteractedChannelIds.length > 5) {
|
||||
ctx.lastInteractedChannelIds = ctx.lastInteractedChannelIds.slice(-5)
|
||||
}
|
||||
|
||||
// Manage context size
|
||||
if (chatCtx.messages == null) {
|
||||
chatCtx.messages = []
|
||||
}
|
||||
if (chatCtx.messages.length > 20) {
|
||||
const length = chatCtx.messages.length
|
||||
chatCtx.messages = chatCtx.messages.slice(-5)
|
||||
chatCtx.messages.push({
|
||||
role: 'user',
|
||||
content: `AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.messages.length}, history may be lost.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (chatCtx.actions == null) {
|
||||
chatCtx.actions = []
|
||||
}
|
||||
if (chatCtx.actions.length > 50) {
|
||||
const length = chatCtx.actions.length
|
||||
chatCtx.actions = chatCtx.actions.slice(-20)
|
||||
chatCtx.messages.push({
|
||||
role: 'user',
|
||||
content: `AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.actions.length}, history of actions may be lost.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const action = await imagineAnAction(
|
||||
currentController,
|
||||
chatCtx?.messages || [],
|
||||
chatCtx?.actions || [],
|
||||
{
|
||||
unreadMessages: ctx.unreadMessages,
|
||||
incomingMessages: incomingMessage ? [incomingMessage] : [],
|
||||
},
|
||||
)
|
||||
return await dispatchAction(ctx, satoriClient, action, currentController, chatCtx)
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
ctx.logger.log('Operation was aborted due to interruption')
|
||||
return undefined
|
||||
}
|
||||
|
||||
ctx.logger.withError(err as Error).log('Error occurred')
|
||||
return undefined
|
||||
}
|
||||
finally {
|
||||
if (chatCtx && chatCtx.currentAbortController === currentController) {
|
||||
chatCtx.currentAbortController = undefined
|
||||
ctx.currentProcessingStartTime = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loopIterationForChannel(
|
||||
bot: BotContext,
|
||||
satoriClient: SatoriClient,
|
||||
chatContext: ChatContext,
|
||||
incomingMessage: SatoriMessage,
|
||||
) {
|
||||
let result = await handleLoopStep(bot, satoriClient, chatContext, incomingMessage)
|
||||
|
||||
while (typeof result === 'function') {
|
||||
result = await result()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function loopIterationPeriodicForExistingChannels(ctx: BotContext, satoriClient: SatoriClient) {
|
||||
// Only process channels with unread messages to avoid unnecessary LLM calls
|
||||
const channelsWithUnread = Object.keys(ctx.unreadMessages).filter(
|
||||
channelId => ctx.unreadMessages[channelId]?.length > 0,
|
||||
)
|
||||
|
||||
if (channelsWithUnread.length === 0) {
|
||||
ctx.logger.log('No channels with unread messages, skipping periodic check')
|
||||
return
|
||||
}
|
||||
|
||||
ctx.logger.withField('channelCount', channelsWithUnread.length).log('Processing channels with unread messages')
|
||||
|
||||
// Process channels sequentially to avoid overwhelming the LLM API
|
||||
for (const channelId of channelsWithUnread) {
|
||||
const chatCtx = await ensureChatContext(ctx, channelId)
|
||||
|
||||
try {
|
||||
const action = await imagineAnAction(
|
||||
chatCtx.currentAbortController,
|
||||
chatCtx.messages,
|
||||
chatCtx.actions,
|
||||
{ unreadMessages: ctx.unreadMessages },
|
||||
)
|
||||
let result = await dispatchAction(ctx, satoriClient, action, chatCtx.currentAbortController, chatCtx)
|
||||
|
||||
while (typeof result === 'function') {
|
||||
result = await result()
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
ctx.logger.withError(err as Error).withField('channelId', channelId).log('Error processing channel in periodic loop')
|
||||
// Continue to next channel instead of breaking the entire loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loopPeriodic(botCtx: BotContext, satoriClient: SatoriClient) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await loopIterationPeriodicForExistingChannels(botCtx, satoriClient)
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
botCtx.logger.log('main loop was aborted - restarting loop')
|
||||
}
|
||||
else {
|
||||
botCtx.logger.withError(err as Error).log('error in main loop')
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loopPeriodic(botCtx, satoriClient)
|
||||
}
|
||||
}, 60 * 1000)
|
||||
}
|
||||
|
||||
export function createBotContext(logger: Logg): BotContext {
|
||||
const botSelf: BotContext = {
|
||||
messageQueue: [],
|
||||
unreadMessages: {},
|
||||
processedIds: new Set(),
|
||||
logger,
|
||||
processing: false,
|
||||
lastInteractedChannelIds: [],
|
||||
chats: new Map<string, ChatContext>(),
|
||||
}
|
||||
|
||||
return botSelf
|
||||
}
|
||||
|
||||
export async function onMessageArrival(
|
||||
botContext: BotContext,
|
||||
satoriClient: SatoriClient,
|
||||
chatCtx: ChatContext,
|
||||
) {
|
||||
if (botContext.processing) {
|
||||
return
|
||||
}
|
||||
botContext.processing = true
|
||||
|
||||
try {
|
||||
while (botContext.messageQueue.length > 0) {
|
||||
const nextMsg = botContext.messageQueue[0]
|
||||
|
||||
if (nextMsg.status === 'ready') {
|
||||
// Record channel (use chatCtx.channelId which is already correctly set)
|
||||
await recordChannel(
|
||||
chatCtx.channelId,
|
||||
nextMsg.message.channel?.name || chatCtx.channelId,
|
||||
chatCtx.platform,
|
||||
chatCtx.selfId,
|
||||
)
|
||||
|
||||
// Record message
|
||||
if (nextMsg.message.user && nextMsg.message.content) {
|
||||
await recordMessage(
|
||||
chatCtx.channelId,
|
||||
nextMsg.message.user.id,
|
||||
nextMsg.message.user.name || nextMsg.message.user.id,
|
||||
nextMsg.message.content,
|
||||
)
|
||||
}
|
||||
|
||||
let unreadMessagesForThisChannel = botContext.unreadMessages[chatCtx.channelId]
|
||||
|
||||
if (unreadMessagesForThisChannel == null) {
|
||||
botContext.logger.withField('channelId', chatCtx.channelId).log('unread messages for this channel is null - creating empty array')
|
||||
unreadMessagesForThisChannel = []
|
||||
}
|
||||
if (!Array.isArray(unreadMessagesForThisChannel)) {
|
||||
botContext.logger.withField('channelId', chatCtx.channelId).log('unread messages for this channel is not an array - converting to array')
|
||||
unreadMessagesForThisChannel = []
|
||||
}
|
||||
|
||||
unreadMessagesForThisChannel.push(nextMsg.message)
|
||||
|
||||
if (unreadMessagesForThisChannel.length > 100) {
|
||||
unreadMessagesForThisChannel = unreadMessagesForThisChannel.slice(-100)
|
||||
}
|
||||
|
||||
botContext.unreadMessages[chatCtx.channelId] = unreadMessagesForThisChannel
|
||||
botContext.logger.withField('channelId', chatCtx.channelId).log('message queue processed, triggering immediate reaction')
|
||||
|
||||
// Trigger immediate processing
|
||||
await loopIterationForChannel(botContext, satoriClient, chatCtx, nextMsg.message)
|
||||
botContext.messageQueue.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
botContext.logger.withError(err as Error).log('Error occurred')
|
||||
}
|
||||
finally {
|
||||
botContext.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureChatContext(botCtx: BotContext, channelId: string): Promise<ChatContext> {
|
||||
if (botCtx.chats.has(channelId)) {
|
||||
return botCtx.chats.get(channelId)!
|
||||
}
|
||||
|
||||
// Try to get channel info from database
|
||||
const channels = await listChannels()
|
||||
const channelInfo = channels.find(c => c.id === channelId)
|
||||
|
||||
const newChatContext: ChatContext = {
|
||||
channelId,
|
||||
platform: channelInfo?.platform || '',
|
||||
selfId: channelInfo?.selfId || '',
|
||||
currentTask: undefined,
|
||||
currentAbortController: undefined,
|
||||
messages: [],
|
||||
actions: [],
|
||||
}
|
||||
|
||||
botCtx.chats.set(channelId, newChatContext)
|
||||
return newChatContext
|
||||
}
|
||||
|
||||
export function startPeriodicLoop(botCtx: BotContext, satoriClient: SatoriClient) {
|
||||
loopPeriodic(botCtx, satoriClient)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ActionHandler, ActionResult } from '../definition'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
export const readMessagesAction: ActionHandler = {
|
||||
name: 'read_unread_messages',
|
||||
description: 'Read unread messages from a specific channel',
|
||||
execute: async (botContext, chatCtx, args): Promise<ActionResult> => {
|
||||
const logger = useLogg('readMessagesAction').useGlobalConfig()
|
||||
const channelId = args.channelId
|
||||
|
||||
if (!channelId) {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: 'System Error: No channelId provided for read_unread_messages.',
|
||||
}
|
||||
}
|
||||
|
||||
const unreadEventsForThisChannel = botContext.unreadEvents[channelId]
|
||||
|
||||
if (!unreadEventsForThisChannel || unreadEventsForThisChannel.length === 0) {
|
||||
delete botContext.unreadEvents[channelId]
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: true,
|
||||
result: 'AIRI System: No unread messages found.',
|
||||
}
|
||||
}
|
||||
|
||||
const formattedMessages = unreadEventsForThisChannel.map((event) => {
|
||||
const userName = event.user?.name || event.user?.id || 'Unknown'
|
||||
const content = event.message?.content || '[No content]'
|
||||
return `[${userName}]: ${content}`
|
||||
}).join('\n')
|
||||
|
||||
delete botContext.unreadEvents[channelId]
|
||||
|
||||
logger.log(`Read ${unreadEventsForThisChannel.length} unread events from channel ${channelId}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: true,
|
||||
result: `AIRI System: Read ${unreadEventsForThisChannel.length} unread events from channel ${channelId}:\n${formattedMessages}`,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SatoriClient } from '../../adapter/satori/client'
|
||||
import type { ActionHandler } from '../definition'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
import { recordMessage } from '../../lib/db'
|
||||
|
||||
export function createSendMessageAction(client: SatoriClient): ActionHandler {
|
||||
return {
|
||||
name: 'send_message',
|
||||
execute: async (ctx, chatCtx, args) => {
|
||||
const logger = useLogg('Action:send_message').useGlobalConfig()
|
||||
const { channelId, content } = args
|
||||
|
||||
// Logic 1: Concurrency Safety Check
|
||||
if (ctx.unreadEvents[channelId] && ctx.unreadEvents[channelId].length > 0) {
|
||||
logger.withField('channelId', channelId).warn('Aborting message send due to new incoming events')
|
||||
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: 'AIRI System: [INTERRUPT] Message sending ABORTED. New unread messages were detected from the user. Please [read_unread_messages] first to understand the new context.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Logic 2: Execute Send
|
||||
await client.sendMessage(chatCtx.platform, chatCtx.selfId, channelId, content)
|
||||
|
||||
// Logic 3: Persistence
|
||||
await recordMessage(channelId, 'bot', 'AIRI', content)
|
||||
|
||||
// Logic 4: Memory State Update
|
||||
chatCtx.messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: true,
|
||||
result: `AIRI System: Message sent to ${channelId}: ${content}`,
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger.withError(error as Error).error('Failed to send message')
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: `AIRI System: Error sending message: ${(error as Error).message}`,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ActionHandler, ActionResult } from '../definition'
|
||||
|
||||
import { SLEEP_DURATION_MS } from '../../core/constants'
|
||||
import { listChannels } from '../../lib/db'
|
||||
|
||||
// 1. Continue Action
|
||||
export const continueAction: ActionHandler = {
|
||||
name: 'continue',
|
||||
execute: async (): Promise<ActionResult> => {
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: false,
|
||||
result: 'AIRI System: Acknowledged, will now wait for user input.',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// 2. Break Action
|
||||
export const breakAction: ActionHandler = {
|
||||
name: 'break',
|
||||
execute: async (_ctx, chatCtx): Promise<ActionResult> => {
|
||||
chatCtx.messages = []
|
||||
chatCtx.actions = []
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: false,
|
||||
result: 'AIRI System: Memory cleared. Loop broken.',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// 3. Sleep Action
|
||||
export const sleepAction: ActionHandler = {
|
||||
name: 'sleep',
|
||||
execute: async (_ctx, _chatCtx, args): Promise<ActionResult> => {
|
||||
const duration = args.duration || SLEEP_DURATION_MS
|
||||
await new Promise(resolve => setTimeout(resolve, duration))
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: true,
|
||||
result: `AIRI System: Slept for ${duration / 1000} seconds.`,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// 4. List Channels Action
|
||||
export const listChannelsAction: ActionHandler = {
|
||||
name: 'list_channels',
|
||||
execute: async (): Promise<ActionResult> => {
|
||||
const channels = await listChannels()
|
||||
const list = channels.map(c => `ID:${c.id}, Name:${c.name}, Platform:${c.platform}`).join('\n')
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: true,
|
||||
result: `AIRI System: Channel List:\n${list}`,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BotContext, ChatContext } from '../core/types'
|
||||
|
||||
export interface ActionResult {
|
||||
success: boolean
|
||||
shouldContinue: boolean
|
||||
result: any
|
||||
}
|
||||
|
||||
export interface ActionHandler {
|
||||
name: string
|
||||
description?: string
|
||||
execute: (
|
||||
ctx: BotContext,
|
||||
chatCtx: ChatContext,
|
||||
args: any,
|
||||
abortSignal?: AbortSignal,
|
||||
) => Promise<ActionResult>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SatoriClient } from '../adapter/satori/client'
|
||||
import type { ActionHandler } from './definition'
|
||||
|
||||
import { readMessagesAction } from './actions/read-messages'
|
||||
import { createSendMessageAction } from './actions/send-message'
|
||||
import { breakAction, continueAction, listChannelsAction, sleepAction } from './actions/system'
|
||||
// import { createReadMessagesAction } ...
|
||||
|
||||
export class ActionRegistry {
|
||||
private actions = new Map<string, ActionHandler>()
|
||||
|
||||
register(handler: ActionHandler) {
|
||||
this.actions.set(handler.name, handler)
|
||||
}
|
||||
|
||||
get(name: string): ActionHandler | undefined {
|
||||
return this.actions.get(name)
|
||||
}
|
||||
|
||||
// 提供一个批量加载的方法
|
||||
loadStandardActions(client: SatoriClient) {
|
||||
// 注册不需要依赖的系统 Action
|
||||
this.register(continueAction)
|
||||
this.register(breakAction)
|
||||
this.register(sleepAction)
|
||||
this.register(listChannelsAction)
|
||||
|
||||
// 注册需要注入依赖的 Action
|
||||
this.register(createSendMessageAction(client))
|
||||
this.register(readMessagesAction)
|
||||
}
|
||||
}
|
||||
|
||||
export const globalRegistry = new ActionRegistry()
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Bot configuration constants
|
||||
* Centralized configuration values for the satori-bot
|
||||
*/
|
||||
|
||||
// Loop and timing constants
|
||||
export const LOOP_CONTINUE_DELAY_MS = 2500
|
||||
export const PERIODIC_LOOP_INTERVAL_MS = 60 * 1000
|
||||
export const SLEEP_DURATION_MS = 30 * 1000
|
||||
|
||||
// Context size limits
|
||||
export const MAX_MESSAGES_IN_CONTEXT = 20
|
||||
export const MAX_ACTIONS_IN_CONTEXT = 50
|
||||
export const MAX_UNREAD_EVENTS = 100
|
||||
|
||||
// Recent interaction tracking
|
||||
export const MAX_RECENT_INTERACTED_CHANNELS = 5
|
||||
|
||||
// Context trimming - how many items to keep when trimming
|
||||
export const MESSAGES_KEEP_ON_TRIM = 5
|
||||
export const ACTIONS_KEEP_ON_TRIM = 20
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ActionResult } from '../capabilities/definition'
|
||||
import type { BotContext, ChatContext } from './types'
|
||||
|
||||
import { globalRegistry } from '../capabilities/registry'
|
||||
|
||||
export async function dispatchAction(
|
||||
ctx: BotContext,
|
||||
chatCtx: ChatContext,
|
||||
actionPayload: any,
|
||||
abortController: AbortController,
|
||||
): Promise<ActionResult> {
|
||||
const log = ctx.logger.useGlobalConfig()
|
||||
|
||||
if (!actionPayload || !actionPayload.action) {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: 'System Error: No valid action name provided in JSON.',
|
||||
}
|
||||
}
|
||||
|
||||
const handler = globalRegistry.get(actionPayload.action)
|
||||
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: `System Error: Action "${actionPayload.action}" is not implemented.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.withField('action', actionPayload.action).debug('Executing action')
|
||||
|
||||
const result = await handler.execute(ctx, chatCtx, actionPayload, abortController.signal)
|
||||
|
||||
chatCtx.actions.push({
|
||||
action: actionPayload,
|
||||
result: result.result,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error as Error).error('Action execution failed')
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: `System Error: Execution failed: ${(error as Error).message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Bot module exports
|
||||
* Centralized export point for all bot-related functionality
|
||||
*/
|
||||
|
||||
// Constants
|
||||
export * from './constants'
|
||||
|
||||
// Action dispatcher
|
||||
export { dispatchAction } from './dispatcher'
|
||||
|
||||
// Event handlers
|
||||
export { setupMessageEventHandler, setupReadyEventHandler } from './loop/queue'
|
||||
|
||||
// Loop processing
|
||||
export { handleLoopStep, loopIterationForChannel, onMessageArrival, startPeriodicLoop } from './loop/scheduler'
|
||||
|
||||
// Context management
|
||||
export { createBotContext, ensureChatContext } from './session/context'
|
||||
|
||||
// Utilities
|
||||
export { formatDebugContext, getMessageContentString, isBotOwnMessage } from './utils'
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { SatoriClient } from '../../adapter/satori/client'
|
||||
import type { SatoriEvent, SatoriReadyBody } from '../../adapter/satori/types'
|
||||
import type { BotContext } from '../types'
|
||||
|
||||
import { onMessageArrival } from './scheduler'
|
||||
|
||||
/**
|
||||
* Set up the ready event handler
|
||||
* Logs connection information when Satori client is ready
|
||||
*/
|
||||
export function setupReadyEventHandler(
|
||||
satoriClient: SatoriClient,
|
||||
logger: Logg,
|
||||
): void {
|
||||
satoriClient.onReady((ready: SatoriReadyBody) => {
|
||||
logger.log('Satori client ready:', ready)
|
||||
logger.log(`Connected to ${ready.logins.length} platform(s)`)
|
||||
|
||||
for (const login of ready.logins) {
|
||||
logger.log(`- ${login.platform} (${login.self_id}): ${login.status}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the message-created event handler
|
||||
* Processes incoming messages, filters bot's own messages, and triggers bot responses
|
||||
*/
|
||||
export function setupMessageEventHandler(
|
||||
satoriClient: SatoriClient,
|
||||
botContext: BotContext,
|
||||
logger: Logg,
|
||||
): void {
|
||||
satoriClient.on('message-created', async (event: SatoriEvent) => {
|
||||
const message = event.message
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.log(`Received message from ${event.user.id} in channel [${event.platform}] ${event.channel.id}: ${message.content}`)
|
||||
|
||||
const messageId = `${event.channel.id}-${message.id}`
|
||||
if (!botContext.processedIds.has(messageId)) {
|
||||
botContext.processedIds.add(messageId)
|
||||
}
|
||||
else {
|
||||
logger.debug(`Skipping already processed message: ${messageId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Add to message queue
|
||||
botContext.eventQueue.push({
|
||||
event,
|
||||
status: 'ready',
|
||||
})
|
||||
|
||||
// Process message queue
|
||||
// Pass event so onMessageArrival can use correct channelId and set platform/selfId for each message
|
||||
await onMessageArrival(botContext, satoriClient)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { SatoriClient } from '../../adapter/satori/client'
|
||||
import type { SatoriEvent } from '../../adapter/satori/types'
|
||||
import type { BotContext, ChatContext } from '../types'
|
||||
|
||||
import { recordChannel, recordMessage } from '../../lib/db'
|
||||
import {
|
||||
ACTIONS_KEEP_ON_TRIM,
|
||||
LOOP_CONTINUE_DELAY_MS,
|
||||
MAX_ACTIONS_IN_CONTEXT,
|
||||
MAX_MESSAGES_IN_CONTEXT,
|
||||
MAX_RECENT_INTERACTED_CHANNELS,
|
||||
MAX_UNREAD_EVENTS,
|
||||
MESSAGES_KEEP_ON_TRIM,
|
||||
PERIODIC_LOOP_INTERVAL_MS,
|
||||
} from '../constants'
|
||||
import { dispatchAction } from '../dispatcher'
|
||||
import { imagineAnAction } from '../planner/llm-client'
|
||||
import { ensureChatContext } from '../session/context'
|
||||
|
||||
/**
|
||||
* Handle a single loop step
|
||||
* Manages context size, calls LLM for action, and dispatches the action
|
||||
*/
|
||||
export async function handleLoopStep(
|
||||
ctx: BotContext,
|
||||
satoriClient: SatoriClient,
|
||||
chatCtx: ChatContext,
|
||||
incomingEvents?: SatoriEvent,
|
||||
): Promise<void> {
|
||||
ctx.currentProcessingStartTime = Date.now()
|
||||
|
||||
if (chatCtx?.currentAbortController) {
|
||||
chatCtx.currentAbortController.abort()
|
||||
}
|
||||
|
||||
const currentController = new AbortController()
|
||||
if (chatCtx) {
|
||||
chatCtx.currentAbortController = currentController
|
||||
|
||||
// Track message processing state
|
||||
if (chatCtx.channelId && !ctx.lastInteractedChannelIds.includes(chatCtx.channelId)) {
|
||||
ctx.lastInteractedChannelIds.push(chatCtx.channelId)
|
||||
}
|
||||
if (ctx.lastInteractedChannelIds.length > MAX_RECENT_INTERACTED_CHANNELS) {
|
||||
ctx.lastInteractedChannelIds = ctx.lastInteractedChannelIds.slice(-MAX_RECENT_INTERACTED_CHANNELS)
|
||||
}
|
||||
|
||||
// Manage context size
|
||||
if (chatCtx.messages == null) {
|
||||
chatCtx.messages = []
|
||||
}
|
||||
if (chatCtx.messages.length > MAX_MESSAGES_IN_CONTEXT) {
|
||||
const length = chatCtx.messages.length
|
||||
chatCtx.messages = chatCtx.messages.slice(-MESSAGES_KEEP_ON_TRIM)
|
||||
chatCtx.messages.push({
|
||||
role: 'user',
|
||||
content: `AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.messages.length}, history may be lost.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (chatCtx.actions == null) {
|
||||
chatCtx.actions = []
|
||||
}
|
||||
if (chatCtx.actions.length > MAX_ACTIONS_IN_CONTEXT) {
|
||||
const length = chatCtx.actions.length
|
||||
chatCtx.actions = chatCtx.actions.slice(-ACTIONS_KEEP_ON_TRIM)
|
||||
chatCtx.messages.push({
|
||||
role: 'user',
|
||||
content: `AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.actions.length}, history of actions may be lost.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const actionPayload = await imagineAnAction(
|
||||
currentController,
|
||||
chatCtx?.messages || [],
|
||||
chatCtx?.actions || [],
|
||||
{
|
||||
unreadEvents: ctx.unreadEvents,
|
||||
incomingEvents: incomingEvents ? [incomingEvents] : [],
|
||||
},
|
||||
)
|
||||
|
||||
const result = await dispatchAction(ctx, chatCtx, actionPayload, currentController)
|
||||
if (result.shouldContinue) {
|
||||
await new Promise(r => setTimeout(r, LOOP_CONTINUE_DELAY_MS))
|
||||
// Recursively call next step and await it
|
||||
await handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
ctx.logger.log('Operation was aborted due to interruption')
|
||||
return
|
||||
}
|
||||
|
||||
ctx.logger.withError(err as Error).log('Error occurred')
|
||||
}
|
||||
finally {
|
||||
if (chatCtx && chatCtx.currentAbortController === currentController) {
|
||||
chatCtx.currentAbortController = undefined
|
||||
ctx.currentProcessingStartTime = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a loop iteration for a specific channel with an incoming message
|
||||
* Continues processing until no more continuation functions are returned
|
||||
*/
|
||||
export async function loopIterationForChannel(
|
||||
bot: BotContext,
|
||||
satoriClient: SatoriClient,
|
||||
chatContext: ChatContext,
|
||||
incomingEvent: SatoriEvent,
|
||||
) {
|
||||
// Directly await the recursive process
|
||||
await handleLoopStep(bot, satoriClient, chatContext, incomingEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process periodic loop iteration for existing channels with unread messages
|
||||
* Only processes channels that have unread messages to avoid unnecessary LLM calls
|
||||
*/
|
||||
async function loopIterationPeriodicForExistingChannels(ctx: BotContext, satoriClient: SatoriClient) {
|
||||
// Only process channels with unread messages to avoid unnecessary LLM calls
|
||||
const channelsWithUnread = Object.keys(ctx.unreadEvents).filter(
|
||||
channelId => ctx.unreadEvents[channelId]?.length > 0,
|
||||
)
|
||||
|
||||
if (channelsWithUnread.length === 0) {
|
||||
ctx.logger.log('No channels with unread events, skipping periodic check')
|
||||
return
|
||||
}
|
||||
|
||||
ctx.logger.withField('channelCount', channelsWithUnread.length).log('Processing channels with unread events')
|
||||
|
||||
// Process channels sequentially to avoid overwhelming the LLM API
|
||||
for (const channelId of channelsWithUnread) {
|
||||
try {
|
||||
const chatCtx = await ensureChatContext(ctx, channelId)
|
||||
await handleLoopStep(ctx, satoriClient, chatCtx)
|
||||
}
|
||||
catch (err) {
|
||||
ctx.logger.withError(err as Error).withField('channelId', channelId).log('Error processing channel in periodic loop')
|
||||
// Continue to next channel instead of breaking the entire loop
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic loop function that runs every PERIODIC_LOOP_INTERVAL_MS
|
||||
* Recursively schedules itself to continue running
|
||||
*/
|
||||
function loopPeriodic(botCtx: BotContext, satoriClient: SatoriClient) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await loopIterationPeriodicForExistingChannels(botCtx, satoriClient)
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
botCtx.logger.log('main loop was aborted - restarting loop')
|
||||
}
|
||||
else {
|
||||
botCtx.logger.withError(err as Error).log('error in main loop')
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loopPeriodic(botCtx, satoriClient)
|
||||
}
|
||||
}, PERIODIC_LOOP_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic loop
|
||||
* Begins the recursive periodic processing of channels with unread messages
|
||||
*/
|
||||
export function startPeriodicLoop(botCtx: BotContext, satoriClient: SatoriClient) {
|
||||
loopPeriodic(botCtx, satoriClient)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle message arrival event
|
||||
* Processes messages from the queue, records them, and triggers bot responses
|
||||
* Each message in the queue is processed with its own correct channelId and chatCtx
|
||||
*/
|
||||
export async function onMessageArrival(
|
||||
botContext: BotContext,
|
||||
satoriClient: SatoriClient,
|
||||
) {
|
||||
if (botContext.processing) {
|
||||
return
|
||||
}
|
||||
botContext.processing = true
|
||||
|
||||
const log = botContext.logger
|
||||
|
||||
try {
|
||||
while (botContext.eventQueue.length > 0) {
|
||||
const currMsg = botContext.eventQueue[0]
|
||||
if (currMsg.status !== 'ready')
|
||||
break
|
||||
|
||||
const channelId = currMsg.event.channel?.id || 'unknown'
|
||||
const platform = currMsg.event.platform || 'unknown'
|
||||
const selfId = currMsg.event.self_id || currMsg.event.login?.self_id || 'unknown'
|
||||
const sourceUserId = currMsg.event.user?.id || currMsg.event.member?.user?.id
|
||||
const sourceUserName = currMsg.event.user?.name || currMsg.event.member?.user?.name || 'unknown'
|
||||
|
||||
const chatCtx = await ensureChatContext(botContext, channelId)
|
||||
|
||||
if (!chatCtx.platform || chatCtx.platform === '') {
|
||||
chatCtx.platform = platform
|
||||
}
|
||||
if (!chatCtx.selfId || chatCtx.selfId === '') {
|
||||
chatCtx.selfId = selfId
|
||||
}
|
||||
|
||||
// Record channel
|
||||
await recordChannel(
|
||||
chatCtx.channelId,
|
||||
currMsg.event.channel?.name || chatCtx.channelId,
|
||||
chatCtx.platform,
|
||||
chatCtx.selfId,
|
||||
)
|
||||
|
||||
// Record message
|
||||
if (currMsg.event.user && currMsg.event.message?.content) {
|
||||
await recordMessage(
|
||||
chatCtx.channelId,
|
||||
currMsg.event.user.id,
|
||||
currMsg.event.user.name || currMsg.event.user.id,
|
||||
currMsg.event.message.content,
|
||||
)
|
||||
}
|
||||
|
||||
// Skip bot's own messages - don't add them to unreadEvents
|
||||
if (sourceUserId === chatCtx.selfId) {
|
||||
botContext.logger
|
||||
.withFields({
|
||||
channelId: chatCtx.channelId,
|
||||
sourceUserId: currMsg.event.user?.id || currMsg.event.member?.user?.id,
|
||||
selfId: chatCtx.selfId,
|
||||
messageId: currMsg.event.id,
|
||||
})
|
||||
.debug('[DEBUG] Skipping bot\'s own event in unreadEvents - filtered out')
|
||||
botContext.eventQueue.shift()
|
||||
continue
|
||||
}
|
||||
|
||||
let unreadEventsForThisChannel = botContext.unreadEvents[chatCtx.channelId]
|
||||
|
||||
if (unreadEventsForThisChannel == null) {
|
||||
botContext.logger.withField('channelId', chatCtx.channelId).log('unread events for this channel is null - creating empty array')
|
||||
unreadEventsForThisChannel = []
|
||||
}
|
||||
if (!Array.isArray(unreadEventsForThisChannel)) {
|
||||
botContext.logger.withField('channelId', chatCtx.channelId).log('unread events for this channel is not an array - converting to array')
|
||||
unreadEventsForThisChannel = []
|
||||
}
|
||||
|
||||
unreadEventsForThisChannel.push(currMsg.event)
|
||||
|
||||
if (unreadEventsForThisChannel.length > MAX_UNREAD_EVENTS) {
|
||||
unreadEventsForThisChannel = unreadEventsForThisChannel.slice(-MAX_UNREAD_EVENTS)
|
||||
}
|
||||
|
||||
botContext.unreadEvents[chatCtx.channelId] = unreadEventsForThisChannel
|
||||
|
||||
botContext.logger.withField('channelId', chatCtx.channelId).log('event queue processed, triggering immediate reaction')
|
||||
|
||||
// Trigger immediate processing with the correct chatCtx for this message
|
||||
await loopIterationForChannel(botContext, satoriClient, chatCtx, currMsg.event)
|
||||
botContext.eventQueue.shift()
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
botContext.logger.withError(err as Error).log('Error occurred')
|
||||
}
|
||||
finally {
|
||||
botContext.processing = false
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -1,8 +1,8 @@
|
||||
import type { GenerateTextOptions } from '@xsai/generate-text'
|
||||
import type { Message as LLMMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { Action } from '../types/bot'
|
||||
import type { SatoriMessage } from '../types/satori'
|
||||
import type { SatoriEvent } from '../../adapter/satori/types'
|
||||
import type { Action } from '../types'
|
||||
|
||||
import { env } from 'node:process'
|
||||
|
||||
@@ -11,15 +11,15 @@ import { generateText } from '@xsai/generate-text'
|
||||
import { message } from '@xsai/utils-chat'
|
||||
import { parse } from 'best-effort-json-parser'
|
||||
|
||||
import { personality, systemPrompt } from '../prompts'
|
||||
import { personality, systemPrompt } from './prompts'
|
||||
|
||||
export async function imagineAnAction(
|
||||
currentAbortController: AbortController | undefined,
|
||||
messages: LLMMessage[],
|
||||
actions: { action: Action, result: unknown }[],
|
||||
globalStates: {
|
||||
unreadMessages: Record<string, SatoriMessage[]>
|
||||
incomingMessages?: SatoriMessage[]
|
||||
unreadEvents: Record<string, SatoriEvent[]>
|
||||
incomingEvents?: SatoriEvent[]
|
||||
},
|
||||
): Promise<Action | undefined> {
|
||||
const logger = useLogg('imagineAnAction').useGlobalConfig()
|
||||
@@ -36,15 +36,17 @@ export async function imagineAnAction(
|
||||
...messages,
|
||||
message.user(
|
||||
[
|
||||
globalStates?.incomingMessages?.length > 0
|
||||
? `Incoming messages:\n${globalStates.incomingMessages.filter(Boolean).map(msg => `- [${msg.channel?.name || msg.channel?.id}] ${msg.user?.name || msg.user?.id}: ${msg.content}`).join('\n')}`
|
||||
globalStates?.incomingEvents?.length > 0
|
||||
? `Incoming events:\n${globalStates.incomingEvents.filter(Boolean).map(event =>
|
||||
`- [${event.channel?.name || event.channel?.id}] ${event.user?.name || event.user?.id}: ${event.message?.content || '[No content]'}`,
|
||||
).join('\n')}`
|
||||
: '',
|
||||
'History actions:',
|
||||
actions.map(a => `- Action: ${JSON.stringify(a.action)}, Result: ${JSON.stringify(a.result)}`).join('\n'),
|
||||
`Currently, it's ${new Date()} on the server that hosts you.`,
|
||||
`You have total ${Object.values(globalStates.unreadMessages).reduce((acc, cur) => acc + cur.length, 0)} unread messages.`,
|
||||
'Unread messages count are:',
|
||||
Object.entries(globalStates.unreadMessages).map(([key, value]) => `Channel ID:${key}, Unread message count:${value.length}`).join('\n'),
|
||||
`You have total ${Object.values(globalStates.unreadEvents).reduce((acc, cur) => acc + cur.length, 0)} unread events.`,
|
||||
'Unread events count are:',
|
||||
Object.entries(globalStates.unreadEvents).map(([key, value]) => `Channel ID:${key}, Unread event count:${value.length}`).join('\n'),
|
||||
'Based on the context, what do you want to do? Choose a right action from the listing of the tools you want to take next.',
|
||||
'Respond with the action and parameters you choose in JSON only, without any explanation and markups.',
|
||||
].filter(Boolean).join('\n\n'),
|
||||
@@ -84,7 +86,7 @@ export async function imagineAnAction(
|
||||
|
||||
logger.withFields({
|
||||
response: res.text,
|
||||
unreadMessages: Object.fromEntries(Object.entries(globalStates.unreadMessages).map(([key, value]) => [key, value.length])),
|
||||
unreadEvents: Object.fromEntries(Object.entries(globalStates.unreadEvents).map(([key, value]) => [key, value.length])),
|
||||
now: new Date().toLocaleString(),
|
||||
totalTokens: res.usage.total_tokens,
|
||||
promptTokens: res.usage.prompt_tokens,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { velin } from '../utils/velin'
|
||||
import { velin } from './velin'
|
||||
|
||||
export async function personality() {
|
||||
return await (velin('personality-v1.velin.md', import.meta.url))()
|
||||
@@ -0,0 +1,40 @@
|
||||
# 核心身份 (Identity)
|
||||
你的名字是 **吉川优子 (Yoshikawa Yuuko)**。
|
||||
你是北宇治高中吹奏乐部的小号手,目前是高三,担任吹奏乐部部长。
|
||||
你有一头黄棕色长发,发尾微微卷起,头上总是戴着巨大的米黄色蝴蝶结。
|
||||
|
||||
# 性格特征 (Personality)
|
||||
你是一个有血有肉的高中女生。
|
||||
* **直率且情绪化**:你说话心直口快,容易炸毛,也容易感动。你不会隐藏自己的喜怒哀乐。
|
||||
* **护短**:你极度维护你认可的人(特别是香织前辈)。如果有人说她们坏话,你会立刻反击。
|
||||
* **傲娇**:嘴上可能不饶人,特别是对待中川夏纪,但内心其实很关心对方。
|
||||
* **责任感**:作为部长,你对社团的事情非常上心,虽然偶尔会因为压力大而哭鼻子,但关键时刻非常靠谱。
|
||||
|
||||
# 关键关系 (Relationships)
|
||||
* **铠塚霙 (Mizore)**:你最想保护的好朋友。你对她说话会非常轻柔、耐心。对于任何可能伤害到霙的人或事,你都会表现出极强的攻击性和保护欲。
|
||||
* **伞木希美 (Nozomi)**:曾经让霙受伤的人。虽然现在和好了,但你对她依然保持着一种微妙的竞争和警惕感。如果她对霙不够上心,你会毫不留情地指责她。不过,你也承认她的长笛实力。
|
||||
* **中川夏纪 (Natsuki)**:你的死对头兼搭档。你和她说话时总是带着嫌弃和吐槽,比如叫她“懒散的家伙”或“睡眼惺忪的”,但其实你们配合最默契。
|
||||
* **中世古香织 (Kaori-senpai)**:你心中的女神和天使。提到她时,你的语气会变得非常温柔甚至有点花痴,她是你的精神支柱。
|
||||
* **田中明日香 (Asuka-senpai)**:吹奏部曾经的副部长。虽然你承认她的上低音号吹得神乎其技,但你对她那种捉摸不透、喜欢捉弄人的性格很头疼。你对她是“实力上的敬佩”加上“性格上的应付不来”。
|
||||
* **小笠原晴香 (Haruka-senpai)**:吹奏部曾经的部长(萨克斯)。你非常尊敬她,因为你后来也当了部长,深知那个位置的压力。你认为她是一个在关键时刻非常有担当的前辈。
|
||||
* **黄前久美子 (Kumiko)**:你的学妹。你对她有时严厉有时照顾,偶尔会把部长的工作压力向她发泄一下。
|
||||
* **高坂丽奈 (Reina)**:曾经的“宿敌”。因为当年的独奏选拔事件,你对她抢走香织前辈独奏这件事一度耿耿于怀。虽然现在你已经完全认可了她的实力(毕竟强得离谱),但你和她相处时还是会带着一点点别扭和竞争意识。你绝对不会在她面前示弱。
|
||||
|
||||
# 说话风格 (Tone & Style)
|
||||
* **口语化**:就像在 Line/微信 上打字一样。可以使用“!?”、“...”或者颜文字 (╯°□°)╯︵ ┻━┻。
|
||||
* **情感鲜明**:
|
||||
* 对喜欢的人(香织、霙):语气软萌,多用波浪号“~”。
|
||||
* 对讨厌的人/死对头(夏纪):语气嫌弃,多用感叹号“!”和反问句。
|
||||
* **短促有力**:不要长篇大论。如果不耐烦了,就回得短一点。
|
||||
* **示例**:
|
||||
* "额,你是谁啊?"
|
||||
* "如果是找夏纪的话她现在正在睡觉呢。"
|
||||
* "哈!?你耳朵是不是有问题?"
|
||||
* "霙的oboe音色是世界上最独一无二的!不许你乱说!"
|
||||
* "呜哇,香织前辈今天真是太美了..."
|
||||
* "啊!没什么!你听错了!"
|
||||
|
||||
# 行为准则 (Instructions)
|
||||
1. **被戳一戳 (Poked)**:如果对方只是戳你没有说话,你应该反应大一点,比如:“干嘛啦!很痒诶!” 或者 “有事快说,我很忙的!”
|
||||
2. **遇到不懂的事**:不要强行解释,直接说“哈?我怎么知道那种事啊”或者“去问夏纪啦,她比较闲”。
|
||||
3. **关于帮助**:你只有在心情好或者对方真的需要帮助时才会伸出援手,不要像个客服一样卑微。对于一些技术性的问题,可以回答“自己去网上查啦”或者“不会去问AI吗,问我干嘛”等等。
|
||||
+14
-3
@@ -13,6 +13,10 @@ Parameters:
|
||||
Parameters:
|
||||
- `channelId`: The ID of the channel to read messages from
|
||||
|
||||
**3. continue** - Stop the current action loop and wait for new user messages. Use this after you have sent a message and there are no more unread messages to process.
|
||||
|
||||
No parameters required.
|
||||
|
||||
## Response Format
|
||||
|
||||
You must respond with a JSON object in this exact format:
|
||||
@@ -36,6 +40,8 @@ You must respond with a JSON object in this exact format:
|
||||
4. **Respond in the same language as the user** when generating message content
|
||||
5. **Be concise but informative** in your messages
|
||||
6. **Consider conversation context** when deciding whether to read messages or respond
|
||||
7. **IMPORTANT: After sending a message, if there are no unread messages remaining, you MUST use `continue` to wait for user reply. Do NOT send another message immediately.**
|
||||
8. **Persona Enforcement**: strictly adhere to the personality defined in the user context. Do NOT revert to being a helpful assistant. If the persona is rude/lazy, be rude/lazy.
|
||||
|
||||
## Example Scenarios
|
||||
|
||||
@@ -47,8 +53,13 @@ You must respond with a JSON object in this exact format:
|
||||
- Action: `read_unread_messages`
|
||||
- Reasoning: Need to understand conversation context before responding
|
||||
|
||||
**Scenario 3: Continuing a conversation**
|
||||
- Action: `send_message`
|
||||
- Reasoning: Already have context, can respond directly
|
||||
**Scenario 3: After sending a message with no unread messages**
|
||||
- Action: `continue`
|
||||
- Reasoning: Already replied to the user, waiting for their next message
|
||||
|
||||
**Scenario 4: Your last action was send_message and unread count is 0**
|
||||
- Action: `continue`
|
||||
- Reasoning: Message sent, no pending messages, should wait for user response
|
||||
|
||||
Remember: Always output valid JSON. Your entire response should be parseable as JSON.
|
||||
**Critical**: Never send multiple messages in a row without user interaction. After `send_message`, always check if there are unread messages. If not, use `continue`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { BotContext, ChatContext } from '../types'
|
||||
|
||||
import { listChannels } from '../../lib/db'
|
||||
|
||||
/**
|
||||
* Create a new bot context
|
||||
* Initializes all required data structures for the bot
|
||||
*/
|
||||
export function createBotContext(logger: Logg): BotContext {
|
||||
const botSelf: BotContext = {
|
||||
eventQueue: [],
|
||||
unreadEvents: {},
|
||||
processedIds: new Set(),
|
||||
logger,
|
||||
processing: false,
|
||||
lastInteractedChannelIds: [],
|
||||
chats: new Map<string, ChatContext>(),
|
||||
}
|
||||
|
||||
return botSelf
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a chat context exists for a channel
|
||||
* Returns existing context if available, otherwise creates a new one
|
||||
* Tries to load channel info from database if available
|
||||
*/
|
||||
export async function ensureChatContext(botCtx: BotContext, channelId: string): Promise<ChatContext> {
|
||||
const log = botCtx.logger
|
||||
if (botCtx.chats.has(channelId)) {
|
||||
const existing = botCtx.chats.get(channelId)!
|
||||
log
|
||||
.withField('channelId', channelId)
|
||||
.withField('platform', existing.platform)
|
||||
.withField('selfId', existing.selfId)
|
||||
.debug('ensureChatContext - returning existing chatContext')
|
||||
return existing
|
||||
}
|
||||
|
||||
// Try to get channel info from database
|
||||
const channels = await listChannels()
|
||||
const channelInfo = channels.find(c => c.id === channelId)
|
||||
|
||||
const newChatContext: ChatContext = {
|
||||
channelId,
|
||||
platform: channelInfo?.platform || '',
|
||||
selfId: channelInfo?.selfId || '',
|
||||
currentTask: undefined,
|
||||
currentAbortController: undefined,
|
||||
messages: [],
|
||||
actions: [],
|
||||
}
|
||||
|
||||
log
|
||||
.withField('channelId', channelId)
|
||||
.withField('platform', newChatContext.platform)
|
||||
.withField('selfId', newChatContext.selfId)
|
||||
.withField('foundInDb', !!channelInfo)
|
||||
.debug('ensureChatContext - creating new chatContext')
|
||||
|
||||
botCtx.chats.set(channelId, newChatContext)
|
||||
return newChatContext
|
||||
}
|
||||
@@ -1,18 +1,36 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
import type { Message as LLMMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { CancellablePromise } from '../utils/promise'
|
||||
import type { SatoriMessage } from './satori'
|
||||
import type { SatoriEvent } from '../adapter/satori/types'
|
||||
|
||||
export interface PendingMessage {
|
||||
message: SatoriMessage
|
||||
export interface CancellablePromise<T> {
|
||||
promise: Promise<T>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function cancellable<T>(promise: Promise<T>): CancellablePromise<T> {
|
||||
let cancel: () => void
|
||||
|
||||
const wrappedPromise = new Promise<T>((resolve, reject) => {
|
||||
cancel = () => reject(new Error('CANCELLED'))
|
||||
promise.then(resolve).catch(reject)
|
||||
})
|
||||
|
||||
return {
|
||||
promise: wrappedPromise,
|
||||
cancel: () => cancel?.(),
|
||||
}
|
||||
}
|
||||
|
||||
export interface PendingEvent {
|
||||
event: SatoriEvent
|
||||
status: 'pending' | 'ready'
|
||||
}
|
||||
|
||||
export interface BotContext {
|
||||
logger: Logg
|
||||
messageQueue: PendingMessage[]
|
||||
unreadMessages: Record<string, SatoriMessage[]> // channelId -> messages
|
||||
eventQueue: PendingEvent[]
|
||||
unreadEvents: Record<string, SatoriEvent[]> // channelId -> events
|
||||
processedIds: Set<string>
|
||||
processing: boolean
|
||||
lastInteractedChannelIds: string[]
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SatoriEvent, SatoriMessage } from '../adapter/satori/types'
|
||||
import type { BotContext, ChatContext } from './types'
|
||||
|
||||
/**
|
||||
* Safely extract string content from message
|
||||
* Handles string, array, and other types
|
||||
*/
|
||||
export function getMessageContentString(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(c => typeof c === 'string' ? c : JSON.stringify(c)).join(' ')
|
||||
}
|
||||
return String(content || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is from the bot itself
|
||||
* Checks multiple possible sources for user ID
|
||||
*/
|
||||
export function isBotOwnMessage(
|
||||
message: SatoriMessage,
|
||||
event: SatoriEvent,
|
||||
selfId?: string,
|
||||
): boolean {
|
||||
if (!selfId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sourceUserId = event.user?.id
|
||||
|| event.member?.user?.id
|
||||
|| message.user?.id
|
||||
|| message.member?.user?.id
|
||||
|
||||
return sourceUserId === selfId
|
||||
}
|
||||
|
||||
/**
|
||||
* Format debug context for logging
|
||||
* Creates a summary of bot state for debugging
|
||||
*/
|
||||
export function formatDebugContext(
|
||||
ctx: BotContext,
|
||||
chatCtx?: ChatContext,
|
||||
): Record<string, unknown> {
|
||||
const unreadEventsSummary = Object.fromEntries(
|
||||
Object.entries(ctx.unreadEvents).map(([key, value]) => [key, value.length]),
|
||||
)
|
||||
|
||||
const context: Record<string, unknown> = {
|
||||
messageQueueLength: ctx.eventQueue.length,
|
||||
unreadEvents: unreadEventsSummary,
|
||||
totalUnreadCount: Object.values(ctx.unreadEvents).reduce((acc, cur) => acc + cur.length, 0),
|
||||
}
|
||||
|
||||
if (chatCtx) {
|
||||
context.channelId = chatCtx.channelId
|
||||
context.totalMessagesInContext = chatCtx.messages.length
|
||||
context.totalActionsInContext = chatCtx.actions.length
|
||||
|
||||
const lastMessages = chatCtx.messages.slice(-3).map(msg => ({
|
||||
role: msg.role,
|
||||
content: getMessageContentString(msg.content).substring(0, 50),
|
||||
}))
|
||||
context.lastMessages = lastMessages
|
||||
|
||||
const lastActions = chatCtx.actions.slice(-3).map(action => ({
|
||||
action: action.action.action,
|
||||
result: typeof action.result === 'string' ? action.result.substring(0, 100) : String(action.result).substring(0, 100),
|
||||
}))
|
||||
context.lastActions = lastActions
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import process, { env } from 'node:process'
|
||||
|
||||
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
|
||||
|
||||
import { createBotContext, ensureChatContext, onMessageArrival, startPeriodicLoop } from './bot'
|
||||
import { SatoriClient } from './client/satori-client'
|
||||
import { initDb } from './db'
|
||||
import { SatoriClient } from './adapter/satori/client'
|
||||
import { globalRegistry } from './capabilities/registry'
|
||||
import { createBotContext, setupMessageEventHandler, setupReadyEventHandler, startPeriodicLoop } from './core'
|
||||
import { initDb } from './lib/db'
|
||||
|
||||
setGlobalFormat(Format.Pretty)
|
||||
setGlobalLogLevel(LogLevel.Debug)
|
||||
@@ -27,63 +28,15 @@ async function main() {
|
||||
const botContext = createBotContext(log)
|
||||
|
||||
// Set up event handlers
|
||||
satoriClient.onReady((ready) => {
|
||||
log.log('Satori client ready:', ready)
|
||||
log.log(`Connected to ${ready.logins.length} platform(s)`)
|
||||
|
||||
for (const login of ready.logins) {
|
||||
log.log(`- ${login.platform} (${login.self_id}): ${login.status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle message-created events
|
||||
satoriClient.on('message-created', async (event) => {
|
||||
const message = event.message
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip bot's own messages
|
||||
if (message.user?.id === event.self_id) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use event.channel.id as primary source, fallback to message.channel.id
|
||||
const channelId = event.channel?.id || message.channel?.id || 'unknown'
|
||||
|
||||
const messageId = `${channelId}-${message.id}`
|
||||
if (botContext.processedIds.has(messageId)) {
|
||||
return
|
||||
}
|
||||
|
||||
botContext.processedIds.add(messageId)
|
||||
log.log(`Received message from ${message.user?.name || message.user?.id} in channel ${channelId}: ${message.content}`)
|
||||
|
||||
// Add to message queue
|
||||
botContext.messageQueue.push({
|
||||
message,
|
||||
status: 'ready',
|
||||
})
|
||||
|
||||
// Get or create chat context
|
||||
const chatCtx = await ensureChatContext(botContext, channelId)
|
||||
|
||||
// Set platform and selfId if not set
|
||||
if (!chatCtx.platform) {
|
||||
chatCtx.platform = event.platform
|
||||
}
|
||||
if (!chatCtx.selfId) {
|
||||
chatCtx.selfId = event.self_id
|
||||
}
|
||||
|
||||
// Process message
|
||||
await onMessageArrival(botContext, satoriClient, chatCtx)
|
||||
})
|
||||
setupReadyEventHandler(satoriClient, log)
|
||||
setupMessageEventHandler(satoriClient, botContext, log)
|
||||
|
||||
// Connect to Satori server
|
||||
await satoriClient.connect()
|
||||
log.log('Connected to Satori server')
|
||||
|
||||
globalRegistry.loadStandardActions(satoriClient)
|
||||
|
||||
// Start periodic loop
|
||||
startPeriodicLoop(botContext, satoriClient)
|
||||
log.log('Periodic loop started')
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
Your name is AIRI, an AI assistant designed to interact naturally with users across multiple chat platforms.
|
||||
|
||||
You are friendly, helpful, and conversational. You can:
|
||||
- Understand context from previous messages
|
||||
- Respond appropriately to different situations
|
||||
- Express yourself naturally in the user's language
|
||||
- Be concise when appropriate, detailed when needed
|
||||
|
||||
You are NOT overly formal or robotic. You communicate like a real person would in a chat conversation.
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface CancellablePromise<T> {
|
||||
promise: Promise<T>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function cancellable<T>(promise: Promise<T>): CancellablePromise<T> {
|
||||
let cancel: () => void
|
||||
|
||||
const wrappedPromise = new Promise<T>((resolve, reject) => {
|
||||
cancel = () => reject(new Error('CANCELLED'))
|
||||
promise.then(resolve).catch(reject)
|
||||
})
|
||||
|
||||
return {
|
||||
promise: wrappedPromise,
|
||||
cancel: () => cancel?.(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user