mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ 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:
parent
902a265aed
commit
d68acec58e
60 changed files with 5530 additions and 177 deletions
153
.agents/skills/chat-sdk/SKILL.md
Normal file
153
.agents/skills/chat-sdk/SKILL.md
Normal 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
34
locales/en-US/agent.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
34
locales/zh-CN/agent.json
Normal 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"
|
||||
}
|
||||
|
|
@ -336,6 +336,7 @@
|
|||
"supervisor.todoList.allComplete": "所有任务已完成",
|
||||
"supervisor.todoList.title": "任务完成",
|
||||
"tab.groupProfile": "群组档案",
|
||||
"tab.integration": "集成",
|
||||
"tab.profile": "助理档案",
|
||||
"tab.search": "搜索",
|
||||
"task.activity.calling": "正在调用技能…",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
527
packages/database/src/models/__tests__/agentBotProvider.test.ts
Normal file
527
packages/database/src/models/__tests__/agentBotProvider.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
123
src/app/(backend)/api/agent/gateway/discord/route.ts
Normal file
123
src/app/(backend)/api/agent/gateway/discord/route.ts
Normal 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 });
|
||||
}
|
||||
31
src/app/(backend)/api/agent/gateway/start/route.ts
Normal file
31
src/app/(backend)/api/agent/gateway/start/route.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
26
src/app/(backend)/api/agent/webhooks/[platform]/route.ts
Normal file
26
src/app/(backend)/api/agent/webhooks/[platform]/route.ts
Normal 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);
|
||||
};
|
||||
198
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts
Normal file
198
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ export function defineConfig(config: CustomNextConfig) {
|
|||
serverExternalPackages: config.serverExternalPackages ?? [
|
||||
'pdfkit',
|
||||
'@napi-rs/canvas',
|
||||
'discord.js',
|
||||
'pdfjs-dist',
|
||||
'ajv',
|
||||
'oidc-provider',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
src/locales/default/agent.ts
Normal file
36
src/locales/default/agent.ts
Normal 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;
|
||||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
309
src/routes/(main)/agent/integration/PlatformDetail/Body.tsx
Normal file
309
src/routes/(main)/agent/integration/PlatformDetail/Body.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
196
src/routes/(main)/agent/integration/PlatformDetail/index.tsx
Normal file
196
src/routes/(main)/agent/integration/PlatformDetail/index.tsx
Normal 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;
|
||||
130
src/routes/(main)/agent/integration/PlatformList.tsx
Normal file
130
src/routes/(main)/agent/integration/PlatformList.tsx
Normal 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;
|
||||
35
src/routes/(main)/agent/integration/const.ts
Normal file
35
src/routes/(main)/agent/integration/const.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
72
src/routes/(main)/agent/integration/index.tsx
Normal file
72
src/routes/(main)/agent/integration/index.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
81
src/server/routers/lambda/agentBotProvider.ts
Normal file
81
src/server/routers/lambda/agentBotProvider.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
445
src/server/services/bot/AgentBridgeService.ts
Normal file
445
src/server/services/bot/AgentBridgeService.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
297
src/server/services/bot/BotMessageRouter.ts
Normal file
297
src/server/services/bot/BotMessageRouter.ts
Normal 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;
|
||||
}
|
||||
423
src/server/services/bot/__tests__/replyTemplate.test.ts
Normal file
423
src/server/services/bot/__tests__/replyTemplate.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
src/server/services/bot/ackPhrases/index.ts
Normal file
162
src/server/services/bot/ackPhrases/index.ts
Normal 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);
|
||||
}
|
||||
415
src/server/services/bot/ackPhrases/vibeMatrix.ts
Normal file
415
src/server/services/bot/ackPhrases/vibeMatrix.ts
Normal 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'],
|
||||
},
|
||||
];
|
||||
27
src/server/services/bot/discordRestApi.ts
Normal file
27
src/server/services/bot/discordRestApi.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
5
src/server/services/bot/index.ts
Normal file
5
src/server/services/bot/index.ts
Normal 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';
|
||||
110
src/server/services/bot/platforms/discord.ts
Normal file
110
src/server/services/bot/platforms/discord.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
6
src/server/services/bot/platforms/index.ts
Normal file
6
src/server/services/bot/platforms/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { PlatformBotClass } from '../types';
|
||||
import { Discord } from './discord';
|
||||
|
||||
export const platformBotRegistry: Record<string, PlatformBotClass> = {
|
||||
discord: Discord,
|
||||
};
|
||||
269
src/server/services/bot/replyTemplate.ts
Normal file
269
src/server/services/bot/replyTemplate.ts
Normal 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);
|
||||
}
|
||||
8
src/server/services/bot/types.ts
Normal file
8
src/server/services/bot/types.ts
Normal 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;
|
||||
212
src/server/services/gateway/GatewayManager.ts
Normal file
212
src/server/services/gateway/GatewayManager.ts
Normal 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;
|
||||
}
|
||||
87
src/server/services/gateway/botConnectQueue.ts
Normal file
87
src/server/services/gateway/botConnectQueue.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/server/services/gateway/index.ts
Normal file
64
src/server/services/gateway/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
39
src/services/agentBotProvider.ts
Normal file
39
src/services/agentBotProvider.ts
Normal 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();
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
81
src/store/agent/slices/bot/action.ts
Normal file
81
src/store/agent/slices/bot/action.ts
Normal 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>;
|
||||
1
src/store/agent/slices/bot/index.ts
Normal file
1
src/store/agent/slices/bot/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { type BotProviderItem, type BotSliceAction, createBotSlice } from './action';
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"buildCommand": "bun run build",
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/agent/gateway/discord",
|
||||
"schedule": "*/9 * * * *"
|
||||
}
|
||||
],
|
||||
"installCommand": "npx pnpm@10.26.2 install",
|
||||
"rewrites": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue