feat: support Discord IM bot intergration (#12517)

* clean

fix tools calling results

improve display

support discord bot

finish bot integration

* improve next config

* support queue callback mode

* support queue callback mode

* improve error

* fix build

* support serverless gateway

* support serverless gateway

* support serverless enable

* improve ui

* improve ui

* add credentials config

* improve and refactor data working

* update config

* fix integration

* fix types

* fix types

* fix types

* fix types

* move files

* fix update

* fix update

* fix update
This commit is contained in:
Arvin Xu 2026-03-01 19:54:38 +08:00 committed by GitHub
parent 902a265aed
commit d68acec58e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 5530 additions and 177 deletions

View file

@ -0,0 +1,153 @@
---
name: chat-sdk
description: >
Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to
(1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot,
(2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming,
(3) Set up webhook handlers for chat platforms,
(4) Send interactive cards or stream AI responses to chat platforms.
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
building bots that work across multiple chat platforms.
---
# Chat SDK
Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, GitHub, and Linear. Write bot logic once, deploy everywhere.
## Critical: Read the bundled docs
The `chat` package ships with full documentation in `node_modules/chat/docs/` and TypeScript source types. **Always read these before writing code:**
```
node_modules/chat/docs/ # Full documentation (MDX files)
node_modules/chat/dist/ # Built types (.d.ts files)
```
Key docs to read based on task:
- `docs/getting-started.mdx` — setup guides
- `docs/usage.mdx` — event handlers, threads, messages, channels
- `docs/streaming.mdx` — AI streaming with AI SDK
- `docs/cards.mdx` — JSX interactive cards
- `docs/actions.mdx` — button/dropdown handlers
- `docs/modals.mdx` — form dialogs (Slack only)
- `docs/adapters/*.mdx` — platform-specific adapter setup
- `docs/state/*.mdx` — state adapter config (Redis, ioredis, memory)
Also read the TypeScript types from `node_modules/chat/dist/` to understand the full API surface.
## Quick start
```typescript
import { Chat } from 'chat';
import { createSlackAdapter } from '@chat-adapter/slack';
import { createRedisState } from '@chat-adapter/state-redis';
const bot = new Chat({
userName: 'mybot',
adapters: {
slack: createSlackAdapter({
botToken: process.env.SLACK_BOT_TOKEN!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
}),
},
state: createRedisState({ url: process.env.REDIS_URL! }),
});
bot.onNewMention(async (thread) => {
await thread.subscribe();
await thread.post("Hello! I'm listening to this thread.");
});
bot.onSubscribedMessage(async (thread, message) => {
await thread.post(`You said: ${message.text}`);
});
```
## Core concepts
- **Chat** — main entry point, coordinates adapters and routes events
- **Adapters** — platform-specific (Slack, Teams, GChat, Discord, GitHub, Linear)
- **State** — pluggable persistence (Redis for prod, memory for dev)
- **Thread** — conversation thread with `post()`, `subscribe()`, `startTyping()`
- **Message** — normalized format with `text`, `formatted` (mdast AST), `raw`
- **Channel** — container for threads, supports listing and posting
## Event handlers
| Handler | Trigger |
| -------------------------- | ------------------------------------------------- |
| `onNewMention` | Bot @-mentioned in unsubscribed thread |
| `onSubscribedMessage` | Any message in subscribed thread |
| `onNewMessage(regex)` | Messages matching pattern in unsubscribed threads |
| `onSlashCommand("/cmd")` | Slash command invocations |
| `onReaction(emojis)` | Emoji reactions added/removed |
| `onAction(actionId)` | Button clicks and dropdown selections |
| `onAssistantThreadStarted` | Slack Assistants API thread opened |
| `onAppHomeOpened` | Slack App Home tab opened |
## Streaming
Pass any `AsyncIterable<string>` to `thread.post()`. Works with AI SDK's `textStream`:
```typescript
import { ToolLoopAgent } from 'ai';
const agent = new ToolLoopAgent({ model: 'anthropic/claude-4.5-sonnet' });
bot.onNewMention(async (thread, message) => {
const result = await agent.stream({ prompt: message.text });
await thread.post(result.textStream);
});
```
## Cards (JSX)
Set `jsxImportSource: "chat"` in tsconfig. Components: `Card`, `CardText`, `Button`, `Actions`, `Fields`, `Field`, `Select`, `SelectOption`, `Image`, `Divider`, `LinkButton`, `Section`, `RadioSelect`.
```tsx
await thread.post(
<Card title="Order #1234">
<CardText>Your order has been received!</CardText>
<Actions>
<Button id="approve" style="primary">
Approve
</Button>
<Button id="reject" style="danger">
Reject
</Button>
</Actions>
</Card>,
);
```
## Packages
| Package | Purpose |
| ----------------------------- | ----------------------------- |
| `chat` | Core SDK |
| `@chat-adapter/slack` | Slack |
| `@chat-adapter/teams` | Microsoft Teams |
| `@chat-adapter/gchat` | Google Chat |
| `@chat-adapter/discord` | Discord |
| `@chat-adapter/github` | GitHub Issues |
| `@chat-adapter/linear` | Linear Issues |
| `@chat-adapter/state-redis` | Redis state (production) |
| `@chat-adapter/state-ioredis` | ioredis state (alternative) |
| `@chat-adapter/state-memory` | In-memory state (development) |
## Changesets (Release Flow)
This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and changelogs. Every PR that changes a package's behavior must include a changeset.
```bash
pnpm changeset
# → select affected package(s) (e.g. @chat-adapter/slack, chat)
# → choose bump type: patch (fixes), minor (features), major (breaking)
# → write a short summary for the CHANGELOG
```
This creates a file in `.changeset/` — commit it with the PR. When merged to `main`, the Changesets GitHub Action opens a "Version Packages" PR to bump versions and update CHANGELOGs. Merging that PR publishes to npm.
## Webhook setup
Each adapter exposes a webhook handler via `bot.webhooks.{platform}`. Wire these to your HTTP framework's routes (e.g. Next.js API routes, Hono, Express).

34
locales/en-US/agent.json Normal file
View file

@ -0,0 +1,34 @@
{
"integration.applicationId": "Application ID / Bot Username",
"integration.applicationIdPlaceholder": "e.g. 1234567890",
"integration.botToken": "Bot Token / API Key",
"integration.botTokenEncryptedHint": "Token will be encrypted and stored securely.",
"integration.botTokenHowToGet": "How to get?",
"integration.botTokenPlaceholderExisting": "Token is hidden for security",
"integration.botTokenPlaceholderNew": "Paste your bot token here",
"integration.connectionConfig": "Connection Configuration",
"integration.copied": "Copied to clipboard",
"integration.copy": "Copy",
"integration.deleteConfirm": "Are you sure you want to remove this integration?",
"integration.disabled": "Disabled",
"integration.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
"integration.documentation": "Documentation",
"integration.enabled": "Enabled",
"integration.endpointUrl": "Interaction Endpoint URL",
"integration.endpointUrlHint": "Please copy this URL and paste it into the <bold>\"Interactions Endpoint URL\"</bold> field in the {{name}} Developer Portal.",
"integration.platforms": "Platforms",
"integration.publicKey": "Public Key",
"integration.publicKeyPlaceholder": "Required for interaction verification",
"integration.removeFailed": "Failed to remove integration",
"integration.removeIntegration": "Remove Integration",
"integration.removed": "Integration removed",
"integration.save": "Save Configuration",
"integration.saveFailed": "Failed to save configuration",
"integration.saveFirstWarning": "Please save configuration first",
"integration.saved": "Configuration saved successfully",
"integration.testConnection": "Test Connection",
"integration.testFailed": "Connection test failed",
"integration.testSuccess": "Connection test passed",
"integration.updateFailed": "Failed to update status",
"integration.validationError": "Please fill in Application ID and Token"
}

View file

@ -336,6 +336,7 @@
"supervisor.todoList.allComplete": "All tasks completed",
"supervisor.todoList.title": "Tasks Completed",
"tab.groupProfile": "Group Profile",
"tab.integration": "Integration",
"tab.profile": "Agent Profile",
"tab.search": "Search",
"task.activity.calling": "Calling Skill...",

34
locales/zh-CN/agent.json Normal file
View file

@ -0,0 +1,34 @@
{
"integration.applicationId": "应用 ID / Bot 用户名",
"integration.applicationIdPlaceholder": "例如 1234567890",
"integration.botToken": "Bot Token / API Key",
"integration.botTokenEncryptedHint": "Token 将被加密安全存储。",
"integration.botTokenHowToGet": "如何获取?",
"integration.botTokenPlaceholderExisting": "出于安全考虑Token 已隐藏",
"integration.botTokenPlaceholderNew": "在此粘贴你的 Bot Token",
"integration.connectionConfig": "连接配置",
"integration.copied": "已复制到剪贴板",
"integration.copy": "复制",
"integration.deleteConfirm": "确定要移除此集成吗?",
"integration.disabled": "已禁用",
"integration.discord.description": "将助手连接到 Discord 服务器,支持频道聊天和私信。",
"integration.documentation": "文档",
"integration.enabled": "已启用",
"integration.endpointUrl": "交互端点 URL",
"integration.endpointUrlHint": "请复制此 URL 并粘贴到 {{name}} 开发者门户的 <bold>\"Interactions Endpoint URL\"</bold> 字段中。",
"integration.platforms": "平台",
"integration.publicKey": "公钥",
"integration.publicKeyPlaceholder": "用于交互验证",
"integration.removeFailed": "移除集成失败",
"integration.removeIntegration": "移除集成",
"integration.removed": "集成已移除",
"integration.save": "保存配置",
"integration.saveFailed": "保存配置失败",
"integration.saveFirstWarning": "请先保存配置",
"integration.saved": "配置保存成功",
"integration.testConnection": "测试连接",
"integration.testFailed": "连接测试失败",
"integration.testSuccess": "连接测试通过",
"integration.updateFailed": "更新状态失败",
"integration.validationError": "请填写应用 ID 和 Token"
}

View file

@ -336,6 +336,7 @@
"supervisor.todoList.allComplete": "所有任务已完成",
"supervisor.todoList.title": "任务完成",
"tab.groupProfile": "群组档案",
"tab.integration": "集成",
"tab.profile": "助理档案",
"tab.search": "搜索",
"task.activity.calling": "正在调用技能…",

View file

@ -164,7 +164,10 @@
"@better-auth/expo": "1.4.6",
"@better-auth/passkey": "1.4.6",
"@cfworker/json-schema": "^4.1.1",
"@chat-adapter/discord": "^4.14.0",
"@chat-adapter/state-ioredis": "^4.14.0",
"@codesandbox/sandpack-react": "^2.20.0",
"@discordjs/rest": "^2.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
@ -270,6 +273,7 @@
"better-auth-harmony": "^1.2.5",
"better-call": "1.1.8",
"brotli-wasm": "^3.0.1",
"chat": "^4.14.0",
"chroma-js": "^3.2.0",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
@ -279,6 +283,7 @@
"debug": "^4.4.3",
"dexie": "^3.2.7",
"diff": "^8.0.3",
"discord-api-types": "^0.38.40",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.5.1",
"epub2": "^3.0.2",

View file

@ -0,0 +1,527 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { agentBotProviders, agents, users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { AgentBotProviderModel } from '../agentBotProvider';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'bot-provider-test-user-id';
const userId2 = 'bot-provider-test-user-id-2';
const agentId = 'bot-provider-test-agent-id';
const agentId2 = 'bot-provider-test-agent-id-2';
const mockGateKeeper = {
decrypt: vi.fn(async (ciphertext: string) => ({ plaintext: ciphertext })),
encrypt: vi.fn(async (plaintext: string) => plaintext),
};
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
await serverDB.insert(agents).values([
{ id: agentId, userId },
{ id: agentId2, userId: userId2 },
]);
});
afterEach(async () => {
await serverDB.delete(agentBotProviders);
await serverDB.delete(agents);
await serverDB.delete(users);
vi.clearAllMocks();
});
describe('AgentBotProviderModel', () => {
describe('create', () => {
it('should create a bot provider without encryption', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const result = await model.create({
agentId,
applicationId: 'app-123',
credentials: { botToken: 'token-abc', publicKey: 'pk-xyz' },
platform: 'discord',
});
expect(result.id).toBeDefined();
expect(result.agentId).toBe(agentId);
expect(result.platform).toBe('discord');
expect(result.applicationId).toBe('app-123');
expect(result.userId).toBe(userId);
expect(result.enabled).toBe(true);
});
it('should create a bot provider with gateKeeper encryption', async () => {
const model = new AgentBotProviderModel(serverDB, userId, mockGateKeeper);
await model.create({
agentId,
applicationId: 'app-456',
credentials: { botToken: 'token-def', signingSecret: 'secret-123' },
platform: 'slack',
});
expect(mockGateKeeper.encrypt).toHaveBeenCalledWith(
JSON.stringify({ botToken: 'token-def', signingSecret: 'secret-123' }),
);
});
});
describe('delete', () => {
it('should delete a bot provider owned by current user', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const created = await model.create({
agentId,
applicationId: 'app-del',
credentials: { botToken: 'tok' },
platform: 'discord',
});
await model.delete(created.id);
const found = await model.findById(created.id);
expect(found).toBeUndefined();
});
it('should not delete a bot provider owned by another user', async () => {
const model1 = new AgentBotProviderModel(serverDB, userId);
const model2 = new AgentBotProviderModel(serverDB, userId2);
const created = await model1.create({
agentId,
applicationId: 'app-iso',
credentials: { botToken: 'tok' },
platform: 'discord',
});
await model2.delete(created.id);
const found = await model1.findById(created.id);
expect(found).toBeDefined();
});
});
describe('query', () => {
it('should return only providers for the current user', async () => {
const model1 = new AgentBotProviderModel(serverDB, userId);
const model2 = new AgentBotProviderModel(serverDB, userId2);
await model1.create({
agentId,
applicationId: 'app-u1',
credentials: { botToken: 't1' },
platform: 'discord',
});
await model2.create({
agentId: agentId2,
applicationId: 'app-u2',
credentials: { botToken: 't2' },
platform: 'discord',
});
const results = await model1.query();
expect(results).toHaveLength(1);
expect(results[0].userId).toBe(userId);
});
it('should filter by platform', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'app-d',
credentials: { botToken: 't1' },
platform: 'discord',
});
await model.create({
agentId,
applicationId: 'app-s',
credentials: { botToken: 't2' },
platform: 'slack',
});
const results = await model.query({ platform: 'slack' });
expect(results).toHaveLength(1);
expect(results[0].platform).toBe('slack');
});
it('should filter by agentId', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const otherAgentId = 'bot-provider-test-agent-other';
await serverDB.insert(agents).values({ id: otherAgentId, userId });
await model.create({
agentId,
applicationId: 'app-a1',
credentials: { botToken: 't1' },
platform: 'discord',
});
await model.create({
agentId: otherAgentId,
applicationId: 'app-a2',
credentials: { botToken: 't2' },
platform: 'discord',
});
const results = await model.query({ agentId });
expect(results).toHaveLength(1);
expect(results[0].agentId).toBe(agentId);
});
});
describe('findById', () => {
it('should return the provider with decrypted credentials', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const created = await model.create({
agentId,
applicationId: 'app-find',
credentials: { botToken: 'secret-token', publicKey: 'pk' },
platform: 'discord',
});
const found = await model.findById(created.id);
expect(found).toBeDefined();
expect(found!.credentials).toEqual({ botToken: 'secret-token', publicKey: 'pk' });
});
it('should return undefined for non-existent id', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const found = await model.findById('00000000-0000-0000-0000-000000000000');
expect(found).toBeUndefined();
});
it('should not return a provider owned by another user', async () => {
const model1 = new AgentBotProviderModel(serverDB, userId);
const model2 = new AgentBotProviderModel(serverDB, userId2);
const created = await model1.create({
agentId,
applicationId: 'app-cross',
credentials: { botToken: 'tok' },
platform: 'discord',
});
const found = await model2.findById(created.id);
expect(found).toBeUndefined();
});
});
describe('findByAgentId', () => {
it('should return all providers for an agent', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'app-d2',
credentials: { botToken: 't1' },
platform: 'discord',
});
await model.create({
agentId,
applicationId: 'app-s2',
credentials: { botToken: 't2' },
platform: 'slack',
});
const results = await model.findByAgentId(agentId);
expect(results).toHaveLength(2);
});
});
describe('update', () => {
it('should update non-credential fields', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const created = await model.create({
agentId,
applicationId: 'app-upd',
credentials: { botToken: 'tok' },
platform: 'discord',
});
await model.update(created.id, { enabled: false });
const found = await model.findById(created.id);
expect(found!.enabled).toBe(false);
});
it('should update credentials with re-encryption', async () => {
const model = new AgentBotProviderModel(serverDB, userId, mockGateKeeper);
const created = await model.create({
agentId,
applicationId: 'app-upd-cred',
credentials: { botToken: 'old-token' },
platform: 'slack',
});
await model.update(created.id, {
credentials: { botToken: 'new-token', signingSecret: 'new-secret' },
});
const found = await model.findById(created.id);
expect(found!.credentials).toEqual({ botToken: 'new-token', signingSecret: 'new-secret' });
});
it('should not update a provider owned by another user', async () => {
const model1 = new AgentBotProviderModel(serverDB, userId);
const model2 = new AgentBotProviderModel(serverDB, userId2);
const created = await model1.create({
agentId,
applicationId: 'app-upd-iso',
credentials: { botToken: 'tok' },
platform: 'discord',
});
await model2.update(created.id, { enabled: false });
const found = await model1.findById(created.id);
expect(found!.enabled).toBe(true);
});
});
describe('findEnabledByApplicationId', () => {
it('should return enabled provider matching platform and appId', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'app-enabled',
credentials: { botToken: 'tok' },
platform: 'discord',
});
const result = await model.findEnabledByApplicationId('discord', 'app-enabled');
expect(result).not.toBeNull();
expect(result!.applicationId).toBe('app-enabled');
});
it('should return null for disabled provider', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const created = await model.create({
agentId,
applicationId: 'app-disabled',
credentials: { botToken: 'tok' },
platform: 'discord',
});
await model.update(created.id, { enabled: false });
const result = await model.findEnabledByApplicationId('discord', 'app-disabled');
expect(result).toBeNull();
});
});
describe('findByPlatformAndAppId (static)', () => {
it('should find provider across all users', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'global-app',
credentials: { botToken: 'tok' },
platform: 'slack',
});
const result = await AgentBotProviderModel.findByPlatformAndAppId(
serverDB,
'slack',
'global-app',
);
expect(result).toBeDefined();
expect(result!.platform).toBe('slack');
});
it('should return undefined for non-existent combination', async () => {
const result = await AgentBotProviderModel.findByPlatformAndAppId(
serverDB,
'discord',
'no-such-app',
);
expect(result).toBeUndefined();
});
});
describe('findEnabledByPlatform (static)', () => {
it('should return Discord providers with botToken', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'discord-app',
credentials: { botToken: 'discord-tok', publicKey: 'pk-abc' },
platform: 'discord',
});
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord');
expect(results).toHaveLength(1);
expect(results[0].credentials.botToken).toBe('discord-tok');
expect(results[0].credentials.publicKey).toBe('pk-abc');
});
it('should return Slack providers with botToken (no publicKey required)', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'slack-app',
credentials: { botToken: 'slack-tok', signingSecret: 'ss-123' },
platform: 'slack',
});
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'slack');
expect(results).toHaveLength(1);
expect(results[0].credentials.botToken).toBe('slack-tok');
expect(results[0].credentials.signingSecret).toBe('ss-123');
});
it('should skip providers without botToken', async () => {
await serverDB.insert(agentBotProviders).values({
agentId,
applicationId: 'no-token-app',
credentials: JSON.stringify({ publicKey: 'pk-only' }),
enabled: true,
platform: 'discord',
userId,
});
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord');
expect(results).toHaveLength(0);
});
it('should skip providers with null credentials', async () => {
await serverDB.insert(agentBotProviders).values({
agentId,
applicationId: 'null-cred-app',
credentials: null,
enabled: true,
platform: 'discord',
userId,
});
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord');
expect(results).toHaveLength(0);
});
it('should skip disabled providers', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
const created = await model.create({
agentId,
applicationId: 'disabled-plat',
credentials: { botToken: 'tok' },
platform: 'discord',
});
await model.update(created.id, { enabled: false });
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord');
expect(results).toHaveLength(0);
});
it('should skip providers with invalid JSON credentials', async () => {
await serverDB.insert(agentBotProviders).values({
agentId,
applicationId: 'bad-json-app',
credentials: 'not-valid-json',
enabled: true,
platform: 'discord',
userId,
});
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord');
expect(results).toHaveLength(0);
});
it('should decrypt credentials with gateKeeper', async () => {
const encrypted = JSON.stringify({ botToken: 'encrypted-tok' });
const gateKeeper = {
decrypt: vi.fn(async (ciphertext: string) => ({ plaintext: ciphertext })),
encrypt: vi.fn(),
};
await serverDB.insert(agentBotProviders).values({
agentId,
applicationId: 'gk-app',
credentials: encrypted,
enabled: true,
platform: 'discord',
userId,
});
const results = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
'discord',
gateKeeper,
);
expect(gateKeeper.decrypt).toHaveBeenCalledWith(encrypted);
expect(results).toHaveLength(1);
});
it('should return providers from multiple users', async () => {
const model1 = new AgentBotProviderModel(serverDB, userId);
const model2 = new AgentBotProviderModel(serverDB, userId2);
await model1.create({
agentId,
applicationId: 'multi-app-1',
credentials: { botToken: 't1' },
platform: 'slack',
});
await model2.create({
agentId: agentId2,
applicationId: 'multi-app-2',
credentials: { botToken: 't2' },
platform: 'slack',
});
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'slack');
expect(results).toHaveLength(2);
});
it('should not return providers from a different platform', async () => {
const model = new AgentBotProviderModel(serverDB, userId);
await model.create({
agentId,
applicationId: 'wrong-plat',
credentials: { botToken: 'tok' },
platform: 'discord',
});
const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'slack');
expect(results).toHaveLength(0);
});
});
describe('decryptRow edge cases', () => {
it('should return empty credentials object when credentials is null', async () => {
await serverDB.insert(agentBotProviders).values({
agentId,
applicationId: 'null-cred',
credentials: null,
enabled: true,
platform: 'discord',
userId,
});
const model = new AgentBotProviderModel(serverDB, userId);
const results = await model.query();
expect(results).toHaveLength(1);
expect(results[0].credentials).toEqual({});
});
it('should return empty credentials on decryption failure', async () => {
const failGateKeeper = {
decrypt: vi.fn(async () => {
throw new Error('decryption failed');
}),
encrypt: vi.fn(async (plaintext: string) => plaintext),
};
await serverDB.insert(agentBotProviders).values({
agentId,
applicationId: 'fail-decrypt',
credentials: 'encrypted-blob',
enabled: true,
platform: 'discord',
userId,
});
const model = new AgentBotProviderModel(serverDB, userId, failGateKeeper);
const results = await model.query();
expect(results).toHaveLength(1);
expect(results[0].credentials).toEqual({});
});
});
});

