mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
♻️ 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:
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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 更加模块化、可维护和可扩展,能够更好地满足不同场景下的上下文处理需求。
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user