mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
💄 style: improve discord interaction (#12573)
* improve discord interaction * improve discord interaction * update * update Message engine * add test * update vercel route
This commit is contained in:
@@ -22,13 +22,14 @@ import {
|
||||
} from '../../processors';
|
||||
import {
|
||||
AgentBuilderContextInjector,
|
||||
AgentManagementContextInjector,
|
||||
DiscordContextProvider,
|
||||
EvalContextSystemInjector,
|
||||
ForceFinishSummaryInjector,
|
||||
AgentManagementContextInjector,
|
||||
GTDPlanInjector,
|
||||
GTDTodoInjector,
|
||||
GroupAgentBuilderContextInjector,
|
||||
GroupContextInjector,
|
||||
GTDPlanInjector,
|
||||
GTDTodoInjector,
|
||||
HistorySummaryProvider,
|
||||
KnowledgeInjector,
|
||||
PageEditorContextInjector,
|
||||
@@ -131,6 +132,7 @@ export class MessagesEngine {
|
||||
variableGenerators,
|
||||
fileContext,
|
||||
agentBuilderContext,
|
||||
discordContext,
|
||||
evalContext,
|
||||
agentManagementContext,
|
||||
groupAgentBuilderContext,
|
||||
@@ -198,6 +200,11 @@ export class MessagesEngine {
|
||||
systemPrompt: agentGroup?.systemPrompt,
|
||||
}),
|
||||
|
||||
// 5.5. Discord context injection (channel/guild info for Discord bot scenarios)
|
||||
...(discordContext
|
||||
? [new DiscordContextProvider({ context: discordContext, enabled: true })]
|
||||
: []),
|
||||
|
||||
// 6. GTD Plan injection (conditionally added, after user memory, before knowledge)
|
||||
...(isGTDPlanEnabled ? [new GTDPlanInjector({ enabled: true, plan: gtd.plan })] : []),
|
||||
|
||||
@@ -246,13 +253,13 @@ export class MessagesEngine {
|
||||
// 12. Tool system role injection (conditionally added)
|
||||
...(toolsConfig?.manifests && toolsConfig.manifests.length > 0
|
||||
? [
|
||||
new ToolSystemRoleProvider({
|
||||
isCanUseFC: capabilities?.isCanUseFC || (() => true),
|
||||
manifests: toolsConfig.manifests,
|
||||
model,
|
||||
provider,
|
||||
}),
|
||||
]
|
||||
new ToolSystemRoleProvider({
|
||||
isCanUseFC: capabilities?.isCanUseFC || (() => true),
|
||||
manifests: toolsConfig.manifests,
|
||||
model,
|
||||
provider,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// 13. History summary injection
|
||||
@@ -272,15 +279,15 @@ export class MessagesEngine {
|
||||
? pageContentContext
|
||||
: initialContext?.pageEditor
|
||||
? {
|
||||
markdown: initialContext.pageEditor.markdown,
|
||||
metadata: {
|
||||
charCount: initialContext.pageEditor.metadata.charCount,
|
||||
lineCount: initialContext.pageEditor.metadata.lineCount,
|
||||
title: initialContext.pageEditor.metadata.title,
|
||||
},
|
||||
// Use latest XML from stepContext if available, otherwise fallback to initial XML
|
||||
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
|
||||
}
|
||||
markdown: initialContext.pageEditor.markdown,
|
||||
metadata: {
|
||||
charCount: initialContext.pageEditor.metadata.charCount,
|
||||
lineCount: initialContext.pageEditor.metadata.lineCount,
|
||||
title: initialContext.pageEditor.metadata.title,
|
||||
},
|
||||
// Use latest XML from stepContext if available, otherwise fallback to initial XML
|
||||
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
|
||||
@@ -321,26 +328,26 @@ export class MessagesEngine {
|
||||
// This must be BEFORE GroupRoleTransformProcessor so we filter based on original agentId/tools
|
||||
...(isAgentGroupEnabled && agentGroup.agentMap && agentGroup.currentAgentId
|
||||
? [
|
||||
new GroupOrchestrationFilterProcessor({
|
||||
agentMap: Object.fromEntries(
|
||||
Object.entries(agentGroup.agentMap).map(([id, info]) => [id, { role: info.role }]),
|
||||
),
|
||||
currentAgentId: agentGroup.currentAgentId,
|
||||
// Only enabled when current agent is NOT supervisor (supervisor needs to see orchestration history)
|
||||
enabled: agentGroup.currentAgentRole !== 'supervisor',
|
||||
}),
|
||||
]
|
||||
new GroupOrchestrationFilterProcessor({
|
||||
agentMap: Object.fromEntries(
|
||||
Object.entries(agentGroup.agentMap).map(([id, info]) => [id, { role: info.role }]),
|
||||
),
|
||||
currentAgentId: agentGroup.currentAgentId,
|
||||
// Only enabled when current agent is NOT supervisor (supervisor needs to see orchestration history)
|
||||
enabled: agentGroup.currentAgentRole !== 'supervisor',
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// 26. Group role transform (convert other agents' messages to user role with speaker tags)
|
||||
// This must be BEFORE ToolCallProcessor so other agents' tool messages are converted first
|
||||
...(isAgentGroupEnabled && agentGroup.currentAgentId
|
||||
? [
|
||||
new GroupRoleTransformProcessor({
|
||||
agentMap: agentGroup.agentMap!,
|
||||
currentAgentId: agentGroup.currentAgentId,
|
||||
}),
|
||||
]
|
||||
new GroupRoleTransformProcessor({
|
||||
agentMap: agentGroup.agentMap!,
|
||||
currentAgentId: agentGroup.currentAgentId,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// =============================================
|
||||
|
||||
@@ -6,10 +6,11 @@ import type { OpenAIChatMessage, UIChatMessage } from '@/types/index';
|
||||
|
||||
import type { AgentInfo } from '../../processors/GroupRoleTransform';
|
||||
import type { AgentBuilderContext } from '../../providers/AgentBuilderContextInjector';
|
||||
import type { AgentManagementContext } from '../../providers/AgentManagementContextInjector';
|
||||
import type { DiscordContext } from '../../providers/DiscordContextProvider';
|
||||
import type { EvalContext } from '../../providers/EvalContextSystemInjector';
|
||||
import type { GroupAgentBuilderContext } from '../../providers/GroupAgentBuilderContextInjector';
|
||||
import type { GroupMemberInfo } from '../../providers/GroupContextInjector';
|
||||
import type { AgentManagementContext } from '../../providers/AgentManagementContextInjector';
|
||||
import type { GTDPlan } from '../../providers/GTDPlanInjector';
|
||||
import type { GTDTodoList } from '../../providers/GTDTodoInjector';
|
||||
import type { SkillMeta } from '../../providers/SkillContextProvider';
|
||||
@@ -244,6 +245,8 @@ export interface MessagesEngineParams {
|
||||
// ========== Extended contexts (both frontend and backend) ==========
|
||||
/** Agent Builder context */
|
||||
agentBuilderContext?: AgentBuilderContext;
|
||||
/** Discord context for injecting channel/guild info into system injection message */
|
||||
discordContext?: DiscordContext;
|
||||
/** Eval context for injecting environment prompts into system message */
|
||||
evalContext?: EvalContext;
|
||||
/** Agent Management context */
|
||||
@@ -302,8 +305,9 @@ export interface MessagesEngineResult {
|
||||
|
||||
export { type AgentInfo } from '../../processors/GroupRoleTransform';
|
||||
export { type AgentBuilderContext } from '../../providers/AgentBuilderContextInjector';
|
||||
export { type EvalContext } from '../../providers/EvalContextSystemInjector';
|
||||
export { type AgentManagementContext } from '../../providers/AgentManagementContextInjector';
|
||||
export { type DiscordContext } from '../../providers/DiscordContextProvider';
|
||||
export { type EvalContext } from '../../providers/EvalContextSystemInjector';
|
||||
export { type GroupAgentBuilderContext } from '../../providers/GroupAgentBuilderContextInjector';
|
||||
export { type GTDPlan } from '../../providers/GTDPlanInjector';
|
||||
export { type GTDTodoItem, type GTDTodoList } from '../../providers/GTDTodoInjector';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DiscordChannelInfo, DiscordGuildInfo } from '@lobechat/prompts';
|
||||
import { formatDiscordContext } from '@lobechat/prompts';
|
||||
import debug from 'debug';
|
||||
|
||||
import { BaseFirstUserContentProvider } from '../base/BaseFirstUserContentProvider';
|
||||
import type { ProcessorOptions } from '../types';
|
||||
|
||||
const log = debug('context-engine:provider:DiscordContextProvider');
|
||||
|
||||
export interface DiscordContext {
|
||||
channel?: DiscordChannelInfo;
|
||||
guild?: DiscordGuildInfo;
|
||||
}
|
||||
|
||||
export interface DiscordContextProviderConfig {
|
||||
context?: DiscordContext;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class DiscordContextProvider extends BaseFirstUserContentProvider {
|
||||
readonly name = 'DiscordContextProvider';
|
||||
|
||||
constructor(
|
||||
private config: DiscordContextProviderConfig,
|
||||
options: ProcessorOptions = {},
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
protected buildContent(): string | null {
|
||||
if (!this.config.enabled || !this.config.context) {
|
||||
log('Discord context injection disabled or no context, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { guild, channel } = this.config.context;
|
||||
if (!guild && !channel) {
|
||||
log('No guild or channel info, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
log('Discord context prepared for injection');
|
||||
|
||||
return formatDiscordContext({ channel, guild });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PipelineContext } from '../../types';
|
||||
import { DiscordContextProvider } from '../DiscordContextProvider';
|
||||
|
||||
describe('DiscordContextProvider', () => {
|
||||
const createContext = (messages: any[]): PipelineContext => ({
|
||||
initialState: { messages: [] },
|
||||
isAborted: false,
|
||||
messages,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
// Helper: extract injected content string from result
|
||||
const getInjectedContent = (result: PipelineContext, index = 0): string =>
|
||||
result.messages[index].content as string;
|
||||
|
||||
describe('Basic Scenarios', () => {
|
||||
it('should inject discord context before first user message', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789', name: 'general', topic: 'General discussion', type: 0 },
|
||||
guild: { id: '123456' },
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [
|
||||
{ content: 'You are a helpful assistant.', role: 'system' },
|
||||
{ content: 'Hello', role: 'user' },
|
||||
];
|
||||
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(result.messages).toHaveLength(3);
|
||||
expect(result.messages[0].content).toBe('You are a helpful assistant.');
|
||||
expect(result.messages[1].role).toBe('user');
|
||||
expect(getInjectedContent(result, 1)).toBe(`<discord_context>
|
||||
<guild id="123456" />
|
||||
<channel id="789" name="general" type="0" topic="General discussion" />
|
||||
</discord_context>`);
|
||||
expect(result.messages[2].content).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should inject guild with name', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789', name: 'dev' },
|
||||
guild: { id: '123', name: 'My Server' },
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'Hi', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(getInjectedContent(result)).toBe(`<discord_context>
|
||||
<guild id="123" name="My Server" />
|
||||
<channel id="789" name="dev" />
|
||||
</discord_context>`);
|
||||
});
|
||||
|
||||
it('should skip injection when disabled', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789', name: 'general' },
|
||||
guild: { id: '123' },
|
||||
},
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const input: any[] = [
|
||||
{ content: 'System', role: 'system' },
|
||||
{ content: 'Hello', role: 'user' },
|
||||
];
|
||||
|
||||
const result = await provider.process(createContext(input));
|
||||
expect(result.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should skip injection when context is undefined', async () => {
|
||||
const provider = new DiscordContextProvider({ enabled: true });
|
||||
|
||||
const input: any[] = [{ content: 'Hello', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should skip injection when both guild and channel are undefined', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'Hello', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Partial Context', () => {
|
||||
it('should inject only guild when channel is missing', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: { guild: { id: '123', name: 'Server' } },
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'Hello', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(getInjectedContent(result)).toBe(`<discord_context>
|
||||
<guild id="123" name="Server" />
|
||||
</discord_context>`);
|
||||
});
|
||||
|
||||
it('should inject only channel when guild is missing', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: { channel: { id: '789', name: 'general', type: 0 } },
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'Hello', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(getInjectedContent(result)).toBe(`<discord_context>
|
||||
<channel id="789" name="general" type="0" />
|
||||
</discord_context>`);
|
||||
});
|
||||
|
||||
it('should handle channel with only id', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789' },
|
||||
guild: { id: '123' },
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'Hello', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(getInjectedContent(result)).toBe(`<discord_context>
|
||||
<guild id="123" />
|
||||
<channel id="789" />
|
||||
</discord_context>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attribute Handling', () => {
|
||||
it('should include topic when provided', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789', topic: 'Bug reports only' },
|
||||
guild: { id: '123' },
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'Hello', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(getInjectedContent(result)).toBe(`<discord_context>
|
||||
<guild id="123" />
|
||||
<channel id="789" topic="Bug reports only" />
|
||||
</discord_context>`);
|
||||
});
|
||||
|
||||
it('should include type=0 (falsy but valid)', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789', type: 0 },
|
||||
guild: { id: '123' },
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'Hello', role: 'user' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(getInjectedContent(result)).toBe(`<discord_context>
|
||||
<guild id="123" />
|
||||
<channel id="789" type="0" />
|
||||
</discord_context>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Consolidation', () => {
|
||||
it('should append to existing system injection message', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789', name: 'general' },
|
||||
guild: { id: '123' },
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [
|
||||
{ content: 'System role', role: 'system' },
|
||||
{
|
||||
content: 'Previous injected content',
|
||||
meta: { systemInjection: true },
|
||||
role: 'user',
|
||||
},
|
||||
{ content: 'Hello', role: 'user' },
|
||||
];
|
||||
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
// Should NOT create a new message — should append to existing injection
|
||||
expect(result.messages).toHaveLength(3);
|
||||
expect(getInjectedContent(result, 1)).toBe(`Previous injected content
|
||||
|
||||
<discord_context>
|
||||
<guild id="123" />
|
||||
<channel id="789" name="general" />
|
||||
</discord_context>`);
|
||||
});
|
||||
|
||||
it('should skip when no user message exists', async () => {
|
||||
const provider = new DiscordContextProvider({
|
||||
context: {
|
||||
channel: { id: '789' },
|
||||
guild: { id: '123' },
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const input: any[] = [{ content: 'System only', role: 'system' }];
|
||||
const result = await provider.process(createContext(input));
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
// Context Provider exports
|
||||
export { AgentBuilderContextInjector } from './AgentBuilderContextInjector';
|
||||
export { AgentManagementContextInjector } from './AgentManagementContextInjector';
|
||||
export { DiscordContextProvider } from './DiscordContextProvider';
|
||||
export { EvalContextSystemInjector } from './EvalContextSystemInjector';
|
||||
export { ForceFinishSummaryInjector } from './ForceFinishSummaryInjector';
|
||||
export { AgentManagementContextInjector } from './AgentManagementContextInjector';
|
||||
export { GroupAgentBuilderContextInjector } from './GroupAgentBuilderContextInjector';
|
||||
export { GroupContextInjector } from './GroupContextInjector';
|
||||
export { GTDPlanInjector } from './GTDPlanInjector';
|
||||
@@ -24,8 +25,6 @@ export type {
|
||||
AgentBuilderContextInjectorConfig,
|
||||
OfficialToolItem,
|
||||
} from './AgentBuilderContextInjector';
|
||||
export type { EvalContext, EvalContextSystemInjectorConfig } from './EvalContextSystemInjector';
|
||||
export type { ForceFinishSummaryInjectorConfig } from './ForceFinishSummaryInjector';
|
||||
export type {
|
||||
AgentManagementContext,
|
||||
AgentManagementContextInjectorConfig,
|
||||
@@ -33,6 +32,9 @@ export type {
|
||||
AvailablePluginInfo,
|
||||
AvailableProviderInfo,
|
||||
} from './AgentManagementContextInjector';
|
||||
export type { DiscordContext, DiscordContextProviderConfig } from './DiscordContextProvider';
|
||||
export type { EvalContext, EvalContextSystemInjectorConfig } from './EvalContextSystemInjector';
|
||||
export type { ForceFinishSummaryInjectorConfig } from './ForceFinishSummaryInjector';
|
||||
export type {
|
||||
GroupAgentBuilderContext,
|
||||
GroupAgentBuilderContextInjectorConfig,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface DiscordGuildInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DiscordChannelInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
topic?: string;
|
||||
type?: number;
|
||||
}
|
||||
|
||||
export interface FormatDiscordContextOptions {
|
||||
channel?: DiscordChannelInfo;
|
||||
guild?: DiscordGuildInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Discord context into XML for system injection message.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const xml = formatDiscordContext({
|
||||
* guild: { id: '123456', name: 'My Server' },
|
||||
* channel: { id: '789', name: 'general', type: 0, topic: 'General discussion' },
|
||||
* });
|
||||
* // Returns:
|
||||
* // <discord_context>
|
||||
* // <guild id="123456" name="My Server" />
|
||||
* // <channel id="789" name="general" type="0" topic="General discussion" />
|
||||
* // </discord_context>
|
||||
* ```
|
||||
*/
|
||||
export const formatDiscordContext = ({ guild, channel }: FormatDiscordContextOptions): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (guild) {
|
||||
const attrs = [`id="${guild.id}"`];
|
||||
if (guild.name) attrs.push(`name="${guild.name}"`);
|
||||
parts.push(` <guild ${attrs.join(' ')} />`);
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
const attrs = [`id="${channel.id}"`];
|
||||
if (channel.name) attrs.push(`name="${channel.name}"`);
|
||||
if (channel.type !== undefined) attrs.push(`type="${channel.type}"`);
|
||||
if (channel.topic) attrs.push(`topic="${channel.topic}"`);
|
||||
parts.push(` <channel ${attrs.join(' ')} />`);
|
||||
}
|
||||
|
||||
return `<discord_context>\n${parts.join('\n')}\n</discord_context>`;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ export * from './agentBuilder';
|
||||
export * from './agentGroup';
|
||||
export * from './chatMessages';
|
||||
export * from './compressContext';
|
||||
export * from './discordContext';
|
||||
export * from './files';
|
||||
export * from './fileSystem';
|
||||
export * from './groupChat';
|
||||
@@ -11,6 +12,7 @@ export * from './messagesToText';
|
||||
export * from './plugin';
|
||||
export * from './search';
|
||||
export * from './skills';
|
||||
export * from './speaker';
|
||||
export * from './systemRole';
|
||||
export * from './toolDiscovery';
|
||||
export * from './userMemory';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface SpeakerInfo {
|
||||
avatar?: string;
|
||||
id: string;
|
||||
nickname?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a speaker XML tag to prepend to a user message.
|
||||
* Used in IM bot scenarios (Discord, Slack, etc.) to identify the message author.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tag = formatSpeakerTag({
|
||||
* id: '123456',
|
||||
* username: 'john',
|
||||
* nickname: 'John Doe',
|
||||
* avatar: 'abc123',
|
||||
* });
|
||||
* // Returns: '<speaker id="123456" username="john" nickname="John Doe" avatar="abc123" />'
|
||||
* ```
|
||||
*/
|
||||
export const formatSpeakerTag = (speaker: SpeakerInfo): string => {
|
||||
const attrs = [`id="${speaker.id}"`];
|
||||
|
||||
if (speaker.username) attrs.push(`username="${speaker.username}"`);
|
||||
if (speaker.nickname) attrs.push(`nickname="${speaker.nickname}"`);
|
||||
if (speaker.avatar) attrs.push(`avatar="${speaker.avatar}"`);
|
||||
|
||||
return `<speaker ${attrs.join(' ')} />`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a user message with a speaker tag prepended.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const prompt = formatSpeakerMessage(
|
||||
* { id: '123456', username: 'john', nickname: 'John Doe' },
|
||||
* 'Hello, how are you?',
|
||||
* );
|
||||
* // Returns:
|
||||
* // '<speaker id="123456" username="john" nickname="John Doe" />\nHello, how are you?'
|
||||
* ```
|
||||
*/
|
||||
export const formatSpeakerMessage = (speaker: SpeakerInfo, text: string): string => {
|
||||
return `${formatSpeakerTag(speaker)}\n${text}`;
|
||||
};
|
||||
@@ -13,8 +13,6 @@ 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> {
|
||||
|
||||
@@ -33,6 +33,7 @@ const TOOL_PRICING: Record<string, number> = {
|
||||
|
||||
export interface RuntimeExecutorContext {
|
||||
agentConfig?: any;
|
||||
discordContext?: any;
|
||||
evalContext?: EvalContext;
|
||||
fileService?: any;
|
||||
messageModel: MessageModel;
|
||||
@@ -155,6 +156,7 @@ export const createRuntimeExecutors = (
|
||||
return info?.abilities?.vision ?? true;
|
||||
},
|
||||
},
|
||||
discordContext: ctx.discordContext,
|
||||
enableHistoryCount: agentConfig.chatConfig?.enableHistoryCount ?? undefined,
|
||||
evalContext: ctx.evalContext,
|
||||
forceFinish: state.forceFinish,
|
||||
|
||||
@@ -54,6 +54,7 @@ export const serverMessagesEngine = async ({
|
||||
capabilities,
|
||||
userMemory,
|
||||
agentBuilderContext,
|
||||
discordContext,
|
||||
evalContext,
|
||||
agentManagementContext,
|
||||
pageContentContext,
|
||||
@@ -118,6 +119,7 @@ export const serverMessagesEngine = async ({
|
||||
|
||||
// Extended contexts
|
||||
...(agentBuilderContext && { agentBuilderContext }),
|
||||
...(discordContext && { discordContext }),
|
||||
...(evalContext && { evalContext }),
|
||||
...(agentManagementContext && { agentManagementContext }),
|
||||
...(pageContentContext && { pageContentContext }),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {
|
||||
AgentBuilderContext,
|
||||
AgentManagementContext,
|
||||
DiscordContext,
|
||||
EvalContext,
|
||||
FileContent,
|
||||
KnowledgeBaseInfo,
|
||||
@@ -68,6 +69,8 @@ export interface ServerMessagesEngineParams {
|
||||
// ========== Capability injection ==========
|
||||
/** Model capability checkers */
|
||||
capabilities?: ServerModelCapabilities;
|
||||
/** Discord context for injecting channel/guild info */
|
||||
discordContext?: DiscordContext;
|
||||
// ========== Eval context ==========
|
||||
/** Eval context for injecting environment prompts into system message */
|
||||
evalContext?: EvalContext;
|
||||
@@ -119,6 +122,7 @@ export interface ServerMessagesEngineParams {
|
||||
export {
|
||||
type AgentBuilderContext,
|
||||
type AgentManagementContext,
|
||||
type DiscordContext,
|
||||
type EvalContext,
|
||||
type FileContent,
|
||||
type KnowledgeBaseInfo,
|
||||
|
||||
@@ -263,6 +263,7 @@ export class AgentRuntimeService {
|
||||
completionWebhook,
|
||||
stepWebhook,
|
||||
webhookDelivery,
|
||||
discordContext,
|
||||
evalContext,
|
||||
maxSteps,
|
||||
} = params;
|
||||
@@ -281,6 +282,7 @@ export class AgentRuntimeService {
|
||||
metadata: {
|
||||
agentConfig,
|
||||
completionWebhook,
|
||||
discordContext,
|
||||
evalContext,
|
||||
// need be removed
|
||||
modelRuntimeConfig,
|
||||
@@ -532,7 +534,9 @@ export class AgentRuntimeService {
|
||||
let toolsCalling:
|
||||
| Array<{ apiName: string; arguments?: string; identifier: string }>
|
||||
| undefined;
|
||||
let toolsResult: Array<{ apiName: string; identifier: string; output?: string }> | undefined;
|
||||
let toolsResult:
|
||||
| Array<{ apiName: string; identifier: string; isSuccess?: boolean; output?: string }>
|
||||
| undefined;
|
||||
let stepSummary: string;
|
||||
|
||||
if (phase === 'tool_result') {
|
||||
@@ -545,6 +549,7 @@ export class AgentRuntimeService {
|
||||
{
|
||||
apiName,
|
||||
identifier,
|
||||
isSuccess: toolPayload?.isSuccess !== false,
|
||||
output:
|
||||
typeof output === 'string'
|
||||
? output
|
||||
@@ -558,21 +563,26 @@ export class AgentRuntimeService {
|
||||
const nextPayload = stepResult.nextContext?.payload as any;
|
||||
const toolCount = nextPayload?.toolCount || 0;
|
||||
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,
|
||||
};
|
||||
});
|
||||
const mappedResults: Array<{
|
||||
apiName: string;
|
||||
identifier: string;
|
||||
isSuccess?: boolean;
|
||||
output?: string;
|
||||
}> = rawToolResults.map((r: any) => {
|
||||
const tc = r.toolCall;
|
||||
const output = r.data;
|
||||
return {
|
||||
apiName: tc?.apiName || 'unknown',
|
||||
identifier: tc?.identifier || 'unknown',
|
||||
isSuccess: r?.isSuccess !== false,
|
||||
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(', ')}`;
|
||||
@@ -1166,6 +1176,7 @@ export class AgentRuntimeService {
|
||||
// Create streaming executor context
|
||||
const executorContext: RuntimeExecutorContext = {
|
||||
agentConfig: metadata?.agentConfig,
|
||||
discordContext: metadata?.discordContext,
|
||||
evalContext: metadata?.evalContext,
|
||||
messageModel: this.messageModel,
|
||||
operationId,
|
||||
|
||||
@@ -30,7 +30,12 @@ export interface StepPresentationData {
|
||||
/** 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 }>;
|
||||
toolsResult?: Array<{
|
||||
apiName: string;
|
||||
identifier: string;
|
||||
isSuccess?: boolean;
|
||||
output?: string;
|
||||
}>;
|
||||
/** Cumulative total cost */
|
||||
totalCost: number;
|
||||
/** Cumulative input tokens */
|
||||
@@ -129,6 +134,8 @@ export interface OperationCreationParams {
|
||||
body?: Record<string, unknown>;
|
||||
url: string;
|
||||
};
|
||||
/** Discord context for injecting channel/guild info into agent system message */
|
||||
discordContext?: any;
|
||||
evalContext?: any;
|
||||
initialContext: AgentRuntimeContext;
|
||||
initialMessages?: any[];
|
||||
|
||||
@@ -77,6 +77,8 @@ interface InternalExecAgentParams extends ExecAgentParams {
|
||||
};
|
||||
/** Cron job ID that triggered this execution (if trigger is 'cron') */
|
||||
cronJobId?: string;
|
||||
/** Discord context for injecting channel/guild info into agent system message */
|
||||
discordContext?: any;
|
||||
/** Eval context for injecting environment prompts into system message */
|
||||
evalContext?: EvalContext;
|
||||
/** Maximum steps for the agent operation */
|
||||
@@ -167,6 +169,7 @@ export class AiAgentService {
|
||||
appContext,
|
||||
autoStart = true,
|
||||
botContext,
|
||||
discordContext,
|
||||
existingMessageIds = [],
|
||||
stepCallbacks,
|
||||
stream,
|
||||
@@ -516,6 +519,7 @@ export class AiAgentService {
|
||||
},
|
||||
autoStart,
|
||||
completionWebhook,
|
||||
discordContext,
|
||||
evalContext,
|
||||
initialContext,
|
||||
initialMessages: allMessages,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatSpeakerMessage } from '@lobechat/prompts';
|
||||
import type { ChatTopicBotContext } from '@lobechat/types';
|
||||
import type { Message, SentMessage, Thread } from 'chat';
|
||||
import { emoji } from 'chat';
|
||||
@@ -63,6 +64,16 @@ async function safeReaction(fn: () => Promise<void>, label: string): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
interface DiscordChannelContext {
|
||||
channel: { id: string; name?: string; topic?: string; type?: number };
|
||||
guild: { id: string };
|
||||
}
|
||||
|
||||
interface ThreadState {
|
||||
channelContext?: DiscordChannelContext;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
interface BridgeHandlerOpts {
|
||||
agentId: string;
|
||||
botContext?: ChatTopicBotContext;
|
||||
@@ -92,7 +103,7 @@ export class AgentBridgeService {
|
||||
* Handle a new @mention — start a fresh conversation.
|
||||
*/
|
||||
async handleMention(
|
||||
thread: Thread<{ topicId?: string }>,
|
||||
thread: Thread<ThreadState>,
|
||||
message: Message,
|
||||
opts: BridgeHandlerOpts,
|
||||
): Promise<void> {
|
||||
@@ -113,6 +124,9 @@ export class AgentBridgeService {
|
||||
await thread.subscribe();
|
||||
await thread.startTyping();
|
||||
|
||||
// Fetch channel context for Discord context injection
|
||||
const channelContext = await this.fetchChannelContext(thread);
|
||||
|
||||
const queueMode = isQueueAgentRuntimeEnabled();
|
||||
|
||||
try {
|
||||
@@ -121,12 +135,13 @@ export class AgentBridgeService {
|
||||
const { topicId } = await this.executeWithCallback(thread, message, {
|
||||
agentId,
|
||||
botContext,
|
||||
channelContext,
|
||||
trigger: 'bot',
|
||||
});
|
||||
|
||||
// Persist topic mapping in thread state for follow-up messages
|
||||
// Persist topic mapping and channel context in thread state for follow-up messages
|
||||
if (topicId) {
|
||||
await thread.setState({ topicId });
|
||||
await thread.setState({ channelContext, topicId });
|
||||
log('handleMention: stored topicId=%s in thread=%s state', topicId, thread.id);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -145,7 +160,7 @@ export class AgentBridgeService {
|
||||
* Handle a follow-up message inside a subscribed thread — multi-turn conversation.
|
||||
*/
|
||||
async handleSubscribedMessage(
|
||||
thread: Thread<{ topicId?: string }>,
|
||||
thread: Thread<ThreadState>,
|
||||
message: Message,
|
||||
opts: BridgeHandlerOpts,
|
||||
): Promise<void> {
|
||||
@@ -160,6 +175,9 @@ export class AgentBridgeService {
|
||||
return this.handleMention(thread, message, { agentId, botContext });
|
||||
}
|
||||
|
||||
// Read cached channel context from thread state
|
||||
const channelContext = threadState?.channelContext;
|
||||
|
||||
const queueMode = isQueueAgentRuntimeEnabled();
|
||||
|
||||
// Immediate feedback: mark as received + show typing
|
||||
@@ -174,6 +192,7 @@ export class AgentBridgeService {
|
||||
await this.executeWithCallback(thread, message, {
|
||||
agentId,
|
||||
botContext,
|
||||
channelContext,
|
||||
topicId,
|
||||
trigger: 'bot',
|
||||
});
|
||||
@@ -193,11 +212,12 @@ export class AgentBridgeService {
|
||||
* Dispatch to queue-mode webhooks or local in-memory callbacks based on runtime mode.
|
||||
*/
|
||||
private async executeWithCallback(
|
||||
thread: Thread<{ topicId?: string }>,
|
||||
thread: Thread<ThreadState>,
|
||||
userMessage: Message,
|
||||
opts: {
|
||||
agentId: string;
|
||||
botContext?: ChatTopicBotContext;
|
||||
channelContext?: DiscordChannelContext;
|
||||
topicId?: string;
|
||||
trigger?: string;
|
||||
},
|
||||
@@ -214,16 +234,17 @@ export class AgentBridgeService {
|
||||
* by the bot-callback webhook endpoint.
|
||||
*/
|
||||
private async executeWithWebhooks(
|
||||
thread: Thread<{ topicId?: string }>,
|
||||
thread: Thread<ThreadState>,
|
||||
userMessage: Message,
|
||||
opts: {
|
||||
agentId: string;
|
||||
botContext?: ChatTopicBotContext;
|
||||
channelContext?: DiscordChannelContext;
|
||||
topicId?: string;
|
||||
trigger?: string;
|
||||
},
|
||||
): Promise<{ reply: string; topicId: string }> {
|
||||
const { agentId, botContext, topicId, trigger } = opts;
|
||||
const { agentId, botContext, channelContext, topicId, trigger } = opts;
|
||||
|
||||
const aiAgentService = new AiAgentService(this.db, this.userId);
|
||||
const timezone = await this.loadTimezone();
|
||||
@@ -274,7 +295,10 @@ export class AgentBridgeService {
|
||||
autoStart: true,
|
||||
botContext,
|
||||
completionWebhook: { body: webhookBody, url: callbackUrl },
|
||||
prompt: userMessage.text,
|
||||
discordContext: channelContext
|
||||
? { channel: channelContext.channel, guild: channelContext.guild }
|
||||
: undefined,
|
||||
prompt: this.formatPrompt(userMessage, botContext),
|
||||
stepWebhook: { body: webhookBody, url: callbackUrl },
|
||||
trigger,
|
||||
userInterventionConfig: { approvalMode: 'headless' },
|
||||
@@ -295,16 +319,17 @@ export class AgentBridgeService {
|
||||
* Local mode: use in-memory step callbacks and wait for completion via Promise.
|
||||
*/
|
||||
private async executeWithInMemoryCallbacks(
|
||||
thread: Thread<{ topicId?: string }>,
|
||||
thread: Thread<ThreadState>,
|
||||
userMessage: Message,
|
||||
opts: {
|
||||
agentId: string;
|
||||
botContext?: ChatTopicBotContext;
|
||||
channelContext?: DiscordChannelContext;
|
||||
topicId?: string;
|
||||
trigger?: string;
|
||||
},
|
||||
): Promise<{ reply: string; topicId: string }> {
|
||||
const { agentId, botContext, topicId, trigger } = opts;
|
||||
const { agentId, botContext, channelContext, topicId, trigger } = opts;
|
||||
|
||||
const aiAgentService = new AiAgentService(this.db, this.userId);
|
||||
const timezone = await this.loadTimezone();
|
||||
@@ -341,7 +366,10 @@ export class AgentBridgeService {
|
||||
appContext: topicId ? { topicId } : undefined,
|
||||
autoStart: true,
|
||||
botContext,
|
||||
prompt: userMessage.text,
|
||||
discordContext: channelContext
|
||||
? { channel: channelContext.channel, guild: channelContext.guild }
|
||||
: undefined,
|
||||
prompt: this.formatPrompt(userMessage, botContext),
|
||||
stepCallbacks: {
|
||||
onAfterStep: async (stepData) => {
|
||||
const { content, shouldContinue, toolsCalling } = stepData;
|
||||
@@ -457,6 +485,79 @@ export class AgentBridgeService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch channel context from the Chat SDK adapter.
|
||||
* Uses fetchThread to get channel name, and decodeThreadId to extract guild/channel IDs.
|
||||
*/
|
||||
private async fetchChannelContext(
|
||||
thread: Thread<ThreadState>,
|
||||
): Promise<DiscordChannelContext | undefined> {
|
||||
try {
|
||||
// Decode thread ID to get guild and channel IDs
|
||||
// Discord format: "discord:guildId:channelId[:threadId]"
|
||||
const decoded = thread.adapter.decodeThreadId(thread.id) as {
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
};
|
||||
|
||||
if (!decoded?.guildId || !decoded?.channelId) {
|
||||
log('fetchChannelContext: could not decode guildId/channelId from thread %s', thread.id);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fetch thread info to get channel name and metadata
|
||||
const threadInfo = await thread.adapter.fetchThread(thread.id);
|
||||
const raw = threadInfo.metadata?.raw as { topic?: string; type?: number } | undefined;
|
||||
|
||||
const context: DiscordChannelContext = {
|
||||
channel: {
|
||||
id: decoded.channelId,
|
||||
name: threadInfo.channelName,
|
||||
topic: raw?.topic,
|
||||
type: raw?.type,
|
||||
},
|
||||
guild: { id: decoded.guildId },
|
||||
};
|
||||
|
||||
log(
|
||||
'fetchChannelContext: guild=%s, channel=%s (%s)',
|
||||
decoded.guildId,
|
||||
decoded.channelId,
|
||||
threadInfo.channelName,
|
||||
);
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
log('fetchChannelContext: failed to fetch channel context: %O', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user message into agent prompt:
|
||||
* 1. Strip bot's own @mention (Discord format: <@botId>)
|
||||
* 2. Add speaker tag with user identity
|
||||
*/
|
||||
private formatPrompt(message: Message, botContext?: ChatTopicBotContext): string {
|
||||
let text = message.text;
|
||||
|
||||
if (botContext?.applicationId) {
|
||||
text = text.replaceAll(new RegExp(`<@!?${botContext.applicationId}>\\s*`, 'g'), '').trim();
|
||||
}
|
||||
|
||||
const { userId, userName, fullName } = message.author;
|
||||
const raw = (message as any).raw?.author as
|
||||
| { avatar?: string | null; global_name?: string | null }
|
||||
| undefined;
|
||||
const avatar = raw?.avatar ?? '';
|
||||
const globalName = raw?.global_name ?? fullName;
|
||||
|
||||
return formatSpeakerMessage(
|
||||
{ avatar, id: userId, nickname: globalName, username: userName },
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily load and cache user timezone from settings.
|
||||
*/
|
||||
@@ -481,7 +582,7 @@ export class AgentBridgeService {
|
||||
* Remove the received reaction from a user message (fire-and-forget).
|
||||
*/
|
||||
private async removeReceivedReaction(
|
||||
thread: Thread<{ topicId?: string }>,
|
||||
thread: Thread<ThreadState>,
|
||||
message: Message,
|
||||
): Promise<void> {
|
||||
await safeReaction(
|
||||
|
||||
@@ -144,12 +144,24 @@ describe('replyTemplate', () => {
|
||||
thinking: false,
|
||||
}),
|
||||
),
|
||||
).toBe(`Here is my response\n\n`);
|
||||
).toBe(`Here is my response`);
|
||||
});
|
||||
|
||||
it('should show processing fallback when no content at all', () => {
|
||||
expect(renderLLMGenerating(makeParams({ thinking: false }))).toBe(`💭 Processing...`);
|
||||
});
|
||||
|
||||
it('should trim leading/trailing newlines from content to prevent extra blank lines', () => {
|
||||
expect(
|
||||
renderLLMGenerating(
|
||||
makeParams({
|
||||
content: '\n\nHere is my response\n\n',
|
||||
thinking: false,
|
||||
toolsCalling: [{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' }],
|
||||
}),
|
||||
),
|
||||
).toBe('Here is my response\n\n○ **builtin·search**(q: "test")');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== renderToolExecuting ====================
|
||||
@@ -170,7 +182,7 @@ describe('replyTemplate', () => {
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
`I will search for that.\n\n⏺ **builtin·web_search**(query: "test")\n ⎿ success:15 chars\n\n💭 Processing...`,
|
||||
`I will search for that.\n\n⏺ **builtin·web_search**(query: "test")\n⎿ success: 15 chars\n\n💭 Processing...`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -210,7 +222,7 @@ describe('replyTemplate', () => {
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
`⏺ **builtin·search**(q: "test")\n ⎿ success:15 chars\n⏺ **lobe-web-browsing·readUrl**(url: "https://example.com")\n ⎿ success:24 chars\n\n💭 Processing...`,
|
||||
`⏺ **builtin·search**(q: "test")\n⎿ success: 15 chars\n⏺ **lobe-web-browsing·readUrl**(url: "https://example.com")\n⎿ success: 24 chars\n\n💭 Processing...`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -228,6 +240,23 @@ describe('replyTemplate', () => {
|
||||
it('should show processing fallback when no lastContent and no tools', () => {
|
||||
expect(renderToolExecuting(makeParams({ stepType: 'call_tool' }))).toBe(`💭 Processing...`);
|
||||
});
|
||||
|
||||
it('should trim leading/trailing newlines from lastContent to prevent extra blank lines', () => {
|
||||
expect(
|
||||
renderToolExecuting(
|
||||
makeParams({
|
||||
lastContent: '\n\nI will search for that.\n\n',
|
||||
lastToolsCalling: [
|
||||
{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' },
|
||||
],
|
||||
stepType: 'call_tool',
|
||||
toolsResult: [{ apiName: 'search', identifier: 'builtin', output: 'Found results' }],
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
`I will search for that.\n\n⏺ **builtin·search**(q: "test")\n⎿ success: 13 chars\n\n💭 Processing...`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== summarizeOutput ====================
|
||||
@@ -240,7 +269,7 @@ describe('replyTemplate', () => {
|
||||
});
|
||||
|
||||
it('should show char count for output', () => {
|
||||
expect(summarizeOutput('Hello world')).toBe('success:11 chars');
|
||||
expect(summarizeOutput('Hello world')).toBe('success: 11 chars');
|
||||
});
|
||||
|
||||
it('should show char count for long output', () => {
|
||||
@@ -249,7 +278,15 @@ describe('replyTemplate', () => {
|
||||
});
|
||||
|
||||
it('should show char count for multi-line output', () => {
|
||||
expect(summarizeOutput('line1\nline2\nline3')).toBe('success:17 chars');
|
||||
expect(summarizeOutput('line1\nline2\nline3')).toBe('success: 17 chars');
|
||||
});
|
||||
|
||||
it('should show error status when isSuccess is false', () => {
|
||||
expect(summarizeOutput('Something went wrong', false)).toBe('error: 20 chars');
|
||||
});
|
||||
|
||||
it('should show success status when isSuccess is true', () => {
|
||||
expect(summarizeOutput('All good', true)).toBe('success: 8 chars');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -379,7 +416,7 @@ describe('replyTemplate', () => {
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
`Previous content\n\n⏺ **builtin·search**(q: "test")\n ⎿ success:13 chars\n\n💭 Processing...`,
|
||||
`Previous content\n\n⏺ **builtin·search**(q: "test")\n⎿ success: 13 chars\n\n💭 Processing...`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export function splitMessage(text: string, limit = DEFAULT_CHAR_LIMIT): string[]
|
||||
// ==================== Params ====================
|
||||
|
||||
type ToolCallItem = { apiName: string; arguments?: string; identifier: string };
|
||||
type ToolResultItem = { apiName: string; identifier: string; output?: string };
|
||||
type ToolResultItem = { apiName: string; identifier: string; isSuccess?: boolean; output?: string };
|
||||
|
||||
export interface RenderStepParams extends StepPresentationData {
|
||||
elapsedMs?: number;
|
||||
@@ -72,13 +72,17 @@ function formatToolCall(tc: ToolCallItem): string {
|
||||
return formatToolName(tc);
|
||||
}
|
||||
|
||||
export function summarizeOutput(output: string | undefined): string | undefined {
|
||||
export function summarizeOutput(
|
||||
output: string | undefined,
|
||||
isSuccess?: boolean,
|
||||
): string | undefined {
|
||||
if (!output) return undefined;
|
||||
const trimmed = output.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
|
||||
const chars = trimmed.length;
|
||||
return `success: ${chars.toLocaleString()} chars`;
|
||||
const status = isSuccess === false ? 'error' : 'success';
|
||||
return `${status}: ${chars.toLocaleString()} chars`;
|
||||
}
|
||||
|
||||
function formatPendingTools(toolsCalling: ToolCallItem[]): string {
|
||||
@@ -92,9 +96,10 @@ function formatCompletedTools(
|
||||
return toolsCalling
|
||||
.map((tc, i) => {
|
||||
const callStr = `⏺ ${formatToolCall(tc)}`;
|
||||
const summary = summarizeOutput(toolsResult?.[i]?.output);
|
||||
const result = toolsResult?.[i];
|
||||
const summary = summarizeOutput(result?.output, result?.isSuccess);
|
||||
if (summary) {
|
||||
return `${callStr}\n ⎿ ${summary}`;
|
||||
return `${callStr}\n⎿ ${summary}`;
|
||||
}
|
||||
return callStr;
|
||||
})
|
||||
@@ -160,7 +165,7 @@ export function renderLLMGenerating(params: RenderStepParams): string {
|
||||
totalTokens,
|
||||
totalToolCalls,
|
||||
} = params;
|
||||
const displayContent = content || lastContent;
|
||||
const displayContent = (content || lastContent)?.trim();
|
||||
const { header, footer } = renderInlineStats({
|
||||
elapsedMs,
|
||||
totalCost,
|
||||
@@ -178,12 +183,12 @@ export function renderLLMGenerating(params: RenderStepParams): string {
|
||||
|
||||
// Sub-state: has reasoning (thinking)
|
||||
if (reasoning && !content) {
|
||||
return `${header}${EMOJI_THINKING} ${reasoning}${footer}`;
|
||||
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}${footer}`;
|
||||
}
|
||||
|
||||
// Sub-state: pure text content (waiting for next step)
|
||||
if (displayContent) {
|
||||
return `${header}${displayContent}\n\n${footer}`;
|
||||
return `${header}${displayContent}${footer}`;
|
||||
}
|
||||
|
||||
return `${header}${EMOJI_THINKING} Processing...${footer}`;
|
||||
@@ -216,7 +221,7 @@ export function renderToolExecuting(params: RenderStepParams): string {
|
||||
|
||||
if (header) parts.push(header.trimEnd());
|
||||
|
||||
if (lastContent) parts.push(lastContent);
|
||||
if (lastContent) parts.push(lastContent.trim());
|
||||
|
||||
if (lastToolsCalling && lastToolsCalling.length > 0) {
|
||||
parts.push(formatCompletedTools(lastToolsCalling, toolsResult));
|
||||
@@ -244,7 +249,7 @@ export function renderFinalReply(
|
||||
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}`;
|
||||
return `${content.trimEnd()}\n\n${footer}`;
|
||||
}
|
||||
|
||||
export function renderError(errorMessage: string): string {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"buildCommand": "bun run build:vercel",
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/agent/gateway/discord",
|
||||
"schedule": "*/9 * * * *"
|
||||
}
|
||||
],
|
||||
"installCommand": "npx pnpm@10.26.2 install",
|
||||
"rewrites": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user