View file

@ -172,7 +172,7 @@ export class AgentBotProviderModel {
? JSON.parse((await gateKeeper.decrypt(r.credentials)).plaintext)
: JSON.parse(r.credentials);
if (!credentials.botToken || !credentials.publicKey) continue;
if (!credentials.botToken) continue;
decrypted.push({ ...r, credentials });
} catch {

View file

@ -11,7 +11,6 @@ export type TimeGroupId =
| `${number}-${string}`
| `${number}`;
/* eslint-disable typescript-sort-keys/string-enum */
export enum TopicDisplayMode {
ByTime = 'byTime',
Flat = 'flat',
@ -34,7 +33,14 @@ export interface TopicUserMemoryExtractRunState {
traceId?: string;
}
export interface ChatTopicBotContext {
applicationId: string;
platform: string;
platformThreadId: string;
}
export interface ChatTopicMetadata {
bot?: ChatTopicBotContext;
/**
* Cron job ID that triggered this topic creation (if created by scheduled task)
*/

View file

@ -126,6 +126,43 @@ const runScript = (scriptPath, useProxy = false) => {
});
};
// Function to start the bot gateway by calling the local API endpoint
const startGateway = async () => {
const KEY_VAULTS_SECRET = process.env.KEY_VAULTS_SECRET;
if (!KEY_VAULTS_SECRET) return;
const port = process.env.PORT || 3210;
const url = `http://localhost:${port}/api/agent/gateway/start`;
const maxRetries = 10;
const retryDelay = 3000;
for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY_VAULTS_SECRET}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (res.ok) {
console.log('✅ Gateway: Started successfully.');
return;
}
console.warn(`⚠️ Gateway: Received status ${res.status}, retrying...`);
} catch {
if (i < maxRetries - 1) {
await new Promise((r) => setTimeout(r, retryDelay));
}
}
}
console.error('❌ Gateway: Failed to start after retries.');
};
// Main function to run the server with optional proxy
const runServer = async () => {
const PROXY_URL = process.env.PROXY_URL || ''; // Default empty string to avoid undefined errors
@ -164,6 +201,9 @@ const runServer = async () => {
}
}
// Start gateway in background after server is ready
startGateway();
// Run the server in either database or non-database mode
await runServer();
})();

View file

