♻️ refactor: refactor message proccesser to the context engine (#9230)

* init context engine

* refactor

* refactor

* move

* refactor

* refactor PlaceholderVariables

* refactor name

* refactor

* update

* fix tests

* update workflow

* clean code

* add test

* move from store into service

* implement the HistoryTruncate and Input Template into context engine

* fix history truncate

* clean
This commit is contained in:
Arvin Xu
2025-09-13 00:12:53 +08:00
committed by GitHub
parent c38079d36f
commit dacfffdb63
48 changed files with 5967 additions and 1443 deletions
+8 -1
View File
@@ -11,7 +11,14 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
package: [file-loaders, prompts, model-runtime, web-crawler, electron-server-ipc, utils]
package:
- file-loaders
- prompts
- model-runtime
- web-crawler
- electron-server-ipc
- utils
- context-engine
name: Test package ${{ matrix.package }}
+1
View File
@@ -144,6 +144,7 @@
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.55",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
"@lobechat/database": "workspace:*",
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
+425
View File
@@ -0,0 +1,425 @@
# Context Engine 架构设计
## 概述
Context Engine 是一个灵活的消息处理管道系统,用于在 AI 对话中动态管理和处理上下文信息。它提供了一种可扩展的方式来注入、转换和验证消息流。
## 核心概念
### 1. 处理器(Processor
处理器是 Context Engine 的基本单元,负责对消息进行特定的处理操作。
#### 处理器分类
根据功能职责,处理器分为以下几类:
1. **注入器(Injector**
- 职责:向消息流中添加新的上下文信息
- 示例:SystemRoleInjector、HistoryInjector、RAGContextInjector
2. **转换器(Transformer**
- 职责:修改现有消息的内容或结构
- 示例:MessageRoleTransformer、ImageContentProcessor
3. **截断器(Truncator**
- 职责:根据特定规则裁剪消息内容
- 示例:HistoryTruncator、TokenBasedTruncator
4. **验证器(Validator**
- 职责:验证消息是否符合特定要求
- 示例:ModelCapabilityValidator
5. **重排器(Reorderer**
- 职责:调整消息的顺序
- 示例:ToolMessageReorder
### 2. 管道(Pipeline
管道是一系列处理器的有序组合,消息流经管道时会被逐个处理器处理。
```typescript
interface Pipeline {
// 添加处理器到管道
add(processor: BaseProcessor): Pipeline;
// 执行管道处理
execute(context: ProcessorContext): Promise<ProcessorContext>;
// 获取管道中的所有处理器
getProcessors(): BaseProcessor[];
}
```
### 3. 上下文(Context
上下文包含了处理器需要的所有信息:
```typescript
interface ProcessorContext {
messages: Message[]; // 消息列表
metadata?: ProcessorMetadata; // 元数据
variables?: Record<string, any>; // 变量
abortSignal?: AbortSignal; // 中止信号
}
```
## 架构设计
### 类层次结构
```
BaseProcessor (抽象基类)
├── BaseInjector (注入器基类)
│ ├── SystemRoleInjector
│ ├── HistoryInjector
│ ├── RAGContextInjector
│ └── ...
├── BaseTransformer (转换器基类)
│ ├── MessageRoleTransformer
│ └── ImageContentProcessor
├── BaseTruncator (截断器基类)
│ ├── HistoryTruncator
│ └── TokenBasedTruncator
├── BaseValidator (验证器基类)
│ └── ModelCapabilityValidator
└── BaseReorderer (重排器基类)
└── ToolMessageReorder
```
### 核心接口设计
#### BaseProcessor
```typescript
abstract class BaseProcessor {
// 处理器类型
abstract readonly type: ProcessorType;
// 处理器名称
abstract readonly name: string;
// 主处理方法
async process(context: ProcessorContext): Promise<ProcessorContext> {
// 1. 前置验证
this.validateInput(context);
// 2. 执行处理
const result = await this.doProcess(context);
// 3. 后置验证
this.validateOutput(result);
return result;
}
// 子类实现的核心处理逻辑
protected abstract doProcess(context: ProcessorContext): Promise<ProcessorContext>;
// 输入验证(可选覆盖)
protected validateInput(context: ProcessorContext): void {}
// 输出验证(可选覆盖)
protected validateOutput(context: ProcessorContext): void {}
}
```
#### BaseInjector
```typescript
abstract class BaseInjector extends BaseProcessor {
readonly type = ProcessorType.Injector;
protected async doProcess(context: ProcessorContext): Promise<ProcessorContext> {
// 1. 判断是否需要注入
if (!this.shouldInject(context)) {
return context;
}
// 2. 构建注入内容
const content = await this.buildContent(context);
// 3. 创建消息
const message = this.createMessage(content, context);
// 4. 确定注入位置
const position = this.getInjectionPosition(context);
// 5. 执行注入
return this.inject(context, message, position);
}
// 子类需要实现的方法
protected abstract shouldInject(context: ProcessorContext): boolean;
protected abstract buildContent(context: ProcessorContext): Promise<string>;
// 可选覆盖的方法
protected getInjectionPosition(context: ProcessorContext): number {
return 0; // 默认注入到开头
}
protected createMessage(content: string, context: ProcessorContext): Message {
return {
role: 'system',
content,
metadata: { injectedBy: this.name }
};
}
}
```
## 使用方式
### 1. 创建自定义处理器
```typescript
// 创建一个自定义注入器
class CustomContextInjector extends BaseInjector {
readonly name = 'custom-context-injector';
protected shouldInject(context: ProcessorContext): boolean {
// 判断逻辑
return !context.messages.some(msg =>
msg.metadata?.injectedBy === this.name
);
}
protected async buildContent(context: ProcessorContext): Promise<string> {
// 构建内容逻辑
return `Custom context: ${context.variables?.customValue}`;
}
}
```
### 2. 构建处理管道
```typescript
// 使用工厂模式创建管道
const pipeline = createPipeline()
.add(new SystemRoleInjector())
.add(new HistoryInjector({ maxMessages: 10 }))
.add(new CustomContextInjector())
.add(new MessageRoleTransformer())
.add(new TokenBasedTruncator({ maxTokens: 4000 }))
.add(new ModelCapabilityValidator());
// 执行管道
const result = await pipeline.execute({
messages: initialMessages,
variables: { customValue: 'test' },
metadata: { model: 'gpt-4' }
});
```
### 3. 条件处理
```typescript
// 基于条件的管道构建
const pipeline = createPipeline();
if (config.enableRAG) {
pipeline.add(new RAGContextInjector());
}
if (config.enableSearch) {
pipeline.add(new SearchContextInjector());
}
// 始终添加的处理器
pipeline
.add(new HistoryTruncator())
.add(new ModelCapabilityValidator());
```
### 4. 错误处理
```typescript
try {
const result = await pipeline.execute(context);
} catch (error) {
if (error instanceof ProcessorError) {
console.error(`Processor ${error.processorName} failed:`, error.message);
} else if (error instanceof PipelineError) {
console.error('Pipeline execution failed:', error.message);
}
}
```
## 配置管理
### 处理器配置
每个处理器可以接受特定的配置选项:
```typescript
interface ProcessorConfig {
// 通用配置
enabled?: boolean;
priority?: number;
// 处理器特定配置
[key: string]: any;
}
// 示例:历史注入器配置
interface HistoryInjectorConfig extends ProcessorConfig {
maxMessages?: number;
includeSystemMessages?: boolean;
preserveTools?: boolean;
}
```
### 管道配置
```typescript
interface PipelineConfig {
processors: Array<{
type: string;
config?: ProcessorConfig;
}>;
// 全局配置
abortOnError?: boolean;
timeout?: number;
}
// 从配置创建管道
const pipeline = createPipelineFromConfig({
processors: [
{ type: 'system-role', config: { enabled: true } },
{ type: 'history', config: { maxMessages: 20 } },
{ type: 'rag', config: { threshold: 0.7 } }
],
abortOnError: false,
timeout: 30000
});
```
## 最佳实践
### 1. 单一职责原则
每个处理器应该只负责一种特定的处理任务。
### 2. 可配置性
处理器应该通过配置参数来控制行为,而不是硬编码。
### 3. 错误处理
- 使用具体的错误类型
- 提供有意义的错误信息
- 考虑错误恢复策略
### 4. 性能优化
- 避免不必要的消息复制
- 使用流式处理处理大量数据
- 实现适当的缓存机制
### 5. 测试
- 为每个处理器编写单元测试
- 测试处理器组合的集成测试
- 边界条件和错误场景测试
## 扩展机制
### 1. 自定义处理器类型
```typescript
// 定义新的处理器类型
enum CustomProcessorType {
Analyzer = 'analyzer',
Enhancer = 'enhancer'
}
// 创建对应的基类
abstract class BaseAnalyzer extends BaseProcessor {
readonly type = CustomProcessorType.Analyzer;
// 分析器特定的方法
protected abstract analyze(messages: Message[]): AnalysisResult;
}
```
### 2. 处理器组合
```typescript
// 创建复合处理器
class CompositeProcessor extends BaseProcessor {
constructor(private processors: BaseProcessor[]) {
super();
}
protected async doProcess(context: ProcessorContext): Promise<ProcessorContext> {
let result = context;
for (const processor of this.processors) {
result = await processor.process(result);
}
return result;
}
}
```
### 3. 插件系统
```typescript
interface ProcessorPlugin {
name: string;
version: string;
processors: ProcessorDefinition[];
}
// 注册插件
registry.registerPlugin({
name: 'custom-processors',
version: '1.0.0',
processors: [
{ type: 'custom-injector', factory: () => new CustomInjector() },
{ type: 'custom-validator', factory: () => new CustomValidator() }
]
});
```
## 迁移指南
### 从当前架构迁移
1. **BaseProvider 迁移到 BaseInjector**
```typescript
// 旧代码
class MyProvider extends BaseProvider {
doProcess(context) {
// 实现
}
}
// 新代码
class MyInjector extends BaseInjector {
shouldInject(context) {
// 判断逻辑
}
buildContent(context) {
// 构建内容
}
}
```
2. **处理器分类**
- 将现有处理器按功能分类
- 继承对应的基类
- 实现必要的抽象方法
3. **配置迁移**
- 统一配置格式
- 支持向后兼容
- 提供迁移工具
## 总结
新的 Context Engine 架构提供了:
1. **清晰的分层结构**:基于功能的处理器分类
2. **灵活的扩展机制**:易于添加新的处理器类型
3. **强大的组合能力**:通过管道组合实现复杂功能
4. **完善的错误处理**:细粒度的错误类型和恢复策略
5. **优秀的可测试性**:模块化设计便于单元测试
这种设计使得 Context Engine 更加模块化、可维护和可扩展,能够更好地满足不同场景下的上下文处理需求。
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@lobechat/context-engine",
"version": "0.1.0",
"private": true,
"description": "Context Pipeline Engine for LobeChat - A flexible and configurable context processing system",
"keywords": [
"context",
"pipeline",
"processor",
"chat",
"ai",
"lobe"
],
"main": "src/index.ts",
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage",
"test:update": "vitest -u"
},
"dependencies": {
"debug": "^4.3.4",
"immer": "^10.0.3",
"lodash-es": "^4.17.21"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/lodash-es": "^4.17.12"
},
"peerDependencies": {
"@lobechat/const": "workspace:*",
"@lobechat/model-runtime": "workspace:*",
"@lobechat/prompts": "workspace:*",
"@lobechat/types": "workspace:*",
"@lobechat/utils": "workspace:*"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}
@@ -0,0 +1,87 @@
import type { ContextProcessor, PipelineContext, ProcessorOptions } from '../types';
import { ProcessorError } from '../types';
/**
* 基础处理器抽象类
* 提供通用的处理器功能和错误处理
*/
export abstract class BaseProcessor implements ContextProcessor {
abstract readonly name: string;
// 为了兼容现有子类构造函数签名,保留参数但不做任何处理
constructor(_options: ProcessorOptions = {}) {}
/**
* 核心处理方法 - 子类需要实现
*/
protected abstract doProcess(context: PipelineContext): Promise<PipelineContext>;
/**
* 公共处理入口,包含错误处理和日志
*/
async process(context: PipelineContext): Promise<PipelineContext> {
try {
this.validateInput(context);
const result = await this.doProcess(context);
this.validateOutput(result);
return result;
} catch (error) {
throw new ProcessorError(
this.name,
`处理失败: ${error}`,
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* 验证输入上下文
*/
protected validateInput(context: PipelineContext): void {
if (!context || !Array.isArray(context.messages)) {
throw new Error('无效的上下文');
}
}
/**
* 验证输出上下文
*/
protected validateOutput(context: PipelineContext): void {
if (!context || !Array.isArray(context.messages)) {
throw new Error('无效的输出上下文');
}
}
/**
* 安全地克隆上下文
*/
protected cloneContext(context: PipelineContext): PipelineContext {
return {
...context,
messages: [...context.messages],
metadata: { ...context.metadata },
};
}
/**
* 中止管道处理
*/
protected abort(context: PipelineContext, reason: string): PipelineContext {
return {
...context,
isAborted: true,
abortReason: reason,
};
}
/**
* 检查消息是否为空
*/
protected isEmptyMessage(message: string | undefined | null): boolean {
return !message || message.trim().length === 0;
}
protected markAsExecuted(context: PipelineContext): PipelineContext {
return context;
}
}
@@ -0,0 +1,22 @@
import type { PipelineContext } from '../types';
import { BaseProcessor } from './BaseProcessor';
/**
* 极简 Provider:约束为“注入系统消息到开头”这一单一职责
*/
export abstract class BaseProvider extends BaseProcessor {
// 子类可选择实现;默认不构建额外上下文
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async buildContext(_context: PipelineContext): Promise<string | null> {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldInject(_context: PipelineContext): boolean {
return true;
}
protected createSystemMessage(content: string): any {
return { content, role: 'system' };
}
}
+32
View File
@@ -0,0 +1,32 @@
// Core types and interfaces
export type * from './types';
// Base classes
export { BaseProcessor } from './base/BaseProcessor';
export { BaseProvider } from './base/BaseProvider';
// Context Engine
export type { ContextEngineConfig } from './pipeline';
export { ContextEngine } from './pipeline';
// Context Providers
export {
HistorySummaryProvider,
InboxGuideProvider,
SystemRoleInjector,
ToolSystemRoleProvider,
} from './providers';
// Processors
export {
HistoryTruncateProcessor,
InputTemplateProcessor,
MessageCleanupProcessor,
MessageContentProcessor,
PlaceholderVariablesProcessor,
ToolCallProcessor,
ToolMessageReorder,
} from './processors';
// Constants
export { PipelineError, ProcessorError, ProcessorType } from './types';
+219
View File
@@ -0,0 +1,219 @@
import debug from 'debug';
import type {
AgentState,
ContextProcessor,
PipelineContext,
PipelineResult,
ProcessorOptions,
} from './types';
import { PipelineError } from './types';
const log = debug('context-engine:ContextEngine');
/**
* Context Engine Configuration
*/
export interface ContextEngineConfig extends ProcessorOptions {
/** Processor pipeline */
pipeline: ContextProcessor[];
}
/**
* Context Engine - Core orchestrator that executes processors sequentially
*/
export class ContextEngine {
private processors: ContextProcessor[] = [];
private options: ProcessorOptions;
constructor(config: ContextEngineConfig) {
const { pipeline, ...options } = config;
this.processors = [...pipeline];
this.options = {
debug: false,
logger: console.log,
...options,
};
}
/**
* Add processor to pipeline
*/
addProcessor(processor: ContextProcessor): this {
this.processors.push(processor);
return this;
}
/**
* Remove processor
*/
removeProcessor(name: string): this {
this.processors = this.processors.filter((p) => p.name !== name);
return this;
}
/**
* Get processor list
*/
getProcessors(): ContextProcessor[] {
return [...this.processors];
}
/**
* Clear all processors
*/
clear(): this {
this.processors = [];
return this;
}
/**
* Execute pipeline processing
*/
async process(input: {
initialState: AgentState;
maxTokens: number;
messages?: Array<any>;
metadata?: Record<string, any>;
model: string;
}): Promise<PipelineResult> {
const startTime = Date.now();
const processorDurations: Record<string, number> = {};
// Create initial pipeline context
let context: PipelineContext = {
initialState: input.initialState,
isAborted: false,
messages: Array.isArray(input.messages) ? [...input.messages] : [],
metadata: {
maxTokens: input.maxTokens,
model: input.model,
...input.metadata,
},
};
log('Starting pipeline processing');
log('Number of processors:', this.processors.length);
let processedCount = 0;
try {
// Execute each processor in sequence
for (const processor of this.processors) {
if (context.isAborted) {
log('Pipeline aborted before processor', processor.name, 'reason:', context.abortReason);
break;
}
const processorStartTime = Date.now();
log('Executing processor:', processor.name);
try {
context = await processor.process(context);
processedCount++;
const duration = Date.now() - processorStartTime;
processorDurations[processor.name] = duration;
log('Processor', processor.name, 'completed in', duration + 'ms');
if (context.isAborted) {
log('Pipeline aborted by processor', processor.name, 'reason:', context.abortReason);
break;
}
} catch (error) {
const duration = Date.now() - processorStartTime;
processorDurations[processor.name] = duration;
log('Processor', processor.name, 'execution failed:', error);
throw new PipelineError(
`Processor [${processor.name}] execution failed`,
processor.name,
error instanceof Error ? error : new Error(String(error)),
);
}
}
const totalDuration = Date.now() - startTime;
log('Pipeline processing completed in', totalDuration + 'ms');
return {
abortReason: context.abortReason,
isAborted: context.isAborted,
messages: context.messages,
metadata: context.metadata,
stats: {
processedCount,
processorDurations,
totalDuration,
},
};
} catch (error) {
log('Pipeline processing failed:', error);
if (error instanceof PipelineError) {
throw error;
}
throw new PipelineError(
'Unknown error occurred during pipeline processing',
undefined,
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Get pipeline statistics
*/
getStats() {
return {
processorCount: this.processors.length,
processorNames: this.processors.map((p) => p.name),
};
}
/**
* Clone pipeline (deep copy processor list)
*/
clone(): ContextEngine {
return new ContextEngine({
pipeline: [...this.processors],
...this.options,
});
}
/**
* Validate pipeline configuration
*/
validate(): { errors: string[]; valid: boolean } {
const errors: string[] = [];
// Check for duplicate processor names
const names = this.processors.map((p) => p.name);
const duplicates = names.filter((name, index) => names.indexOf(name) !== index);
if (duplicates.length > 0) {
errors.push(`Found duplicate processor names: ${duplicates.join(', ')}`);
}
// Check if processors are empty
if (this.processors.length === 0) {
errors.push('No processors in pipeline');
}
// Check if processors implement required methods
this.processors.forEach((processor) => {
if (!processor.name) {
errors.push('Processor missing name');
}
if (typeof processor.process !== 'function') {
errors.push(`Processor [${processor.name}] missing process method`);
}
});
return {
errors,
valid: errors.length === 0,
};
}
}
@@ -0,0 +1,76 @@
import debug from 'debug';
import { BaseProcessor } from '../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:HistoryTruncateProcessor');
export interface HistoryTruncateConfig {
/** Whether to enable history count limit */
enableHistoryCount?: boolean;
/** Maximum number of historical messages to keep */
historyCount?: number;
}
/**
* Slice messages based on history count configuration
* @param messages Original messages array
* @param options Configuration options for slicing
* @returns Sliced messages array
*/
export const getSlicedMessages = (
messages: any[],
options: {
enableHistoryCount?: boolean;
historyCount?: number;
},
): any[] => {
// if historyCount is not enabled, return all messages
if (!options.enableHistoryCount || options.historyCount === undefined) return messages;
// if historyCount is negative or set to 0, return empty array
if (options.historyCount <= 0) return [];
// if historyCount is positive, return last N messages
return messages.slice(-options.historyCount);
};
/**
* History Truncate Processor
* Responsible for limiting message history based on configuration
*/
export class HistoryTruncateProcessor extends BaseProcessor {
readonly name = 'HistoryTruncateProcessor';
constructor(
private config: HistoryTruncateConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
const originalCount = clonedContext.messages.length;
// Apply history truncation
clonedContext.messages = getSlicedMessages(clonedContext.messages, {
enableHistoryCount: this.config.enableHistoryCount,
historyCount: this.config.historyCount,
});
const finalCount = clonedContext.messages.length;
const truncatedCount = originalCount - finalCount;
// Update metadata
clonedContext.metadata.historyTruncated = truncatedCount;
clonedContext.metadata.finalMessageCount = finalCount;
log(
`History truncation completed, truncated ${truncatedCount} messages (${originalCount}${finalCount})`,
);
return this.markAsExecuted(clonedContext);
}
}
@@ -0,0 +1,83 @@
import debug from 'debug';
import { template } from 'lodash-es';
import { BaseProcessor } from '../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:InputTemplateProcessor');
export interface InputTemplateConfig {
/** Input message template string */
inputTemplate?: string;
}
/**
* Input Template Processor
* Responsible for applying input message templates to user messages
*/
export class InputTemplateProcessor extends BaseProcessor {
readonly name = 'InputTemplateProcessor';
constructor(
private config: InputTemplateConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// Skip processing if no template is configured
if (!this.config.inputTemplate) {
log('No input template configured, skipping processing');
return this.markAsExecuted(clonedContext);
}
let processedCount = 0;
try {
// Compile the template
const compiler = template(this.config.inputTemplate, {
interpolate: /{{\s*(text)\s*}}/g,
});
log(`Applying input template: ${this.config.inputTemplate}`);
// Process each message
for (let i = 0; i < clonedContext.messages.length; i++) {
const message = clonedContext.messages[i];
// Only apply template to user messages
if (message.role === 'user') {
try {
const originalContent = message.content;
const processedContent = compiler({ text: originalContent });
if (processedContent !== originalContent) {
clonedContext.messages[i] = {
...message,
content: processedContent,
};
processedCount++;
log(`Applied template to message ${message.id}`);
}
} catch (error) {
log.extend('error')(`Error applying template to message ${message.id}: ${error}`);
// Keep original message on error
}
}
}
} catch (error) {
log.extend('error')(`Template compilation failed: ${error}`);
// Skip processing if template compilation fails
}
// Update metadata
clonedContext.metadata.inputTemplateProcessed = processedCount;
log(`Input template processing completed, processed ${processedCount} messages`);
return this.markAsExecuted(clonedContext);
}
}
@@ -0,0 +1,87 @@
import debug from 'debug';
import { BaseProcessor } from '../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:MessageCleanupProcessor');
/**
* 消息清理处理器
* 负责清理消息中的多余字段,只保留 OpenAI 格式所需的必要字段
*/
export class MessageCleanupProcessor extends BaseProcessor {
readonly name = 'MessageCleanupProcessor';
constructor(options: ProcessorOptions = {}) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
let cleanedCount = 0;
// 清理每条消息,只保留必要字段
for (let i = 0; i < clonedContext.messages.length; i++) {
const message = clonedContext.messages[i];
const cleanedMessage = this.cleanMessage(message);
if (cleanedMessage !== message) {
clonedContext.messages[i] = cleanedMessage;
cleanedCount++;
}
}
// 更新元数据
clonedContext.metadata.messageCleanup = {
cleanedCount,
totalMessages: clonedContext.messages.length,
};
log(`Message cleanup completed, cleaned ${cleanedCount} messages`);
return this.markAsExecuted(clonedContext);
}
/**
* 清理单条消息,只保留必要字段
*/
private cleanMessage(message: any): any {
switch (message.role) {
case 'system': {
return {
content: message.content,
role: message.role,
};
}
case 'user': {
return {
content: message.content,
role: message.role,
};
}
case 'assistant': {
return {
content: message.content,
role: message.role,
...(message.tool_calls && { tool_calls: message.tool_calls }),
};
}
case 'tool': {
return {
content: message.content,
role: message.role,
tool_call_id: message.tool_call_id,
...(message.name && { name: message.name }),
};
}
default: {
// 对于未知角色,保持原样
return message;
}
}
}
}
@@ -0,0 +1,298 @@
import { filesPrompts } from '@lobechat/prompts';
import { imageUrlToBase64, isLocalUrl, parseDataUri } from '@lobechat/utils';
import debug from 'debug';
import { BaseProcessor } from '../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:MessageContentProcessor');
export interface FileContextConfig {
/** Whether to enable file context injection */
enabled?: boolean;
/** Whether to include file URLs in file context prompts */
includeFileUrl?: boolean;
}
export interface MessageContentConfig {
/** File context configuration */
fileContext?: FileContextConfig;
/** Function to check if vision is supported */
isCanUseVision?: (model: string, provider: string) => boolean | undefined;
/** Model name */
model: string;
/** Provider name */
provider: string;
}
export interface UserMessageContentPart {
image_url?: {
detail?: string;
url: string;
};
signature?: string;
text?: string;
thinking?: string;
type: 'text' | 'image_url' | 'thinking';
}
/**
* Message Content Processor
* Responsible for handling content format conversion of user and assistant messages
*/
export class MessageContentProcessor extends BaseProcessor {
readonly name = 'MessageContentProcessor';
constructor(
private config: MessageContentConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
let processedCount = 0;
let userMessagesProcessed = 0;
let assistantMessagesProcessed = 0;
// 处理每条消息的内容
for (let i = 0; i < clonedContext.messages.length; i++) {
const message = clonedContext.messages[i];
try {
let updatedMessage = message;
if (message.role === 'user') {
updatedMessage = await this.processUserMessage(message);
if (updatedMessage !== message) {
userMessagesProcessed++;
processedCount++;
}
} else if (message.role === 'assistant') {
updatedMessage = await this.processAssistantMessage(message);
if (updatedMessage !== message) {
assistantMessagesProcessed++;
processedCount++;
}
}
if (updatedMessage !== message) {
clonedContext.messages[i] = updatedMessage;
log(`Processed message content ${message.id}, role: ${message.role}`);
}
} catch (error) {
log.extend('error')(`Error processing message ${message.id} content: ${error}`);
// 继续处理其他消息
}
}
// 更新元数据
clonedContext.metadata.messageContentProcessed = processedCount;
clonedContext.metadata.userMessagesProcessed = userMessagesProcessed;
clonedContext.metadata.assistantMessagesProcessed = assistantMessagesProcessed;
log(
`Message content processing completed, processed ${processedCount} messages (user: ${userMessagesProcessed}, assistant: ${assistantMessagesProcessed})`,
);
return this.markAsExecuted(clonedContext);
}
/**
* Process user message content
*/
private async processUserMessage(message: any): Promise<any> {
// Check if images or files need processing
const hasImages = message.imageList && message.imageList.length > 0;
const hasFiles = message.fileList && message.fileList.length > 0;
// If no images and files, return plain text content directly
if (!hasImages && !hasFiles) {
return {
...message,
content: message.content,
};
}
const contentParts: UserMessageContentPart[] = [];
// Add text content
let textContent = message.content || '';
// Add file context (if file context is enabled and has files or images)
if ((hasFiles || hasImages) && this.config.fileContext?.enabled) {
const filesContext = filesPrompts({
addUrl: this.config.fileContext.includeFileUrl ?? true,
fileList: message.fileList,
imageList: message.imageList,
});
if (filesContext) {
textContent = (textContent + '\n\n' + filesContext).trim();
}
}
// Add text part
if (textContent) {
contentParts.push({
text: textContent,
type: 'text',
});
}
// Process image content
if (hasImages && this.config.isCanUseVision?.(this.config.model, this.config.provider)) {
const imageContentParts = await this.processImageList(message.imageList || []);
contentParts.push(...imageContentParts);
}
// 明确返回的字段,只保留必要的消息字段
const hasFileContext = (hasFiles || hasImages) && this.config.fileContext?.enabled;
const hasVisionContent =
hasImages && this.config.isCanUseVision?.(this.config.model, this.config.provider);
// 如果只有文本内容且没有添加文件上下文也没有视觉内容,返回纯文本
if (
contentParts.length === 1 &&
contentParts[0].type === 'text' &&
!hasFileContext &&
!hasVisionContent
) {
return {
content: contentParts[0].text,
createdAt: message.createdAt,
id: message.id,
meta: message.meta,
role: message.role,
updatedAt: message.updatedAt,
// 保留其他可能需要的字段,但移除已处理的文件相关字段
...(message.tools && { tools: message.tools }),
...(message.tool_calls && { tool_calls: message.tool_calls }),
...(message.tool_call_id && { tool_call_id: message.tool_call_id }),
...(message.name && { name: message.name }),
};
}
// 返回结构化内容
return {
content: contentParts,
createdAt: message.createdAt,
id: message.id,
meta: message.meta,
role: message.role,
updatedAt: message.updatedAt,
// 保留其他可能需要的字段,但移除已处理的文件相关字段
...(message.tools && { tools: message.tools }),
...(message.tool_calls && { tool_calls: message.tool_calls }),
...(message.tool_call_id && { tool_call_id: message.tool_call_id }),
...(message.name && { name: message.name }),
};
}
/**
* 处理助手消息内容
*/
private async processAssistantMessage(message: any): Promise<any> {
// 检查是否有推理内容(thinking mode
const shouldIncludeThinking = message.reasoning && !!message.reasoning?.signature;
if (shouldIncludeThinking) {
const contentParts: UserMessageContentPart[] = [
{
signature: message.reasoning!.signature,
thinking: message.reasoning!.content,
type: 'thinking',
},
{
text: message.content,
type: 'text',
},
];
return {
...message,
content: contentParts,
};
}
// 检查是否有图片(助手消息也可能包含图片)
const hasImages = message.imageList && message.imageList.length > 0;
if (hasImages && this.config.isCanUseVision?.(this.config.model, this.config.provider)) {
// 创建结构化内容
const contentParts: UserMessageContentPart[] = [];
if (message.content) {
contentParts.push({
text: message.content,
type: 'text',
});
}
// 处理图片内容
const imageContentParts = await this.processImageList(message.imageList || []);
contentParts.push(...imageContentParts);
return {
...message,
content: contentParts,
};
}
// 普通助手消息,返回纯文本内容
return {
...message,
content: message.content,
};
}
/**
* 处理图片列表
*/
private async processImageList(imageList: any[]): Promise<UserMessageContentPart[]> {
if (!imageList || imageList.length === 0) {
return [];
}
return Promise.all(
imageList.map(async (image) => {
const { type } = parseDataUri(image.url);
let processedUrl = image.url;
if (type === 'url' && isLocalUrl(image.url)) {
const { base64, mimeType } = await imageUrlToBase64(image.url);
processedUrl = `data:${mimeType};base64,${base64}`;
}
return {
image_url: { detail: 'auto', url: processedUrl },
type: 'image_url',
} as UserMessageContentPart;
}),
);
}
/**
* 验证内容部分格式
*/
private validateContentPart(part: UserMessageContentPart): boolean {
if (!part || !part.type) return false;
switch (part.type) {
case 'text': {
return typeof part.text === 'string';
}
case 'image_url': {
return !!(part.image_url && part.image_url.url);
}
case 'thinking': {
return !!(part.thinking && part.signature);
}
default: {
return false;
}
}
}
}
@@ -0,0 +1,196 @@
import debug from 'debug';
import { BaseProcessor } from '../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:PlaceholderVariablesProcessor');
const placeholderVariablesRegex = /{{(.*?)}}/g;
export interface PlaceholderVariablesConfig {
/** Recursive parsing depth, default is 2 */
depth?: number;
/** Variable generators mapping, key is variable name, value is generator function */
variableGenerators: Record<string, () => string>;
}
/**
* Extract all {{variable}} placeholder variable names from text
* @param text String containing template variables
* @returns Array of variable names, e.g. ['date', 'nickname']
*/
const extractPlaceholderVariables = (text: string): string[] => {
const matches = [...text.matchAll(placeholderVariablesRegex)];
return matches.map((m) => m[1].trim());
};
/**
* Replace template variables with actual values, supporting recursive parsing of nested variables
* @param text - Original text containing variables
* @param variableGenerators - Variable generators mapping
* @param depth - Recursive depth, default 2, set higher to support {{date}} within {{text}}
* @returns Text with variables replaced
*/
export const parsePlaceholderVariables = (
text: string,
variableGenerators: Record<string, () => string>,
depth = 2,
): string => {
let result = text;
// Recursive parsing to handle cases like {{text}} containing additional preset variables
for (let i = 0; i < depth; i++) {
try {
const extractedVariables = extractPlaceholderVariables(result);
const availableVariables = Object.fromEntries(
extractedVariables
.map((key) => [key, variableGenerators[key]?.()])
.filter(([, value]) => value !== undefined),
);
// Only perform replacement when there are available variables
if (Object.keys(availableVariables).length === 0) break;
// Replace variables one by one to avoid lodash template's error handling for undefined variables
let tempResult = result;
for (const [key, value] of Object.entries(availableVariables)) {
const regex = new RegExp(
`{{\\s*${key.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}\\s*}}`,
'g',
);
// @ts-ignore
tempResult = tempResult.replace(regex, value);
}
if (tempResult === result) break;
result = tempResult;
} catch {
break;
}
}
return result;
};
/**
* Parse message content and replace placeholder variables
* @param messages Original messages array
* @param variableGenerators Variable generators mapping
* @param depth Recursive parsing depth, default is 2
* @returns Processed messages array
*/
export const parsePlaceholderVariablesMessages = (
messages: any[],
variableGenerators: Record<string, () => string>,
depth = 2,
): any[] =>
messages.map((message) => {
if (!message?.content) return message;
const { content } = message;
// Handle string type directly
if (typeof content === 'string') {
return { ...message, content: parsePlaceholderVariables(content, variableGenerators, depth) };
}
// Handle array type by processing text elements
if (Array.isArray(content)) {
return {
...message,
content: content.map((item) =>
item?.type === 'text'
? { ...item, text: parsePlaceholderVariables(item.text, variableGenerators, depth) }
: item,
),
};
}
return message;
});
/**
* PlaceholderVariables Processor
* Responsible for handling placeholder variable replacement in messages
*/
export class PlaceholderVariablesProcessor extends BaseProcessor {
readonly name = 'PlaceholderVariablesProcessor';
constructor(
private config: PlaceholderVariablesConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
let processedCount = 0;
const depth = this.config.depth ?? 2;
log(
`Starting placeholder variables processing with ${Object.keys(this.config.variableGenerators).length} generators`,
);
// 处理每条消息的占位符变量
for (let i = 0; i < clonedContext.messages.length; i++) {
const message = clonedContext.messages[i];
try {
const originalMessage = JSON.stringify(message);
const processedMessage = this.processMessagePlaceholders(message, depth);
if (JSON.stringify(processedMessage) !== originalMessage) {
clonedContext.messages[i] = processedMessage;
processedCount++;
log(`Processed placeholders in message ${message.id}, role: ${message.role}`);
}
} catch (error) {
log.extend('error')(`Error processing placeholders in message ${message.id}: ${error}`);
// 继续处理其他消息
}
}
// 更新元数据
clonedContext.metadata.placeholderVariablesProcessed = processedCount;
log(`Placeholder variables processing completed, processed ${processedCount} messages`);
return this.markAsExecuted(clonedContext);
}
/**
* 处理单个消息的占位符变量
*/
private processMessagePlaceholders(message: any, depth: number): any {
if (!message?.content) return message;
const { content } = message;
// Handle string type directly
if (typeof content === 'string') {
return {
...message,
content: parsePlaceholderVariables(content, this.config.variableGenerators, depth),
};
}
// Handle array type by processing text elements
if (Array.isArray(content)) {
return {
...message,
content: content.map((item) =>
item?.type === 'text'
? {
...item,
text: parsePlaceholderVariables(item.text, this.config.variableGenerators, depth),
}
: item,
),
};
}
return message;
}
}
@@ -0,0 +1,186 @@
import debug from 'debug';
import { BaseProcessor } from '../base/BaseProcessor';
import type { MessageToolCall, PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:ToolCallProcessor');
export interface ToolCallConfig {
/** Function to generate tool calling name */
genToolCallingName?: (identifier: string, apiName: string, type?: string) => string;
/** Function to check if function calling is supported */
isCanUseFC?: (model: string, provider: string) => boolean;
/** Model name */
model: string;
/** Provider name */
provider: string;
}
/**
* Tool Call Processor
* Responsible for converting ChatMessage format tool calls to OpenAI format
*/
export class ToolCallProcessor extends BaseProcessor {
readonly name = 'ToolCallProcessor';
constructor(
private config: ToolCallConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
const supportTools = this.config.isCanUseFC
? this.config.isCanUseFC(this.config.model, this.config.provider)
: true;
let processedCount = 0;
let toolCallsConverted = 0;
let toolMessagesConverted = 0;
// 处理每条消息的工具调用
for (let i = 0; i < clonedContext.messages.length; i++) {
const message = clonedContext.messages[i];
try {
const updatedMessage = await this.processMessage(message, supportTools);
if (updatedMessage !== message) {
processedCount++;
clonedContext.messages[i] = updatedMessage;
// 统计转换的工具调用和工具消息数量
if (message.role === 'assistant' && message.tools) {
toolCallsConverted += message.tools.length;
}
if (message.role === 'tool') {
toolMessagesConverted++;
}
log(`处理消息 ${message.id},角色: ${message.role}`);
}
} catch (error) {
log.extend('error')(`处理消息 ${message.id} 工具调用时出错: ${error}`);
// 继续处理其他消息
}
}
// 更新元数据
clonedContext.metadata.toolCallProcessed = processedCount;
clonedContext.metadata.toolCallsConverted = toolCallsConverted;
clonedContext.metadata.toolMessagesConverted = toolMessagesConverted;
clonedContext.metadata.supportTools = supportTools;
log(
`Tool call processing completed, processed ${processedCount} messages, converted ${toolCallsConverted} tool calls, ${toolMessagesConverted} tool messages`,
);
return this.markAsExecuted(clonedContext);
}
/**
* 处理单条消息的工具调用
*/
private async processMessage(message: any, supportTools: boolean): Promise<any> {
switch (message.role) {
case 'assistant': {
return this.processAssistantMessage(message, supportTools);
}
case 'tool': {
return this.processToolMessage(message, supportTools);
}
default: {
return message;
}
}
}
/**
* 处理助手消息的工具调用
*/
private processAssistantMessage(message: any, supportTools: boolean): any {
// 检查是否有工具调用
const hasTools = message.tools && message.tools.length > 0;
const hasEmptyToolCalls = message.tool_calls && message.tool_calls.length === 0;
if (!supportTools || (!hasTools && hasEmptyToolCalls)) {
// 如果不支持工具或只有空的工具调用,返回普通消息(移除工具相关属性)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tools, tool_calls, ...messageWithoutTools } = message;
return messageWithoutTools;
}
if (!hasTools) {
// 如果没有 tools 但有其他工具调用属性,只移除 tools
return message;
}
// 将 tools 转换为 tool_calls 格式
const tool_calls = message.tools.map(
(tool: any): MessageToolCall => ({
function: {
arguments: tool.arguments,
name: this.config.genToolCallingName
? this.config.genToolCallingName(tool.identifier, tool.apiName, tool.type)
: `${tool.identifier}.${tool.apiName}`,
},
id: tool.id,
type: 'function',
}),
);
return { ...message, tool_calls };
}
/**
* 处理工具消息
*/
private processToolMessage(message: any, supportTools: boolean): any {
if (!supportTools) {
// 如果不支持工具,将工具消息转换为用户消息
return {
...message,
name: undefined,
plugin: undefined,
role: 'user',
tool_call_id: undefined,
};
}
// 生成工具名称
const toolName = message.plugin
? this.config.genToolCallingName
? this.config.genToolCallingName(
message.plugin.identifier,
message.plugin.apiName,
message.plugin.type,
)
: `${message.plugin.identifier}.${message.plugin.apiName}`
: undefined;
return {
...message,
name: toolName,
// 保留 tool_call_id 用于关联
};
}
/**
* 验证工具调用格式
*/
private validateToolCall(tool: any): boolean {
return !!(tool && tool.id && tool.identifier && tool.apiName && tool.arguments);
}
/**
* 验证工具消息格式
*/
private validateToolMessage(message: any): boolean {
return !!(message && message.tool_call_id && message.content !== undefined);
}
}
@@ -0,0 +1,113 @@
import debug from 'debug';
import { BaseProcessor } from '../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:ToolMessageReorder');
/**
* Reorder tool messages to ensure that tool messages are displayed in the correct order.
* see https://github.com/lobehub/lobe-chat/pull/3155
*/
export class ToolMessageReorder extends BaseProcessor {
readonly name = 'ToolMessageReorder';
constructor(options: ProcessorOptions = {}) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// 重新排序消息
const reorderedMessages = this.reorderToolMessages(clonedContext.messages);
const originalCount = clonedContext.messages.length;
const reorderedCount = reorderedMessages.length;
clonedContext.messages = reorderedMessages;
// 更新元数据
clonedContext.metadata.toolMessageReorder = {
originalCount,
removedInvalidTools: originalCount - reorderedCount,
reorderedCount,
};
if (originalCount !== reorderedCount) {
log(
'Tool message reordering completed, removed',
originalCount - reorderedCount,
'invalid tool messages',
);
} else {
log('Tool message reordering completed, message order optimized');
}
return this.markAsExecuted(clonedContext);
}
/**
* 重新排序工具消息
*/
private reorderToolMessages(messages: any[]): any[] {
// 1. 先收集所有 assistant 消息中的有效 tool_call_id
const validToolCallIds = new Set<string>();
messages.forEach((message) => {
if (message.role === 'assistant' && message.tool_calls) {
message.tool_calls.forEach((toolCall: any) => {
validToolCallIds.add(toolCall.id);
});
}
});
// 2. 收集所有有效的 tool 消息
const toolMessages: Record<string, any> = {};
messages.forEach((message) => {
if (
message.role === 'tool' &&
message.tool_call_id &&
validToolCallIds.has(message.tool_call_id)
) {
toolMessages[message.tool_call_id] = message;
}
});
// 3. 重新排序消息
const reorderedMessages: any[] = [];
messages.forEach((message) => {
// 跳过无效的 tool 消息
if (
message.role === 'tool' &&
(!message.tool_call_id || !validToolCallIds.has(message.tool_call_id))
) {
log('Skipping invalid tool message:', message.id);
return;
}
// 检查是否已经添加过该 tool 消息
const hasPushed = reorderedMessages.some(
(m) => !!message.tool_call_id && m.tool_call_id === message.tool_call_id,
);
if (hasPushed) return;
reorderedMessages.push(message);
// 如果是 assistant 消息且有 tool_calls,添加对应的 tool 消息
if (message.role === 'assistant' && message.tool_calls) {
message.tool_calls.forEach((toolCall: any) => {
const correspondingToolMessage = toolMessages[toolCall.id];
if (correspondingToolMessage) {
reorderedMessages.push(correspondingToolMessage);
delete toolMessages[toolCall.id];
}
});
}
});
return reorderedMessages;
}
// 简化:移除验证/统计等辅助方法
}
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import { HistoryTruncateProcessor, getSlicedMessages } from '../HistoryTruncate';
describe('HistoryTruncateProcessor', () => {
describe('getSlicedMessages', () => {
const messages = [
{ id: '1', content: 'First', role: 'user' },
{ id: '2', content: 'Second', role: 'assistant' },
{ id: '3', content: 'Third', role: 'user' },
{ id: '4', content: 'Fourth', role: 'assistant' },
{ id: '5', content: 'Fifth', role: 'user' },
];
it('should return all messages when history count is disabled', () => {
const result = getSlicedMessages(messages, { enableHistoryCount: false });
expect(result).toEqual(messages);
});
it('should return all messages when historyCount is undefined', () => {
const result = getSlicedMessages(messages, {
enableHistoryCount: true,
historyCount: undefined,
});
expect(result).toEqual(messages);
});
it('should return last N messages based on historyCount', () => {
const result = getSlicedMessages(messages, {
enableHistoryCount: true,
historyCount: 2,
});
expect(result).toEqual([
{ id: '4', content: 'Fourth', role: 'assistant' },
{ id: '5', content: 'Fifth', role: 'user' },
]);
});
it('should include new user message in count when includeNewUserMessage is true', () => {
const result = getSlicedMessages(messages, {
enableHistoryCount: true,
historyCount: 3,
});
expect(result).toEqual([
{ id: '3', content: 'Third', role: 'user' },
{ id: '4', content: 'Fourth', role: 'assistant' },
{ id: '5', content: 'Fifth', role: 'user' },
]);
});
it('should return empty array when historyCount is 0', () => {
const result = getSlicedMessages(messages, {
enableHistoryCount: true,
historyCount: 0,
});
expect(result).toEqual([]);
});
it('should return empty array when historyCount is negative', () => {
const result = getSlicedMessages(messages, {
enableHistoryCount: true,
historyCount: -1,
});
expect(result).toEqual([]);
});
it('should return all messages when historyCount exceeds array length', () => {
const result = getSlicedMessages(messages, {
enableHistoryCount: true,
historyCount: 10,
});
expect(result).toEqual(messages);
});
it('should handle empty message array', () => {
const result = getSlicedMessages([], {
enableHistoryCount: true,
historyCount: 2,
});
expect(result).toEqual([]);
});
});
describe('HistoryTruncateProcessor', () => {
it('should truncate messages based on configuration', async () => {
const processor = new HistoryTruncateProcessor({
enableHistoryCount: true,
historyCount: 3,
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{ id: '1', content: 'First', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
{
id: '2',
content: 'Second',
role: 'assistant',
createdAt: Date.now(),
updatedAt: Date.now(),
},
{ id: '3', content: 'Third', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
{
id: '4',
content: 'Fourth',
role: 'assistant',
createdAt: Date.now(),
updatedAt: Date.now(),
},
{ id: '5', content: 'Fifth', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
expect(result.messages).toHaveLength(3); // 2 + 1 for new user message
expect(result.messages).toEqual([
expect.objectContaining({ content: 'Third' }),
expect.objectContaining({ content: 'Fourth' }),
expect.objectContaining({ content: 'Fifth' }),
]);
expect(result.metadata.historyTruncated).toBe(2);
expect(result.metadata.finalMessageCount).toBe(3);
});
it('should not truncate when history count is disabled', async () => {
const processor = new HistoryTruncateProcessor({
enableHistoryCount: false,
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{ id: '1', content: 'First', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
{
id: '2',
content: 'Second',
role: 'assistant',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
expect(result.messages).toHaveLength(2);
expect(result.metadata.historyTruncated).toBe(0);
expect(result.metadata.finalMessageCount).toBe(2);
});
});
});
@@ -0,0 +1,243 @@
import { describe, expect, it, vi } from 'vitest';
import { InputTemplateProcessor } from '../InputTemplate';
describe('InputTemplateProcessor', () => {
it('should apply template to user messages', async () => {
const processor = new InputTemplateProcessor({
inputTemplate: 'Template: {{text}} - End',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'Original user message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: '2',
role: 'assistant',
content: 'Assistant response',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Template: Original user message - End');
expect(result.messages[1].content).toBe('Assistant response'); // Assistant message unchanged
expect(result.metadata.inputTemplateProcessed).toBe(1);
});
it('should skip processing when no template is configured', async () => {
const processor = new InputTemplateProcessor({});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'User message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
expect(result.messages[0].content).toBe('User message'); // Unchanged
expect(result.metadata.inputTemplateProcessed).toBeUndefined();
});
it('should handle template without {{text}} placeholder', async () => {
const processor = new InputTemplateProcessor({
inputTemplate: 'Static template content',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'Original message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Static template content');
expect(result.metadata.inputTemplateProcessed).toBe(1);
});
it('should handle template compilation errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const processor = new InputTemplateProcessor({
inputTemplate: '<%- invalid javascript code %>',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'User message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
// Should skip processing due to compilation error
expect(result.messages[0].content).toBe('User message'); // Original content preserved
expect(result.metadata.inputTemplateProcessed).toBe(0);
consoleSpy.mockRestore();
});
it('should handle template application errors gracefully', async () => {
const processor = new InputTemplateProcessor({
inputTemplate: '{{text}} <%- throw new Error("Application error") %>',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'User message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
// Should keep original message when template application fails
expect(result.messages[0].content).toBe('User message');
expect(result.metadata.inputTemplateProcessed).toBe(0);
});
it('should only process user messages, not assistant messages', async () => {
const processor = new InputTemplateProcessor({
inputTemplate: 'Processed: {{text}}',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'User message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: '2',
role: 'assistant',
content: 'Assistant message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: '3',
role: 'system',
content: 'System message',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Processed: User message');
expect(result.messages[1].content).toBe('Assistant message'); // Unchanged
expect(result.messages[2].content).toBe('System message'); // Unchanged
expect(result.metadata.inputTemplateProcessed).toBe(1);
});
});
@@ -0,0 +1,394 @@
import { ChatImageItem, ChatMessage } from '@lobechat/types';
import { describe, expect, it, vi } from 'vitest';
import type { PipelineContext } from '../../types';
import { MessageContentProcessor } from '../MessageContent';
vi.mock('@lobechat/utils', () => ({
imageUrlToBase64: vi.fn().mockResolvedValue({
base64: 'base64-data',
mimeType: 'image/png',
}),
isLocalUrl: vi.fn((url: string) => url.includes('localhost') || url.includes('127.0.0.1')),
parseDataUri: vi.fn((url: string) => {
if (url.startsWith('data:')) {
return { type: 'data' };
}
return { type: 'url' };
}),
}));
const createContext = (messages: ChatMessage[]): PipelineContext => ({
initialState: { messages: [] } as any,
messages,
metadata: { model: 'gpt-4', provider: 'openai', maxTokens: 100000 },
isAborted: false,
});
const mockIsCanUseVision = vi.fn();
describe('MessageContentProcessor', () => {
describe('Image processing functionality', () => {
it('should return empty content parts if model cannot use vision', async () => {
mockIsCanUseVision.mockReturnValue(false);
const processor = new MessageContentProcessor({
model: 'any-model',
provider: 'any-provider',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'user',
content: 'Hello',
imageList: [{ url: 'image_url', alt: '', id: 'test' } as ChatImageItem],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
// Since vision is not supported, should return plain text content
expect(result.messages[0].content).toBe('Hello');
});
it('should process images if model can use vision', async () => {
mockIsCanUseVision.mockReturnValue(true);
const processor = new MessageContentProcessor({
model: 'gpt-4-vision',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'user',
content: 'Hello',
imageList: [
{ url: 'http://example.com/image.jpg', alt: '', id: 'test' } as ChatImageItem,
],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
// Should return structured content with image
expect(Array.isArray(result.messages[0].content)).toBe(true);
const content = result.messages[0].content as any[];
expect(content).toHaveLength(2);
expect(content[0].type).toBe('text');
expect(content[1].type).toBe('image_url');
expect(content[1].image_url.url).toBe('http://example.com/image.jpg');
});
it('should handle vision disabled scenario correctly', async () => {
mockIsCanUseVision.mockReturnValue(false);
const processor = new MessageContentProcessor({
model: 'text-model',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'user',
content: 'Hello',
imageList: [{ url: 'image_url', alt: '', id: 'test' } as ChatImageItem],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
expect(mockIsCanUseVision).toHaveBeenCalledWith('text-model', 'openai');
// Should return plain text since vision is not supported
expect(result.messages[0].content).toBe('Hello');
});
it('should process local image URLs to base64', async () => {
mockIsCanUseVision.mockReturnValue(true);
const processor = new MessageContentProcessor({
model: 'gpt-4-vision',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'user',
content: 'Hello',
imageList: [
{ url: 'http://localhost:3000/image.jpg', alt: '', id: 'test' } as ChatImageItem,
],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
const content = result.messages[0].content as any[];
expect(content[1].image_url.url).toBe('data:image/png;base64,base64-data');
});
});
describe('Assistant message with images', () => {
it('should handle assistant message with imageList and content', async () => {
mockIsCanUseVision.mockReturnValue(true);
const processor = new MessageContentProcessor({
model: 'gpt-4-vision',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'assistant',
content: 'Here is an image.',
imageList: [
{ id: 'img1', url: 'http://example.com/image.png', alt: 'test.png' } as ChatImageItem,
],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
const content = result.messages[0].content as any[];
expect(content).toEqual([
{ text: 'Here is an image.', type: 'text' },
{ image_url: { detail: 'auto', url: 'http://example.com/image.png' }, type: 'image_url' },
]);
});
it('should handle assistant message with imageList but no content', async () => {
mockIsCanUseVision.mockReturnValue(true);
const processor = new MessageContentProcessor({
model: 'gpt-4-vision',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'assistant',
content: '',
imageList: [
{ id: 'img1', url: 'http://example.com/image.png', alt: 'test.png' } as ChatImageItem,
],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
const content = result.messages[0].content as any[];
expect(content).toEqual([
{ image_url: { detail: 'auto', url: 'http://example.com/image.png' }, type: 'image_url' },
]);
});
});
describe('File context processing', () => {
it('should add file context when enabled', async () => {
mockIsCanUseVision.mockReturnValue(false);
const processor = new MessageContentProcessor({
model: 'gpt-4',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: true },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'user',
content: 'Hello',
imageList: [{ id: 'img1', url: 'http://example.com/image.jpg', alt: 'test.png' }],
fileList: [
{
id: 'file1',
name: 'test.txt',
fileType: 'text/plain',
size: 100,
url: 'http://example.com/test.txt',
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
// Should return structured content when has files and images
expect(Array.isArray(result.messages[0].content)).toBe(true);
const content = result.messages[0].content as any[];
expect(content).toHaveLength(1);
expect(content[0].type).toBe('text');
expect(content[0].text).toContain('SYSTEM CONTEXT');
expect(content[0].text).toContain('Hello');
});
it('should not add file context when disabled', async () => {
mockIsCanUseVision.mockReturnValue(false);
const processor = new MessageContentProcessor({
model: 'gpt-4',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'user',
content: 'Hello',
fileList: [
{
id: 'file1',
name: 'test.txt',
fileType: 'text/plain',
size: 100,
url: 'http://example.com/test.txt',
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
// Should not include file context
expect(result.messages[0].content).toBe('Hello');
});
});
describe('Reasoning/thinking content', () => {
it('should handle assistant messages with reasoning correctly', async () => {
const processor = new MessageContentProcessor({
model: 'gpt-4',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: false },
});
const messages: ChatMessage[] = [
{
id: 'test',
role: 'assistant',
content: 'The answer is 42.',
reasoning: {
content: 'I need to calculate the answer to life, universe, and everything.',
signature: 'thinking_process',
},
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
const content = result.messages[0].content as any[];
expect(content).toEqual([
{
signature: 'thinking_process',
thinking: 'I need to calculate the answer to life, universe, and everything.',
type: 'thinking',
},
{
text: 'The answer is 42.',
type: 'text',
},
]);
});
});
describe('Message processing metadata', () => {
it('should update processing metadata correctly', async () => {
mockIsCanUseVision.mockReturnValue(false);
const processor = new MessageContentProcessor({
model: 'gpt-4',
provider: 'openai',
isCanUseVision: mockIsCanUseVision,
fileContext: { enabled: true },
});
const messages: ChatMessage[] = [
{
id: 'test1',
role: 'user',
content: 'Hello',
imageList: [{ id: 'img1', url: 'http://example.com/image.jpg', alt: 'test.png' }],
fileList: [
{
id: 'file1',
name: 'test.txt',
fileType: 'text/plain',
size: 100,
url: 'http://example.com/test.txt',
},
],
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
{
id: 'test2',
role: 'assistant',
content: 'Response',
reasoning: {
content: 'Thinking...',
signature: 'thinking',
},
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {},
},
];
const result = await processor.process(createContext(messages));
expect(result.metadata.messageContentProcessed).toBe(2);
expect(result.metadata.userMessagesProcessed).toBe(1);
expect(result.metadata.assistantMessagesProcessed).toBe(1);
});
});
});
@@ -0,0 +1,334 @@
import { describe, expect, it } from 'vitest';
import {
PlaceholderVariablesProcessor,
parsePlaceholderVariables,
parsePlaceholderVariablesMessages,
} from '../PlaceholderVariables';
describe('PlaceholderVariablesProcessor', () => {
const mockVariableGenerators = {
date: () => '2023-12-25',
time: () => '14:30:45',
username: () => 'TestUser',
random: () => '12345',
nested: () => 'Value with {{date}} inside',
};
describe('parsePlaceholderVariables', () => {
it('should replace simple placeholder variables', () => {
const text = 'Today is {{date}} and the time is {{time}}';
const result = parsePlaceholderVariables(text, mockVariableGenerators);
expect(result).toBe('Today is 2023-12-25 and the time is 14:30:45');
});
it('should handle missing variables gracefully', () => {
const text = 'Hello {{username}}, missing: {{missing}}';
const result = parsePlaceholderVariables(text, mockVariableGenerators);
expect(result).toBe('Hello TestUser, missing: {{missing}}');
});
it('should handle nested variables with recursion', () => {
const text = 'Nested: {{nested}}';
const result = parsePlaceholderVariables(text, mockVariableGenerators);
expect(result).toBe('Nested: Value with 2023-12-25 inside');
});
it('should respect depth limit', () => {
const text = 'Nested: {{nested}}';
const result = parsePlaceholderVariables(text, mockVariableGenerators, 1);
expect(result).toBe('Nested: Value with {{date}} inside');
});
it('should handle empty text', () => {
const text = '';
const result = parsePlaceholderVariables(text, mockVariableGenerators);
expect(result).toBe('');
});
it('should handle text without placeholders', () => {
const text = 'No placeholders here';
const result = parsePlaceholderVariables(text, mockVariableGenerators);
expect(result).toBe('No placeholders here');
});
});
describe('parsePlaceholderVariablesMessages', () => {
it('should process string content messages', () => {
const messages = [
{
id: '1',
role: 'user',
content: 'Hello {{username}}, today is {{date}}',
},
{
id: '2',
role: 'assistant',
content: 'Hi there! The time is {{time}}',
},
];
const result = parsePlaceholderVariablesMessages(messages, mockVariableGenerators);
expect(result).toEqual([
{
id: '1',
role: 'user',
content: 'Hello TestUser, today is 2023-12-25',
},
{
id: '2',
role: 'assistant',
content: 'Hi there! The time is 14:30:45',
},
]);
});
it('should process array content messages with text parts', () => {
const messages = [
{
id: '1',
role: 'user',
content: [
{
type: 'text',
text: 'Hello {{username}}, today is {{date}}',
},
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,abc123' },
},
],
},
];
const result = parsePlaceholderVariablesMessages(messages, mockVariableGenerators);
expect(result).toEqual([
{
id: '1',
role: 'user',
content: [
{
type: 'text',
text: 'Hello TestUser, today is 2023-12-25',
},
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,abc123' },
},
],
},
]);
});
it('should skip messages without content', () => {
const messages = [
{
id: '1',
role: 'user',
},
{
id: '2',
role: 'assistant',
content: null,
},
];
const result = parsePlaceholderVariablesMessages(messages, mockVariableGenerators);
expect(result).toEqual(messages);
});
it('should handle mixed content types', () => {
const messages = [
{
id: '1',
role: 'user',
content: 'Simple {{username}} message',
},
{
id: '2',
role: 'user',
content: [{ type: 'text', text: 'Complex {{date}} message' }],
},
{
id: '3',
role: 'assistant',
content: { type: 'object', data: 'not processed' },
},
];
const result = parsePlaceholderVariablesMessages(messages, mockVariableGenerators);
expect(result).toEqual([
{
id: '1',
role: 'user',
content: 'Simple TestUser message',
},
{
id: '2',
role: 'user',
content: [{ type: 'text', text: 'Complex 2023-12-25 message' }],
},
{
id: '3',
role: 'assistant',
content: { type: 'object', data: 'not processed' },
},
]);
});
});
describe('PlaceholderVariablesProcessor', () => {
it('should process messages through the processor', async () => {
const processor = new PlaceholderVariablesProcessor({
variableGenerators: mockVariableGenerators,
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'Hello {{username}}, today is {{date}}',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
executedProcessors: [],
};
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Hello TestUser, today is 2023-12-25');
expect(result.metadata.placeholderVariablesProcessed).toBe(1);
});
it('should handle processing errors gracefully', async () => {
const faultyGenerators = {
error: () => {
throw new Error('Generator error');
},
working: () => 'works',
};
const processor = new PlaceholderVariablesProcessor({
variableGenerators: faultyGenerators,
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'This {{working}} but this {{error}} fails',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
executedProcessors: [],
};
// Should not throw, but continue processing
const result = await processor.process(context);
expect(result.messages).toHaveLength(1);
});
it('should use custom depth setting', async () => {
const processor = new PlaceholderVariablesProcessor({
variableGenerators: mockVariableGenerators,
depth: 1,
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'Nested: {{nested}}',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
executedProcessors: [],
};
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Nested: Value with {{date}} inside');
});
it('should not modify messages that do not need processing', async () => {
const processor = new PlaceholderVariablesProcessor({
variableGenerators: mockVariableGenerators,
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'No variables here',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
executedProcessors: [],
};
const result = await processor.process(context);
expect(result.metadata.placeholderVariablesProcessed).toBe(0);
});
});
});
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { ToolMessageReorder } from '../ToolMessageReorder';
const createContext = (messages: any[]): PipelineContext => ({
initialState: { messages: [] } as any,
messages,
metadata: { model: 'gpt-4', maxTokens: 4096 },
isAborted: false,
});
describe('ToolMessageReorder', () => {
it('should place tool messages right after their assistant calls and drop invalid tools', async () => {
const proc = new ToolMessageReorder();
const messages = [
{ id: 'u1', role: 'user', content: 'hi' },
{
id: 'a1',
role: 'assistant',
content: 'calling',
tool_calls: [
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } },
],
},
{ id: 't1', role: 'tool', content: '{"ok":1}', tool_call_id: 'call_1' },
{ id: 't_invalid', role: 'tool', content: '{"ok":0}' },
];
const ctx = createContext(messages);
const res = await proc.process(ctx);
expect(res.messages.map((m) => m.id)).toEqual(['u1', 'a1', 't1']);
});
it('should reorderToolMessages', async () => {
const proc = new ToolMessageReorder();
const messages = [
{
content: '## Tools\n\nYou can use these tools',
role: 'system',
},
{
content: '',
role: 'assistant',
tool_calls: [
{
function: {
arguments:
'{"query":"LobeChat","searchEngines":["brave","google","duckduckgo","qwant"]}',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
},
id: 'call_6xCmrOtFOyBAcqpqO1TGfw2B',
type: 'function',
},
{
function: {
arguments:
'{"query":"LobeChat","searchEngines":["brave","google","duckduckgo","qwant"]}',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
},
id: 'tool_call_nXxXHW8Z',
type: 'function',
},
],
},
{
content: '[]',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
role: 'tool',
tool_call_id: 'call_6xCmrOtFOyBAcqpqO1TGfw2B',
},
{
content: 'LobeHub 是一个专注于设计和开发现代人工智能生成内容(AIGC)工具和组件的团队。',
role: 'assistant',
},
{
content: '[]',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
role: 'tool',
tool_call_id: 'tool_call_nXxXHW8Z',
},
{
content: '[]',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
role: 'tool',
tool_call_id: 'tool_call_2f3CEKz9',
},
{
content: '### LobeHub 智能AI聚合神器\n\nLobeHub 是一个强大的AI聚合平台',
role: 'assistant',
},
];
const ctx = createContext(messages);
const output = await proc.process(ctx);
expect(output.messages).toEqual([
{
content: '## Tools\n\nYou can use these tools',
role: 'system',
},
{
content: '',
role: 'assistant',
tool_calls: [
{
function: {
arguments:
'{"query":"LobeChat","searchEngines":["brave","google","duckduckgo","qwant"]}',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
},
id: 'call_6xCmrOtFOyBAcqpqO1TGfw2B',
type: 'function',
},
{
function: {
arguments:
'{"query":"LobeChat","searchEngines":["brave","google","duckduckgo","qwant"]}',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
},
id: 'tool_call_nXxXHW8Z',
type: 'function',
},
],
},
{
content: '[]',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
role: 'tool',
tool_call_id: 'call_6xCmrOtFOyBAcqpqO1TGfw2B',
},
{
content: '[]',
name: 'lobe-web-browsing____searchWithSearXNG____builtin',
role: 'tool',
tool_call_id: 'tool_call_nXxXHW8Z',
},
{
content: 'LobeHub 是一个专注于设计和开发现代人工智能生成内容(AIGC)工具和组件的团队。',
role: 'assistant',
},
{
content: '### LobeHub 智能AI聚合神器\n\nLobeHub 是一个强大的AI聚合平台',
role: 'assistant',
},
]);
});
it('should correctly reorder when a tool message appears before the assistant message', async () => {
const messages = [
{
role: 'system',
content: 'System message',
},
{
role: 'tool',
tool_call_id: 'tool_call_1',
name: 'test-plugin____testApi',
content: 'Tool result',
},
{
role: 'assistant',
content: '',
tool_calls: [
{ id: 'tool_call_1', type: 'function', function: { name: 'testApi', arguments: '{}' } },
],
},
];
const proc = new ToolMessageReorder();
const ctx = createContext(messages);
const { messages: output } = await proc.process(ctx);
// Verify reordering logic works and covers line 688 hasPushed check
// In this test, tool messages are duplicated but the second occurrence is skipped
expect(output.length).toBe(4); // Original has 3, assistant will add corresponding tool message again
expect(output[0].role).toBe('system');
expect(output[1].role).toBe('tool');
expect(output[2].role).toBe('assistant');
expect(output[3].role).toBe('tool'); // Tool message added by assistant's tool_calls
});
});
@@ -0,0 +1,15 @@
// Transformer processors
export { HistoryTruncateProcessor } from './HistoryTruncate';
export { InputTemplateProcessor } from './InputTemplate';
export { MessageCleanupProcessor } from './MessageCleanup';
export { MessageContentProcessor } from './MessageContent';
export { PlaceholderVariablesProcessor } from './PlaceholderVariables';
export { ToolCallProcessor } from './ToolCall';
export { ToolMessageReorder } from './ToolMessageReorder';
// Re-export types
export type { HistoryTruncateConfig } from './HistoryTruncate';
export type { InputTemplateConfig } from './InputTemplate';
export type { MessageContentConfig, UserMessageContentPart } from './MessageContent';
export type { PlaceholderVariablesConfig } from './PlaceholderVariables';
export type { ToolCallConfig } from './ToolCall';
@@ -0,0 +1,102 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:HistorySummaryProvider');
/**
* History Summary Configuration
*/
export interface HistorySummaryConfig {
/** History summary template function */
formatHistorySummary?: (summary: string) => string;
/** History summary content */
historySummary?: string;
}
/**
* Default history summary formatter function
*/
const defaultHistorySummaryFormatter = (historySummary: string): string => `<chat_history_summary>
<docstring>Users may have lots of chat messages, here is the summary of the history:</docstring>
<summary>${historySummary}</summary>
</chat_history_summary>`;
/**
* History Summary Provider
* Responsible for injecting history conversation summary into system messages
*/
export class HistorySummaryProvider extends BaseProvider {
readonly name = 'HistorySummaryProvider';
constructor(
private config: HistorySummaryConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// 检查是否有历史摘要
if (!this.config.historySummary) {
log('No history summary content, skipping processing');
return this.markAsExecuted(clonedContext);
}
// 格式化历史摘要
const formattedSummary = this.formatHistorySummary(this.config.historySummary);
// 注入历史摘要
this.injectHistorySummary(clonedContext, formattedSummary);
// 更新元数据
clonedContext.metadata.historySummary = {
formattedLength: formattedSummary.length,
injected: true,
originalLength: this.config.historySummary.length,
};
log(
`History summary injection completed, original length: ${this.config.historySummary.length}, formatted length: ${formattedSummary.length}`,
);
return this.markAsExecuted(clonedContext);
}
/**
* Format history summary
*/
private formatHistorySummary(historySummary: string): string {
const formatter = this.config.formatHistorySummary || defaultHistorySummaryFormatter;
return formatter(historySummary);
}
/**
* Inject history summary to system message
*/
private injectHistorySummary(context: PipelineContext, formattedSummary: string): void {
const existingSystemMessage = context.messages.find((msg) => msg.role === 'system');
if (existingSystemMessage) {
// 合并到现有系统消息
existingSystemMessage.content = [existingSystemMessage.content, formattedSummary]
.filter(Boolean)
.join('\n\n');
log(
`History summary merged to existing system message, final length: ${existingSystemMessage.content.length}`,
);
} else {
// 创建新的系统消息
const systemMessage = {
content: formattedSummary,
role: 'system' as const,
};
context.messages.unshift(systemMessage as any);
log(`New history summary system message created, content length: ${formattedSummary.length}`);
}
}
}
@@ -0,0 +1,102 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:InboxGuideProvider');
/**
* Inbox Guide System Role Configuration
*/
export interface InboxGuideConfig {
/** Inbox guide system role content */
inboxGuideSystemRole: string;
/** Inbox session ID constant */
inboxSessionId: string;
/** Whether it's a welcome question */
isWelcomeQuestion?: boolean;
/** Session ID */
sessionId?: string;
}
/**
* Inbox Guide Provider
* Responsible for injecting guide system role for welcome questions in inbox sessions
*/
export class InboxGuideProvider extends BaseProvider {
readonly name = 'InboxGuideProvider';
constructor(
private config: InboxGuideConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// 检查是否需要注入收件箱引导
const shouldInject = this.shouldInjectInboxGuide();
if (!shouldInject) {
log('Inbox guide injection conditions not met, skipping processing');
return this.markAsExecuted(clonedContext);
}
// 注入收件箱引导系统角色
this.injectInboxGuideSystemRole(clonedContext);
// 更新元数据
clonedContext.metadata.inboxGuide = {
contentLength: this.config.inboxGuideSystemRole.length,
injected: true,
isWelcomeQuestion: this.config.isWelcomeQuestion,
sessionId: this.config.sessionId,
};
log('Inbox guide system role injection completed');
return this.markAsExecuted(clonedContext);
}
/**
* Check if inbox guide should be injected
*/
private shouldInjectInboxGuide(): boolean {
return (
(this.config.isWelcomeQuestion &&
this.config.sessionId === this.config.inboxSessionId &&
!!this.config.inboxGuideSystemRole) ||
false
);
}
/**
* Inject inbox guide system role
*/
private injectInboxGuideSystemRole(context: PipelineContext): void {
const existingSystemMessage = context.messages.find((msg) => msg.role === 'system');
if (existingSystemMessage) {
// 合并到现有系统消息
existingSystemMessage.content = [
existingSystemMessage.content,
this.config.inboxGuideSystemRole,
]
.filter(Boolean)
.join('\n\n');
log(
`Inbox guide merged to existing system message, final length: ${existingSystemMessage.content.length}`,
);
} else {
context.messages.unshift({
content: this.config.inboxGuideSystemRole,
role: 'system' as const,
} as any);
log(
`New inbox guide system message created, content length: ${this.config.inboxGuideSystemRole.length}`,
);
}
}
}
@@ -0,0 +1,64 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:SystemRoleInjector');
export interface SystemRoleInjectorConfig {
/** System role content to inject */
systemRole?: string;
}
/**
* System Role Injector
* Responsible for injecting system role messages at the beginning of the conversation
*/
export class SystemRoleInjector extends BaseProvider {
readonly name = 'SystemRoleInjector';
constructor(
private config: SystemRoleInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// Skip injection if no system role is configured
if (!this.config.systemRole || this.config.systemRole.trim() === '') {
log('No system role configured, skipping injection');
return this.markAsExecuted(clonedContext);
}
// Check if system role already exists at the beginning
const hasExistingSystemRole =
clonedContext.messages.length > 0 && clonedContext.messages[0].role === 'system';
if (hasExistingSystemRole) {
log('System role already exists, skipping injection');
return this.markAsExecuted(clonedContext);
}
// Inject system role at the beginning
const systemMessage = {
content: this.config.systemRole,
createdAt: Date.now(),
id: `system-${Date.now()}`,
meta: {},
role: 'system' as const,
updatedAt: Date.now(),
};
clonedContext.messages.unshift(systemMessage);
// Update metadata
clonedContext.metadata.systemRoleInjected = true;
log(`System role injected: "${this.config.systemRole.slice(0, 50)}..."`);
return this.markAsExecuted(clonedContext);
}
}
@@ -0,0 +1,118 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:ToolSystemRoleProvider');
/**
* Tool System Role Configuration
*/
export interface ToolSystemRoleConfig {
/** Function to get tool system roles */
getToolSystemRoles: (tools: any[]) => string | undefined;
/** Function to check if function calling is supported */
isCanUseFC: (model: string, provider: string) => boolean | undefined;
/** Model name */
model: string;
/** Provider name */
provider: string;
/** Available tools list */
tools?: any[];
}
/**
* Tool System Role Provider
* Responsible for injecting tool-related system roles for models that support tool calling
*/
export class ToolSystemRoleProvider extends BaseProvider {
readonly name = 'ToolSystemRoleProvider';
constructor(
private config: ToolSystemRoleConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// 检查工具相关条件
const toolSystemRole = this.getToolSystemRole();
if (!toolSystemRole) {
log('No need to inject tool system role, skipping processing');
return this.markAsExecuted(clonedContext);
}
// 注入工具系统角色
this.injectToolSystemRole(clonedContext, toolSystemRole);
// 更新元数据
clonedContext.metadata.toolSystemRole = {
contentLength: toolSystemRole.length,
injected: true,
supportsFunctionCall: this.config.isCanUseFC(this.config.model, this.config.provider),
toolsCount: this.config.tools?.length || 0,
};
log(`Tool system role injection completed, tools count: ${this.config.tools?.length || 0}`);
return this.markAsExecuted(clonedContext);
}
/**
* Get tool system role content
*/
private getToolSystemRole(): string | undefined {
const { tools, model, provider } = this.config;
// 检查是否有工具
const hasTools = tools && tools.length > 0;
if (!hasTools) {
log('No available tools');
return undefined;
}
// 检查是否支持函数调用
const hasFC = this.config.isCanUseFC(model, provider);
if (!hasFC) {
log(`Model ${model} (${provider}) does not support function calling`);
return undefined;
}
// 获取工具系统角色
const toolSystemRole = this.config.getToolSystemRoles(tools);
if (!toolSystemRole) {
log('Failed to get tool system role content');
return undefined;
}
return toolSystemRole;
}
/**
* Inject tool system role
*/
private injectToolSystemRole(context: PipelineContext, toolSystemRole: string): void {
const existingSystemMessage = context.messages.find((msg) => msg.role === 'system');
if (existingSystemMessage) {
// 合并到现有系统消息
existingSystemMessage.content = [existingSystemMessage.content, toolSystemRole]
.filter(Boolean)
.join('\n\n');
log(
`Tool system role merged to existing system message, final length: ${existingSystemMessage.content.length}`,
);
} else {
context.messages.unshift({
content: toolSystemRole,
id: `tool-system-role-${Date.now()}`,
role: 'system' as const,
} as any);
log(`New tool system message created, content length: ${toolSystemRole.length}`);
}
}
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { HistorySummaryProvider } from '../HistorySummary';
const createContext = (messages: any[]): PipelineContext => ({
initialState: { messages: [] } as any,
messages,
metadata: { model: 'gpt-4', maxTokens: 4096 },
isAborted: false,
});
describe('HistorySummaryProvider', () => {
const mockHistorySummary = 'User discussed AI topics previously';
it('should inject history summary with default formatting', async () => {
const provider = new HistorySummaryProvider({
historySummary: mockHistorySummary,
});
const messages = [{ id: 'u1', role: 'user', content: 'Continue our discussion' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should have system message with formatted history summary
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeDefined();
expect(systemMessage!.content).toContain('<chat_history_summary>');
expect(systemMessage!.content).toContain(
'<docstring>Users may have lots of chat messages, here is the summary of the history:</docstring>',
);
expect(systemMessage!.content).toContain(`<summary>${mockHistorySummary}</summary>`);
expect(systemMessage!.content).toContain('</chat_history_summary>');
// Should update metadata
expect(result.metadata.historySummary).toEqual({
injected: true,
originalLength: mockHistorySummary.length,
formattedLength: systemMessage!.content.length,
});
});
it('should inject history summary with custom formatting', async () => {
const customFormatter = (summary: string) => `## History\n${summary}`;
const provider = new HistorySummaryProvider({
historySummary: mockHistorySummary,
formatHistorySummary: customFormatter,
});
const messages = [{ id: 'u1', role: 'user', content: 'Continue our discussion' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should have system message with custom formatted history summary
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeDefined();
expect(systemMessage!.content).toBe(`## History\n${mockHistorySummary}`);
});
it('should merge history summary with existing system message', async () => {
const provider = new HistorySummaryProvider({
historySummary: mockHistorySummary,
});
const existingSystemContent = 'You are a helpful assistant.';
const messages = [
{ id: 's1', role: 'system', content: existingSystemContent },
{ id: 'u1', role: 'user', content: 'Continue our discussion' },
];
const ctx = createContext(messages);
const result = await provider.process(ctx);
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage!.content).toContain(existingSystemContent);
expect(systemMessage!.content).toContain('<chat_history_summary>');
expect(systemMessage!.content).toContain(mockHistorySummary);
});
it('should skip injection when no history summary is provided', async () => {
const provider = new HistorySummaryProvider({});
const messages = [{ id: 'u1', role: 'user', content: 'Hello' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
// Should not have metadata
expect(result.metadata.historySummary).toBeUndefined();
});
it('should skip injection when history summary is empty', async () => {
const provider = new HistorySummaryProvider({
historySummary: '',
});
const messages = [{ id: 'u1', role: 'user', content: 'Hello' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
});
});
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { InboxGuideProvider } from '../InboxGuide';
const createContext = (messages: any[]): PipelineContext => ({
initialState: { messages: [] } as any,
messages,
metadata: { model: 'gpt-4', maxTokens: 4096 },
isAborted: false,
});
describe('InboxGuideProvider', () => {
const mockInboxGuideContent = '# Role: LobeChat Support Assistant\n\nWelcome to LobeChat!';
const INBOX_SESSION_ID = 'inbox';
it('should inject inbox guide for welcome questions in inbox session', async () => {
const provider = new InboxGuideProvider({
isWelcomeQuestion: true,
sessionId: INBOX_SESSION_ID,
inboxSessionId: INBOX_SESSION_ID,
inboxGuideSystemRole: mockInboxGuideContent,
});
const messages = [{ id: 'u1', role: 'user', content: 'Hello, this is my first question' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should have system message with inbox guide content
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeDefined();
expect(systemMessage!.content).toBe(mockInboxGuideContent);
// Should update metadata
expect(result.metadata.inboxGuide).toEqual({
injected: true,
sessionId: INBOX_SESSION_ID,
isWelcomeQuestion: true,
contentLength: mockInboxGuideContent.length,
});
});
it('should merge inbox guide with existing system message', async () => {
const provider = new InboxGuideProvider({
isWelcomeQuestion: true,
sessionId: INBOX_SESSION_ID,
inboxSessionId: INBOX_SESSION_ID,
inboxGuideSystemRole: mockInboxGuideContent,
});
const existingSystemContent = 'Existing system message';
const messages = [
{ id: 's1', role: 'system', content: existingSystemContent },
{ id: 'u1', role: 'user', content: 'Hello' },
];
const ctx = createContext(messages);
const result = await provider.process(ctx);
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage!.content).toBe(`${existingSystemContent}\n\n${mockInboxGuideContent}`);
});
it('should skip injection when not welcome question', async () => {
const provider = new InboxGuideProvider({
isWelcomeQuestion: false,
sessionId: INBOX_SESSION_ID,
inboxSessionId: INBOX_SESSION_ID,
inboxGuideSystemRole: mockInboxGuideContent,
});
const messages = [{ id: 'u1', role: 'user', content: 'Regular question' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
// Should not have metadata
expect(result.metadata.inboxGuide).toBeUndefined();
});
it('should skip injection when not in inbox session', async () => {
const provider = new InboxGuideProvider({
isWelcomeQuestion: true,
sessionId: 'other-session',
inboxSessionId: INBOX_SESSION_ID,
inboxGuideSystemRole: mockInboxGuideContent,
});
const messages = [{ id: 'u1', role: 'user', content: 'Hello' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
});
it('should skip injection when no inbox guide content', async () => {
const provider = new InboxGuideProvider({
isWelcomeQuestion: true,
sessionId: INBOX_SESSION_ID,
inboxSessionId: INBOX_SESSION_ID,
inboxGuideSystemRole: '',
});
const messages = [{ id: 'u1', role: 'user', content: 'Hello' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
});
});
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';
import { SystemRoleInjector } from '../SystemRoleInjector';
describe('SystemRoleInjector', () => {
it('should inject system role at the beginning of messages', async () => {
const provider = new SystemRoleInjector({
systemRole: 'You are a helpful assistant.',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'Hello',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await provider.process(context);
expect(result.messages).toHaveLength(2);
expect(result.messages[0]).toEqual(
expect.objectContaining({
content: 'You are a helpful assistant.',
role: 'system',
}),
);
expect(result.messages[1]).toEqual(
expect.objectContaining({
content: 'Hello',
role: 'user',
}),
);
expect(result.metadata.systemRoleInjected).toBe(true);
});
it('should skip injection when no system role is configured', async () => {
const provider = new SystemRoleInjector({
systemRole: '',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'Hello',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await provider.process(context);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
expect(result.metadata.systemRoleInjected).toBeUndefined();
});
it('should skip injection when system role already exists', async () => {
const provider = new SystemRoleInjector({
systemRole: 'You are a helpful assistant.',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: 'system-1',
role: 'system',
content: 'Existing system role',
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: '1',
role: 'user',
content: 'Hello',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await provider.process(context);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content).toBe('Existing system role');
expect(result.metadata.systemRoleInjected).toBeUndefined();
});
it('should handle whitespace-only system role', async () => {
const provider = new SystemRoleInjector({
systemRole: ' \n \t ',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [
{
id: '1',
role: 'user',
content: 'Hello',
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await provider.process(context);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
expect(result.metadata.systemRoleInjected).toBeUndefined();
});
it('should handle empty message array', async () => {
const provider = new SystemRoleInjector({
systemRole: 'You are a helpful assistant.',
});
const context = {
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
messages: [],
metadata: {
model: 'gpt-4',
maxTokens: 4096,
},
isAborted: false,
};
const result = await provider.process(context);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]).toEqual(
expect.objectContaining({
content: 'You are a helpful assistant.',
role: 'system',
}),
);
expect(result.metadata.systemRoleInjected).toBe(true);
});
});
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { ToolSystemRoleProvider } from '../ToolSystemRole';
const createContext = (messages: any[]): PipelineContext => ({
initialState: { messages: [] } as any,
messages,
metadata: { model: 'gpt-4', maxTokens: 4096 },
isAborted: false,
});
describe('ToolSystemRoleProvider', () => {
const mockToolSystemRole = 'You have access to the following tools:\n- calculator\n- weather';
it('should inject tool system role when tools are available and FC is supported', async () => {
const mockGetToolSystemRoles = (tools: any[]) => mockToolSystemRole;
const mockIsCanUseFC = (model: string, provider: string) => true;
const provider = new ToolSystemRoleProvider({
tools: ['calculator', 'weather'],
model: 'gpt-4',
provider: 'openai',
getToolSystemRoles: mockGetToolSystemRoles,
isCanUseFC: mockIsCanUseFC,
});
const messages = [{ id: 'u1', role: 'user', content: 'What is 2+2?' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should have system message with tool system role
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeDefined();
expect(systemMessage!.content).toBe(mockToolSystemRole);
// Should update metadata
expect(result.metadata.toolSystemRole).toEqual({
injected: true,
toolsCount: 2,
supportsFunctionCall: true,
contentLength: mockToolSystemRole.length,
});
});
it('should merge tool system role with existing system message', async () => {
const mockGetToolSystemRoles = (tools: any[]) => mockToolSystemRole;
const mockIsCanUseFC = (model: string, provider: string) => true;
const provider = new ToolSystemRoleProvider({
tools: ['calculator'],
model: 'gpt-4',
provider: 'openai',
getToolSystemRoles: mockGetToolSystemRoles,
isCanUseFC: mockIsCanUseFC,
});
const existingSystemContent = 'You are a helpful assistant.';
const messages = [
{ id: 's1', role: 'system', content: existingSystemContent },
{ id: 'u1', role: 'user', content: 'Calculate something' },
];
const ctx = createContext(messages);
const result = await provider.process(ctx);
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage!.content).toBe(`${existingSystemContent}\n\n${mockToolSystemRole}`);
});
it('should skip injection when no tools are available', async () => {
const mockGetToolSystemRoles = (tools: any[]) => mockToolSystemRole;
const mockIsCanUseFC = (model: string, provider: string) => true;
const provider = new ToolSystemRoleProvider({
tools: [],
model: 'gpt-4',
provider: 'openai',
getToolSystemRoles: mockGetToolSystemRoles,
isCanUseFC: mockIsCanUseFC,
});
const messages = [{ id: 'u1', role: 'user', content: 'Hello' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
// Should not have metadata
expect(result.metadata.toolSystemRole).toBeUndefined();
});
it('should skip injection when function calling is not supported', async () => {
const mockGetToolSystemRoles = (tools: any[]) => mockToolSystemRole;
const mockIsCanUseFC = (model: string, provider: string) => false;
const provider = new ToolSystemRoleProvider({
tools: ['calculator'],
model: 'gpt-3.5-turbo',
provider: 'openai',
getToolSystemRoles: mockGetToolSystemRoles,
isCanUseFC: mockIsCanUseFC,
});
const messages = [{ id: 'u1', role: 'user', content: 'Calculate something' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
});
it('should skip injection when getToolSystemRoles returns undefined', async () => {
const mockGetToolSystemRoles = (tools: any[]) => undefined;
const mockIsCanUseFC = (model: string, provider: string) => true;
const provider = new ToolSystemRoleProvider({
tools: ['calculator'],
model: 'gpt-4',
provider: 'openai',
getToolSystemRoles: mockGetToolSystemRoles,
isCanUseFC: mockIsCanUseFC,
});
const messages = [{ id: 'u1', role: 'user', content: 'Calculate something' }];
const ctx = createContext(messages);
const result = await provider.process(ctx);
// Should not have system message
const systemMessage = result.messages.find((msg) => msg.role === 'system');
expect(systemMessage).toBeUndefined();
});
});
@@ -0,0 +1,11 @@
// Context Provider exports
export { HistorySummaryProvider } from './HistorySummary';
export { InboxGuideProvider } from './InboxGuide';
export { SystemRoleInjector } from './SystemRoleInjector';
export { ToolSystemRoleProvider } from './ToolSystemRole';
// Re-export types
export type { HistorySummaryConfig } from './HistorySummary';
export type { InboxGuideConfig } from './InboxGuide';
export type { SystemRoleInjectorConfig } from './SystemRoleInjector';
export type { ToolSystemRoleConfig } from './ToolSystemRole';
+201
View File
@@ -0,0 +1,201 @@
import { ChatMessage } from '@lobechat/types';
/**
* 智能体状态 - 从原项目类型推断
*/
export interface AgentState {
[key: string]: any;
messages: ChatMessage[];
model?: string;
provider?: string;
systemRole?: string;
tools?: string[];
}
/**
* 聊天图像项
*/
export interface ChatImageItem {
alt?: string;
id: string;
url: string;
}
/**
* 消息工具调用
*/
export interface MessageToolCall {
function: {
arguments: string;
name: string;
};
id: string;
type: 'function';
}
export interface Message {
[key: string]: any;
content: string | any[];
role: string;
}
/**
* 管道上下文 - 在管道中流动的核心数据结构
*/
export interface PipelineContext {
/** 中止原因 */
abortReason?: string;
/** 不可变的输入状态 */
readonly initialState: AgentState;
/** 允许处理器提前终止管道 */
isAborted: boolean;
/** 正在构建的可变消息列表 */
messages: Message[];
/** 处理器间通信的元数据 */
metadata: {
/** 其他自定义元数据 */
[key: string]: any;
/** 当前 token 估算值 */
currentTokenCount?: number;
/** 最大 token 限制 */
maxTokens: number;
/** 模型标识 */
model: string;
};
}
/**
* 上下文处理器接口 - 管道中处理站的标准化接口
*/
export interface ContextProcessor {
/** 处理器名称,用于调试和日志 */
name: string;
/** 核心处理方法 */
process: (context: PipelineContext) => Promise<PipelineContext>;
}
/**
* 处理器配置选项
*/
export interface ProcessorOptions {
/** 是否启用调试模式 */
debug?: boolean;
/** 自定义日志函数 */
logger?: (message: string, level?: 'info' | 'warn' | 'error') => void;
}
/**
* 管道执行结果
*/
export interface PipelineResult {
/** 中止原因 */
abortReason?: string;
/** 是否被中止 */
isAborted: boolean;
/** 最终处理的消息 */
messages: any[];
/** 处理过程中的元数据 */
metadata: Record<string, any>;
/** 执行统计 */
stats: {
/** 处理的处理器数量 */
processedCount: number;
/** 各处理器执行时间 */
processorDurations: Record<string, number>;
/** 总处理时间 */
totalDuration: number;
};
}
/**
* Processor type enum
*/
export enum ProcessorType {
/** Processor type */
PROCESSOR = 'processor',
}
/** Legacy processor type - kept for backward compatibility */
export type ProcessorTypeLegacy =
| 'injector'
| 'transformer'
| 'validator'
| 'optimizer'
| 'processor';
/**
* Token 计数器接口
*/
export interface TokenCounter {
count: (messages: ChatMessage[] | string) => Promise<number>;
}
/**
* 文件上下文信息
*/
export interface FileContext {
addUrl?: boolean;
fileList?: string[];
imageList?: ChatImageItem[];
}
/**
* RAG 检索块
*/
export interface RetrievalChunk {
content: string;
id: string;
metadata?: Record<string, any>;
similarity: number;
}
/**
* RAG 上下文
*/
export interface RAGContext {
chunks: RetrievalChunk[];
queryId?: string;
rewriteQuery?: string;
}
/**
* 模型能力
*/
export interface ModelCapabilities {
supportsFunctionCall: boolean;
supportsReasoning: boolean;
supportsSearch: boolean;
supportsVision: boolean;
}
/**
* 处理器错误
*/
export class ProcessorError extends Error {
constructor(
public processorName: string,
message: string,
public originalError?: Error,
) {
super(`[${processorName}] ${message}`);
this.name = 'ProcessorError';
}
}
/**
* 管道错误
*/
export class PipelineError extends Error {
constructor(
message: string,
public processorName?: string,
public originalError?: Error,
) {
super(message);
this.name = 'PipelineError';
}
}
export type { ChatMessage } from '@lobechat/types';
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'lcov', 'text-summary'],
},
environment: 'happy-dom',
},
});
@@ -1,4 +1,4 @@
const historySummaryPrompt = (historySummary: string) => `<chat_history_summary>
export const historySummaryPrompt = (historySummary: string) => `<chat_history_summary>
<docstring>Users may have lots of chat messages, here is the summary of the history:</docstring>
<summary>${historySummary}</summary>
</chat_history_summary>
+2
View File
@@ -8,4 +8,6 @@ export * from './parseModels';
export * from './pricing';
export * from './safeParseJSON';
export * from './sleep';
export * from './uriParser';
export * from './url';
export * from './uuid';
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { parseDataUri } from './uriParser';
describe('parseDataUri', () => {
it('should parse a valid data URI', () => {
const dataUri = 'data:image/png;base64,abc';
const result = parseDataUri(dataUri);
expect(result).toEqual({ base64: 'abc', mimeType: 'image/png', type: 'base64' });
});
it('should parse a valid URL', () => {
const url = 'https://example.com/image.jpg';
const result = parseDataUri(url);
expect(result).toEqual({ base64: null, mimeType: null, type: 'url' });
});
it('should return null for an invalid input', () => {
const invalidInput = 'invalid-data';
const result = parseDataUri(invalidInput);
expect(result).toEqual({ base64: null, mimeType: null, type: null });
});
it('should handle an empty input', () => {
const emptyInput = '';
const result = parseDataUri(emptyInput);
expect(result).toEqual({ base64: null, mimeType: null, type: null });
});
});
+24
View File
@@ -0,0 +1,24 @@
interface UriParserResult {
base64: string | null;
mimeType: string | null;
type: 'url' | 'base64' | null;
}
export const parseDataUri = (dataUri: string): UriParserResult => {
// 正则表达式匹配整个 Data URI 结构
const dataUriMatch = dataUri.match(/^data:([^;]+);base64,(.+)$/);
if (dataUriMatch) {
// 如果是合法的 Data URI
return { base64: dataUriMatch[2], mimeType: dataUriMatch[1], type: 'base64' };
}
try {
new URL(dataUri);
// 如果是合法的 URL
return { base64: null, mimeType: null, type: 'url' };
} catch {
// 既不是 Data URI 也不是合法 URL
return { base64: null, mimeType: null, type: null };
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
import {
LobeAnthropicAI,
LobeAzureOpenAI,
LobeBedrockAI,
LobeDeepSeekAI,
LobeGoogleAI,
LobeGroq,
LobeMistralAI,
LobeMoonshotAI,
LobeOllamaAI,
LobeOpenAI,
LobeOpenAICompatibleRuntime,
LobeOpenRouterAI,
LobePerplexityAI,
LobeQwenAI,
LobeTogetherAI,
LobeZeroOneAI,
LobeZhipuAI,
ModelProvider,
ModelRuntime,
} from '@lobechat/model-runtime';
import { merge } from 'lodash-es';
import OpenAI from 'openai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { UserStore } from '@/store/user';
import { UserSettingsState, initialSettingsState } from '@/store/user/slices/settings/initialState';
import { initializeWithClientStore } from './clientModelRuntime';
// Mocking external dependencies
vi.mock('i18next', () => ({
t: vi.fn((key) => `translated_${key}`),
}));
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(new Response(JSON.stringify({ some: 'data' })))),
);
vi.mock('@/utils/fetch', async (importOriginal) => {
const module = await importOriginal();
return { ...(module as any), getMessageError: vi.fn() };
});
// Mock image processing utilities
vi.mock('@/utils/url', () => ({
isLocalUrl: vi.fn(),
}));
vi.mock('@/utils/imageToBase64', () => ({
imageUrlToBase64: vi.fn(),
}));
vi.mock('@lobechat/model-runtime', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as any),
parseDataUri: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(async () => {
// 清除所有模块的缓存
vi.resetModules();
// 默认设置 isServerMode 为 false
vi.mock('@/const/version', () => ({
isServerMode: false,
isDeprecatedEdition: true,
isDesktop: false,
}));
// Reset all mocks
vi.clearAllMocks();
// Set default mock return values for image processing utilities
const { isLocalUrl } = await import('@/utils/url');
const { imageUrlToBase64 } = await import('@/utils/imageToBase64');
const { parseDataUri } = await import('@lobechat/model-runtime');
vi.mocked(parseDataUri).mockReturnValue({ type: 'url', base64: null, mimeType: null });
vi.mocked(isLocalUrl).mockReturnValue(false);
vi.mocked(imageUrlToBase64).mockResolvedValue({
base64: 'mock-base64',
mimeType: 'image/jpeg',
});
});
describe('ModelRuntimeOnClient', () => {
describe('initializeWithClientStore', () => {
describe('should initialize with options correctly', () => {
it('OpenAI provider: with apikey and endpoint', async () => {
// Mock the global store to return the user's OpenAI API key and endpoint
merge(initialSettingsState, {
settings: {
keyVaults: {
openai: {
apiKey: 'user-openai-key',
baseURL: 'user-openai-endpoint',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.OpenAI, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeOpenAI);
expect(runtime['_runtime'].baseURL).toBe('user-openai-endpoint');
});
it('Azure provider: with apiKey, apiVersion, endpoint', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
azure: {
apiKey: 'user-azure-key',
endpoint: 'user-azure-endpoint',
apiVersion: '2024-06-01',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Azure, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeAzureOpenAI);
});
it('Google provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
google: {
apiKey: 'user-google-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Google, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeGoogleAI);
});
it('Moonshot AI provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
moonshot: {
apiKey: 'user-moonshot-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Moonshot, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeMoonshotAI);
});
it('Bedrock provider: with accessKeyId, region, secretAccessKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
bedrock: {
accessKeyId: 'user-bedrock-access-key',
region: 'user-bedrock-region',
secretAccessKey: 'user-bedrock-secret',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Bedrock, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeBedrockAI);
});
it('Ollama provider: with endpoint', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
ollama: {
baseURL: 'http://127.0.0.1:1234',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Ollama, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeOllamaAI);
});
it('Perplexity provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
perplexity: {
apiKey: 'user-perplexity-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Perplexity, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobePerplexityAI);
});
it('Anthropic provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
anthropic: {
apiKey: 'user-anthropic-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Anthropic, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeAnthropicAI);
});
it('Mistral provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
mistral: {
apiKey: 'user-mistral-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Mistral, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeMistralAI);
});
it('OpenRouter provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
openrouter: {
apiKey: 'user-openrouter-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.OpenRouter, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeOpenRouterAI);
});
it('TogetherAI provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
togetherai: {
apiKey: 'user-togetherai-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.TogetherAI, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeTogetherAI);
});
it('ZeroOneAI provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
zeroone: {
apiKey: 'user-zeroone-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.ZeroOne, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeZeroOneAI);
});
it('Groq provider: with apiKey,endpoint', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
groq: {
apiKey: 'user-groq-key',
baseURL: 'user-groq-endpoint',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Groq, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
const lobeOpenAICompatibleInstance = runtime['_runtime'] as LobeOpenAICompatibleRuntime;
expect(lobeOpenAICompatibleInstance).toBeInstanceOf(LobeGroq);
expect(lobeOpenAICompatibleInstance.baseURL).toBe('user-groq-endpoint');
expect(lobeOpenAICompatibleInstance.client).toBeInstanceOf(OpenAI);
expect(lobeOpenAICompatibleInstance.client.apiKey).toBe('user-groq-key');
});
it('DeepSeek provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
deepseek: {
apiKey: 'user-deepseek-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.DeepSeek, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeDeepSeekAI);
});
it('Qwen provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
qwen: {
apiKey: 'user-qwen-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.Qwen, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeQwenAI);
});
/**
* Should not have a unknown provider in client, but has
* similar cases in server side
*/
it('Unknown provider: with apiKey', async () => {
merge(initialSettingsState, {
settings: {
keyVaults: {
unknown: {
apiKey: 'user-unknown-key',
endpoint: 'user-unknown-endpoint',
},
},
},
} as any as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore('unknown' as ModelProvider, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeOpenAI);
});
/**
* The following test cases need to be enforce
*/
it('ZhiPu AI provider: with apiKey', async () => {
// Mock the generateApiToken function
vi.mock('@/libs/model-runtime/zhipu/authToken', () => ({
generateApiToken: vi
.fn()
.mockResolvedValue(
'eyJhbGciOiJIUzI1NiIsInNpZ25fdHlwZSI6IlNJR04iLCJ0eXAiOiJKV1QifQ.eyJhcGlfa2V5IjoiemhpcHUiLCJleHAiOjE3MTU5MTc2NzMsImlhdCI6MTcxMzMyNTY3M30.gt8o-hUDvJFPJLYcH4EhrT1LAmTXI8YnybHeQjpD9oM',
),
}));
merge(initialSettingsState, {
settings: {
keyVaults: {
zhipu: {
apiKey: 'zhipu.user-key',
},
},
},
} as UserSettingsState) as unknown as UserStore;
const runtime = await initializeWithClientStore(ModelProvider.ZhiPu, {});
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeZhipuAI);
});
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import { ModelRuntime } from '@lobechat/model-runtime';
import { createPayloadWithKeyVaults } from '../_auth';
/**
* Initializes the AgentRuntime with the client store.
* @param provider - The provider name.
* @param payload - Init options
* @returns The initialized AgentRuntime instance
*
* **Note**: if you try to fetch directly, use `fetchOnClient` instead.
*/
export const initializeWithClientStore = (provider: string, payload?: any) => {
/**
* Since #5267, we map parameters for client-fetch in function `getProviderAuthPayload`
* which called by `createPayloadWithKeyVaults` below.
* @see https://github.com/lobehub/lobe-chat/pull/5267
* @file src/services/_auth.ts
*/
const providerAuthPayload = { ...payload, ...createPayloadWithKeyVaults(provider) };
const commonOptions = {
// Allow OpenAI SDK and Anthropic SDK run on browser
dangerouslyAllowBrowser: true,
};
/**
* Configuration override order:
* payload -> providerAuthPayload -> commonOptions
*/
return ModelRuntime.initializeWithProvider(provider, {
...commonOptions,
...providerAuthPayload,
...payload,
});
};
@@ -0,0 +1,848 @@
import { ChatMessage } from '@lobechat/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { contextEngineering } from './contextEngineering';
import * as helpers from './helper';
// Mock VARIABLE_GENERATORS
vi.mock('@/utils/client/parserPlaceholder', () => ({
VARIABLE_GENERATORS: {
date: () => '2023-12-25',
time: () => '14:30:45',
username: () => 'TestUser',
random: () => '12345',
},
}));
// 默认设置 isServerMode 为 false
let isServerMode = false;
vi.mock('@lobechat/const', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as any),
get isServerMode() {
return isServerMode;
},
isDeprecatedEdition: false,
isDesktop: false,
};
});
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
describe('contextEngineering', () => {
describe('handle with files content in server mode', () => {
it('should includes files', async () => {
isServerMode = true;
// Mock isCanUseVision to return true for vision models
vi.spyOn(helpers, 'isCanUseVision').mockReturnValue(true);
const messages = [
{
content: 'Hello',
role: 'user',
imageList: [
{
id: 'imagecx1',
url: 'http://example.com/xxx0asd-dsd.png',
alt: 'ttt.png',
},
],
fileList: [
{
fileType: 'plain/txt',
size: 100000,
id: 'file1',
url: 'http://abc.com/abc.txt',
name: 'abc.png',
},
{
id: 'file_oKMve9qySLMI',
name: '2402.16667v1.pdf',
type: 'application/pdf',
size: 11256078,
url: 'https://xxx.com/ppp/480497/5826c2b8-fde0-4de1-a54b-a224d5e3d898.pdf',
},
],
}, // Message with files
{ content: 'Hey', role: 'assistant' }, // Regular user message
] as ChatMessage[];
const output = await contextEngineering({
messages,
model: 'gpt-4o',
provider: 'openai',
});
expect(output).toEqual([
{
content: [
{
text: `Hello
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<files_info>
<images>
<images_docstring>here are user upload images you can refer to</images_docstring>
<image name="ttt.png" url="http://example.com/xxx0asd-dsd.png"></image>
</images>
<files>
<files_docstring>here are user upload files you can refer to</files_docstring>
<file id="file1" name="abc.png" type="plain/txt" size="100000" url="http://abc.com/abc.txt"></file>
<file id="file_oKMve9qySLMI" name="2402.16667v1.pdf" type="undefined" size="11256078" url="https://xxx.com/ppp/480497/5826c2b8-fde0-4de1-a54b-a224d5e3d898.pdf"></file>
</files>
</files_info>
<!-- END SYSTEM CONTEXT -->`,
type: 'text',
},
{
image_url: { detail: 'auto', url: 'http://example.com/xxx0asd-dsd.png' },
type: 'image_url',
},
],
role: 'user',
},
{
content: 'Hey',
role: 'assistant',
},
]);
isServerMode = false;
});
it('should include image files in server mode', async () => {
isServerMode = true;
vi.spyOn(helpers, 'isCanUseVision').mockReturnValue(false);
const messages = [
{
content: 'Hello',
role: 'user',
imageList: [
{
id: 'file1',
url: 'http://example.com/image.jpg',
alt: 'abc.png',
},
],
}, // Message with files
{ content: 'Hey', role: 'assistant' }, // Regular user message
] as ChatMessage[];
const output = await contextEngineering({
messages,
provider: 'openai',
model: 'gpt-4-vision-preview',
});
expect(output).toEqual([
{
content: [
{
text: `Hello
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<files_info>
<images>
<images_docstring>here are user upload images you can refer to</images_docstring>
<image name="abc.png" url="http://example.com/image.jpg"></image>
</images>
</files_info>
<!-- END SYSTEM CONTEXT -->`,
type: 'text',
},
],
role: 'user',
},
{
content: 'Hey',
role: 'assistant',
},
]);
isServerMode = false;
});
});
it('should handle empty tool calls messages correctly', async () => {
const messages = [
{
content: '## Tools\n\nYou can use these tools',
role: 'system',
},
{
content: '',
role: 'assistant',
tool_calls: [],
},
] as ChatMessage[];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
});
expect(result).toEqual([
{
content: '## Tools\n\nYou can use these tools',
role: 'system',
},
{
content: '',
role: 'assistant',
},
]);
});
it('should handle assistant messages with reasoning correctly', async () => {
const messages = [
{
role: 'assistant',
content: 'The answer is 42.',
reasoning: {
content: 'I need to calculate the answer to life, universe, and everything.',
signature: 'thinking_process',
},
},
] as ChatMessage[];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
});
expect(result).toEqual([
{
content: [
{
signature: 'thinking_process',
thinking: 'I need to calculate the answer to life, universe, and everything.',
type: 'thinking',
},
{
text: 'The answer is 42.',
type: 'text',
},
],
role: 'assistant',
},
]);
});
it('should inject INBOX_GUIDE_SYSTEM_ROLE for welcome questions in inbox session', async () => {
// Don't mock INBOX_GUIDE_SYSTEMROLE, use the real one
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Hello, this is my first question',
createdAt: Date.now(),
id: 'test-welcome',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering(
{
messages,
model: 'gpt-4',
provider: 'openai',
},
{
isWelcomeQuestion: true,
trace: { sessionId: 'inbox' },
},
);
// Should have system message with inbox guide content
const systemMessage = result.find((msg) => msg.role === 'system');
expect(systemMessage).toBeDefined();
// Check for characteristic content of the actual INBOX_GUIDE_SYSTEMROLE
expect(systemMessage!.content).toContain('LobeChat Support Assistant');
expect(systemMessage!.content).toContain('LobeHub');
expect(Object.keys(systemMessage!).length).toEqual(2);
});
it('should inject historySummary into system message when provided', async () => {
const historySummary = 'Previous conversation summary: User discussed AI topics.';
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Continue our discussion',
createdAt: Date.now(),
id: 'test-history',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering(
{
messages,
model: 'gpt-4',
provider: 'openai',
},
{
historySummary,
},
);
// Should have system message with history summary
const systemMessage = result.find((msg) => msg.role === 'system');
expect(systemMessage).toBeDefined();
expect(systemMessage!.content).toContain(historySummary);
expect(Object.keys(systemMessage!).length).toEqual(2);
});
describe('getAssistantContent', () => {
it('should handle assistant message with imageList and content', async () => {
// Mock isCanUseVision to return true for vision models
vi.spyOn(helpers, 'isCanUseVision').mockReturnValue(true);
const messages: ChatMessage[] = [
{
role: 'assistant',
content: 'Here is an image.',
imageList: [{ id: 'img1', url: 'http://example.com/image.png', alt: 'test.png' }],
createdAt: Date.now(),
id: 'test-id',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4-vision-preview',
provider: 'openai',
});
expect(result[0].content).toEqual([
{ text: 'Here is an image.', type: 'text' },
{ image_url: { detail: 'auto', url: 'http://example.com/image.png' }, type: 'image_url' },
]);
});
it('should handle assistant message with imageList but no content', async () => {
// Mock isCanUseVision to return true for vision models
vi.spyOn(helpers, 'isCanUseVision').mockReturnValue(true);
const messages: ChatMessage[] = [
{
role: 'assistant',
content: '',
imageList: [{ id: 'img1', url: 'http://example.com/image.png', alt: 'test.png' }],
createdAt: Date.now(),
id: 'test-id-2',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4-vision-preview',
provider: 'openai',
});
expect(result[0].content).toEqual([
{ image_url: { detail: 'auto', url: 'http://example.com/image.png' }, type: 'image_url' },
]);
});
});
it('should not include tool_calls for assistant message if model does not support tools', async () => {
// Mock isCanUseFC to return false
vi.spyOn(helpers, 'isCanUseFC').mockReturnValue(false);
const messages: ChatMessage[] = [
{
role: 'assistant',
content: 'I have a tool call.',
tools: [
{
id: 'tool_123',
type: 'default',
apiName: 'testApi',
arguments: '{}',
identifier: 'test-plugin',
},
],
createdAt: Date.now(),
id: 'test-id-3',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'some-model-without-fc',
provider: 'openai',
});
expect(result[0].tool_calls).toBeUndefined();
expect(result[0].content).toBe('I have a tool call.');
});
describe('Process placeholder variables', () => {
it('should process placeholder variables in string content', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Hello {{username}}, today is {{date}} and the time is {{time}}',
createdAt: Date.now(),
id: 'test-placeholder-1',
meta: {},
updatedAt: Date.now(),
},
{
role: 'assistant',
content: 'Hi there! Your random number is {{random}}',
createdAt: Date.now(),
id: 'test-placeholder-2',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
});
expect(result[0].content).toBe(
'Hello TestUser, today is 2023-12-25 and the time is 14:30:45',
);
expect(result[1].content).toBe('Hi there! Your random number is 12345');
});
it('should process placeholder variables in array content', async () => {
const messages = [
{
role: 'user',
content: [
{
type: 'text',
text: 'Hello {{username}}, today is {{date}}',
},
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,abc123' },
},
],
createdAt: Date.now(),
id: 'test-placeholder-array',
meta: {},
updatedAt: Date.now(),
},
] as any;
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
});
expect(Array.isArray(result[0].content)).toBe(true);
const content = result[0].content as any[];
expect(content[0].text).toBe('Hello TestUser, today is 2023-12-25');
expect(content[1].image_url.url).toBe('data:image/png;base64,abc123');
});
it('should handle missing placeholder variables gracefully', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Hello {{username}}, missing: {{missing_var}}',
createdAt: Date.now(),
id: 'test-placeholder-missing',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
});
expect(result[0].content).toBe('Hello TestUser, missing: {{missing_var}}');
});
it('should not modify messages without placeholder variables', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Hello there, no variables here',
createdAt: Date.now(),
id: 'test-no-placeholders',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
});
expect(result[0].content).toBe('Hello there, no variables here');
});
it('should process placeholder variables combined with other processors', async () => {
isServerMode = true;
vi.spyOn(helpers, 'isCanUseVision').mockReturnValue(true);
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Hello {{username}}, check this image from {{date}}',
imageList: [
{
id: 'img1',
url: 'http://example.com/test.jpg',
alt: 'test image',
},
],
createdAt: Date.now(),
id: 'test-combined',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4o',
provider: 'openai',
});
expect(Array.isArray(result[0].content)).toBe(true);
const content = result[0].content as any[];
// Should contain processed placeholder variables in the text content
expect(content[0].text).toContain('Hello TestUser, check this image from 2023-12-25');
// Should also contain file context from MessageContentProcessor
expect(content[0].text).toContain('SYSTEM CONTEXT');
// Should contain image from vision processing
expect(content[1].type).toBe('image_url');
expect(content[1].image_url.url).toBe('http://example.com/test.jpg');
isServerMode = false;
});
});
describe('Message preprocessing processors', () => {
it('should truncate message history when enabled', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Message 1',
createdAt: Date.now(),
id: 'test-1',
meta: {},
updatedAt: Date.now(),
},
{
role: 'assistant',
content: 'Response 1',
createdAt: Date.now(),
id: 'test-2',
meta: {},
updatedAt: Date.now(),
},
{
role: 'user',
content: 'Message 2',
createdAt: Date.now(),
id: 'test-3',
meta: {},
updatedAt: Date.now(),
},
{
role: 'assistant',
content: 'Response 2',
createdAt: Date.now(),
id: 'test-4',
meta: {},
updatedAt: Date.now(),
},
{
role: 'user',
content: 'Latest message',
createdAt: Date.now(),
id: 'test-5',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
enableHistoryCount: true,
historyCount: 4, // Should keep last 2 messages
});
// Should only keep the last 2 messages
expect(result).toHaveLength(4);
expect(result).toEqual([
{ content: 'Response 1', role: 'assistant' },
{ content: 'Message 2', role: 'user' },
{ content: 'Response 2', role: 'assistant' },
{ content: 'Latest message', role: 'user' },
]);
});
it('should apply input template to user messages', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Original user input',
createdAt: Date.now(),
id: 'test-template',
meta: {},
updatedAt: Date.now(),
},
{
role: 'assistant',
content: 'Assistant response',
createdAt: Date.now(),
id: 'test-assistant',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
inputTemplate: 'Template: {{text}} - End',
});
// Should apply template to user message only
expect(result).toEqual([
{
content: 'Template: Original user input - End',
role: 'user',
},
{
role: 'assistant',
content: 'Assistant response',
},
]);
expect(result[1].content).toBe('Assistant response'); // Unchanged
});
it('should inject system role at the beginning', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'User message',
createdAt: Date.now(),
id: 'test-user',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
systemRole: 'You are a helpful assistant.',
});
// Should have system role at the beginning
expect(result).toEqual([
{ content: 'You are a helpful assistant.', role: 'system' },
{ content: 'User message', role: 'user' },
]);
});
it('should combine all preprocessing steps correctly', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Old message 1',
createdAt: Date.now(),
id: 'test-old-1',
meta: {},
updatedAt: Date.now(),
},
{
role: 'assistant',
content: 'Old response',
createdAt: Date.now(),
id: 'test-old-2',
meta: {},
updatedAt: Date.now(),
},
{
role: 'user',
content: 'Recent input with {{username}}',
createdAt: Date.now(),
id: 'test-recent',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
systemRole: 'System instructions.',
inputTemplate: 'Processed: {{text}}',
enableHistoryCount: true,
historyCount: 2, // Should keep last 1 message
});
// System role should be first
expect(result).toEqual([
{
content: 'System instructions.',
role: 'system',
},
{
role: 'assistant',
content: 'Old response',
},
{
content: 'Processed: Recent input with TestUser',
role: 'user',
},
]);
});
it('should skip preprocessing when no configuration is provided', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Simple message',
createdAt: Date.now(),
id: 'test-simple',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
});
// Should pass message unchanged
expect(result).toEqual([
{
content: 'Simple message',
role: 'user',
},
]);
});
it('should handle history truncation with system role injection correctly', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'Message 1',
createdAt: Date.now(),
id: 'test-1',
meta: {},
updatedAt: Date.now(),
},
{
role: 'user',
content: 'Message 2',
createdAt: Date.now(),
id: 'test-2',
meta: {},
updatedAt: Date.now(),
},
{
role: 'user',
content: 'Message 3',
createdAt: Date.now(),
id: 'test-3',
meta: {},
updatedAt: Date.now(),
},
];
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
systemRole: 'System role here.',
enableHistoryCount: true,
historyCount: 1, // Should keep only 1 message
});
// Should have system role + 1 truncated message
expect(result).toEqual([
{
content: 'System role here.',
role: 'system',
},
{
content: 'Message 3', // Only the last message should remain
role: 'user',
},
]);
});
it('should handle input template compilation errors gracefully', async () => {
const messages: ChatMessage[] = [
{
role: 'user',
content: 'User message',
createdAt: Date.now(),
id: 'test-error',
meta: {},
updatedAt: Date.now(),
},
];
// This should not throw an error, but handle it gracefully
const result = await contextEngineering({
messages,
model: 'gpt-4',
provider: 'openai',
inputTemplate: '<%- invalid javascript syntax %>',
});
// Should keep original message when template fails
expect(result).toEqual([
{
content: 'User message',
role: 'user',
},
]);
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import { INBOX_SESSION_ID, isDesktop, isServerMode } from '@lobechat/const';
import {
type AgentState,
ContextEngine,
HistorySummaryProvider,
HistoryTruncateProcessor,
InboxGuideProvider,
InputTemplateProcessor,
MessageCleanupProcessor,
MessageContentProcessor,
PlaceholderVariablesProcessor,
SystemRoleInjector,
ToolCallProcessor,
ToolMessageReorder,
ToolSystemRoleProvider,
} from '@lobechat/context-engine';
import { historySummaryPrompt } from '@lobechat/prompts';
import { ChatMessage, OpenAIChatMessage } from '@lobechat/types';
import { INBOX_GUIDE_SYSTEMROLE } from '@/const/guide';
import { getToolStoreState } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import { VARIABLE_GENERATORS } from '@/utils/client/parserPlaceholder';
import { genToolCallingName } from '@/utils/toolCall';
import { isCanUseFC, isCanUseVision } from './helper';
import { FetchOptions } from './types';
export const contextEngineering = async (
{
messages = [],
tools,
model,
provider,
systemRole,
inputTemplate,
enableHistoryCount,
historyCount,
}: {
enableHistoryCount?: boolean;
historyCount?: number;
inputTemplate?: string;
messages: ChatMessage[];
model: string;
provider: string;
systemRole?: string;
tools?: string[];
},
options?: FetchOptions,
): Promise<OpenAIChatMessage[]> => {
const pipeline = new ContextEngine({
pipeline: [
// 1. History truncation (MUST be first, before any message injection)
new HistoryTruncateProcessor({ enableHistoryCount, historyCount }),
// --------- Create system role injection providers
// 2. System role injection (agent's system role)
new SystemRoleInjector({ systemRole }),
// 3. Inbox guide system role injection
new InboxGuideProvider({
inboxGuideSystemRole: INBOX_GUIDE_SYSTEMROLE,
inboxSessionId: INBOX_SESSION_ID,
isWelcomeQuestion: options?.isWelcomeQuestion,
sessionId: options?.trace?.sessionId,
}),
// 4. Tool system role injection
new ToolSystemRoleProvider({
getToolSystemRoles: (tools) => toolSelectors.enabledSystemRoles(tools)(getToolStoreState()),
isCanUseFC,
model,
provider,
tools,
}),
// 5. History summary injection
new HistorySummaryProvider({
formatHistorySummary: historySummaryPrompt,
historySummary: options?.historySummary,
}),
// Create message processing processors
// 6. Input template processing
new InputTemplateProcessor({
inputTemplate,
}),
// 7. Placeholder variables processing
new PlaceholderVariablesProcessor({ variableGenerators: VARIABLE_GENERATORS }),
// 8. Message content processing
new MessageContentProcessor({
fileContext: { enabled: isServerMode, includeFileUrl: !isDesktop },
isCanUseVision,
model,
provider,
}),
// 9. Tool call processing
new ToolCallProcessor({ genToolCallingName, isCanUseFC, model, provider }),
// 10. Tool message reordering
new ToolMessageReorder(),
// 11. Message cleanup (final step, keep only necessary fields)
new MessageCleanupProcessor(),
],
});
const initialState: AgentState = { messages, model, provider, systemRole, tools };
const result = await pipeline.process({
initialState,
maxTokens: 10_000_000,
messages,
model,
});
return result.messages;
};
+61
View File
@@ -0,0 +1,61 @@
import { isDeprecatedEdition } from '@lobechat/const';
import { ModelProvider } from '@lobechat/model-runtime';
import { getAiInfraStoreState } from '@/store/aiInfra';
import { aiModelSelectors, aiProviderSelectors } from '@/store/aiInfra/selectors';
import { getUserStoreState, useUserStore } from '@/store/user';
import { modelConfigSelectors, modelProviderSelectors } from '@/store/user/selectors';
export const isCanUseFC = (model: string, provider: string): boolean => {
// TODO: remove isDeprecatedEdition condition in V2.0
if (isDeprecatedEdition) {
return modelProviderSelectors.isModelEnabledFunctionCall(model)(getUserStoreState());
}
return aiModelSelectors.isModelSupportToolUse(model, provider)(getAiInfraStoreState()) || false;
};
export const isCanUseVision = (model: string, provider: string): boolean => {
// TODO: remove isDeprecatedEdition condition in V2.0
if (isDeprecatedEdition) {
return modelProviderSelectors.isModelEnabledVision(model)(getUserStoreState());
}
return aiModelSelectors.isModelSupportVision(model, provider)(getAiInfraStoreState());
};
/**
* TODO: we need to update this function to auto find deploymentName with provider setting config
*/
export const findDeploymentName = (model: string, provider: string) => {
let deploymentId = model;
// TODO: remove isDeprecatedEdition condition in V2.0
if (isDeprecatedEdition) {
const chatModelCards = modelProviderSelectors.getModelCardsById(ModelProvider.Azure)(
useUserStore.getState(),
);
const deploymentName = chatModelCards.find((i) => i.id === model)?.deploymentName;
if (deploymentName) deploymentId = deploymentName;
} else {
// find the model by id
const modelItem = getAiInfraStoreState().enabledAiModels?.find(
(i) => i.id === model && i.providerId === provider,
);
if (modelItem && modelItem.config?.deploymentName) {
deploymentId = modelItem.config?.deploymentName;
}
}
return deploymentId;
};
export const isEnableFetchOnClient = (provider: string) => {
// TODO: remove this condition in V2.0
if (isDeprecatedEdition) {
return modelConfigSelectors.isProviderFetchOnClient(provider)(useUserStore.getState());
} else {
return aiProviderSelectors.isProviderFetchOnClient(provider)(getAiInfraStoreState());
}
};
@@ -2,22 +2,16 @@ import {
AgentRuntimeError,
ChatCompletionErrorPayload,
ModelProvider,
ModelRuntime,
parseDataUri,
} from '@lobechat/model-runtime';
import { BuiltinSystemRolePrompts, filesPrompts } from '@lobechat/prompts';
import { ChatErrorType, TracePayload, TraceTagMap } from '@lobechat/types';
import { PluginRequestPayload, createHeadersWithPluginSettings } from '@lobehub/chat-plugin-sdk';
import { produce } from 'immer';
import { merge } from 'lodash-es';
import { enableAuth } from '@/const/auth';
import { INBOX_GUIDE_SYSTEMROLE } from '@/const/guide';
import { INBOX_SESSION_ID } from '@/const/session';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { isDeprecatedEdition, isDesktop, isServerMode } from '@/const/version';
import { isDeprecatedEdition, isDesktop } from '@/const/version';
import { getAgentStoreState } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { aiModelSelectors, aiProviderSelectors, getAiInfraStoreState } from '@/store/aiInfra';
import { getSessionStoreState } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
@@ -25,18 +19,14 @@ import { getToolStoreState } from '@/store/tool';
import { pluginSelectors, toolSelectors } from '@/store/tool/selectors';
import { getUserStoreState, useUserStore } from '@/store/user';
import {
modelConfigSelectors,
modelProviderSelectors,
preferenceSelectors,
userGeneralSettingsSelectors,
userProfileSelectors,
} from '@/store/user/selectors';
import { WebBrowsingManifest } from '@/tools/web-browsing';
import { WorkingModel } from '@/types/agent';
import { ChatImageItem, ChatMessage, MessageToolCall } from '@/types/message';
import type { ChatStreamPayload, OpenAIChatMessage } from '@/types/openai/chat';
import { UserMessageContentPart } from '@/types/openai/chat';
import { parsePlaceholderVariablesMessages } from '@/utils/client/parserPlaceholder';
import { ChatMessage } from '@/types/message';
import type { ChatStreamPayload } from '@/types/openai/chat';
import { fetchWithInvokeStream } from '@/utils/electron/desktopRemoteRPCFetch';
import { createErrorResponse } from '@/utils/errorResponse';
import {
@@ -45,74 +35,14 @@ import {
getMessageError,
standardizeAnimationStyle,
} from '@/utils/fetch';
import { imageUrlToBase64 } from '@/utils/imageToBase64';
import { genToolCallingName } from '@/utils/toolCall';
import { createTraceHeader, getTraceId } from '@/utils/trace';
import { isLocalUrl } from '@/utils/url';
import { createHeaderWithAuth, createPayloadWithKeyVaults } from './_auth';
import { API_ENDPOINTS } from './_url';
const isCanUseFC = (model: string, provider: string) => {
// TODO: remove isDeprecatedEdition condition in V2.0
if (isDeprecatedEdition) {
return modelProviderSelectors.isModelEnabledFunctionCall(model)(getUserStoreState());
}
return aiModelSelectors.isModelSupportToolUse(model, provider)(getAiInfraStoreState());
};
const isCanUseVision = (model: string, provider: string) => {
// TODO: remove isDeprecatedEdition condition in V2.0
if (isDeprecatedEdition) {
return modelProviderSelectors.isModelEnabledVision(model)(getUserStoreState());
}
return aiModelSelectors.isModelSupportVision(model, provider)(getAiInfraStoreState());
};
/**
* TODO: we need to update this function to auto find deploymentName with provider setting config
*/
const findDeploymentName = (model: string, provider: string) => {
let deploymentId = model;
// TODO: remove isDeprecatedEdition condition in V2.0
if (isDeprecatedEdition) {
const chatModelCards = modelProviderSelectors.getModelCardsById(ModelProvider.Azure)(
useUserStore.getState(),
);
const deploymentName = chatModelCards.find((i) => i.id === model)?.deploymentName;
if (deploymentName) deploymentId = deploymentName;
} else {
// find the model by id
const modelItem = getAiInfraStoreState().enabledAiModels?.find(
(i) => i.id === model && i.providerId === provider,
);
if (modelItem && modelItem.config?.deploymentName) {
deploymentId = modelItem.config?.deploymentName;
}
}
return deploymentId;
};
const isEnableFetchOnClient = (provider: string) => {
// TODO: remove this condition in V2.0
if (isDeprecatedEdition) {
return modelConfigSelectors.isProviderFetchOnClient(provider)(useUserStore.getState());
} else {
return aiProviderSelectors.isProviderFetchOnClient(provider)(getAiInfraStoreState());
}
};
interface FetchOptions extends FetchSSEOptions {
historySummary?: string;
isWelcomeQuestion?: boolean;
signal?: AbortSignal | undefined;
trace?: TracePayload;
}
import { createHeaderWithAuth } from '../_auth';
import { API_ENDPOINTS } from '../_url';
import { initializeWithClientStore } from './clientModelRuntime';
import { contextEngineering } from './contextEngineering';
import { findDeploymentName, isCanUseFC, isEnableFetchOnClient } from './helper';
import { FetchOptions } from './types';
interface GetChatCompletionPayload extends Partial<Omit<ChatStreamPayload, 'messages'>> {
messages: ChatMessage[];
@@ -141,37 +71,6 @@ interface CreateAssistantMessageStream extends FetchSSEOptions {
trace?: TracePayload;
}
/**
* Initializes the AgentRuntime with the client store.
* @param provider - The provider name.
* @param payload - Init options
* @returns The initialized AgentRuntime instance
*
* **Note**: if you try to fetch directly, use `fetchOnClient` instead.
*/
export function initializeWithClientStore(provider: string, payload?: any) {
/**
* Since #5267, we map parameters for client-fetch in function `getProviderAuthPayload`
* which called by `createPayloadWithKeyVaults` below.
* @see https://github.com/lobehub/lobe-chat/pull/5267
* @file src/services/_auth.ts
*/
const providerAuthPayload = { ...payload, ...createPayloadWithKeyVaults(provider) };
const commonOptions = {
// Allow OpenAI SDK and Anthropic SDK run on browser
dangerouslyAllowBrowser: true,
};
/**
* Configuration override order:
* payload -> providerAuthPayload -> commonOptions
*/
return ModelRuntime.initializeWithProvider(provider, {
...commonOptions,
...providerAuthPayload,
...payload,
});
}
class ChatService {
createAssistantMessage = async (
{ plugins: enabledPlugins, messages, ...params }: GetChatCompletionPayload,
@@ -209,29 +108,35 @@ class ChatService {
pluginIds.push(WebBrowsingManifest.identifier);
}
// ============ 1. preprocess placeholder variables ============ //
const parsedMessages = parsePlaceholderVariablesMessages(messages);
// ============ 1. preprocess messages ============ //
// ============ 2. preprocess messages ============ //
const agentStoreState = getAgentStoreState();
const agentConfig = agentSelectors.currentAgentConfig(agentStoreState);
const oaiMessages = await this.processMessages(
// Apply context engineering with preprocessing configuration
const oaiMessages = await contextEngineering(
{
messages: parsedMessages,
enableHistoryCount: agentChatConfigSelectors.enableHistoryCount(agentStoreState),
// include user messages
historyCount: agentChatConfigSelectors.historyCount(agentStoreState) + 2,
inputTemplate: chatConfig.inputTemplate,
messages,
model: payload.model,
provider: payload.provider!,
systemRole: agentConfig.systemRole,
tools: pluginIds,
},
options,
);
// ============ 3. preprocess tools ============ //
// ============ 2. preprocess tools ============ //
const tools = this.prepareTools(pluginIds, {
model: payload.model,
provider: payload.provider!,
});
// ============ 4. process extend params ============ //
// ============ 3. process extend params ============ //
let extendParams: Record<string, any> = {};
@@ -513,7 +418,7 @@ class ChatService {
onLoadingChange?.(true);
try {
const oaiMessages = await this.processMessages({
const oaiMessages = await contextEngineering({
messages: params.messages as any,
model: params.model!,
provider: params.provider!,
@@ -545,191 +450,6 @@ class ChatService {
}
};
private processMessages = async (
{
messages = [],
tools,
model,
provider,
}: {
messages: ChatMessage[];
model: string;
provider: string;
tools?: string[];
},
options?: FetchOptions,
): Promise<OpenAIChatMessage[]> => {
// handle content type for vision model
// for the models with visual ability, add image url to content
// refs: https://platform.openai.com/docs/guides/vision/quick-start
const getUserContent = async (m: ChatMessage) => {
// only if message doesn't have images and files, then return the plain content
if ((!m.imageList || m.imageList.length === 0) && (!m.fileList || m.fileList.length === 0))
return m.content;
const imageList = m.imageList || [];
const imageContentParts = await this.processImageList({ imageList, model, provider });
const filesContext = isServerMode
? filesPrompts({ addUrl: !isDesktop, fileList: m.fileList, imageList })
: '';
return [
{ text: (m.content + '\n\n' + filesContext).trim(), type: 'text' },
...imageContentParts,
] as UserMessageContentPart[];
};
const getAssistantContent = async (m: ChatMessage) => {
// signature is a signal of anthropic thinking mode
const shouldIncludeThinking = m.reasoning && !!m.reasoning?.signature;
if (shouldIncludeThinking) {
return [
{
signature: m.reasoning!.signature,
thinking: m.reasoning!.content,
type: 'thinking',
},
{ text: m.content, type: 'text' },
] as UserMessageContentPart[];
}
// only if message doesn't have images and files, then return the plain content
if (m.imageList && m.imageList.length > 0) {
const imageContentParts = await this.processImageList({
imageList: m.imageList,
model,
provider,
});
return [
!!m.content ? { text: m.content, type: 'text' } : undefined,
...imageContentParts,
].filter(Boolean) as UserMessageContentPart[];
}
return m.content;
};
let postMessages = await Promise.all(
messages.map(async (m): Promise<OpenAIChatMessage> => {
const supportTools = isCanUseFC(model, provider);
switch (m.role) {
case 'user': {
return { content: await getUserContent(m), role: m.role };
}
case 'assistant': {
const content = await getAssistantContent(m);
if (!supportTools) {
return { content, role: m.role };
}
return {
content,
role: m.role,
tool_calls: m.tools?.map(
(tool): MessageToolCall => ({
function: {
arguments: tool.arguments,
name: genToolCallingName(tool.identifier, tool.apiName, tool.type),
},
id: tool.id,
type: 'function',
}),
),
};
}
case 'tool': {
if (!supportTools) {
return { content: m.content, role: 'user' };
}
return {
content: m.content,
name: genToolCallingName(m.plugin!.identifier, m.plugin!.apiName, m.plugin?.type),
role: m.role,
tool_call_id: m.tool_call_id,
};
}
default: {
return { content: m.content, role: m.role as any };
}
}
}),
);
postMessages = produce(postMessages, (draft) => {
// if it's a welcome question, inject InboxGuide SystemRole
const inboxGuideSystemRole =
options?.isWelcomeQuestion &&
options?.trace?.sessionId === INBOX_SESSION_ID &&
INBOX_GUIDE_SYSTEMROLE;
// Inject Tool SystemRole
const hasTools = tools && tools?.length > 0;
const hasFC = hasTools && isCanUseFC(model, provider);
const toolsSystemRoles =
hasFC && toolSelectors.enabledSystemRoles(tools)(getToolStoreState());
const injectSystemRoles = BuiltinSystemRolePrompts({
historySummary: options?.historySummary,
plugins: toolsSystemRoles as string,
welcome: inboxGuideSystemRole as string,
});
if (!injectSystemRoles) return;
const systemMessage = draft.find((i) => i.role === 'system');
if (systemMessage) {
systemMessage.content = [systemMessage.content, injectSystemRoles]
.filter(Boolean)
.join('\n\n');
} else {
draft.unshift({
content: injectSystemRoles,
role: 'system',
});
}
});
return this.reorderToolMessages(postMessages);
};
/**
* Process imageList: convert local URLs to base64 and format as UserMessageContentPart
*/
private processImageList = async ({
model,
provider,
imageList,
}: {
imageList: ChatImageItem[];
model: string;
provider: string;
}) => {
if (!isCanUseVision(model, provider)) {
return [];
}
return Promise.all(
imageList.map(async (image) => {
const { type } = parseDataUri(image.url);
let processedUrl = image.url;
if (type === 'url' && isLocalUrl(image.url)) {
const { base64, mimeType } = await imageUrlToBase64(image.url);
processedUrl = `data:${mimeType};base64,${base64}`;
}
return { image_url: { detail: 'auto', url: processedUrl }, type: 'image_url' } as const;
}),
);
};
private mapTrace = (trace?: TracePayload, tag?: TraceTagMap): TracePayload => {
const tags = sessionMetaSelectors.currentAgentMeta(getSessionStoreState()).tags || [];
@@ -768,68 +488,6 @@ class ChatService {
return agentRuntime.chat(data, { signal: params.signal });
};
/**
* Reorder tool messages to ensure that tool messages are displayed in the correct order.
* see https://github.com/lobehub/lobe-chat/pull/3155
*/
private reorderToolMessages = (messages: OpenAIChatMessage[]): OpenAIChatMessage[] => {
// 1. 先收集所有 assistant 消息中的有效 tool_call_id
const validToolCallIds = new Set<string>();
messages.forEach((message) => {
if (message.role === 'assistant' && message.tool_calls) {
message.tool_calls.forEach((toolCall) => {
validToolCallIds.add(toolCall.id);
});
}
});
// 2. 收集所有有效的 tool 消息
const toolMessages: Record<string, OpenAIChatMessage> = {};
messages.forEach((message) => {
if (
message.role === 'tool' &&
message.tool_call_id &&
validToolCallIds.has(message.tool_call_id)
) {
toolMessages[message.tool_call_id] = message;
}
});
// 3. 重新排序消息
const reorderedMessages: OpenAIChatMessage[] = [];
messages.forEach((message) => {
// 跳过无效的 tool 消息
if (
message.role === 'tool' &&
(!message.tool_call_id || !validToolCallIds.has(message.tool_call_id))
) {
return;
}
// 检查是否已经添加过该 tool 消息
const hasPushed = reorderedMessages.some(
(m) => !!message.tool_call_id && m.tool_call_id === message.tool_call_id,
);
if (hasPushed) return;
reorderedMessages.push(message);
// 如果是 assistant 消息且有 tool_calls,添加对应的 tool 消息
if (message.role === 'assistant' && message.tool_calls) {
message.tool_calls.forEach((toolCall) => {
const correspondingToolMessage = toolMessages[toolCall.id];
if (correspondingToolMessage) {
reorderedMessages.push(correspondingToolMessage);
delete toolMessages[toolCall.id];
}
});
}
});
return reorderedMessages;
};
private prepareTools = (pluginIds: string[], { model, provider }: WorkingModel) => {
let filterTools = toolSelectors.enabledSchema(pluginIds)(getToolStoreState());
+9
View File
@@ -0,0 +1,9 @@
import { TracePayload } from '@lobechat/types';
import { FetchSSEOptions } from '@/utils/fetch';
export interface FetchOptions extends FetchSSEOptions {
historySummary?: string;
isWelcomeQuestion?: boolean;
signal?: AbortSignal | undefined;
trace?: TracePayload;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { ChatModelCard } from '@/types/llm';
import { getMessageError } from '@/utils/fetch';
import { API_ENDPOINTS } from './_url';
import { initializeWithClientStore } from './chat';
import { initializeWithClientStore } from './chat/clientModelRuntime';
const isEnableFetchOnClient = (provider: string) => {
// TODO: remove this condition in V2.0
@@ -47,7 +47,7 @@ const getEnabledModelById = (id: string, provider: string) => (s: AIProviderStor
const isModelSupportToolUse = (id: string, provider: string) => (s: AIProviderStoreState) => {
const model = getEnabledModelById(id, provider)(s);
return model?.abilities?.functionCall;
return model?.abilities?.functionCall || false;
};
const isModelSupportFiles = (id: string, provider: string) => (s: AIProviderStoreState) => {
@@ -59,7 +59,7 @@ const isModelSupportFiles = (id: string, provider: string) => (s: AIProviderStor
const isModelSupportVision = (id: string, provider: string) => (s: AIProviderStoreState) => {
const model = getEnabledModelById(id, provider)(s);
return model?.abilities?.vision;
return model?.abilities?.vision || false;
};
const isModelSupportReasoning = (id: string, provider: string) => (s: AIProviderStoreState) => {
@@ -4,7 +4,6 @@ import { knowledgeBaseQAPrompts } from '@lobechat/prompts';
import { TraceEventType, TraceNameMap } from '@lobechat/types';
import { t } from 'i18next';
import { produce } from 'immer';
import { template } from 'lodash-es';
import { StateCreator } from 'zustand/vanilla';
import { LOADING_FLAT, MESSAGE_CANCEL_FLAT } from '@/const/message';
@@ -16,7 +15,6 @@ import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selector
import { getAgentStoreState } from '@/store/agent/store';
import { aiModelSelectors, aiProviderSelectors } from '@/store/aiInfra';
import { getAiInfraStoreState } from '@/store/aiInfra/store';
import { chatHelpers } from '@/store/chat/helpers';
import { ChatStore } from '@/store/chat/store';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { getFileStoreState } from '@/store/file/store';
@@ -545,46 +543,9 @@ export const generateAIChat: StateCreator<
const agentConfig = agentSelectors.currentAgentConfig(getAgentStoreState());
const chatConfig = agentChatConfigSelectors.currentChatConfig(getAgentStoreState());
const compiler = template(chatConfig.inputTemplate, {
interpolate: /{{\s*(text)\s*}}/g,
});
// ================================== //
// messages uniformly preprocess //
// ================================== //
// 1. slice messages with config
const historyCount = agentChatConfigSelectors.historyCount(getAgentStoreState());
const enableHistoryCount = agentChatConfigSelectors.enableHistoryCount(getAgentStoreState());
let preprocessMsgs = chatHelpers.getSlicedMessages(messages, {
includeNewUserMessage: true,
enableHistoryCount,
historyCount,
});
// 2. replace inputMessage template
preprocessMsgs = !chatConfig.inputTemplate
? preprocessMsgs
: preprocessMsgs.map((m) => {
if (m.role === 'user') {
try {
return { ...m, content: compiler({ text: m.content }) };
} catch (error) {
console.error(error);
return m;
}
}
return m;
});
// 3. add systemRole
if (agentConfig.systemRole) {
preprocessMsgs.unshift({ content: agentConfig.systemRole, role: 'system' } as ChatMessage);
}
// 4. handle max_tokens
agentConfig.params.max_tokens = chatConfig.enableMaxTokens
? agentConfig.params.max_tokens
@@ -610,7 +571,7 @@ export const generateAIChat: StateCreator<
await chatService.createAssistantMessageStream({
abortController,
params: {
messages: preprocessMsgs,
messages,
model,
provider,
...agentConfig.params,