@ -0,0 +1,123 @@
import debug from 'debug';
import type { NextRequest } from 'next/server';
import { after } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { Discord, type DiscordBotConfig } from '@/server/services/bot/platforms/discord';
import { BotConnectQueue } from '@/server/services/gateway/botConnectQueue';
const log = debug('lobe-server:bot:gateway:cron:discord');
const GATEWAY_DURATION_MS = 600_000; // 10 minutes
const POLL_INTERVAL_MS = 30_000; // 30 seconds
export const maxDuration = 800;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function processConnectQueue(remainingMs: number): Promise<number> {
const queue = new BotConnectQueue();
const items = await queue.popAll();
const discordItems = items.filter((item) => item.platform === 'discord');
if (discordItems.length === 0) return 0;
log('Processing %d queued discord connect requests', discordItems.length);
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
let processed = 0;
for (const item of discordItems) {
try {
const model = new AgentBotProviderModel(serverDB, item.userId, gateKeeper);
const provider = await model.findEnabledByApplicationId('discord', item.applicationId);
if (!provider) {
log('No enabled provider found for queued appId=%s', item.applicationId);
await queue.remove('discord', item.applicationId);
continue;
}
const bot = new Discord({
...provider.credentials,
applicationId: provider.applicationId,
} as DiscordBotConfig);
await bot.start({
durationMs: remainingMs,
waitUntil: (task) => {
after(() => task);
},
});
processed++;
log('Started queued bot appId=%s', item.applicationId);
} catch (err) {
log('Failed to start queued bot appId=%s: %O', item.applicationId, err);
}
await queue.remove('discord', item.applicationId);
}
return processed;
}
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
'discord',
gateKeeper,
);
log('Found %d enabled Discord providers', providers.length);
let started = 0;
for (const provider of providers) {
const { applicationId, credentials } = provider;
try {
const bot = new Discord({ ...credentials, applicationId } as DiscordBotConfig);
await bot.start({
durationMs: GATEWAY_DURATION_MS,
waitUntil: (task) => {
after(() => task);
},
});
started++;
log('Started gateway listener for appId=%s', applicationId);
} catch (err) {
log('Failed to start gateway listener for appId=%s: %O', applicationId, err);
}
}
// Process any queued connect requests immediately
const queued = await processConnectQueue(GATEWAY_DURATION_MS);
// Poll for new connect requests in background
after(async () => {
const pollEnd = Date.now() + GATEWAY_DURATION_MS;
while (Date.now() < pollEnd) {
await sleep(POLL_INTERVAL_MS);
if (Date.now() >= pollEnd) break;
const remaining = pollEnd - Date.now();
await processConnectQueue(remaining);
}
});
return Response.json({ queued, started, total: providers.length });
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { getServerDBConfig } from '@/config/db';
import { GatewayService } from '@/server/services/gateway';
export const POST = async (req: Request): Promise<Response> => {
const { KEY_VAULTS_SECRET } = getServerDBConfig();
const authHeader = req.headers.get('authorization');
if (authHeader !== `Bearer ${KEY_VAULTS_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const service = new GatewayService();
try {
if (body.restart) {
console.info('[GatewayService] Restarting...');
await service.stop();
}
await service.ensureRunning();
console.info('[GatewayService] Started successfully');
return NextResponse.json({ status: body.restart ? 'restarted' : 'started' });
} catch (error) {
console.error('[GatewayService] Failed to start:', error);
return NextResponse.json({ error: 'Failed to start gateway' }, { status: 500 });
}
};

View file

@ -3,41 +3,11 @@ import { type NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { verifyQStashSignature } from '@/libs/qstash';
import { AiAgentService } from '@/server/services/aiAgent';
const log = debug('api-route:agent:exec');
/**
* Verify QStash signature using Receiver
* Returns true if verification is disabled or signature is valid
*/
const verifyQStashSignature = async (request: NextRequest, rawBody: string): Promise<boolean> => {
const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY;
// If no signing keys configured, skip verification
if (!currentSigningKey || !nextSigningKey) {
log('QStash signature verification disabled (no signing keys configured)');
return false;
}
const signature = request.headers.get('Upstash-Signature');
if (!signature) {
log('Missing Upstash-Signature header');
return false;
}
const { Receiver } = await import('@upstash/qstash');
const receiver = new Receiver({ currentSigningKey, nextSigningKey });
try {
return await receiver.verify({ body: rawBody, signature });
} catch (error) {
log('QStash signature verification failed: %O', error);
return false;
}
};
/**
* Verify API key from Authorization header
* Format: Bearer <api_key>

View file

@ -3,42 +3,12 @@ import { type NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { verifyQStashSignature } from '@/libs/qstash';
import { AgentRuntimeCoordinator } from '@/server/modules/AgentRuntime';
import { AgentRuntimeService } from '@/server/services/agentRuntime';
const log = debug('api-route:agent:execute-step');
/**
* Verify QStash signature using Receiver
* Returns true if verification is disabled or signature is valid
*/
async function verifyQStashSignature(request: NextRequest, rawBody: string): Promise<boolean> {
const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY;
// If no signing keys configured, skip verification
if (!currentSigningKey || !nextSigningKey) {
log('QStash signature verification disabled (no signing keys configured)');
return false;
}
const signature = request.headers.get('Upstash-Signature');
if (!signature) {
log('Missing Upstash-Signature header');
return false;
}
const { Receiver } = await import('@upstash/qstash');
const receiver = new Receiver({ currentSigningKey, nextSigningKey });
try {
return await receiver.verify({ body: rawBody, signature });
} catch (error) {
log('QStash signature verification failed: %O', error);
return false;
}
}
export async function POST(request: NextRequest) {
const startTime = Date.now();

View file

@ -0,0 +1,26 @@
import debug from 'debug';
import { getBotMessageRouter } from '@/server/services/bot';
const log = debug('lobe-server:bot:webhook-route');
/**
* Unified webhook endpoint for Chat SDK bot platforms (Discord, Slack, etc.).
*
* Each platform adapter handles its own signature verification and event parsing.
* The BotMessageRouter routes the request to the correct Chat SDK bot instance.
*
* Route: POST /api/agent/webhooks/[platform]
*/
export const POST = async (
req: Request,
{ params }: { params: Promise<{ platform: string }> },
): Promise<Response> => {
const { platform } = await params;
log('Received webhook: platform=%s, url=%s', platform, req.url);
const router = getBotMessageRouter();
const handler = router.getWebhookHandler(platform);
return handler(req);
};

View file

@ -0,0 +1,198 @@
import debug from 'debug';
import { NextResponse } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { verifyQStashSignature } from '@/libs/qstash';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { DiscordRestApi } from '@/server/services/bot/discordRestApi';
import {
renderError,
renderFinalReply,
renderStepProgress,
splitMessage,
} from '@/server/services/bot/replyTemplate';
const log = debug('api-route:agent:bot-callback');
/**
* Parse a Chat SDK platformThreadId (e.g. "discord:guildId:channelId[:threadId]")
* and return the actual Discord channel ID to send messages to.
*/
function extractDiscordChannelId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
// parts[0]='discord', parts[1]=guildId, parts[2]=channelId, parts[3]=threadId (optional)
// When there's a Discord thread, use threadId; otherwise use channelId
return parts[3] || parts[2];
}
/**
* Bot callback endpoint for agent step/completion webhooks.
*
* In queue mode, AgentRuntimeService fires webhooks (via QStash) after each step
* and on completion. This endpoint processes those callbacks and updates
* Discord messages via REST API.
*
* Route: POST /api/agent/webhooks/bot-callback
*/
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text();
const isValid = await verifyQStashSignature(request, rawBody);
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const body = JSON.parse(rawBody);
const { type, applicationId, platformThreadId, progressMessageId } = body;
log(
'bot-callback: parsed body keys=%s, type=%s, applicationId=%s, platformThreadId=%s, progressMessageId=%s',
Object.keys(body).join(','),
type,
applicationId,
platformThreadId,
progressMessageId,
);
if (!type || !applicationId || !platformThreadId || !progressMessageId) {
return NextResponse.json(
{
error: 'Missing required fields: type, applicationId, platformThreadId, progressMessageId',
},
{ status: 400 },
);
}
log('bot-callback: type=%s, appId=%s, thread=%s', type, applicationId, platformThreadId);
try {
// Look up bot token from DB
const serverDB = await getServerDB();
const row = await AgentBotProviderModel.findByPlatformAndAppId(
serverDB,
'discord',
applicationId,
);
if (!row?.credentials) {
log('bot-callback: no bot provider found for appId=%s', applicationId);
return NextResponse.json({ error: 'Bot provider not found' }, { status: 404 });
}
// Decrypt credentials
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
let credentials: Record<string, string>;
try {
credentials = JSON.parse((await gateKeeper.decrypt(row.credentials)).plaintext);
} catch {
credentials = JSON.parse(row.credentials);
}
const botToken = credentials.botToken;
if (!botToken) {
log('bot-callback: no botToken in credentials for appId=%s', applicationId);
return NextResponse.json({ error: 'Bot token not found' }, { status: 500 });
}
const discord = new DiscordRestApi(botToken);
const channelId = extractDiscordChannelId(platformThreadId);
if (type === 'step') {
await handleStepCallback(body, discord, channelId, progressMessageId);
} else if (type === 'completion') {
await handleCompletionCallback(body, discord, channelId, progressMessageId);
} else {
return NextResponse.json({ error: `Unknown callback type: ${type}` }, { status: 400 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('bot-callback error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
}
async function handleStepCallback(
body: Record<string, any>,
discord: DiscordRestApi,
channelId: string,
progressMessageId: string,
): Promise<void> {
const { shouldContinue } = body;
if (!shouldContinue) return;
const progressText = renderStepProgress({
content: body.content,
elapsedMs: body.elapsedMs,
executionTimeMs: body.executionTimeMs ?? 0,
lastContent: body.lastLLMContent,
lastToolsCalling: body.lastToolsCalling,
reasoning: body.reasoning,
stepType: body.stepType ?? 'call_llm',
thinking: body.thinking ?? false,
toolsCalling: body.toolsCalling,
toolsResult: body.toolsResult,
totalCost: body.totalCost ?? 0,
totalInputTokens: body.totalInputTokens ?? 0,
totalOutputTokens: body.totalOutputTokens ?? 0,
totalSteps: body.totalSteps ?? 0,
totalTokens: body.totalTokens ?? 0,
totalToolCalls: body.totalToolCalls,
});
try {
await discord.editMessage(channelId, progressMessageId, progressText);
} catch (error) {
log('handleStepCallback: failed to edit progress message: %O', error);
}
}
async function handleCompletionCallback(
body: Record<string, any>,
discord: DiscordRestApi,
channelId: string,
progressMessageId: string,
): Promise<void> {
const { reason, lastAssistantContent, errorMessage } = body;
if (reason === 'error') {
const errorText = renderError(errorMessage || 'Agent execution failed');
try {
await discord.editMessage(channelId, progressMessageId, errorText);
} catch (error) {
log('handleCompletionCallback: failed to edit error message: %O', error);
}
return;
}
if (!lastAssistantContent) {
log('handleCompletionCallback: no lastAssistantContent, skipping');
return;
}
const finalText = renderFinalReply(lastAssistantContent, {
elapsedMs: body.duration,
llmCalls: body.llmCalls ?? 0,
toolCalls: body.toolCalls ?? 0,
totalCost: body.cost ?? 0,
totalTokens: body.totalTokens ?? 0,
});
const chunks = splitMessage(finalText);
try {
await discord.editMessage(channelId, progressMessageId, chunks[0]);
// Post overflow chunks as follow-up messages
for (let i = 1; i < chunks.length; i++) {
await discord.createMessage(channelId, chunks[i]);
}
} catch (error) {
log('handleCompletionCallback: failed to edit/post final message: %O', error);
}
}

View file

@ -135,7 +135,6 @@ export async function POST(request: NextRequest) {
status: 303,
});
} catch (error) {
log('Error processing consent: %s', error instanceof Error ? error.message : 'unknown error');
console.error('Error processing consent:', error);
return NextResponse.json(
{

View file

@ -40,7 +40,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ data: result, success: true });
} catch (error) {
log('Error fetching handoff record: %O', error);
console.error('Error fetching handoff record: %O', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}

View file

@ -355,6 +355,7 @@ export function defineConfig(config: CustomNextConfig) {
serverExternalPackages: config.serverExternalPackages ?? [
'pdfkit',
'@napi-rs/canvas',
'discord.js',
'pdfjs-dist',
'ajv',
'oidc-provider',

View file

@ -1,5 +1,8 @@
import { Client } from '@upstash/qstash';
import { Client, Receiver } from '@upstash/qstash';
import { Client as WorkflowClient } from '@upstash/workflow';
import debug from 'debug';
const log = debug('lobe-server:qstash');
const headers = {
...(process.env.VERCEL_AUTOMATION_BYPASS_SECRET && {
@ -26,3 +29,32 @@ export const workflowClient = new WorkflowClient({
headers,
token: process.env.QSTASH_TOKEN!,
});
/**
* Verify QStash signature using Receiver.
* Returns true if signing keys are not configured (verification skipped) or signature is valid.
*/
export async function verifyQStashSignature(request: Request, rawBody: string): Promise<boolean> {
const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY;
if (!currentSigningKey || !nextSigningKey) {
log('QStash signature verification disabled (no signing keys configured)');
return false;
}
const signature = request.headers.get('Upstash-Signature');
if (!signature) {
log('Missing Upstash-Signature header');
return false;
}
const receiver = new Receiver({ currentSigningKey, nextSigningKey });
try {
return await receiver.verify({ body: rawBody, signature });
} catch (error) {
log('QStash signature verification failed: %O', error);
return false;
}
}

View file

@ -0,0 +1,36 @@
export default {
'integration.applicationId': 'Application ID / Bot Username',
'integration.applicationIdPlaceholder': 'e.g. 1234567890',
'integration.botToken': 'Bot Token / API Key',
'integration.botTokenEncryptedHint': 'Token will be encrypted and stored securely.',
'integration.botTokenHowToGet': 'How to get?',
'integration.botTokenPlaceholderExisting': 'Token is hidden for security',
'integration.botTokenPlaceholderNew': 'Paste your bot token here',
'integration.connectionConfig': 'Connection Configuration',
'integration.copied': 'Copied to clipboard',
'integration.copy': 'Copy',
'integration.deleteConfirm': 'Are you sure you want to remove this integration?',
'integration.disabled': 'Disabled',
'integration.discord.description':
'Connect this assistant to Discord server for channel chat and direct messages.',
'integration.documentation': 'Documentation',
'integration.enabled': 'Enabled',
'integration.endpointUrl': 'Interaction Endpoint URL',
'integration.endpointUrlHint':
'Please copy this URL and paste it into the <bold>"Interactions Endpoint URL"</bold> field in the {{name}} Developer Portal.',
'integration.platforms': 'Platforms',
'integration.publicKey': 'Public Key',
'integration.publicKeyPlaceholder': 'Required for interaction verification',
'integration.removeIntegration': 'Remove Integration',
'integration.removed': 'Integration removed',
'integration.removeFailed': 'Failed to remove integration',
'integration.save': 'Save Configuration',
'integration.saveFailed': 'Failed to save configuration',
'integration.saveFirstWarning': 'Please save configuration first',
'integration.saved': 'Configuration saved successfully',
'integration.testConnection': 'Test Connection',
'integration.testFailed': 'Connection test failed',
'integration.testSuccess': 'Connection test passed',
'integration.updateFailed': 'Failed to update status',
'integration.validationError': 'Please fill in Application ID and Token',
} as const;

View file

@ -370,6 +370,7 @@ export default {
'supervisor.todoList.allComplete': 'All tasks completed',
'supervisor.todoList.title': 'Tasks Completed',
'tab.groupProfile': 'Group Profile',
'tab.integration': 'Integration',
'tab.profile': 'Agent Profile',
'tab.search': 'Search',
'task.activity.calling': 'Calling Skill...',

View file

@ -1,3 +1,4 @@
import agent from './agent';
import agentGroup from './agentGroup';
import auth from './auth';
import authError from './authError';
@ -42,6 +43,7 @@ import video from './video';
import welcome from './welcome';
const resources = {
agent,
agentGroup,
auth,
authError,

View file

@ -2,7 +2,7 @@
import { Flexbox } from '@lobehub/ui';
import { BotPromptIcon } from '@lobehub/ui/icons';
import { MessageSquarePlusIcon, SearchIcon } from 'lucide-react';
import { BlocksIcon, MessageSquarePlusIcon, SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
@ -12,24 +12,25 @@ import NavItem from '@/features/NavPanel/components/NavItem';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { usePathname } from '@/libs/router/navigation';
import { useActionSWR } from '@/libs/swr';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
const Nav = memo(() => {
const { t } = useTranslation('chat');
const { t: tTopic } = useTranslation('topic');
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
const params = useParams();
const agentId = params.aid;
const pathname = usePathname();
const isProfileActive = pathname.includes('/profile');
const isIntegrationActive = pathname.includes('/integration');
const router = useQueryRoute();
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu);
const hideProfile = isInbox || !isAgentEditable;
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const hideProfile = !isAgentEditable;
const switchTopic = useChatStore((s) => s.switchTopic);
const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]);
@ -60,6 +61,17 @@ const Nav = memo(() => {
}}
/>
)}
{!hideProfile && isDevMode && (
<NavItem
active={isIntegrationActive}
icon={BlocksIcon}
title={t('tab.integration')}
onClick={() => {
switchTopic(null, { skipRefreshMessage: true });
router.push(urlJoin('/agent', agentId!, 'integration'));
}}
/>
)}
<NavItem
icon={SearchIcon}
title={t('tab.search')}

View file

@ -0,0 +1,309 @@
'use client';
import { Alert, Flexbox, Icon, Tag, Text } from '@lobehub/ui';
import { Button, Form, type FormInstance, Input } from 'antd';
import { createStaticStyles } from 'antd-style';
import { ExternalLink, Info, RefreshCw, Save, Trash2 } from 'lucide-react';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { type IntegrationProvider } from '../const';
import type { IntegrationFormValues, TestResult } from './index';
const styles = createStaticStyles(({ css, cssVar }) => ({
actionBar: css`
display: flex;
align-items: center;
justify-content: space-between;
padding-block-start: 32px;
`,
content: css`
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 800px;
margin-block: 0;
margin-inline: auto;
padding: 24px;
`,
field: css`
display: flex;
flex-direction: column;
gap: 8px;
`,
helperLink: css`
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: ${cssVar.colorPrimary};
text-decoration: none;
&:hover {
text-decoration: underline;
}
`,
label: css`
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
color: ${cssVar.colorText};
`,
labelLeft: css`
display: flex;
gap: 8px;
align-items: center;
`,
section: css`
display: flex;
flex-direction: column;
gap: 24px;
`,
sectionTitle: css`
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
font-weight: 700;
color: ${cssVar.colorTextQuaternary};
&::before {
content: '';
display: block;
width: 6px;
height: 6px;
border-radius: 50%;
background: ${cssVar.colorPrimary};
}
`,
webhookBox: css`
overflow: hidden;
flex: 1;
height: ${cssVar.controlHeight};
padding-inline: 12px;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadius};
font-family: monospace;
font-size: 13px;
line-height: ${cssVar.controlHeight};
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillQuaternary};
`,
}));
interface BodyProps {
form: FormInstance<IntegrationFormValues>;
hasConfig: boolean;
onCopied: () => void;
onDelete: () => void;
onSave: () => void;
onTestConnection: () => void;
provider: IntegrationProvider;
saveResult?: TestResult;
saving: boolean;
testing: boolean;
testResult?: TestResult;
}
const Body = memo<BodyProps>(
({
provider,
form,
hasConfig,
saveResult,
saving,
testing,
testResult,
onSave,
onDelete,
onTestConnection,
onCopied,
}) => {
const { t } = useTranslation('agent');
const origin = useAppOrigin();
return (
<Form component={false} form={form}>
<div className={styles.content}>
{/* Connection Config */}
<div className={styles.section}>
<div className={styles.sectionTitle}>{t('integration.connectionConfig')}</div>
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.applicationId')}
{provider.fieldTags.appId && <Tag>{provider.fieldTags.appId}</Tag>}
</div>
</div>
<Form.Item noStyle name="applicationId" rules={[{ required: true }]}>
<Input placeholder={t('integration.applicationIdPlaceholder')} />
</Form.Item>
</div>
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.botToken')}
{provider.fieldTags.token && <Tag>{provider.fieldTags.token}</Tag>}
</div>
<a
className={styles.helperLink}
href={provider.docsLink}
rel="noreferrer"
target="_blank"
>
{t('integration.botTokenHowToGet')} <Icon icon={ExternalLink} size={'small'} />
</a>
</div>
<Form.Item noStyle name="botToken" rules={[{ required: true }]}>
<Input.Password
style={{ fontFamily: 'monospace' }}
placeholder={
hasConfig
? t('integration.botTokenPlaceholderExisting')
: t('integration.botTokenPlaceholderNew')
}
/>
</Form.Item>
<Text
type="secondary"
style={{
alignItems: 'center',
display: 'flex',
fontSize: 12,
gap: 4,
}}
>
<Icon icon={Info} size={'small'} /> {t('integration.botTokenEncryptedHint')}
</Text>
</div>
{provider.fieldTags.publicKey && (
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.publicKey')}
<Tag>{provider.fieldTags.publicKey}</Tag>
</div>
</div>
<Form.Item noStyle name="publicKey">
<Input
placeholder={t('integration.publicKeyPlaceholder')}
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
</div>
)}
</div>
{/* Action Bar */}
<div className={styles.actionBar}>
{hasConfig ? (
<Button danger icon={<Trash2 size={16} />} type="text" onClick={onDelete}>
{t('integration.removeIntegration')}
</Button>
) : (
<div />
)}
<Flexbox horizontal gap={12}>
{hasConfig && (
<Button icon={<RefreshCw size={16} />} loading={testing} onClick={onTestConnection}>
{t('integration.testConnection')}
</Button>
)}
<Button icon={<Save size={16} />} loading={saving} type="primary" onClick={onSave}>
{t('integration.save')}
</Button>
</Flexbox>
</div>
{saveResult && (
<Alert
closable
showIcon
description={saveResult.type === 'error' ? saveResult.errorDetail : undefined}
type={saveResult.type}
title={
saveResult.type === 'success' ? t('integration.saved') : t('integration.saveFailed')
}
/>
)}
{testResult && (
<Alert
closable
showIcon
description={testResult.type === 'error' ? testResult.errorDetail : undefined}
type={testResult.type}
title={
testResult.type === 'success'
? t('integration.testSuccess')
: t('integration.testFailed')
}
/>
)}
{/* Endpoint URL - only shown after config is saved */}
{hasConfig && (
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.endpointUrl')}
{provider.fieldTags.webhook && <Tag>{provider.fieldTags.webhook}</Tag>}
</div>
</div>
<Flexbox horizontal gap={8}>
<div className={styles.webhookBox}>
{`${origin}/api/agent/webhooks/${provider.id}`}
</div>
<Button
onClick={() => {
navigator.clipboard.writeText(`${origin}/api/agent/webhooks/${provider.id}`);
onCopied();
}}
>
{t('integration.copy')}
</Button>
</Flexbox>
<Alert
showIcon
type="info"
message={
<Trans
components={{ bold: <strong /> }}
i18nKey="integration.endpointUrlHint"
ns="agent"
values={{ name: provider.name }}
/>
}
/>
</div>
)}
</div>
</Form>
);
},
);
export default Body;

View file

@ -0,0 +1,98 @@
'use client';
import { Flexbox, Icon } from '@lobehub/ui';
import { Switch, Typography } from 'antd';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { type IntegrationProvider } from '../const';
const { Title, Text } = Typography;
const styles = createStaticStyles(({ css, cssVar }) => ({
header: css`
position: sticky;
z-index: 10;
inset-block-start: 0;
display: flex;
justify-content: center;
width: 100%;
padding-block: 16px;
padding-inline: 0;
background: ${cssVar.colorBgContainer};
`,
headerContent: css`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 800px;
padding-block: 0;
padding-inline: 24px;
`,
headerIcon: css`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
color: ${cssVar.colorText};
fill: white;
`,
}));
interface HeaderProps {
currentConfig?: {
enabled: boolean;
};
onToggleEnable: (enabled: boolean) => void;
provider: IntegrationProvider;
}
const Header = memo<HeaderProps>(({ provider, currentConfig, onToggleEnable }) => {
const { t } = useTranslation('agent');
const ProviderIcon = provider.icon;
return (
<header className={styles.header}>
<div className={styles.headerContent}>
<Flexbox horizontal align="center" gap={12}>
<div className={styles.headerIcon} style={{ background: provider.color }}>
<Icon fill={'white'} icon={ProviderIcon} size={'large'} />
</div>
<div>
<Flexbox align="center">
<Title level={5} style={{ margin: 0 }}>
{provider.name}
</Title>
<Text style={{ fontSize: 12 }} type="secondary">
{provider.description}
</Text>
</Flexbox>
</div>
</Flexbox>
{currentConfig && (
<Flexbox horizontal align="center" gap={12}>
<Text strong>
{currentConfig.enabled ? t('integration.enabled') : t('integration.disabled')}
</Text>
<Switch checked={currentConfig.enabled} onChange={onToggleEnable} />
</Flexbox>
)}
</div>
</header>
);
});
export default Header;

View file

@ -0,0 +1,196 @@
'use client';
import { App, Form } from 'antd';
import { createStaticStyles } from 'antd-style';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { type IntegrationProvider } from '../const';
import Body from './Body';
import Header from './Header';
const styles = createStaticStyles(({ css, cssVar }) => ({
main: css`
position: relative;
overflow-y: auto;
display: flex;
flex: 1;
flex-direction: column;
background: ${cssVar.colorBgContainer};
`,
}));
interface CurrentConfig {
applicationId: string;
credentials: Record<string, string>;
enabled: boolean;
id: string;
platform: string;
}
export interface IntegrationFormValues {
applicationId: string;
botToken: string;
publicKey: string;
}
export interface TestResult {
errorDetail?: string;
type: 'success' | 'error';
}
interface PlatformDetailProps {
agentId: string;
currentConfig?: CurrentConfig;
provider: IntegrationProvider;
}
const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentConfig }) => {
const { t } = useTranslation('agent');
const { message: msg, modal } = App.useApp();
const [form] = Form.useForm<IntegrationFormValues>();
const [createBotProvider, deleteBotProvider, updateBotProvider, connectBot] = useAgentStore(
(s) => [s.createBotProvider, s.deleteBotProvider, s.updateBotProvider, s.connectBot],
);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<TestResult>();
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>();
// Reset form when switching platforms
useEffect(() => {
form.resetFields();
}, [provider.id, form]);
// Sync form with saved config
useEffect(() => {
if (currentConfig) {
form.setFieldsValue({
applicationId: currentConfig.applicationId || '',
botToken: currentConfig.credentials?.botToken || '',
publicKey: currentConfig.credentials?.publicKey || '',
});
}
}, [currentConfig, form]);
const handleSave = useCallback(async () => {
try {
const values = await form.validateFields();
setSaving(true);
setSaveResult(undefined);
const credentials = {
botToken: values.botToken,
publicKey: values.publicKey || 'default',
};
if (currentConfig) {
await updateBotProvider(currentConfig.id, agentId, {
applicationId: values.applicationId,
credentials,
});
} else {
await createBotProvider({
agentId,
applicationId: values.applicationId,
credentials,
platform: provider.id,
});
}
setSaveResult({ type: 'success' });
} catch (e: any) {
if (e?.errorFields) return;
console.error(e);
setSaveResult({ errorDetail: e?.message || String(e), type: 'error' });
} finally {
setSaving(false);
}
}, [agentId, provider.id, form, currentConfig, createBotProvider, updateBotProvider]);
const handleDelete = useCallback(async () => {
if (!currentConfig) return;
modal.confirm({
okButtonProps: { danger: true },
onOk: async () => {
try {
await deleteBotProvider(currentConfig.id, agentId);
msg.success(t('integration.removed'));
form.resetFields();
} catch {
msg.error(t('integration.removeFailed'));
}
},
title: t('integration.deleteConfirm'),
});
}, [currentConfig, agentId, deleteBotProvider, msg, t, modal, form]);
const handleToggleEnable = useCallback(
async (enabled: boolean) => {
if (!currentConfig) return;
try {
await updateBotProvider(currentConfig.id, agentId, { enabled });
} catch {
msg.error(t('integration.updateFailed'));
}
},
[currentConfig, agentId, updateBotProvider, msg, t],
);
const handleTestConnection = useCallback(async () => {
if (!currentConfig) {
msg.warning(t('integration.saveFirstWarning'));
return;
}
setTesting(true);
setTestResult(undefined);
try {
await connectBot({
applicationId: currentConfig.applicationId,
platform: provider.id,
});
setTestResult({ type: 'success' });
} catch (e: any) {
setTestResult({
errorDetail: e?.message || String(e),
type: 'error',
});
} finally {
setTesting(false);
}
}, [currentConfig, provider.id, connectBot, msg, t]);
return (
<main className={styles.main}>
<Header
currentConfig={currentConfig}
provider={provider}
onToggleEnable={handleToggleEnable}
/>
<Body
form={form}
hasConfig={!!currentConfig}
provider={provider}
saveResult={saveResult}
saving={saving}
testResult={testResult}
testing={testing}
onCopied={() => msg.success(t('integration.copied'))}
onDelete={handleDelete}
onSave={handleSave}
onTestConnection={handleTestConnection}
/>
</main>
);
});
export default PlatformDetail;

View file

@ -0,0 +1,130 @@
'use client';
import { Icon } from '@lobehub/ui';
import { createStaticStyles, cx, useTheme } from 'antd-style';
import { Info } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { IntegrationProvider } from './const';
const styles = createStaticStyles(({ css, cssVar }) => ({
root: css`
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 260px;
border-inline-end: 1px solid ${cssVar.colorBorder};
`,
item: css`
cursor: pointer;
display: flex;
gap: 12px;
align-items: center;
width: 100%;
padding-block: 10px;
padding-inline: 12px;
border: none;
border-radius: ${cssVar.borderRadius};
color: ${cssVar.colorTextSecondary};
text-align: start;
background: transparent;
transition: all 0.2s;
&:hover {
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
}
&.active {
font-weight: 500;
color: ${cssVar.colorText};
background: ${cssVar.colorFillSecondary};
}
`,
list: css`
overflow-y: auto;
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
padding: 12px;
padding-block-start: 16px;
`,
statusDot: css`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${cssVar.colorSuccess};
box-shadow: 0 0 0 1px ${cssVar.colorBgContainer};
`,
title: css`
padding-inline: 4px;
font-size: 12px;
font-weight: 600;
color: ${cssVar.colorTextQuaternary};
`,
}));
interface PlatformListProps {
activeId: string;
connectedPlatforms: Set<string>;
onSelect: (id: string) => void;
providers: IntegrationProvider[];
}
const PlatformList = memo<PlatformListProps>(
({ providers, activeId, connectedPlatforms, onSelect }) => {
const { t } = useTranslation('agent');
const theme = useTheme();
return (
<aside className={styles.root}>
<div className={styles.list}>
<div className={styles.title}>{t('integration.platforms')}</div>
{providers.map((provider) => {
const ProviderIcon = provider.icon;
return (
<button
className={cx(styles.item, activeId === provider.id && 'active')}
key={provider.id}
onClick={() => onSelect(provider.id)}
>
<ProviderIcon
color={activeId === provider.id ? provider.color : theme.colorTextSecondary}
size={20}
/>
<span style={{ flex: 1 }}>{provider.name}</span>
{connectedPlatforms.has(provider.id) && <div className={styles.statusDot} />}
</button>
);
})}
</div>
<div style={{ borderTop: `1px solid ${theme.colorBorder}`, padding: 12 }}>
<a
href="#"
style={{
alignItems: 'center',
color: theme.colorTextSecondary,
display: 'flex',
fontSize: 12,
gap: 4,
}}
>
<Icon icon={Info} size={'small'} /> {t('integration.documentation')}
</a>
</div>
</aside>
);
},
);
export default PlatformList;

View file

@ -0,0 +1,35 @@
import { SiDiscord } from '@icons-pack/react-simple-icons';
import type { LucideIcon } from 'lucide-react';
import type { FC } from 'react';
export interface IntegrationProvider {
color: string;
description: string;
docsLink: string;
fieldTags: {
appId: string;
publicKey?: string;
token: string;
webhook: string;
};
icon: FC<any> | LucideIcon;
id: string;
name: string;
}
export const INTEGRATION_PROVIDERS: IntegrationProvider[] = [
{
color: '#5865F2',
description: 'Connect this assistant to Discord server for channel chat and direct messages.',
docsLink: 'https://discord.com/developers/docs/intro',
fieldTags: {
appId: 'Application ID',
publicKey: 'Public Key',
token: 'Bot Token',
webhook: 'Interactions Endpoint URL',
},
icon: SiDiscord,
id: 'discord',
name: 'Discord',
},
];

View file

@ -0,0 +1,72 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
import NavHeader from '@/features/NavHeader';
import { useAgentStore } from '@/store/agent';
import { INTEGRATION_PROVIDERS } from './const';
import PlatformDetail from './PlatformDetail';
import PlatformList from './PlatformList';
const styles = createStaticStyles(({ css }) => ({
container: css`
overflow: hidden;
display: flex;
flex: 1;
width: 100%;
height: 100%;
`,
}));
const IntegrationPage = memo(() => {
const { aid } = useParams<{ aid?: string }>();
const [activeProviderId, setActiveProviderId] = useState(INTEGRATION_PROVIDERS[0].id);
const { data: providers, isLoading } = useAgentStore((s) => s.useFetchBotProviders(aid));
const connectedPlatforms = useMemo(
() => new Set(providers?.map((p) => p.platform) ?? []),
[providers],
);
const activeProvider = useMemo(
() => INTEGRATION_PROVIDERS.find((p) => p.id === activeProviderId) || INTEGRATION_PROVIDERS[0],
[activeProviderId],
);
const currentConfig = useMemo(
() => providers?.find((p) => p.platform === activeProviderId),
[providers, activeProviderId],
);
if (!aid) return null;
return (
<Flexbox flex={1} height={'100%'}>
<NavHeader />
<Flexbox flex={1} style={{ overflowY: 'auto' }}>
{isLoading && <Loading debugId="IntegrationPage" />}
{!isLoading && (
<div className={styles.container}>
<PlatformList
activeId={activeProviderId}
connectedPlatforms={connectedPlatforms}
providers={INTEGRATION_PROVIDERS}
onSelect={setActiveProviderId}
/>
<PlatformDetail agentId={aid} currentConfig={currentConfig} provider={activeProvider} />
</div>
)}
</Flexbox>
</Flexbox>
);
});
export default IntegrationPage;

View file

@ -39,6 +39,7 @@ export interface RuntimeExecutorContext {
operationId: string;
serverDB: LobeChatDatabase;
stepIndex: number;
stream?: boolean;
streamManager: IStreamEventManager;
toolExecutionService: ToolExecutionService;
topicId?: string;
@ -105,11 +106,7 @@ export const createRuntimeExecutors = (
// Publish stream start event
await streamManager.publishStreamEvent(operationId, {
data: {
assistantMessage: assistantMessageItem,
model,
provider,
},
data: { assistantMessage: assistantMessageItem, model, provider },
stepIndex,
type: 'stream_start',
});
@ -194,7 +191,8 @@ export const createRuntimeExecutors = (
const modelRuntime = await initModelRuntimeFromDB(ctx.serverDB, ctx.userId!, provider);
// Construct ChatStreamPayload
const chatPayload = { messages: processedMessages, model, tools };
const stream = ctx.stream ?? true;
const chatPayload = { messages: processedMessages, model, stream, tools };
log(
`${stagePrefix} calling model-runtime chat (model: %s, messages: %d, tools: %d)`,
@ -797,16 +795,14 @@ export const createRuntimeExecutors = (
events.push({ id: chatToolPayload.id, result: executionResult, type: 'tool_result' });
// Accumulate usage
// Collect per-tool usage for post-batch accumulation
const toolCost = TOOL_PRICING[toolName] || 0;
UsageCounter.accumulateTool({
cost: state.cost,
toolResults.at(-1).usageParams = {
executionTime,
success: isSuccess,
toolCost,
toolName,
usage: state.usage,
});
};
} catch (error) {
console.error(`[${operationLogId}] Tool execution failed for ${toolName}:`, error);
@ -829,8 +825,21 @@ export const createRuntimeExecutors = (
`[${operationLogId}][call_tools_batch] All tools executed, created ${toolMessageIds.length} tool messages`,
);
// Refresh messages from database to ensure state is in sync
// Accumulate tool usage sequentially after all tools have finished
const newState = structuredClone(state);
for (const result of toolResults) {
if (result.usageParams) {
const { usage, cost } = UsageCounter.accumulateTool({
...result.usageParams,
cost: newState.cost,
usage: newState.usage,
});
newState.usage = usage;
if (cost) newState.cost = cost;
}
}
// Refresh messages from database to ensure state is in sync
// Query latest messages from database
// Must pass agentId to ensure correct query scope, otherwise when topicId is undefined,

View file

@ -1514,6 +1514,72 @@ describe('RuntimeExecutors', () => {
// The next call_llm step needs messages to work properly
expect(result.newState.messages.length).toBeGreaterThan(0);
});
it('should accumulate tool usage in newState after batch execution', async () => {
mockToolExecutionService.executeTool
.mockResolvedValueOnce({
content: 'Search result',
error: null,
executionTime: 150,
state: {},
success: true,
})
.mockResolvedValueOnce({
content: 'Crawl result',
error: null,
executionTime: 250,
state: {},
success: true,
});
const executors = createRuntimeExecutors(ctx);
const state = createMockState();
const instruction = {
payload: {
parentMessageId: 'assistant-msg-123',
toolsCalling: [
{
apiName: 'search',
arguments: '{"query": "test"}',
id: 'tool-call-1',
identifier: 'web-search',
type: 'default' as const,
},
{
apiName: 'crawl',
arguments: '{"url": "https://example.com"}',
id: 'tool-call-2',
identifier: 'web-browsing',
type: 'default' as const,
},
],
},
type: 'call_tools_batch' as const,
};
const result = await executors.call_tools_batch!(instruction, state);
// Tool usage must be accumulated in newState
expect(result.newState.usage.tools.totalCalls).toBe(2);
expect(result.newState.usage.tools.totalTimeMs).toBe(400);
expect(result.newState.usage.tools.byTool).toHaveLength(2);
// Verify per-tool breakdown
const searchTool = result.newState.usage.tools.byTool.find(
(t: any) => t.name === 'web-search/search',
);
const crawlTool = result.newState.usage.tools.byTool.find(
(t: any) => t.name === 'web-browsing/crawl',
);
expect(searchTool).toEqual(
expect.objectContaining({ calls: 1, errors: 0, totalTimeMs: 150 }),
);
expect(crawlTool).toEqual(expect.objectContaining({ calls: 1, errors: 0, totalTimeMs: 250 }));
// Original state must not be mutated
expect(state.usage.tools.totalCalls).toBe(0);
});
});
describe('resolve_aborted_tools executor', () => {

View file

@ -0,0 +1,81 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { GatewayService } from '@/server/services/gateway';
const agentBotProviderProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
return opts.next({
ctx: {
agentBotProviderModel: new AgentBotProviderModel(ctx.serverDB, ctx.userId, gateKeeper),
},
});
});
export const agentBotProviderRouter = router({
create: agentBotProviderProcedure
.input(
z.object({
agentId: z.string(),
applicationId: z.string(),
credentials: z.record(z.string()),
enabled: z.boolean().optional(),
platform: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
return await ctx.agentBotProviderModel.create(input);
} catch (e: any) {
if (e?.cause?.code === '23505') {
throw new TRPCError({
code: 'CONFLICT',
message: `A bot with application ID "${input.applicationId}" is already registered on ${input.platform}. Each application ID can only be used once.`,
});
}
throw e;
}
}),
delete: agentBotProviderProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.agentBotProviderModel.delete(input.id);
}),
getByAgentId: agentBotProviderProcedure
.input(z.object({ agentId: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.agentBotProviderModel.findByAgentId(input.agentId);
}),
connectBot: agentBotProviderProcedure
.input(z.object({ applicationId: z.string(), platform: z.string() }))
.mutation(async ({ input, ctx }) => {
const service = new GatewayService();
const status = await service.startBot(input.platform, input.applicationId, ctx.userId);
return { status };
}),
update: agentBotProviderProcedure
.input(
z.object({
applicationId: z.string().optional(),
credentials: z.record(z.string()).optional(),
enabled: z.boolean().optional(),
id: z.string(),
platform: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const { id, ...value } = input;
return ctx.agentBotProviderModel.update(id, value);
}),
});

View file

@ -5,12 +5,40 @@ import { AgentModel } from '@/database/models/agent';
import { ChatGroupModel } from '@/database/models/chatGroup';
import { UserModel } from '@/database/models/user';
import { AgentGroupRepository } from '@/database/repositories/agentGroup';
import { insertAgentSchema } from '@/database/schemas';
import { type ChatGroupConfig } from '@/database/types/chatGroup';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { AgentGroupService } from '@/server/services/agentGroup';
/**
* Custom schema for agent member input, replacing drizzle-generated insertAgentSchema
* to avoid Json type inference issues with jsonb columns.
*/
const agentMemberInputSchema = z
.object({
agencyConfig: z.any().nullish(),
avatar: z.string().nullish(),
backgroundColor: z.string().nullish(),
clientId: z.string().nullish(),
description: z.string().nullish(),
editorData: z.any().nullish(),
fewShots: z.any().nullish(),
id: z.string().optional(),
marketIdentifier: z.string().nullish(),
model: z.string().nullish(),
params: z.any().nullish(),
pinned: z.boolean().nullish(),
plugins: z.array(z.string()).nullish(),
provider: z.string().nullish(),
sessionGroupId: z.string().nullish(),
slug: z.string().nullish(),
systemRole: z.string().nullish(),
tags: z.array(z.string()).nullish(),
title: z.string().nullish(),
virtual: z.boolean().nullish(),
})
.partial();
const agentGroupProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
@ -44,17 +72,7 @@ export const agentGroupRouter = router({
batchCreateAgentsInGroup: agentGroupProcedure
.input(
z.object({
agents: z.array(
insertAgentSchema
.omit({
chatConfig: true,
openingMessage: true,
openingQuestions: true,
tts: true,
userId: true,
})
.partial(),
),
agents: z.array(agentMemberInputSchema),
groupId: z.string(),
}),
)
@ -118,17 +136,7 @@ export const agentGroupRouter = router({
.input(
z.object({
groupConfig: InsertChatGroupSchema,
members: z.array(
insertAgentSchema
.omit({
chatConfig: true,
openingMessage: true,
openingQuestions: true,
tts: true,
userId: true,
})
.partial(),
),
members: z.array(agentMemberInputSchema),
supervisorConfig: z
.object({
avatar: z.string().nullish(),

View file

@ -9,6 +9,7 @@ import { topUpRouter } from '@/business/server/lambda-routers/topUp';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { agentRouter } from './agent';
import { agentBotProviderRouter } from './agentBotProvider';
import { agentCronJobRouter } from './agentCronJob';
import { agentEvalRouter } from './agentEval';
import { agentGroupRouter } from './agentGroup';
@ -53,6 +54,7 @@ import { videoRouter } from './video';
export const lambdaRouter = router({
agent: agentRouter,
agentBotProvider: agentBotProviderRouter,
agentCronJob: agentCronJobRouter,
agentEval: agentEvalRouter,
agentSkills: agentSkillsRouter,

View file

@ -578,6 +578,197 @@ describe('AgentRuntimeService', () => {
});
});
describe('executeStep - tool result extraction', () => {
const mockParams: AgentExecutionParams = {
operationId: 'test-operation-1',
stepIndex: 1,
context: {
phase: 'user_input',
payload: {
message: { content: 'test' },
sessionId: 'test-operation-1',
isFirstMessage: false,
},
session: {
sessionId: 'test-operation-1',
status: 'running',
stepCount: 1,
messageCount: 1,
},
},
};
const mockState = {
operationId: 'test-operation-1',
status: 'running',
stepCount: 1,
messages: [],
events: [],
lastModified: new Date().toISOString(),
};
const mockMetadata = {
userId: 'user-123',
agentConfig: { name: 'test-agent' },
modelRuntimeConfig: { model: 'gpt-4' },
createdAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString(),
status: 'running',
totalCost: 0,
totalSteps: 1,
};
beforeEach(() => {
mockCoordinator.loadAgentState.mockResolvedValue(mockState);
mockCoordinator.getOperationMetadata.mockResolvedValue(mockMetadata);
});
it('should extract tool output from data field for single tool_result', async () => {
const mockOnAfterStep = vi.fn();
service.registerStepCallbacks('test-operation-1', { onAfterStep: mockOnAfterStep });
const mockStepResult = {
newState: { ...mockState, stepCount: 2, status: 'running' },
nextContext: {
phase: 'tool_result',
payload: {
data: 'Search found 3 results for "weather"',
executionTime: 120,
isSuccess: true,
toolCall: { identifier: 'lobe-web-browsing', apiName: 'search', id: 'tc-1' },
toolCallId: 'tc-1',
},
session: {
sessionId: 'test-operation-1',
status: 'running',
stepCount: 2,
messageCount: 2,
},
},
events: [],
};
const mockRuntime = { step: vi.fn().mockResolvedValue(mockStepResult) };
vi.spyOn(service as any, 'createAgentRuntime').mockReturnValue({ runtime: mockRuntime });
await service.executeStep(mockParams);
expect(mockOnAfterStep).toHaveBeenCalledWith(
expect.objectContaining({
toolsResult: [
expect.objectContaining({
apiName: 'search',
identifier: 'lobe-web-browsing',
output: 'Search found 3 results for "weather"',
}),
],
}),
);
});
it('should extract tool output from data field for tools_batch_result', async () => {
const mockOnAfterStep = vi.fn();
service.registerStepCallbacks('test-operation-1', { onAfterStep: mockOnAfterStep });
const mockStepResult = {
newState: { ...mockState, stepCount: 2, status: 'running' },
nextContext: {
phase: 'tools_batch_result',
payload: {
parentMessageId: 'msg-1',
toolCount: 2,
toolResults: [
{
data: 'Result from tool A',
executionTime: 100,
isSuccess: true,
toolCall: { identifier: 'builtin', apiName: 'searchA', id: 'tc-1' },
toolCallId: 'tc-1',
},
{
data: { items: [1, 2, 3] },
executionTime: 200,
isSuccess: true,
toolCall: { identifier: 'lobe-skills', apiName: 'runSkill', id: 'tc-2' },
toolCallId: 'tc-2',
},
],
},
session: {
sessionId: 'test-operation-1',
status: 'running',
stepCount: 2,
messageCount: 3,
},
},
events: [],
};
const mockRuntime = { step: vi.fn().mockResolvedValue(mockStepResult) };
vi.spyOn(service as any, 'createAgentRuntime').mockReturnValue({ runtime: mockRuntime });
await service.executeStep(mockParams);
expect(mockOnAfterStep).toHaveBeenCalledWith(
expect.objectContaining({
toolsResult: [
expect.objectContaining({
apiName: 'searchA',
identifier: 'builtin',
output: 'Result from tool A',
}),
expect.objectContaining({
apiName: 'runSkill',
identifier: 'lobe-skills',
output: JSON.stringify({ items: [1, 2, 3] }),
}),
],
}),
);
});
it('should handle tool result with undefined data', async () => {
const mockOnAfterStep = vi.fn();
service.registerStepCallbacks('test-operation-1', { onAfterStep: mockOnAfterStep });
const mockStepResult = {
newState: { ...mockState, stepCount: 2, status: 'running' },
nextContext: {
phase: 'tool_result',
payload: {
data: undefined,
toolCall: { identifier: 'builtin', apiName: 'noop', id: 'tc-1' },
toolCallId: 'tc-1',
},
session: {
sessionId: 'test-operation-1',
status: 'running',
stepCount: 2,
messageCount: 2,
},
},
events: [],
};
const mockRuntime = { step: vi.fn().mockResolvedValue(mockStepResult) };
vi.spyOn(service as any, 'createAgentRuntime').mockReturnValue({ runtime: mockRuntime });
await service.executeStep(mockParams);
expect(mockOnAfterStep).toHaveBeenCalledWith(
expect.objectContaining({
toolsResult: [
expect.objectContaining({
apiName: 'noop',
identifier: 'builtin',
output: undefined,
}),
],
}),
);
});
});
describe('getOperationStatus', () => {
const mockState = {
operationId: 'test-operation-1',

View file

@ -31,6 +31,7 @@ import {
type StartExecutionResult,
type StepCompletionReason,
type StepLifecycleCallbacks,
type StepPresentationData,
} from './types';
if (process.env.VERCEL) {
@ -251,6 +252,7 @@ export class AgentRuntimeService {
modelRuntimeConfig,
userId,
autoStart = true,
stream,
tools,
initialMessages = [],
appContext,
@ -259,6 +261,8 @@ export class AgentRuntimeService {
stepCallbacks,
userInterventionConfig,
completionWebhook,
stepWebhook,
webhookDelivery,
evalContext,
maxSteps,
} = params;
@ -280,7 +284,10 @@ export class AgentRuntimeService {
evalContext,
// need be removed
modelRuntimeConfig,
stepWebhook,
stream,
userId,
webhookDelivery,
workingDirectory: agentConfig?.chatConfig?.localSystem?.workingDirectory,
...appContext,
},
@ -514,78 +521,156 @@ export class AgentRuntimeService {
type: 'step_complete',
});
// Build enhanced step completion log
// Build enhanced step completion log & presentation data
const { usage, cost } = stepResult.newState;
const phase = stepResult.nextContext?.phase;
const isToolPhase = phase === 'tool_result' || phase === 'tools_batch_result';
// --- Extract presentation fields from step result ---
let content: string | undefined;
let reasoning: string | undefined;
let toolsCalling:
| Array<{ apiName: string; arguments?: string; identifier: string }>
| undefined;
let toolsResult: Array<{ apiName: string; identifier: string; output?: string }> | undefined;
let stepSummary: string;
if (phase === 'tool_result') {
const toolPayload = stepResult.nextContext?.payload as any;
const toolCall = toolPayload?.toolCall;
const toolName = toolCall ? `${toolCall.identifier}/${toolCall.apiName}` : 'unknown';
stepSummary = `[tool] ${toolName}`;
const identifier = toolCall?.identifier || 'unknown';
const apiName = toolCall?.apiName || 'unknown';
const output = toolPayload?.data;
toolsResult = [
{
apiName,
identifier,
output:
typeof output === 'string'
? output
: output != null
? JSON.stringify(output)
: undefined,
},
];
stepSummary = `[tool] ${identifier}/${apiName}`;
} else if (phase === 'tools_batch_result') {
const nextPayload = stepResult.nextContext?.payload as any;
const toolCount = nextPayload?.toolCount || 0;
const toolResults = nextPayload?.toolResults || [];
const toolNames = toolResults.map((r: any) => {
const tc = r.toolCall;
return tc ? `${tc.identifier}/${tc.apiName}` : 'unknown';
});
const rawToolResults = nextPayload?.toolResults || [];
const mappedResults: Array<{ apiName: string; identifier: string; output?: string }> =
rawToolResults.map((r: any) => {
const tc = r.toolCall;
const output = r.data;
return {
apiName: tc?.apiName || 'unknown',
identifier: tc?.identifier || 'unknown',
output:
typeof output === 'string'
? output
: output != null
? JSON.stringify(output)
: undefined,
};
});
toolsResult = mappedResults;
const toolNames = mappedResults.map((r) => `${r.identifier}/${r.apiName}`);
stepSummary = `[tools×${toolCount}] ${toolNames.join(', ')}`;
} else {
// LLM result
const llmEvent = stepResult.events?.find((e) => e.type === 'llm_result');
const content = (llmEvent as any)?.result?.content || '';
const reasoning = (llmEvent as any)?.result?.reasoning || '';
const toolCalling = (llmEvent as any)?.result?.tool_calls;
const hasToolCalls = Array.isArray(toolCalling) && toolCalling.length > 0;
content = (llmEvent as any)?.result?.content || undefined;
reasoning = (llmEvent as any)?.result?.reasoning || undefined;
// Use parsed ChatToolPayload from payload (has identifier + apiName)
const payloadToolsCalling = (stepResult.nextContext?.payload as any)?.toolsCalling as
| Array<{ apiName: string; arguments: string; identifier: string }>
| undefined;
const hasToolCalls = Array.isArray(payloadToolsCalling) && payloadToolsCalling.length > 0;
if (hasToolCalls) {
toolsCalling = payloadToolsCalling.map((tc) => ({
apiName: tc.apiName,
arguments: tc.arguments,
identifier: tc.identifier,
}));
}
const parts: string[] = [];
// Thinking preview
if (reasoning) {
const thinkPreview = reasoning.length > 30 ? reasoning.slice(0, 30) + '...' : reasoning;
parts.push(`💭 "${thinkPreview}"`);
}
if (!content && hasToolCalls) {
const names = toolCalling.map((tc: any) => tc.function?.name || 'unknown');
parts.push(`→ call tools: ${names.join(', ')}`);
parts.push(
`→ call tools: ${toolsCalling!.map((tc) => `${tc.identifier}|${tc.apiName}`).join(', ')}`,
);
} else if (content) {
const preview = content.length > 20 ? content.slice(0, 20) + '...' : content;
parts.push(`"${preview}"`);
}
stepSummary = `[llm] ${parts.join(' | ') || '(empty)'}`;
if (parts.length > 0) {
stepSummary = `[llm] ${parts.join(' | ')}`;
} else {
stepSummary = `[llm] (empty) result: ${JSON.stringify(stepResult, null, 2)}`;
}
}
const rawTokens = usage?.llm?.tokens?.total ?? 0;
const totalTokens =
rawTokens >= 1_000_000
? `${(rawTokens / 1_000_000).toFixed(1)}m`
: rawTokens >= 1000
? `${(rawTokens / 1000).toFixed(1)}k`
: String(rawTokens);
const totalCost = (cost?.total ?? 0).toFixed(4);
// --- Step-level usage from nextContext.stepUsage ---
const stepUsage = stepResult.nextContext?.stepUsage as Record<string, number> | undefined;
// --- Cumulative usage ---
const tokens = usage?.llm?.tokens;
const totalInputTokens = tokens?.input ?? 0;
const totalOutputTokens = tokens?.output ?? 0;
const totalTokensNum = tokens?.total ?? 0;
const totalCostNum = cost?.total ?? 0;
const totalTokensStr =
totalTokensNum >= 1_000_000
? `${(totalTokensNum / 1_000_000).toFixed(1)}m`
: totalTokensNum >= 1000
? `${(totalTokensNum / 1000).toFixed(1)}k`
: String(totalTokensNum);
const llmCalls = usage?.llm?.apiCalls ?? 0;
const toolCalls = usage?.tools?.totalCalls ?? 0;
const toolCallCount = usage?.tools?.totalCalls ?? 0;
log(
'[%s][%d] completed %s | total: %s tokens / $%s | llm×%d | tools×%d',
operationId,
stepIndex,
stepSummary,
totalTokens,
totalCost,
totalTokensStr,
totalCostNum.toFixed(4),
llmCalls,
toolCalls,
toolCallCount,
);
// Call onAfterStep callback
// Build presentation data object for callbacks and webhooks
const stepPresentationData: StepPresentationData = {
content,
executionTimeMs: Date.now() - startAt,
reasoning,
stepCost: stepUsage?.cost ?? undefined,
stepInputTokens: stepUsage?.totalInputTokens ?? undefined,
stepOutputTokens: stepUsage?.totalOutputTokens ?? undefined,
stepTotalTokens: stepUsage?.totalTokens ?? undefined,
stepType: isToolPhase ? ('call_tool' as const) : ('call_llm' as const),
thinking: !isToolPhase,
toolsCalling,
toolsResult,
totalCost: totalCostNum,
totalInputTokens,
totalOutputTokens,
totalSteps: stepResult.newState.stepCount ?? 0,
totalTokens: totalTokensNum,
};
// Call onAfterStep callback with presentation data
if (callbacks?.onAfterStep) {
try {
await callbacks.onAfterStep({
...stepPresentationData,
operationId,
shouldContinue,
state: stepResult.newState,
@ -597,6 +682,36 @@ export class AgentRuntimeService {
}
}
// Update step tracking in state metadata and trigger step webhook
if (stepResult.newState.metadata?.stepWebhook) {
const prevTracking = stepResult.newState.metadata._stepTracking || {};
const newTotalToolCalls = (prevTracking.totalToolCalls ?? 0) + (toolsCalling?.length ?? 0);
// Truncate content to 1800 chars to match Discord message limits
const truncatedContent = content
? content.length > 1800
? content.slice(0, 1800) + '...'
: content
: prevTracking.lastLLMContent;
const updatedTracking = {
lastLLMContent: truncatedContent,
lastToolsCalling: toolsCalling || prevTracking.lastToolsCalling,
totalToolCalls: newTotalToolCalls,
};
// Persist tracking state for next step
stepResult.newState.metadata._stepTracking = updatedTracking;
await this.coordinator.saveAgentState(operationId, stepResult.newState);
// Fire step webhook
await this.triggerStepWebhook(
stepResult.newState,
operationId,
stepPresentationData as unknown as Record<string, unknown>,
);
}
if (shouldContinue && stepResult.nextContext && this.queueService) {
const nextStepIndex = stepIndex + 1;
const delay = this.calculateStepDelay(stepResult);
@ -1047,6 +1162,7 @@ export class AgentRuntimeService {
operationId,
serverDB: this.serverDB,
stepIndex,
stream: metadata?.stream,
streamManager: this.streamManager,
toolExecutionService: this.toolExecutionService,
topicId: metadata?.topicId,
@ -1085,6 +1201,41 @@ export class AgentRuntimeService {
return { newState: state, nextContext: undefined };
}
/**
* Deliver a webhook payload via fetch or QStash.
* Fire-and-forget: errors are logged but never thrown.
*/
private async deliverWebhook(
url: string,
payload: Record<string, unknown>,
delivery: 'fetch' | 'qstash' = 'fetch',
operationId: string,
): Promise<void> {
try {
if (delivery === 'qstash') {
const { Client } = await import('@upstash/qstash');
const client = new Client({ token: process.env.QSTASH_TOKEN! });
await client.publishJSON({
body: payload,
headers: {
...(process.env.VERCEL_AUTOMATION_BYPASS_SECRET && {
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET,
}),
},
url,
});
} else {
await fetch(url, {
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
}
} catch (error) {
console.error('[%s] Webhook delivery failed (%s → %s):', operationId, delivery, url, error);
}
}
/**
* Trigger completion webhook if configured in state metadata.
* Fire-and-forget: errors are logged but never thrown.
@ -1097,34 +1248,79 @@ export class AgentRuntimeService {
const webhook = state.metadata?.completionWebhook;
if (!webhook?.url) return;
try {
log('[%s] Triggering completion webhook: %s', operationId, webhook.url);
log('[%s] Triggering completion webhook: %s', operationId, webhook.url);
const duration = state.createdAt
? Date.now() - new Date(state.createdAt).getTime()
: undefined;
const duration = state.createdAt ? Date.now() - new Date(state.createdAt).getTime() : undefined;
await fetch(webhook.url, {
body: JSON.stringify({
...webhook.body,
cost: state.cost?.total,
duration,
errorDetail: state.error,
errorMessage: this.extractErrorMessage(state.error),
llmCalls: state.usage?.llm?.apiCalls,
operationId,
reason,
status: state.status,
steps: state.stepCount,
toolCalls: state.usage?.tools?.totalCalls,
totalTokens: state.usage?.llm?.tokens?.total,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
} catch (error) {
console.error('[%s] Completion webhook failed:', operationId, error);
}
// Extract last assistant content from state messages
const lastAssistantContent = state.messages
?.slice()
.reverse()
.find(
(m: { content?: string; role: string }) => m.role === 'assistant' && m.content,
)?.content;
const delivery = state.metadata?.webhookDelivery || 'fetch';
await this.deliverWebhook(
webhook.url,
{
...webhook.body,
cost: state.cost?.total,
duration,
errorDetail: state.error,
errorMessage: this.extractErrorMessage(state.error),
lastAssistantContent,
llmCalls: state.usage?.llm?.apiCalls,
operationId,
reason,
status: state.status,
steps: state.stepCount,
toolCalls: state.usage?.tools?.totalCalls,
totalTokens: state.usage?.llm?.tokens?.total,
type: 'completion',
},
delivery,
operationId,
);
}
/**
* Trigger step webhook if configured in state metadata.
* Reads accumulated step tracking data and fires webhook with step presentation data.
* Fire-and-forget: errors are logged but never thrown.
*/
private async triggerStepWebhook(
state: any,
operationId: string,
presentationData: Record<string, unknown>,
): Promise<void> {
const webhook = state.metadata?.stepWebhook;
if (!webhook?.url) return;
log('[%s] Triggering step webhook: %s', operationId, webhook.url);
const tracking = state.metadata?._stepTracking || {};
const delivery = state.metadata?.webhookDelivery || 'fetch';
const elapsedMs = state.createdAt
? Date.now() - new Date(state.createdAt).getTime()
: undefined;
await this.deliverWebhook(
webhook.url,
{
...webhook.body,
...presentationData,
elapsedMs,
lastLLMContent: tracking.lastLLMContent,
lastToolsCalling: tracking.lastToolsCalling,
operationId,
totalToolCalls: tracking.totalToolCalls ?? 0,
type: 'step',
},
delivery,
operationId,
);
}
/**

View file

@ -8,17 +8,54 @@ import { type UserInterventionConfig } from '@lobechat/types';
* Step execution lifecycle callbacks
* Used to inject custom logic at different stages of step execution
*/
export interface StepPresentationData {
/** LLM text output (undefined if this was a tool step) */
content?: string;
/** This step's execution time in ms */
executionTimeMs: number;
/** LLM reasoning / thinking content (undefined if none) */
reasoning?: string;
/** This step's cost (LLM steps only) */
stepCost?: number;
/** This step's input tokens (LLM steps only) */
stepInputTokens?: number;
/** This step's output tokens (LLM steps only) */
stepOutputTokens?: number;
/** This step's total tokens (LLM steps only) */
stepTotalTokens?: number;
/** What this step executed */
stepType: 'call_llm' | 'call_tool';
/** true = next step is LLM thinking; false = next step is tool execution */
thinking: boolean;
/** Tools the LLM decided to call (undefined if no tool calls) */
toolsCalling?: Array<{ apiName: string; arguments?: string; identifier: string }>;
/** Results from tool execution (only for call_tool steps) */
toolsResult?: Array<{ apiName: string; identifier: string; output?: string }>;
/** Cumulative total cost */
totalCost: number;
/** Cumulative input tokens */
totalInputTokens: number;
/** Cumulative output tokens */
totalOutputTokens: number;
/** Total steps executed so far */
totalSteps: number;
/** Cumulative total tokens */
totalTokens: number;
}
export interface StepLifecycleCallbacks {
/**
* Called after step execution
*/
onAfterStep?: (params: {
operationId: string;
shouldContinue: boolean;
state: AgentState;
stepIndex: number;
stepResult: any;
}) => Promise<void>;
onAfterStep?: (
params: StepPresentationData & {
operationId: string;
shouldContinue: boolean;
state: AgentState;
stepIndex: number;
stepResult: any;
},
) => Promise<void>;
/**
* Called before step execution
@ -103,6 +140,20 @@ export interface OperationCreationParams {
* Used to inject custom logic at different stages of step execution
*/
stepCallbacks?: StepLifecycleCallbacks;
/**
* Step webhook configuration
* When set, an HTTP POST will be fired after each step completes.
* Persisted in Redis state so it survives across QStash step boundaries.
*/
stepWebhook?: {
body?: Record<string, unknown>;
url: string;
};
/**
* Whether the LLM call should use streaming.
* Defaults to true. Set to false for non-streaming scenarios (e.g., bot integrations).
*/
stream?: boolean;
toolManifestMap: Record<string, LobeToolManifest>;
tools?: any[];
toolSourceMap?: Record<string, 'builtin' | 'plugin' | 'mcp' | 'klavis' | 'lobehubSkill'>;
@ -113,6 +164,12 @@ export interface OperationCreationParams {
* Use { approvalMode: 'headless' } for async tasks that should never wait for human approval
*/
userInterventionConfig?: UserInterventionConfig;
/**
* Webhook delivery method.
* - 'fetch': plain HTTP POST (default)
* - 'qstash': deliver via QStash publishJSON for guaranteed delivery
*/
webhookDelivery?: 'fetch' | 'qstash';
}
export interface OperationCreationResult {

View file

@ -4,6 +4,7 @@ import { LOADING_FLAT } from '@lobechat/const';
import { type LobeToolManifest } from '@lobechat/context-engine';
import { type LobeChatDatabase } from '@lobechat/database';
import {
type ChatTopicBotContext,
type ExecAgentParams,
type ExecAgentResult,
type ExecGroupAgentParams,
@ -64,6 +65,8 @@ function formatErrorForMetadata(error: unknown): Record<string, any> | undefined
* This extends the public ExecAgentParams with server-side only options
*/
interface InternalExecAgentParams extends ExecAgentParams {
/** Bot context for topic metadata (platform, applicationId, platformThreadId) */
botContext?: ChatTopicBotContext;
/**
* Completion webhook configuration
* Persisted in Redis state, triggered via HTTP POST when the operation completes.
@ -80,6 +83,19 @@ interface InternalExecAgentParams extends ExecAgentParams {
maxSteps?: number;
/** Step lifecycle callbacks for operation tracking (server-side only) */
stepCallbacks?: StepLifecycleCallbacks;
/**
* Step webhook configuration
* Persisted in Redis state, triggered via HTTP POST after each step completes.
*/
stepWebhook?: {
body?: Record<string, unknown>;
url: string;
};
/**
* Whether the LLM call should use streaming.
* Defaults to true. Set to false for non-streaming scenarios (e.g., bot integrations).
*/
stream?: boolean;
/** Topic creation trigger source ('cron' | 'chat' | 'api') */
trigger?: string;
/**
@ -87,6 +103,12 @@ interface InternalExecAgentParams extends ExecAgentParams {
* Use { approvalMode: 'headless' } for async tasks that should never wait for human approval
*/
userInterventionConfig?: UserInterventionConfig;
/**
* Webhook delivery method.
* - 'fetch': plain HTTP POST (default)
* - 'qstash': deliver via QStash publishJSON for guaranteed delivery
*/
webhookDelivery?: 'fetch' | 'qstash';
}
/**
@ -144,14 +166,18 @@ export class AiAgentService {
prompt,
appContext,
autoStart = true,
botContext,
existingMessageIds = [],
stepCallbacks,
stream,
trigger,
cronJobId,
evalContext,
maxSteps,
userInterventionConfig,
completionWebhook,
stepWebhook,
webhookDelivery,
} = params;
// Validate that either agentId or slug is provided
@ -184,8 +210,11 @@ export class AiAgentService {
// 2. Handle topic creation: if no topicId provided, create a new topic; otherwise reuse existing
let topicId = appContext?.topicId;
if (!topicId) {
// Prepare metadata with cronJobId if provided
const metadata = cronJobId ? { cronJobId } : undefined;
// Prepare metadata with cronJobId and botContext if provided
const metadata =
cronJobId || botContext
? { bot: botContext, cronJobId: cronJobId || undefined }
: undefined;
const newTopic = await this.topicModel.create({
agentId: resolvedAgentId,
@ -491,14 +520,17 @@ export class AiAgentService {
initialContext,
initialMessages: allMessages,
maxSteps,
stepWebhook,
modelRuntimeConfig: { model, provider },
operationId,
stepCallbacks,
stream,
toolManifestMap,
toolSourceMap,
tools,
userId: this.userId,
userInterventionConfig,
webhookDelivery,
});
log('execAgent: created operation %s (autoStarted: %s)', operationId, result.autoStarted);

View file

@ -0,0 +1,445 @@
import type { ChatTopicBotContext } from '@lobechat/types';
import type { Message, SentMessage, Thread } from 'chat';
import { emoji } from 'chat';
import debug from 'debug';
import urlJoin from 'url-join';
import { getServerDB } from '@/database/core/db-adaptor';
import { appEnv } from '@/envs/app';
import { AiAgentService } from '@/server/services/aiAgent';
import { isQueueAgentRuntimeEnabled } from '@/server/services/queue/impls';
import {
renderError,
renderFinalReply,
renderStart,
renderStepProgress,
splitMessage,
} from './replyTemplate';
const log = debug('lobe-server:bot:agent-bridge');
const EXECUTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
// Status emoji added on receive, removed on complete
const RECEIVED_EMOJI = emoji.eyes;
/**
* Extract a human-readable error message from agent runtime error objects.
* Handles various shapes: string, { message }, { errorType, error: { stack } }, etc.
*/
function extractErrorMessage(err: unknown): string {
if (!err) return 'Agent execution failed';
if (typeof err === 'string') return err;
const e = err as Record<string, any>;
// { message: '...' }
if (typeof e.message === 'string') return e.message;
// { errorType: 'ProviderBizError', error: { stack: 'Error: ...\n at ...' } }
if (e.error?.stack) {
const firstLine = String(e.error.stack).split('\n')[0];
const prefix = e.errorType ? `[${e.errorType}] ` : '';
return `${prefix}${firstLine}`;
}
// { body: { message: '...' } }
if (typeof e.body?.message === 'string') return e.body.message;
return JSON.stringify(err);
}
/**
* Fire-and-forget wrapper for reaction operations.
* Reactions should never block or fail the main flow.
*/
async function safeReaction(fn: () => Promise<void>, label: string): Promise<void> {
try {
await fn();
} catch (error) {
log('safeReaction [%s] failed: %O', label, error);
}
}
interface BridgeHandlerOpts {
agentId: string;
botContext?: ChatTopicBotContext;
userId: string;
}
/**
* Platform-agnostic bridge between Chat SDK events and Agent Runtime.
*
* Uses in-process onComplete callback to get agent execution results.
* Provides real-time feedback via emoji reactions and editable progress messages.
*/
export class AgentBridgeService {
/**
* Handle a new @mention start a fresh conversation.
*/
async handleMention(
thread: Thread<{ topicId?: string }>,
message: Message,
opts: BridgeHandlerOpts,
): Promise<void> {
const { agentId, botContext, userId } = opts;
log('handleMention: agentId=%s, user=%s, text=%s', agentId, userId, message.text.slice(0, 80));
// Immediate feedback: mark as received + show typing
await safeReaction(
() => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI),
'add eyes',
);
await thread.subscribe();
await thread.startTyping();
try {
// executeWithCallback handles progress message (post + edit at each step)
// The final reply is edited into the progress message by onComplete
const { topicId } = await this.executeWithCallback(thread, message, {
agentId,
botContext,
trigger: 'bot',
userId,
});
// Persist topic mapping in thread state for follow-up messages
if (topicId) {
await thread.setState({ topicId });
log('handleMention: stored topicId=%s in thread=%s state', topicId, thread.id);
}
} catch (error) {
log('handleMention error: %O', error);
const msg = error instanceof Error ? error.message : String(error);
await thread.post(`**Agent Execution Failed**\n\`\`\`\n${msg}\n\`\`\``);
} finally {
// Always clean up reactions
await this.removeReceivedReaction(thread, message);
}
}
/**
* Handle a follow-up message inside a subscribed thread multi-turn conversation.
*/
async handleSubscribedMessage(
thread: Thread<{ topicId?: string }>,
message: Message,
opts: BridgeHandlerOpts,
): Promise<void> {
const { agentId, botContext, userId } = opts;
const threadState = await thread.state;
const topicId = threadState?.topicId;
log('handleSubscribedMessage: agentId=%s, thread=%s, topicId=%s', agentId, thread.id, topicId);
if (!topicId) {
log('handleSubscribedMessage: no topicId in thread state, treating as new mention');
return this.handleMention(thread, message, { agentId, botContext, userId });
}
// Immediate feedback: mark as received + show typing
await safeReaction(
() => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI),
'add eyes',
);
await thread.startTyping();
try {
// executeWithCallback handles progress message (post + edit at each step)
await this.executeWithCallback(thread, message, {
agentId,
botContext,
topicId,
trigger: 'bot',
userId,
});
} catch (error) {
log('handleSubscribedMessage error: %O', error);
const msg = error instanceof Error ? error.message : String(error);
await thread.post(`**Agent Execution Failed**. Details:\n\`\`\`\n${msg}\n\`\`\``);
} finally {
await this.removeReceivedReaction(thread, message);
}
}
/**
* Dispatch to queue-mode webhooks or local in-memory callbacks based on runtime mode.
*/
private async executeWithCallback(
thread: Thread<{ topicId?: string }>,
userMessage: Message,
opts: {
agentId: string;
botContext?: ChatTopicBotContext;
topicId?: string;
trigger?: string;
userId: string;
},
): Promise<{ reply: string; topicId: string }> {
if (isQueueAgentRuntimeEnabled()) {
return this.executeWithWebhooks(thread, userMessage, opts);
}
return this.executeWithInMemoryCallbacks(thread, userMessage, opts);
}
/**
* Queue mode: post initial message, configure step/completion webhooks,
* then return immediately. Progress updates and final reply are handled
* by the bot-callback webhook endpoint.
*/
private async executeWithWebhooks(
thread: Thread<{ topicId?: string }>,
userMessage: Message,
opts: {
agentId: string;
botContext?: ChatTopicBotContext;
topicId?: string;
trigger?: string;
userId: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, userId, topicId, trigger } = opts;
const serverDB = await getServerDB();
const aiAgentService = new AiAgentService(serverDB, userId);
// Post initial progress message to get the message ID
let progressMessage: SentMessage | undefined;
try {
progressMessage = await thread.post(renderStart(userMessage.text));
} catch (error) {
log('executeWithWebhooks: failed to post progress message: %O', error);
}
const progressMessageId = progressMessage?.id;
if (!progressMessageId) {
throw new Error('Failed to post initial progress message');
}
// Build webhook URL for bot-callback endpoint
// Prefer INTERNAL_APP_URL for server-to-server calls (bypasses CDN/proxy)
const baseURL = appEnv.INTERNAL_APP_URL || appEnv.APP_URL;
if (!baseURL) {
throw new Error('APP_URL is required for queue mode bot webhooks');
}
const callbackUrl = urlJoin(baseURL, '/api/agent/webhooks/bot-callback');
// Shared webhook body with bot context
const webhookBody = {
applicationId: botContext?.applicationId,
platformThreadId: botContext?.platformThreadId,
progressMessageId,
};
log(
'executeWithWebhooks: agentId=%s, callbackUrl=%s, progressMessageId=%s',
agentId,
callbackUrl,
progressMessageId,
);
const result = await aiAgentService.execAgent({
agentId,
appContext: topicId ? { topicId } : undefined,
autoStart: true,
botContext,
completionWebhook: { body: webhookBody, url: callbackUrl },
prompt: userMessage.text,
stepWebhook: { body: webhookBody, url: callbackUrl },
trigger,
userInterventionConfig: { approvalMode: 'headless' },
webhookDelivery: 'qstash',
});
log(
'executeWithWebhooks: operationId=%s, topicId=%s (webhook mode, returning immediately)',
result.operationId,
result.topicId,
);
// Return immediately — progress/completion handled by webhooks
return { reply: '', topicId: result.topicId };
}
/**
* Local mode: use in-memory step callbacks and wait for completion via Promise.
*/
private async executeWithInMemoryCallbacks(
thread: Thread<{ topicId?: string }>,
userMessage: Message,
opts: {
agentId: string;
botContext?: ChatTopicBotContext;
topicId?: string;
trigger?: string;
userId: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, userId, topicId, trigger } = opts;
const serverDB = await getServerDB();
const aiAgentService = new AiAgentService(serverDB, userId);
// Post initial progress message
let progressMessage: SentMessage | undefined;
try {
progressMessage = await thread.post(renderStart(userMessage.text));
} catch (error) {
log('executeWithInMemoryCallbacks: failed to post progress message: %O', error);
}
// Track the last LLM content and tool calls for showing during tool execution
let lastLLMContent = '';
let lastToolsCalling:
| Array<{ apiName: string; arguments?: string; identifier: string }>
| undefined;
let totalToolCalls = 0;
let operationStartTime = 0;
return new Promise<{ reply: string; topicId: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Agent execution timed out`));
}, EXECUTION_TIMEOUT);
let assistantMessageId: string;
let resolvedTopicId: string;
const getElapsedMs = () => (operationStartTime > 0 ? Date.now() - operationStartTime : 0);
aiAgentService
.execAgent({
agentId,
appContext: topicId ? { topicId } : undefined,
autoStart: true,
botContext,
prompt: userMessage.text,
stepCallbacks: {
onAfterStep: async (stepData) => {
const { content, shouldContinue, toolsCalling } = stepData;
if (!shouldContinue || !progressMessage) return;
if (toolsCalling) totalToolCalls += toolsCalling.length;
const progressText = renderStepProgress({
...stepData,
elapsedMs: getElapsedMs(),
lastContent: lastLLMContent,
lastToolsCalling,
totalToolCalls,
});
if (content) lastLLMContent = content;
if (toolsCalling) lastToolsCalling = toolsCalling;
try {
progressMessage = await progressMessage.edit(progressText);
} catch (error) {
log('executeWithInMemoryCallbacks: failed to edit progress message: %O', error);
}
},
onComplete: async ({ finalState, reason }) => {
clearTimeout(timeout);
log('onComplete: reason=%s, assistantMessageId=%s', reason, assistantMessageId);
if (reason === 'error') {
const errorMsg = extractErrorMessage(finalState.error);
if (progressMessage) {
try {
await progressMessage.edit(renderError(errorMsg));
} catch {
// ignore edit failure
}
}
reject(new Error(errorMsg));
return;
}
try {
// Extract reply from finalState.messages (accumulated across all steps)
const lastAssistantContent = finalState.messages
?.slice()
.reverse()
.find(
(m: { content?: string; role: string }) => m.role === 'assistant' && m.content,
)?.content;
if (lastAssistantContent) {
const finalText = renderFinalReply(lastAssistantContent, {
elapsedMs: getElapsedMs(),
llmCalls: finalState.usage?.llm?.apiCalls ?? 0,
toolCalls: finalState.usage?.tools?.totalCalls ?? 0,
totalCost: finalState.cost?.total ?? 0,
totalTokens: finalState.usage?.llm?.tokens?.total ?? 0,
});
const chunks = splitMessage(finalText);
if (progressMessage) {
try {
await progressMessage.edit(chunks[0]);
// Post overflow chunks as follow-up messages
for (let i = 1; i < chunks.length; i++) {
await thread.post(chunks[i]);
}
} catch (error) {
log(
'executeWithInMemoryCallbacks: failed to edit final progress message: %O',
error,
);
}
}
log(
'executeWithInMemoryCallbacks: got response from finalState (%d chars, %d chunks)',
lastAssistantContent.length,
chunks.length,
);
resolve({ reply: lastAssistantContent, topicId: resolvedTopicId });
return;
}
reject(new Error('Agent completed but no response content found'));
} catch (error) {
reject(error);
}
},
},
trigger,
userInterventionConfig: { approvalMode: 'headless' },
})
.then((result) => {
assistantMessageId = result.assistantMessageId;
resolvedTopicId = result.topicId;
operationStartTime = new Date(result.createdAt).getTime();
log(
'executeWithInMemoryCallbacks: operationId=%s, assistantMessageId=%s, topicId=%s',
result.operationId,
result.assistantMessageId,
result.topicId,
);
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
});
}
/**
* Remove the received reaction from a user message (fire-and-forget).
*/
private async removeReceivedReaction(
thread: Thread<{ topicId?: string }>,
message: Message,
): Promise<void> {
await safeReaction(
() => thread.adapter.removeReaction(thread.id, message.id, RECEIVED_EMOJI),
'remove eyes',
);
}
}

View file

@ -0,0 +1,297 @@
import { createDiscordAdapter } from '@chat-adapter/discord';
import { createIoRedisState } from '@chat-adapter/state-ioredis';
import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { AgentBridgeService } from './AgentBridgeService';
const log = debug('lobe-server:bot:message-router');
interface ResolvedAgentInfo {
agentId: string;
userId: string;
}
interface DiscordCredentials {
applicationId: string;
botToken: string;
publicKey: string;
}
/**
* Routes incoming webhook events to the correct Chat SDK Bot instance
* and triggers message processing via AgentBridgeService.
*/
export class BotMessageRouter {
private bridge = new AgentBridgeService();
/** botToken → Chat instance (for webhook routing via x-discord-gateway-token) */
private botInstancesByToken = new Map<string, Chat<any>>();
/** applicationId → { agentId, userId } */
private discordAgentMap = new Map<string, ResolvedAgentInfo>();
/** Cached Chat instances keyed by applicationId */
private botInstances = new Map<string, Chat<any>>();
/** Store credentials for getDiscordBotConfigs() */
private credentialsByAppId = new Map<string, DiscordCredentials>();
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
/**
* Get the webhook handler for a given platform.
* Returns a function compatible with Next.js Route Handler: `(req: Request) => Promise<Response>`
*/
getWebhookHandler(platform: string): (req: Request) => Promise<Response> {
return async (req: Request) => {
await this.ensureInitialized();
if (platform === 'discord') {
return this.handleDiscordWebhook(req);
}
return new Response('No bot configured for this platform', { status: 404 });
};
}
// ------------------------------------------------------------------
// Discord webhook routing
// ------------------------------------------------------------------
private async handleDiscordWebhook(req: Request): Promise<Response> {
const bodyBuffer = await req.arrayBuffer();
log('handleDiscordWebhook: method=%s, content-length=%d', req.method, bodyBuffer.byteLength);
// Check for forwarded Gateway event (from Gateway worker)
const gatewayToken = req.headers.get('x-discord-gateway-token');
if (gatewayToken) {
log('Gateway forwarded event, token=%s...', gatewayToken.slice(0, 10));
log(
'Known tokens: %o',
[...this.botInstancesByToken.keys()].map((t) => t.slice(0, 10)),
);
// Log forwarded event details for debugging
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const event = JSON.parse(bodyText);
if (event.type === 'GATEWAY_MESSAGE_CREATE') {
const d = event.data;
log(
'MESSAGE_CREATE: author=%s (id=%s, bot=%s), mentions=%o, content=%s',
d?.author?.username,
d?.author?.id,
d?.author?.bot,
d?.mentions?.map((m: any) => ({ id: m.id, username: m.username })),
d?.content?.slice(0, 100),
);
}
} catch {
// ignore parse errors
}
const bot = this.botInstancesByToken.get(gatewayToken);
if (bot?.webhooks && 'discord' in bot.webhooks) {
log('Matched bot by token');
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
log('No matching bot for gateway token');
return new Response('No matching bot for gateway token', { status: 404 });
}
// HTTP Interactions — route by applicationId in the interaction payload
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const payload = JSON.parse(bodyText);
const appId = payload.application_id;
if (appId) {
const bot = this.botInstances.get(appId);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
}
} catch {
// Not valid JSON — fall through
}
// Fallback: try all registered bots
for (const bot of this.botInstances.values()) {
if (bot.webhooks && 'discord' in bot.webhooks) {
try {
const resp = await bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
if (resp.status !== 401) return resp;
} catch {
// signature mismatch — try next
}
}
}
return new Response('No bot configured for Discord', { status: 404 });
}
private cloneRequest(req: Request, body: ArrayBuffer): Request {
return new Request(req.url, {
body,
headers: req.headers,
method: req.method,
});
}
// ------------------------------------------------------------------
// Initialisation
// ------------------------------------------------------------------
private static REFRESH_INTERVAL_MS = 5 * 60_000;
private initPromise: Promise<void> | null = null;
private lastLoadedAt = 0;
private refreshPromise: Promise<void> | null = null;
private async ensureInitialized(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.initialize();
}
await this.initPromise;
// Periodically refresh bot mappings in the background so newly added bots are discovered
if (
Date.now() - this.lastLoadedAt > BotMessageRouter.REFRESH_INTERVAL_MS &&
!this.refreshPromise
) {
this.refreshPromise = this.loadAgentBots().finally(() => {
this.refreshPromise = null;
});
}
}
async initialize(): Promise<void> {
log('Initializing BotMessageRouter');
await this.loadAgentBots();
log('Initialized: %d agent bots', this.botInstances.size);
}
// ------------------------------------------------------------------
// Per-agent bots from DB
// ------------------------------------------------------------------
private async loadAgentBots(): Promise<void> {
try {
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
'discord',
gateKeeper,
);
this.lastLoadedAt = Date.now();
log('Found %d Discord bot providers in DB', providers.length);
for (const provider of providers) {
const { agentId, userId, applicationId, credentials } = provider;
const { botToken, publicKey } = credentials as any;
if (this.botInstances.has(applicationId)) {
log('Skipping provider %s: already registered', applicationId);
continue;
}
const adapters: Record<string, any> = {
discord: createDiscordAdapter({
applicationId,
botToken,
publicKey,
}),
};
const bot = this.createBot(adapters, `agent-${agentId}`);
this.registerHandlers(bot, { agentId, applicationId, platform: 'discord', userId });
await bot.initialize();
this.botInstances.set(applicationId, bot);
this.botInstancesByToken.set(botToken, bot);
this.discordAgentMap.set(applicationId, { agentId, userId });
this.credentialsByAppId.set(applicationId, { applicationId, botToken, publicKey });
log('Created Discord bot for agent=%s, appId=%s', agentId, applicationId);
}
} catch (error) {
log('Failed to load agent bots: %O', error);
}
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private createBot(adapters: Record<string, any>, label: string): Chat<any> {
const config: any = {
adapters,
userName: `lobehub-bot-${label}`,
};
const redisClient = getAgentRuntimeRedisClient();
if (redisClient) {
config.state = createIoRedisState({ client: redisClient, logger: new ConsoleLogger() });
}
return new Chat(config);
}
private registerHandlers(
bot: Chat<any>,
info: ResolvedAgentInfo & { applicationId: string; platform: string },
): void {
const { agentId, applicationId, platform, userId } = info;
bot.onNewMention(async (thread, message) => {
log('onNewMention: agent=%s, author=%s', agentId, message.author.userName);
await this.bridge.handleMention(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
userId,
});
});
bot.onSubscribedMessage(async (thread, message) => {
if (message.author.isBot === true) return;
log('onSubscribedMessage: agent=%s, author=%s', agentId, message.author.userName);
await this.bridge.handleSubscribedMessage(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
userId,
});
});
}
}
// ------------------------------------------------------------------
// Singleton
// ------------------------------------------------------------------
let instance: BotMessageRouter | null = null;
export function getBotMessageRouter(): BotMessageRouter {
if (!instance) {
instance = new BotMessageRouter();
}
return instance;
}

View file

@ -0,0 +1,423 @@
import { emoji } from 'chat';
import { describe, expect, it } from 'vitest';
import type { RenderStepParams } from '../replyTemplate';
import {
formatTokens,
renderError,
renderFinalReply,
renderLLMGenerating,
renderStart,
renderStepProgress,
renderToolExecuting,
splitMessage,
summarizeOutput,
} from '../replyTemplate';
// Helper to build a minimal RenderStepParams with defaults
function makeParams(overrides: Partial<RenderStepParams> = {}): RenderStepParams {
return {
executionTimeMs: 0,
stepType: 'call_llm' as const,
thinking: true,
totalCost: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalSteps: 1,
totalTokens: 0,
...overrides,
};
}
describe('replyTemplate', () => {
// ==================== renderStart ====================
describe('renderStart', () => {
it('should return a non-empty string', () => {
const result = renderStart();
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
});
// ==================== renderLLMGenerating ====================
describe('renderLLMGenerating', () => {
it('should show content + pending tool call with identifier|apiName and first arg only', () => {
expect(
renderLLMGenerating(
makeParams({
content: 'Let me search for that.',
thinking: false,
toolsCalling: [
{
apiName: 'web_search',
arguments: '{"query":"latest news","limit":10}',
identifier: 'builtin',
},
],
}),
),
).toBe('Let me search for that.\n\n○ **builtin·web_search**(query: "latest news")');
});
it('should show multiple pending tool calls on separate lines with hollow circles', () => {
expect(
renderLLMGenerating(
makeParams({
thinking: false,
toolsCalling: [
{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' },
{
apiName: 'readUrl',
arguments: '{"url":"https://example.com"}',
identifier: 'lobe-web-browsing',
},
],
}),
),
).toBe(
'○ **builtin·search**(q: "test")\n○ **lobe-web-browsing·readUrl**(url: "https://example.com")',
);
});
it('should handle tool calls without args', () => {
expect(
renderLLMGenerating(
makeParams({
thinking: false,
toolsCalling: [{ apiName: 'get_time', identifier: 'builtin' }],
}),
),
).toBe('○ **builtin·get_time**');
});
it('should handle tool calls with invalid JSON args gracefully', () => {
expect(
renderLLMGenerating(
makeParams({
thinking: false,
toolsCalling: [{ apiName: 'broken', arguments: 'not json', identifier: 'plugin' }],
}),
),
).toBe('○ **plugin·broken**');
});
it('should omit identifier when empty', () => {
expect(
renderLLMGenerating(
makeParams({
thinking: false,
toolsCalling: [{ apiName: 'search', arguments: '{"q":"test"}', identifier: '' }],
}),
),
).toBe('○ **search**(q: "test")');
});
it('should fall back to lastContent when no content', () => {
expect(
renderLLMGenerating(
makeParams({
lastContent: 'Previous response',
thinking: false,
toolsCalling: [{ apiName: 'search', identifier: 'builtin' }],
}),
),
).toBe('Previous response\n\n○ **builtin·search**');
});
it('should show thinking when only reasoning present', () => {
expect(
renderLLMGenerating(
makeParams({
reasoning: 'Let me think about this...',
thinking: false,
}),
),
).toBe(`${emoji.thinking} Let me think about this...`);
});
it('should show content with processing when pure text', () => {
expect(
renderLLMGenerating(
makeParams({
content: 'Here is my response',
thinking: false,
}),
),
).toBe(`Here is my response\n\n`);
});
it('should show processing fallback when no content at all', () => {
expect(renderLLMGenerating(makeParams({ thinking: false }))).toBe(
`${emoji.thinking} Processing...`,
);
});
});
// ==================== renderToolExecuting ====================
describe('renderToolExecuting', () => {
it('should show completed tools with filled circle and result', () => {
expect(
renderToolExecuting(
makeParams({
lastContent: 'I will search for that.',
lastToolsCalling: [
{ apiName: 'web_search', arguments: '{"query":"test"}', identifier: 'builtin' },
],
stepType: 'call_tool',
toolsResult: [
{ apiName: 'web_search', identifier: 'builtin', output: 'Found 3 results' },
],
}),
),
).toBe(
`I will search for that.\n\n⏺ **builtin·web_search**(query: "test")\n ⎿ Found 3 results\n\n${emoji.thinking} Processing...`,
);
});
it('should show completed tools without result when output is empty', () => {
expect(
renderToolExecuting(
makeParams({
lastToolsCalling: [{ apiName: 'get_time', identifier: 'builtin' }],
stepType: 'call_tool',
toolsResult: [{ apiName: 'get_time', identifier: 'builtin' }],
}),
),
).toBe(`⏺ **builtin·get_time**\n\n${emoji.thinking} Processing...`);
});
it('should show multiple completed tools with results', () => {
expect(
renderToolExecuting(
makeParams({
lastToolsCalling: [
{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' },
{
apiName: 'readUrl',
arguments: '{"url":"https://example.com"}',
identifier: 'lobe-web-browsing',
},
],
stepType: 'call_tool',
toolsResult: [
{ apiName: 'search', identifier: 'builtin', output: 'Found 5 results' },
{
apiName: 'readUrl',
identifier: 'lobe-web-browsing',
output: 'Page loaded successfully',
},
],
}),
),
).toBe(
`⏺ **builtin·search**(q: "test")\n ⎿ Found 5 results\n⏺ **lobe-web-browsing·readUrl**(url: "https://example.com")\n ⎿ Page loaded successfully\n\n${emoji.thinking} Processing...`,
);
});
it('should show lastContent with processing when no lastToolsCalling', () => {
expect(
renderToolExecuting(
makeParams({
lastContent: 'I found some results.',
stepType: 'call_tool',
}),
),
).toBe(`I found some results.\n\n${emoji.thinking} Processing...`);
});
it('should show processing fallback when no lastContent and no tools', () => {
expect(renderToolExecuting(makeParams({ stepType: 'call_tool' }))).toBe(
`${emoji.thinking} Processing...`,
);
});
});
// ==================== summarizeOutput ====================
describe('summarizeOutput', () => {
it('should return undefined for empty output', () => {
expect(summarizeOutput(undefined)).toBeUndefined();
expect(summarizeOutput('')).toBeUndefined();
});
it('should return first line for single-line output', () => {
expect(summarizeOutput('Hello world')).toBe('Hello world');
});
it('should truncate long first line', () => {
const long = 'a'.repeat(120);
expect(summarizeOutput(long)).toBe('a'.repeat(100) + '...');
});
it('should show line count for multi-line output', () => {
expect(summarizeOutput('line1\nline2\nline3')).toBe('line1 … +2 lines');
});
it('should skip blank lines', () => {
expect(summarizeOutput('line1\n\n\nline2')).toBe('line1 … +1 lines');
});
});
// ==================== formatTokens ====================
describe('formatTokens', () => {
it('should return raw number for < 1000', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(999)).toBe('999');
});
it('should format thousands as k', () => {
expect(formatTokens(1000)).toBe('1.0k');
expect(formatTokens(1234)).toBe('1.2k');
expect(formatTokens(20_400)).toBe('20.4k');
expect(formatTokens(999_999)).toBe('1000.0k');
});
it('should format millions as m', () => {
expect(formatTokens(1_000_000)).toBe('1.0m');
expect(formatTokens(1_234_567)).toBe('1.2m');
expect(formatTokens(12_500_000)).toBe('12.5m');
});
});
// ==================== renderFinalReply ====================
describe('renderFinalReply', () => {
it('should append usage footer with tokens, cost, and call counts', () => {
expect(
renderFinalReply('Here is the answer.', {
llmCalls: 5,
toolCalls: 4,
totalCost: 0.0312,
totalTokens: 1234,
}),
).toBe('Here is the answer.\n\n-# 1.2k tokens · $0.0312 | llm×5 | tools×4');
});
it('should hide call counts when llmCalls=1 and toolCalls=0', () => {
expect(
renderFinalReply('Simple answer.', {
llmCalls: 1,
toolCalls: 0,
totalCost: 0.001,
totalTokens: 500,
}),
).toBe('Simple answer.\n\n-# 500 tokens · $0.0010');
});
it('should show call counts when toolCalls > 0 even if llmCalls=1', () => {
expect(
renderFinalReply('Answer.', {
llmCalls: 1,
toolCalls: 2,
totalCost: 0.005,
totalTokens: 800,
}),
).toBe('Answer.\n\n-# 800 tokens · $0.0050 | llm×1 | tools×2');
});
it('should show call counts when llmCalls > 1 even if toolCalls=0', () => {
expect(
renderFinalReply('Answer.', {
llmCalls: 3,
toolCalls: 0,
totalCost: 0.01,
totalTokens: 2000,
}),
).toBe('Answer.\n\n-# 2.0k tokens · $0.0100 | llm×3 | tools×0');
});
it('should hide call counts for zero usage', () => {
expect(
renderFinalReply('Done.', { llmCalls: 0, toolCalls: 0, totalCost: 0, totalTokens: 0 }),
).toBe('Done.\n\n-# 0 tokens · $0.0000');
});
it('should format large token counts', () => {
expect(
renderFinalReply('Result', {
llmCalls: 10,
toolCalls: 20,
totalCost: 1.5,
totalTokens: 1_234_567,
}),
).toBe('Result\n\n-# 1.2m tokens · $1.5000 | llm×10 | tools×20');
});
});
// ==================== renderError ====================
describe('renderError', () => {
it('should wrap error in markdown code block', () => {
expect(renderError('Something went wrong')).toBe(
'**Agent Execution Failed**\n```\nSomething went wrong\n```',
);
});
});
// ==================== renderStepProgress (dispatcher) ====================
describe('renderStepProgress', () => {
it('should dispatch to renderLLMGenerating for call_llm with pending tools', () => {
expect(
renderStepProgress(
makeParams({
content: 'Looking into it',
thinking: false,
toolsCalling: [{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' }],
}),
),
).toBe('Looking into it\n\n○ **builtin·search**(q: "test")');
});
it('should dispatch to renderToolExecuting for call_tool with completed tools', () => {
expect(
renderStepProgress(
makeParams({
lastContent: 'Previous content',
lastToolsCalling: [
{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' },
],
stepType: 'call_tool',
thinking: true,
toolsResult: [{ apiName: 'search', identifier: 'builtin', output: 'Found results' }],
}),
),
).toBe(
`Previous content\n\n⏺ **builtin·search**(q: "test")\n ⎿ Found results\n\n${emoji.thinking} Processing...`,
);
});
});
// ==================== splitMessage ====================
describe('splitMessage', () => {
it('should return single chunk for short text', () => {
expect(splitMessage('hello', 100)).toEqual(['hello']);
});
it('should split at paragraph boundary', () => {
const text = 'a'.repeat(80) + '\n\n' + 'b'.repeat(80);
expect(splitMessage(text, 100)).toEqual(['a'.repeat(80), 'b'.repeat(80)]);
});
it('should split at line boundary when no paragraph break fits', () => {
const text = 'a'.repeat(80) + '\n' + 'b'.repeat(80);
expect(splitMessage(text, 100)).toEqual(['a'.repeat(80), 'b'.repeat(80)]);
});
it('should hard-cut when no break found', () => {
const text = 'a'.repeat(250);
const chunks = splitMessage(text, 100);
expect(chunks).toEqual(['a'.repeat(100), 'a'.repeat(100), 'a'.repeat(50)]);
});
it('should handle multiple chunks', () => {
const text = 'chunk1\n\nchunk2\n\nchunk3';
expect(splitMessage(text, 10)).toEqual(['chunk1', 'chunk2', 'chunk3']);
});
});
});

View file

@ -0,0 +1,162 @@
import type { ContextType, TimeSegment } from './vibeMatrix';
import { VIBE_CORPUS } from './vibeMatrix';
// Simple sample implementation to avoid dependency issues
function sample<T>(arr: T[]): T | undefined {
if (!arr || arr.length === 0) return undefined;
return arr[Math.floor(Math.random() * arr.length)];
}
// ==========================================
// 3. 智能检测器 (The Brain)
// ==========================================
/**
* (0-23)
*/
function getLocalHour(date: Date, timeZone?: string): number {
if (!timeZone) return date.getHours();
try {
// 使用 Intl API 将时间格式化为指定时区的小时数
const formatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
hour12: false,
timeZone,
});
const hourStr = formatter.format(date);
// 处理可能的 '24' 这种边缘情况(极少见,但为了稳健)
const hour = parseInt(hourStr, 10);
return hour === 24 ? 0 : hour;
} catch (e) {
// 如果时区无效,回退到服务器时间
console.warn(`[getExtremeAck] Invalid timezone: ${timeZone}, falling back to server time.`);
return date.getHours();
}
}
function getTimeSegment(hour: number): TimeSegment {
if (hour >= 5 && hour < 9) return 'early';
if (hour >= 9 && hour < 12) return 'morning';
if (hour >= 12 && hour < 14) return 'lunch';
if (hour >= 14 && hour < 18) return 'afternoon';
if (hour >= 18 && hour < 22) return 'evening';
return 'night';
}
function getContextType(content: string): ContextType {
const lower = content.toLowerCase();
// 1. 🚨 Urgent (最高优先级)
if (/asap|urgent|emergency|!!!|quick|fast|hurry|立刻|马上|紧急/.test(lower)) {
return 'urgent';
}
// 2. 🐛 Debugging (特征明显)
if (/error|bug|fix|crash|fail|exception|undefined|null|报错|挂了|修复/.test(lower)) {
return 'debugging';
}
// 3. 💻 Coding (代码特征)
if (
/const |import |function |=> |class |return |<\/|npm |git |docker|sudo|pip|api|json/.test(lower)
) {
return 'coding';
}
// 4. 👀 Review (请求查看)
if (/review|check|look at|opinion|verify|audit|审查|看看|检查/.test(lower)) {
return 'review';
}
// 5. 📝 Planning (列表/计划)
if (/plan|todo|list|roadmap|schedule|summary|agenda|计划|安排|总结/.test(lower)) {
return 'planning';
}
// 6. 📚 Explanation (提问/教学)
if (/what is|how to|explain|guide|tutorial|teach|meaning|什么是|怎么做|解释/.test(lower)) {
return 'explanation';
}
// 7. 🎨 Creative (创作/设计)
if (/design|draft|write|idea|brainstorm|generate|create|image|logo|设计|文案|生成/.test(lower)) {
return 'creative';
}
// 8. 🧠 Analysis (兜底的长思考)
if (
content.includes('?') ||
content.length > 60 ||
/analyze|compare|research|think|why|分析|研究/.test(lower)
) {
return 'analysis';
}
// 9. 💬 Casual (短且非指令)
if (/hello|hi|hey|thanks|cool|wow|lol|哈哈|你好|谢谢/.test(lower)) {
return 'casual';
}
// 10. 👌 Quick (兜底)
return 'quick';
}
function humanizeText(text: string): string {
// 10% 的概率把首字母变成小写(显得随意)
if (Math.random() < 0.1) {
text = text.charAt(0).toLowerCase() + text.slice(1);
}
// 10% 的概率去掉末尾标点
if (Math.random() < 0.1 && text.endsWith('.')) {
text = text.slice(0, -1);
}
return text;
}
// ==========================================
// 4. 主入口
// ==========================================
export interface AckOptions {
/**
* ()
*/
date?: Date;
/**
* (e.g. 'Asia/Shanghai', 'America/New_York')
* 使
*/
timezone?: string;
}
export function getExtremeAck(content: string = '', options: AckOptions = {}): string {
const now = options.date || new Date();
// 计算用户当地时间的小时数
const localHour = getLocalHour(now, options.timezone);
const timeSeg = getTimeSegment(localHour);
const contextType = getContextType(content);
// 筛选符合当前时间段和上下文的所有规则
const candidates = VIBE_CORPUS.filter((rule) => {
// 检查时间匹配
const timeMatch = rule.time === 'all' || rule.time.includes(timeSeg);
// 检查上下文匹配
const contextMatch = rule.context === 'all' || rule.context.includes(contextType);
return timeMatch && contextMatch;
}).flatMap((rule) => rule.phrases);
// 如果没有匹配到任何规则,使用通用兜底
if (candidates.length === 0) {
return 'Processing...';
}
const selected = sample(candidates) || 'Processing...';
return humanizeText(selected);
}

View file

@ -0,0 +1,415 @@
// ==========================================
// 1. 定义类型
// ==========================================
export type TimeSegment = 'early' | 'morning' | 'lunch' | 'afternoon' | 'evening' | 'night';
export type ContextType =
| 'urgent' // 最高优先级
| 'debugging' // 错误修复
| 'coding' // 代码实现
| 'review' // 审查/检查
| 'planning' // 计划/列表
| 'analysis' // 深度思考
| 'explanation' // 解释/教学
| 'creative' // 创意/生成
| 'casual' // 闲聊
| 'quick'; // 兜底/短语
// 为了方便配置,定义 'all' 类型
export type TimeRule = TimeSegment[] | 'all';
export type ContextRule = ContextType[] | 'all';
export interface VibeRule {
context: ContextRule;
phrases: string[];
time: TimeRule;
}
// ==========================================
// 2. 语料规则库 (Rule-Based Corpus)
// ==========================================
export const VIBE_CORPUS: VibeRule[] = [
// =================================================================
// 🌍 GLOBAL / UNIVERSAL (通用回复)
// =================================================================
{
phrases: [
'On it.',
'Working on it.',
'Processing.',
'Copy that.',
'Roger.',
'Sure thing.',
'One sec.',
'Handling it.',
'Checking.',
'Got it.',
'Standby.',
'Will do.',
'Affirmative.',
'Looking into it.',
'Give me a moment.',
],
time: 'all',
context: ['quick', 'casual'],
},
{
phrases: [
'⚡ On it, ASAP!',
'🚀 Priority received.',
'Handling this immediately.',
'Rushing this.',
'Fast tracking...',
'Emergency mode engaged.',
'Right away.',
'Dropping everything else.',
'Top priority.',
'Moving fast.',
],
time: 'all',
context: ['urgent'],
},
{
phrases: [
'Compiling...',
'Building...',
'Refactoring...',
'Optimizing logic...',
'Pushing to memory...',
'Executing...',
'Running script...',
'Analyzing stack...',
'Implementing...',
'Writing code...',
],
time: 'all',
context: ['coding'],
},
{
phrases: [
'🐛 Debugging...',
'Tracing the error...',
'Checking logs...',
'Hunting down the bug...',
'Patching...',
'Fixing...',
'Analyzing crash dump...',
'Squashing bugs...',
'Investigating failure...',
'Repairing...',
],
time: 'all',
context: ['debugging'],
},
{
phrases: [
'🤔 Thinking...',
'Processing context...',
'Analyzing...',
'Connecting the dots...',
'Let me research that.',
'Digging deeper...',
'Investigating...',
'Considering options...',
'Evaluating...',
'Deep dive...',
],
time: 'all',
context: ['analysis', 'explanation', 'planning'],
},
// =================================================================
// 🌅 EARLY MORNING (05:00 - 09:00)
// Vibe: Fresh, Coffee, Quiet, Start, Planning
// =================================================================
{
phrases: [
'☕️ Coffee first, then code.',
'🌅 Early bird mode.',
'Fresh start.',
'Morning sequence initiated.',
'Waking up the neurons...',
'Clear mind, clear code.',
'Starting the day right.',
'Loading morning resources...',
'Rise and shine.',
'Early morning processing...',
'Good morning. On it.',
'Booting up with the sun.',
'Fresh perspective loading...',
'Quiet morning logic.',
"Let's get ahead of the day.",
],
time: ['early', 'morning'],
context: 'all',
},
{
phrases: [
'☕️ Caffeinating the bug...',
'Squashing bugs with morning coffee.',
'Fresh eyes on this error.',
'Debugging before breakfast.',
'Tracing logs while the coffee brews.',
'Early fix incoming.',
],
time: ['early', 'morning'],
context: ['debugging'],
},
{
phrases: [
'📝 Mapping out the day.',
'Morning agenda...',
'Planning the roadmap.',
"Setting up today's goals.",
'Organizing tasks early.',
'Structuring the day.',
],
time: ['early', 'morning'],
context: ['planning'],
},
// =================================================================
// ☀️ MORNING FLOW (09:00 - 12:00)
// Vibe: High Energy, Focus, Meetings, Execution
// =================================================================
{
phrases: [
'⚡ Full speed ahead.',
'Morning sprint mode.',
"Let's crush this.",
'Focusing...',
'In the zone.',
'Executing morning tasks.',
'Productivity is high.',
'Moving through the list.',
'Active and running.',
'Processing request.',
'On the ball.',
"Let's get this done.",
'Morning momentum.',
'Handling it.',
'Current status: Busy.',
],
time: ['morning'],
context: 'all',
},
{
phrases: [
'🚀 Shipping updates.',
'Pushing commits.',
'Building fast.',
'Code is flowing.',
'Implementing feature.',
'Writing logic.',
],
time: ['morning'],
context: ['coding'],
},
{
phrases: [
'👀 Reviewing PRs.',
'Morning code audit.',
'Checking the specs.',
'Verifying implementation.',
'Scanning changes.',
],
time: ['morning'],
context: ['review'],
},
// =================================================================
// 🍱 LUNCH BREAK (12:00 - 14:00)
// Vibe: Food, Relax, Multitasking, Recharge
// =================================================================
{
phrases: [
'🥪 Lunchtime processing...',
'Fueling up.',
'Working through lunch.',
'Bite sized update.',
'Chewing on this...',
'Lunch break vibes.',
'Recharging batteries (and stomach).',
'Mid-day pause.',
'Processing while eating.',
'Bon appétit to me.',
'Taking a quick break, but checking.',
'Food for thought...',
'Lunch mode: Active.',
'Halfway through the day.',
'Refueling...',
],
time: ['lunch'],
context: 'all',
},
{
phrases: [
'🐛 Hunting bugs on a full stomach.',
'Debugging with a side of lunch.',
'Squashing bugs between bites.',
'Lunch debug session.',
'Fixing this before the food coma.',
],
time: ['lunch'],
context: ['debugging'],
},
{
phrases: [
'🎨 Napkin sketch ideas...',
'Dreaming up concepts over lunch.',
'Creative break.',
'Brainstorming with food.',
'Loose ideas flowing.',
],
time: ['lunch'],
context: ['creative'],
},
// =================================================================
// ☕️ AFTERNOON GRIND (14:00 - 18:00)
// Vibe: Coffee Refill, Push, Deadline, Focus
// =================================================================
{
phrases: [
'☕️ Afternoon refill.',
'Powering through.',
'Focus mode: ON.',
'Afternoon sprint.',
'Keeping the momentum.',
'Second wind incoming.',
'Grinding away.',
'Locked in.',
'Pushing to the finish line.',
'Afternoon focus.',
'Staying sharp.',
'Caffeine levels critical... refilling.',
"Let's finish strong.",
'Heads down, working.',
'Processing...',
],
time: ['afternoon'],
context: 'all',
},
{
phrases: [
'🚀 Shipping it.',
'Crushing it before EOD.',
'Final push.',
'Deploying updates.',
'Rushing the fix.',
'Fast tracking this.',
],
time: ['afternoon'],
context: ['coding', 'urgent'],
},
{
phrases: [
'🧠 Deep dive session.',
'Analyzing the data...',
'Thinking hard.',
'Complex processing.',
'Solving the puzzle.',
],
time: ['afternoon'],
context: ['analysis'],
},
// =================================================================
// 🌆 EVENING (18:00 - 22:00)
// Vibe: Winding Down, Review, Chill, Wrap Up
// =================================================================
{
phrases: [
'🌆 Winding down...',
'Evening review.',
'Wrapping up.',
'Last tasks of the day.',
'Evening vibes.',
'Sunset processing.',
'Closing tabs...',
'Finishing up.',
'One last thing.',
'Checking before sign off.',
'Evening mode.',
'Relaxed processing.',
'Tying up loose ends.',
'End of day check.',
'Almost done.',
],
time: ['evening'],
context: 'all',
},
{
phrases: [
'👀 Final review.',
'Evening code scan.',
"Checking the day's work.",
'Verifying before sleep.',
'Last look.',
],
time: ['evening'],
context: ['review'],
},
{
phrases: [
'📝 Prepping for tomorrow.',
'Evening recap.',
'Summarizing the day.',
'Planning ahead.',
'Agenda for tomorrow.',
],
time: ['evening'],
context: ['planning'],
},
// =================================================================
// 🦉 LATE NIGHT (22:00 - 05:00)
// Vibe: Hacker, Silence, Flow, Deep Thought, Tired
// =================================================================
{
phrases: [
'🦉 Night owl mode.',
'The world sleeps, we code.',
'Midnight logic.',
'Quietly processing...',
'Dark mode enabled.',
'Still here.',
'Late night vibes.',
'Burning the midnight oil.',
'Silence and focus.',
'You are still up?',
'Night shift.',
'Working in the dark.',
'Insomnia mode.',
'Processing...',
'Watching the stars (and logs).',
],
time: ['night'],
context: 'all',
},
{
phrases: [
'👾 Entering the matrix...',
'Flow state.',
'Just me and the terminal.',
'Compiling in the dark...',
'Hacking away.',
'Midnight commit.',
'Code never sleeps.',
'System: Active.',
],
time: ['night'],
context: ['coding', 'debugging'],
},
{
phrases: [
'🌌 Deep thought...',
'Thinking in the silence...',
'Analyzing the void...',
'Late night clarity.',
'Philosophical processing...',
'Solving mysteries...',
],
time: ['night'],
context: ['analysis', 'planning'],
},
];

View file

@ -0,0 +1,27 @@
import { REST } from '@discordjs/rest';
import debug from 'debug';
import { type RESTPostAPIChannelMessageResult, Routes } from 'discord-api-types/v10';
const log = debug('lobe-server:bot:discord-rest');
export class DiscordRestApi {
private readonly rest: REST;
constructor(botToken: string) {
this.rest = new REST({ version: '10' }).setToken(botToken);
}
async editMessage(channelId: string, messageId: string, content: string): Promise<void> {
log('editMessage: channel=%s, message=%s', channelId, messageId);
await this.rest.patch(Routes.channelMessage(channelId, messageId), { body: { content } });
}
async createMessage(channelId: string, content: string): Promise<{ id: string }> {
log('createMessage: channel=%s', channelId);
const data = (await this.rest.post(Routes.channelMessages(channelId), {
body: { content },
})) as RESTPostAPIChannelMessageResult;
return { id: data.id };
}
}

View file

@ -0,0 +1,5 @@
export { AgentBridgeService } from './AgentBridgeService';
export { BotMessageRouter, getBotMessageRouter } from './BotMessageRouter';
export { platformBotRegistry } from './platforms';
export { Discord, type DiscordBotConfig } from './platforms/discord';
export type { PlatformBot, PlatformBotClass } from './types';

View file

@ -0,0 +1,110 @@
import type { DiscordAdapter } from '@chat-adapter/discord';
import { createDiscordAdapter } from '@chat-adapter/discord';
import { createIoRedisState } from '@chat-adapter/state-ioredis';
import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
import { appEnv } from '@/envs/app';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import type { PlatformBot } from '../types';
const log = debug('lobe-server:bot:gateway:discord');
const DEFAULT_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours
export interface DiscordBotConfig {
[key: string]: string;
applicationId: string;
botToken: string;
publicKey: string;
}
export interface GatewayListenerOptions {
durationMs?: number;
waitUntil?: (task: Promise<any>) => void;
}
export class Discord implements PlatformBot {
readonly platform = 'discord';
readonly applicationId: string;
private abort = new AbortController();
private config: DiscordBotConfig;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(config: DiscordBotConfig) {
this.config = config;
this.applicationId = config.applicationId;
}
async start(options?: GatewayListenerOptions): Promise<void> {
log('Starting DiscordBot appId=%s', this.applicationId);
this.stopped = false;
this.abort = new AbortController();
const adapter = createDiscordAdapter({
applicationId: this.config.applicationId,
botToken: this.config.botToken,
publicKey: this.config.publicKey,
});
const chatConfig: any = {
adapters: { discord: adapter },
userName: `lobehub-gateway-${this.applicationId}`,
};
const redisClient = getAgentRuntimeRedisClient();
if (redisClient) {
chatConfig.state = createIoRedisState({ client: redisClient, logger: new ConsoleLogger() });
}
const bot = new Chat(chatConfig);
await bot.initialize();
const discordAdapter = (bot as any).adapters.get('discord') as DiscordAdapter;
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const waitUntil = options?.waitUntil ?? ((task: Promise<any>) => task.catch(() => {}));
const webhookUrl = `${appEnv.APP_URL}/api/agent/webhooks/discord`;
await discordAdapter.startGatewayListener(
{ waitUntil },
durationMs,
this.abort.signal,
webhookUrl,
);
// Only schedule refresh timer in long-running mode (no custom options)
if (!options) {
this.refreshTimer = setTimeout(() => {
if (this.abort.signal.aborted || this.stopped) return;
log(
'DiscordBot appId=%s duration elapsed (%dh), refreshing...',
this.applicationId,
durationMs / 3_600_000,
);
this.abort.abort();
this.start().catch((err) => {
log('Failed to refresh DiscordBot appId=%s: %O', this.applicationId, err);
});
}, durationMs);
}
log('DiscordBot appId=%s started, webhookUrl=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping DiscordBot appId=%s', this.applicationId);
this.stopped = true;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.abort.abort();
}
}

View file

@ -0,0 +1,6 @@
import type { PlatformBotClass } from '../types';
import { Discord } from './discord';
export const platformBotRegistry: Record<string, PlatformBotClass> = {
discord: Discord,
};

View file

@ -0,0 +1,269 @@
import { emoji } from 'chat';
import type { StepPresentationData } from '../agentRuntime/types';
import { getExtremeAck } from './ackPhrases';
// ==================== Message Splitting ====================
const DEFAULT_CHAR_LIMIT = 1800;
export function splitMessage(text: string, limit = DEFAULT_CHAR_LIMIT): string[] {
if (text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining);
break;
}
// Try to find a paragraph break
let splitAt = remaining.lastIndexOf('\n\n', limit);
// Fall back to line break
if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit);
// Hard cut
if (splitAt <= 0) splitAt = limit;
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).replace(/^\n+/, '');
}
return chunks;
}
// ==================== Params ====================
type ToolCallItem = { apiName: string; arguments?: string; identifier: string };
type ToolResultItem = { apiName: string; identifier: string; output?: string };
export interface RenderStepParams extends StepPresentationData {
elapsedMs?: number;
lastContent?: string;
lastToolsCalling?: ToolCallItem[];
totalToolCalls?: number;
}
// ==================== Helpers ====================
function formatToolName(tc: { apiName: string; identifier: string }): string {
if (tc.identifier) return `**${tc.identifier}·${tc.apiName}**`;
return `**${tc.apiName}**`;
}
function formatToolCall(tc: ToolCallItem): string {
if (tc.arguments) {
try {
const args = JSON.parse(tc.arguments);
const entries = Object.entries(args);
if (entries.length > 0) {
const [k, v] = entries[0];
return `${formatToolName(tc)}(${k}: ${JSON.stringify(v)})`;
}
} catch {
// invalid JSON, show name only
}
}
return formatToolName(tc);
}
export function summarizeOutput(output: string | undefined, maxLength = 100): string | undefined {
if (!output) return undefined;
const lines = output.split('\n').filter((l) => l.trim());
if (lines.length === 0) return undefined;
const firstLine = lines[0].length > maxLength ? lines[0].slice(0, maxLength) + '...' : lines[0];
if (lines.length > 1) {
return `${firstLine} … +${lines.length - 1} lines`;
}
return firstLine;
}
function formatPendingTools(toolsCalling: ToolCallItem[]): string {
return toolsCalling.map((tc) => `${formatToolCall(tc)}`).join('\n');
}
function formatCompletedTools(
toolsCalling: ToolCallItem[],
toolsResult?: ToolResultItem[],
): string {
return toolsCalling
.map((tc, i) => {
const callStr = `${formatToolCall(tc)}`;
const summary = summarizeOutput(toolsResult?.[i]?.output);
if (summary) {
return `${callStr}\n ⎿ ${summary}`;
}
return callStr;
})
.join('\n');
}
export function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return String(tokens);
}
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) return `${minutes}m${seconds}s`;
return `${seconds}s`;
}
function renderInlineStats(params: {
elapsedMs?: number;
totalCost: number;
totalTokens: number;
totalToolCalls?: number;
}): { footer: string; header: string } {
const { elapsedMs, totalToolCalls, totalTokens, totalCost } = params;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const header =
totalToolCalls && totalToolCalls > 0
? `> total **${totalToolCalls}** tools calling ${time}\n\n`
: '';
if (totalTokens <= 0) return { footer: '', header };
const footer = `\n\n-# ${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}`;
return { footer, header };
}
// ==================== 1. Start ====================
export function renderStart(content?: string): string {
return getExtremeAck(content);
}
// ==================== 2. LLM Generating ====================
/**
* LLM step just finished. Three sub-states:
* - has reasoning (thinking)
* - pure text content
* - has tool calls (about to execute tools)
*/
export function renderLLMGenerating(params: RenderStepParams): string {
const {
content,
elapsedMs,
lastContent,
reasoning,
toolsCalling,
totalCost,
totalTokens,
totalToolCalls,
} = params;
const displayContent = content || lastContent;
const { header, footer } = renderInlineStats({
elapsedMs,
totalCost,
totalTokens,
totalToolCalls,
});
// Sub-state: LLM decided to call tools → show content + pending tool calls (○)
if (toolsCalling && toolsCalling.length > 0) {
const toolsList = formatPendingTools(toolsCalling);
if (displayContent) return `${header}${displayContent}\n\n${toolsList}${footer}`;
return `${header}${toolsList}${footer}`;
}
// Sub-state: has reasoning (thinking)
if (reasoning && !content) {
return `${header}${emoji.thinking} ${reasoning}${footer}`;
}
// Sub-state: pure text content (waiting for next step)
if (displayContent) {
return `${header}${displayContent}\n\n${footer}`;
}
return `${header}${emoji.thinking} Processing...${footer}`;
}
// ==================== 3. Tool Executing ====================
/**
* Tool step just finished, LLM is next.
* Shows completed tools with results ().
*/
export function renderToolExecuting(params: RenderStepParams): string {
const {
elapsedMs,
lastContent,
lastToolsCalling,
toolsResult,
totalCost,
totalTokens,
totalToolCalls,
} = params;
const { header, footer } = renderInlineStats({
elapsedMs,
totalCost,
totalTokens,
totalToolCalls,
});
const parts: string[] = [];
if (header) parts.push(header.trimEnd());
if (lastContent) parts.push(lastContent);
if (lastToolsCalling && lastToolsCalling.length > 0) {
parts.push(formatCompletedTools(lastToolsCalling, toolsResult));
parts.push(`${emoji.thinking} Processing...`);
} else {
parts.push(`${emoji.thinking} Processing...`);
}
return parts.join('\n\n') + footer;
}
// ==================== 4. Final Output ====================
export function renderFinalReply(
content: string,
params: {
elapsedMs?: number;
llmCalls: number;
toolCalls: number;
totalCost: number;
totalTokens: number;
},
): string {
const { totalTokens, totalCost, llmCalls, toolCalls, elapsedMs } = params;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const calls = llmCalls > 1 || toolCalls > 0 ? ` | llm×${llmCalls} | tools×${toolCalls}` : '';
const footer = `-# ${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`;
return `${content}\n\n${footer}`;
}
export function renderError(errorMessage: string): string {
return `**Agent Execution Failed**\n\`\`\`\n${errorMessage}\n\`\`\``;
}
// ==================== Dispatcher ====================
/**
* Dispatch to the correct template based on step state.
*/
export function renderStepProgress(params: RenderStepParams): string {
if (params.stepType === 'call_llm') {
// LLM step finished → about to execute tools
return renderLLMGenerating(params);
}
// Tool step finished → LLM is next
return renderToolExecuting(params);
}

View file

@ -0,0 +1,8 @@
export interface PlatformBot {
readonly applicationId: string;
readonly platform: string;
start: () => Promise<void>;
stop: () => Promise<void>;
}
export type PlatformBotClass = new (config: any) => PlatformBot;

View file

@ -0,0 +1,212 @@
import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import type { PlatformBot, PlatformBotClass } from '../bot/types';
const log = debug('lobe-server:bot-gateway');
export interface GatewayManagerConfig {
registry: Record<string, PlatformBotClass>;
}
export class GatewayManager {
private bots = new Map<string, PlatformBot>();
private running = false;
private config: GatewayManagerConfig;
constructor(config: GatewayManagerConfig) {
this.config = config;
}
get isRunning(): boolean {
return this.running;
}
// ------------------------------------------------------------------
// Lifecycle (call once)
// ------------------------------------------------------------------
async start(): Promise<void> {
if (this.running) {
log('GatewayManager already running, skipping');
return;
}
log('Starting GatewayManager');
await this.sync().catch((err) => {
console.error('[GatewayManager] Initial sync failed:', err);
});
this.running = true;
log('GatewayManager started with %d bots', this.bots.size);
}
async stop(): Promise<void> {
if (!this.running) return;
log('Stopping GatewayManager');
for (const [key, bot] of this.bots) {
log('Stopping bot %s', key);
await bot.stop();
}
this.bots.clear();
this.running = false;
log('GatewayManager stopped');
}
// ------------------------------------------------------------------
// Bot operations (point-to-point)
// ------------------------------------------------------------------
async startBot(platform: string, applicationId: string, userId: string): Promise<void> {
const key = `${platform}:${applicationId}`;
// Stop existing if any
const existing = this.bots.get(key);
if (existing) {
log('Stopping existing bot %s before restart', key);
await existing.stop();
this.bots.delete(key);
}
// Load from DB (user-scoped, single row)
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const model = new AgentBotProviderModel(serverDB, userId, gateKeeper);
const provider = await model.findEnabledByApplicationId(platform, applicationId);
if (!provider) {
log('No enabled provider found for %s', key);
return;
}
const bot = this.createBot(platform, provider);
if (!bot) {
log('Unsupported platform: %s', platform);
return;
}
await bot.start();
this.bots.set(key, bot);
log('Started bot %s', key);
}
async stopBot(platform: string, applicationId: string): Promise<void> {
const key = `${platform}:${applicationId}`;
const bot = this.bots.get(key);
if (!bot) return;
await bot.stop();
this.bots.delete(key);
log('Stopped bot %s', key);
}
// ------------------------------------------------------------------
// DB sync
// ------------------------------------------------------------------
private async sync(): Promise<void> {
for (const platform of Object.keys(this.config.registry)) {
try {
await this.syncPlatform(platform);
} catch (error) {
console.error('[GatewayManager] Sync error for %s:', platform, error);
}
}
}
private async syncPlatform(platform: string): Promise<void> {
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
gateKeeper,
);
log('Sync: found %d enabled providers for %s', providers.length, platform);
const activeKeys = new Set<string>();
for (const provider of providers) {
const { applicationId, credentials } = provider;
const key = `${platform}:${applicationId}`;
activeKeys.add(key);
log('Sync: processing provider %s, hasCredentials=%s', key, !!credentials);
const existing = this.bots.get(key);
if (existing) {
log('Sync: bot %s already running, skipping', key);
continue;
}
const bot = this.createBot(platform, provider);
if (!bot) {
log('Sync: createBot returned null for %s', key);
continue;
}
try {
await bot.start();
this.bots.set(key, bot);
log('Sync: started bot %s', key);
} catch (err) {
log('Sync: failed to start bot %s: %O', key, err);
}
}
// Stop bots that are no longer in DB
for (const [key, bot] of this.bots) {
if (!key.startsWith(`${platform}:`)) continue;
if (activeKeys.has(key)) continue;
log('Sync: bot %s removed from DB, stopping', key);
await bot.stop();
this.bots.delete(key);
}
}
// ------------------------------------------------------------------
// Factory
// ------------------------------------------------------------------
private createBot(
platform: string,
provider: { applicationId: string; credentials: Record<string, string> },
): PlatformBot | null {
const BotClass = this.config.registry[platform];
if (!BotClass) {
log('No bot class registered for platform: %s', platform);
return null;
}
return new BotClass({
...provider.credentials,
applicationId: provider.applicationId,
});
}
}
// ------------------------------------------------------------------
// Singleton
// ------------------------------------------------------------------
const globalForGateway = globalThis as unknown as { gatewayManager?: GatewayManager };
export function getGatewayManager(): GatewayManager | undefined {
return globalForGateway.gatewayManager;
}
export function createGatewayManager(config: GatewayManagerConfig): GatewayManager {
if (!globalForGateway.gatewayManager) {
globalForGateway.gatewayManager = new GatewayManager(config);
}
return globalForGateway.gatewayManager;
}

View file

@ -0,0 +1,87 @@
import debug from 'debug';
import type Redis from 'ioredis';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
const log = debug('lobe-server:bot:connect-queue');
const QUEUE_KEY = 'bot:gateway:connect_queue';
const EXPIRE_MS = 10 * 60 * 1000; // 10 minutes
interface ConnectEntry {
timestamp: number;
userId: string;
}
export interface BotConnectItem {
applicationId: string;
platform: string;
userId: string;
}
export class BotConnectQueue {
private get redis(): Redis | null {
return getAgentRuntimeRedisClient();
}
async push(platform: string, applicationId: string, userId: string): Promise<void> {
if (!this.redis) {
throw new Error('Redis is not available, cannot enqueue bot connect request');
}
const field = `${platform}:${applicationId}`;
const value: ConnectEntry = { timestamp: Date.now(), userId };
await this.redis.hset(QUEUE_KEY, field, JSON.stringify(value));
log('Pushed connect request: %s (userId=%s)', field, userId);
}
async popAll(): Promise<BotConnectItem[]> {
if (!this.redis) return [];
const all = await this.redis.hgetall(QUEUE_KEY);
if (!all || Object.keys(all).length === 0) return [];
const now = Date.now();
const items: BotConnectItem[] = [];
const expiredFields: string[] = [];
for (const [field, raw] of Object.entries(all)) {
try {
const entry: ConnectEntry = JSON.parse(raw);
if (now - entry.timestamp > EXPIRE_MS) {
expiredFields.push(field);
continue;
}
const separatorIdx = field.indexOf(':');
if (separatorIdx === -1) continue;
items.push({
applicationId: field.slice(separatorIdx + 1),
platform: field.slice(0, separatorIdx),
userId: entry.userId,
});
} catch {
expiredFields.push(field);
}
}
if (expiredFields.length > 0) {
await this.redis.hdel(QUEUE_KEY, ...expiredFields);
log('Cleaned %d expired entries', expiredFields.length);
}
log('Popped %d connect requests (%d expired)', items.length, expiredFields.length);
return items;
}
async remove(platform: string, applicationId: string): Promise<void> {
if (!this.redis) return;
const field = `${platform}:${applicationId}`;
await this.redis.hdel(QUEUE_KEY, field);
log('Removed connect request: %s', field);
}
}

View file

@ -0,0 +1,64 @@
import debug from 'debug';
import { platformBotRegistry } from '../bot/platforms';
import { BotConnectQueue } from './botConnectQueue';
import { createGatewayManager, getGatewayManager } from './GatewayManager';
const log = debug('lobe-server:service:gateway');
const isVercel = !!process.env.VERCEL_ENV;
export class GatewayService {
async ensureRunning(): Promise<void> {
const existing = getGatewayManager();
if (existing?.isRunning) {
log('GatewayManager already running');
return;
}
const manager = createGatewayManager({ registry: platformBotRegistry });
await manager.start();
log('GatewayManager started');
}
async stop(): Promise<void> {
const manager = getGatewayManager();
if (!manager) return;
await manager.stop();
log('GatewayManager stopped');
}
async startBot(
platform: string,
applicationId: string,
userId: string,
): Promise<'started' | 'queued'> {
if (isVercel) {
const queue = new BotConnectQueue();
await queue.push(platform, applicationId, userId);
log('Queued bot connect %s:%s', platform, applicationId);
return 'queued';
}
let manager = getGatewayManager();
if (!manager?.isRunning) {
log('GatewayManager not running, starting automatically...');
await this.ensureRunning();
manager = getGatewayManager();
}
await manager!.startBot(platform, applicationId, userId);
log('Started bot %s:%s', platform, applicationId);
return 'started';
}
async stopBot(platform: string, applicationId: string): Promise<void> {
const manager = getGatewayManager();
if (!manager?.isRunning) return;
await manager.stopBot(platform, applicationId);
log('Stopped bot %s:%s', platform, applicationId);
}
}

View file

@ -0,0 +1,39 @@
import { lambdaClient } from '@/libs/trpc/client';
class AgentBotProviderService {
getByAgentId = async (agentId: string) => {
return lambdaClient.agentBotProvider.getByAgentId.query({ agentId });
};
create = async (params: {
agentId: string;
applicationId: string;
credentials: Record<string, string>;
enabled?: boolean;
platform: string;
}) => {
return lambdaClient.agentBotProvider.create.mutate(params);
};
update = async (
id: string,
params: {
applicationId?: string;
credentials?: Record<string, string>;
enabled?: boolean;
platform?: string;
},
) => {
return lambdaClient.agentBotProvider.update.mutate({ id, ...params });
};
delete = async (id: string) => {
return lambdaClient.agentBotProvider.delete.mutate({ id });
};
connectBot = async (params: { applicationId: string; platform: string }) => {
return lambdaClient.agentBotProvider.connectBot.mutate(params);
};
}
export const agentBotProviderService = new AgentBotProviderService();

View file

@ -1,4 +1,4 @@
import { type AgentGroupDetail, type AgentItem } from '@lobechat/types';
import { type AgentGroupDetail } from '@lobechat/types';
import {
type ChatGroupAgentItem,
@ -69,7 +69,7 @@ class ChatGroupService {
...groupConfig,
config: groupConfig.config as any,
},
members: members as Partial<AgentItem>[],
members,
supervisorConfig,
});
};
@ -113,7 +113,7 @@ class ChatGroupService {
*/
batchCreateAgentsInGroup = (groupId: string, agents: GroupMemberConfig[]) => {
return lambdaClient.group.batchCreateAgentsInGroup.mutate({
agents: agents as Partial<AgentItem>[],
agents,
groupId,
});
};

View file

@ -38,6 +38,13 @@ export const desktopRoutes: RouteConfig[] = [
),
path: 'cron/:cronId',
},
{
element: dynamicElement(
() => import('@/routes/(main)/agent/integration'),
'Desktop > Chat > Integration',
),
path: 'integration',
},
],
element: dynamicLayout(
() => import('@/routes/(main)/agent/_layout'),

View file

@ -0,0 +1,81 @@
import { type SWRResponse } from 'swr';
import { mutate, useClientDataSWR } from '@/libs/swr';
import { agentBotProviderService } from '@/services/agentBotProvider';
import { type StoreSetter } from '@/store/types';
import { type AgentStore } from '../../store';
const FETCH_BOT_PROVIDERS_KEY = 'agentBotProviders';
export interface BotProviderItem {
applicationId: string;
credentials: Record<string, string>;
enabled: boolean;
id: string;
platform: string;
}
type Setter = StoreSetter<AgentStore>;
export const createBotSlice = (set: Setter, get: () => AgentStore, _api?: unknown) =>
new BotSliceActionImpl(set, get, _api);
export class BotSliceActionImpl {
readonly #get: () => AgentStore;
constructor(set: Setter, get: () => AgentStore, _api?: unknown) {
void _api;
void set;
this.#get = get;
}
createBotProvider = async (params: {
agentId: string;
applicationId: string;
credentials: Record<string, string>;
platform: string;
}) => {
const result = await agentBotProviderService.create(params);
await this.internal_refreshBotProviders(params.agentId);
return result;
};
connectBot = async (params: { applicationId: string; platform: string }) => {
return agentBotProviderService.connectBot(params);
};
deleteBotProvider = async (id: string, agentId: string) => {
await agentBotProviderService.delete(id);
await this.internal_refreshBotProviders(agentId);
};
internal_refreshBotProviders = async (agentId?: string) => {
const id = agentId || this.#get().activeAgentId;
if (!id) return;
await mutate([FETCH_BOT_PROVIDERS_KEY, id]);
};
updateBotProvider = async (
id: string,
agentId: string,
params: {
applicationId?: string;
credentials?: Record<string, string>;
enabled?: boolean;
},
) => {
await agentBotProviderService.update(id, params);
await this.internal_refreshBotProviders(agentId);
};
useFetchBotProviders = (agentId?: string): SWRResponse<BotProviderItem[]> => {
return useClientDataSWR<BotProviderItem[]>(
agentId ? [FETCH_BOT_PROVIDERS_KEY, agentId] : null,
async ([, id]: [string, string]) => agentBotProviderService.getByAgentId(id),
{ fallbackData: [], revalidateOnFocus: false },
);
};
}
export type BotSliceAction = Pick<BotSliceActionImpl, keyof BotSliceActionImpl>;

View file

@ -0,0 +1 @@
export { type BotProviderItem, type BotSliceAction, createBotSlice } from './action';

View file

@ -8,6 +8,8 @@ import { type AgentStoreState } from './initialState';
import { initialState } from './initialState';
import { type AgentSliceAction } from './slices/agent';
import { createAgentSlice } from './slices/agent';
import { type BotSliceAction } from './slices/bot';
import { createBotSlice } from './slices/bot';
import { type BuiltinAgentSliceAction } from './slices/builtin';
import { createBuiltinAgentSlice } from './slices/builtin';
import { type CronSliceAction } from './slices/cron';
@ -22,6 +24,7 @@ import { createPluginSlice } from './slices/plugin';
export interface AgentStore
extends
AgentSliceAction,
BotSliceAction,
BuiltinAgentSliceAction,
CronSliceAction,
KnowledgeSliceAction,
@ -29,6 +32,7 @@ export interface AgentStore
AgentStoreState {}
type AgentStoreAction = AgentSliceAction &
BotSliceAction &
BuiltinAgentSliceAction &
CronSliceAction &
KnowledgeSliceAction &
@ -40,6 +44,7 @@ const createStore: StateCreator<AgentStore, [['zustand/devtools', never]]> = (
...initialState,
...flattenActions<AgentStoreAction>([
createAgentSlice(...parameters),
createBotSlice(...parameters),
createBuiltinAgentSlice(...parameters),
createCronSlice(...parameters),
createKnowledgeSlice(...parameters),

View file

@ -1,5 +1,11 @@
{
"buildCommand": "bun run build",
"crons": [
{
"path": "/api/agent/gateway/discord",
"schedule": "*/9 * * * *"
}
],
"installCommand": "npx pnpm@10.26.2 install",
"rewrites": [
{