feat(core): 添加高级LLM参数配置功能

- 在模型配置中引入 `llmParams` 字段,允许用户为每个模型单独设置高级LLM参数
- 更新文档,详细说明 `llmParams` 的使用方法及示例
- 在模型管理器中实现对 `llmParams` 的验证和处理逻辑
- 增强 LLMService 以支持从 `llmParams` 中提取参数,优化模型行为控制
- 更新 UI 组件以支持高级参数的配置和展示,提升用户体验
This commit is contained in:
linshen committed 2025-06-07 16:30:39 +08:00
1 parent b87b5bea36
commit 688bb2e2bf
23 files changed
+2809 -189

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ VITE_DEEPSEEK_API_KEY=your_deepseek_api_key
# SiliconFlow API配置
VITE_SILICONFLOW_API_KEY=your_siliconflow_api_key
# 智谱 API配置
VITE_ZHIPU_API_KEY=your_zhipu_api_key
# 自定义API配置(可选)
VITE_CUSTOM_API_KEY=your_custom_api_key
VITE_CUSTOM_API_BASE_URL=your_custom_api_base_url
+4
View File
@@ -133,6 +133,10 @@ services:
- DeepSeek (DeepSeek-V3)
- 自定义API(OpenAI兼容接口)
除了API密钥,您还可以在模型配置界面为每个模型单独设置高级LLM参数。这些参数通过一个名为 `llmParams` 的字段进行配置,它允许您以键值对的形式指定LLM SDK支持的任何参数,从而更精细地控制模型行为。
例如,您可以这样配置:`"llmParams": { "temperature": 0.5, "max_tokens": 1024 }`。
有关 `llmParams` 的更详细说明和特定提供商的示例(如OpenAI的 `timeout`,Gemini的 `maxOutputTokens` 等),请参阅 [技术开发指南](docs/technical-development-guide.md)。
### 方式二:通过环境变量配置
Docker部署时通过 `-e` 参数配置环境变量:
```bash
+43
View File
@@ -36,6 +36,7 @@ src/
- **多服务商兼容**: Provider标识区分
- **敏感信息管理**: 环境变量+本地加密存储
- **用户自管理API密钥**: 避免后端开销,保持应用简单性
- **参数透明化**: 只使用用户明确配置的参数,不设置默认值避免误解
---
@@ -68,6 +69,7 @@ try {
2. **测试隔离**: 使用动态唯一标识符避免冲突
3. **错误场景**: 覆盖网络错误、无效Token等异常
4. **状态管理**: 独立测试数据库、正确清理状态
5. **参数测试**: 为每个LLM参数创建独立测试用例,确保参数兼容性
### 测试模板
```js
@@ -81,6 +83,32 @@ describe("功能测试", () => {
await expect(func()).rejects.toThrow("预期错误");
});
});
// LLM参数测试最佳实践
describe("Individual Parameter Tests", () => {
// 为每个advancedParameterDefinitions中的参数创建独立测试
it("should accept valid temperature", async () => {
await modelManager.updateModel(configKey, {
// ... 基础配置
llmParams: { temperature: 0.3 } // 只测试一个参数
});
const response = await llmService.sendMessage(messages, configKey);
expect(response).toBeDefined();
});
// 组合参数测试
it("should handle multiple parameters together", async () => {
await modelManager.updateModel(configKey, {
// ... 基础配置
llmParams: {
temperature: 0.6,
max_tokens: 150,
top_p: 0.9
}
});
});
});
```
---
@@ -126,6 +154,7 @@ const handlers = {
| UI配置导入验证不充分 | 中 | ✅ 已修复 |
| 数据迁移竞态条件 | 中 | ✅ 已修复 |
| 测试覆盖缺失 | 高 | ✅ 已修复 |
| LLM参数默认值误导用户 | 中 | ✅ 已修复 |
#### 关键修复示例
```ts
@@ -143,6 +172,20 @@ for (const [key, value] of Object.entries(typedData.userSettings)) {
}
await this.storage.setItem(key, value);
}
// LLM参数透明化处理 (2024-12-20)
// ❌ 旧版本:自动设置默认值
if (completionConfig.temperature === undefined) {
completionConfig.temperature = 0.7; // 可能误导用户
}
// ✅ 新版本:只使用用户明确配置的参数
const completionConfig: any = {
model: modelConfig.defaultModel,
messages: formattedMessages,
...restLlmParams // 只传递用户配置的参数
};
// 不设置任何默认值,让API服务商使用其默认配置
```
---
+384
View File
@@ -0,0 +1,384 @@
# LLM高级参数配置指南
## 概述
`llmParams` 功能允许您为每个模型配置详细的参数,以精确控制LLM的行为。本系统采用**智能参数分类**和**透明化传递**机制,确保参数配置的专业性和可靠性。
## 🔧 核心设计原则
### 1. 参数透明化 (2024.12.20 更新)
- **不设置默认值**: 系统不会自动添加任何默认值,避免用户误解
- **直接传递**: 用户配置什么参数就传递什么参数
- **SDK原生**: 依赖各LLM服务商的SDK默认行为
### 2. 智能参数分类
- **按提供商过滤**: UI自动根据模型类型显示相关参数
- **避免混淆**: OpenAI类型模型只显示OpenAI参数,Gemini模型只显示Gemini参数
- **参数隔离**: 不同提供商的参数互不干扰
### 3. 扩展性保证
- **自定义参数**: 支持任意SDK兼容的自定义参数
- **未来兼容**: 新参数无需修改核心代码即可使用
- **类型保持**: 保持参数原始类型和结构
## 🚀 参数生效机制
### OpenAI兼容提供商 (OpenAI, DeepSeek, Zhipu, SiliconFlow, Custom)
#### 参数传递流程
```typescript
// 1. 分离特殊参数
const {
timeout, // 客户端配置参数
model, // 避免覆盖主模型配置
messages, // 避免覆盖主消息
...restLlmParams // 所有其他参数
} = modelConfig.llmParams || {};
// 2. 创建客户端实例
const openai = new OpenAI({
apiKey,
baseURL,
timeout: timeout || (isStream ? 90000 : 60000), // 仅timeout有特殊处理
maxRetries: isStream ? 2 : 3
});
// 3. 构建API请求 - 无默认值设置
const completionConfig = {
model: modelConfig.defaultModel,
messages: formattedMessages,
...restLlmParams // 直接传递所有其他参数
};
// 4. 发送请求
const response = await openai.chat.completions.create(completionConfig);
```
#### 支持的参数
| 参数名 | 类型 | 范围 | 说明 |
|--------|------|------|------|
| `timeout` | integer | ≥1000 | 请求超时(毫秒) - 客户端配置 |
| `temperature` | number | 0.0-2.0 | 控制输出随机性 |
| `max_tokens` | integer | ≥1 | 最大生成token数量 |
| `top_p` | number | 0.0-1.0 | 核心采样参数 |
| `presence_penalty` | number | -2.0-2.0 | 存在惩罚 |
| `frequency_penalty` | number | -2.0-2.0 | 频率惩罚 |
| `stop` | array | - | 停止序列 |
| `seed` | integer | - | 随机种子 |
| `stream` | boolean | - | 流式输出(系统自动处理) |
### Gemini提供商
#### 参数传递流程
```typescript
// 1. 分离已知参数和未知参数
const {
temperature,
maxOutputTokens,
topP,
topK,
candidateCount,
stopSequences,
...otherSafeParams // 未知参数也会传递
} = modelConfig.llmParams || {};
// 2. 构建生成配置 - 无默认值设置
const generationConfig = { ...otherSafeParams };
// 3. 仅添加用户明确配置的参数
if (temperature !== undefined) {
generationConfig.temperature = temperature;
}
if (maxOutputTokens !== undefined) {
generationConfig.maxOutputTokens = maxOutputTokens;
}
// ... 其他参数类似处理
// 4. 创建聊天会话
const chat = model.startChat({
history: formatHistory(messages),
...(Object.keys(generationConfig).length > 0 && { generationConfig })
});
```
#### 支持的参数
| 参数名 | 类型 | 范围 | 说明 |
|--------|------|------|------|
| `temperature` | number | 0.0-2.0 | 控制输出随机性 |
| `maxOutputTokens` | integer | ≥1 | 最大输出token数量 |
| `topP` | number | 0.0-1.0 | 核心采样参数 |
| `topK` | integer | ≥1 | Top-K采样 |
| `candidateCount` | integer | 1-8 | 候选响应数量 |
| `stopSequences` | array | - | 停止序列数组 |
## 🎯 UI智能参数管理
### 参数类型自动识别
系统会根据模型的`provider`字段自动显示相关参数:
```typescript
// 根据provider过滤参数定义
const availableParams = advancedParameterDefinitions.filter(def =>
def.appliesToProviders.includes(currentProvider) &&
!Object.keys(currentParams).includes(def.name)
);
```
### 提供商映射关系
```typescript
const providerMapping = {
// OpenAI兼容类型
'openai': ['temperature', 'top_p', 'max_tokens', 'presence_penalty', 'frequency_penalty', 'timeout'],
'deepseek': ['temperature', 'top_p', 'max_tokens', 'presence_penalty', 'frequency_penalty', 'timeout'],
'zhipu': ['temperature', 'top_p', 'max_tokens', 'presence_penalty', 'frequency_penalty', 'timeout'],
'siliconflow': ['temperature', 'top_p', 'max_tokens', 'presence_penalty', 'frequency_penalty', 'timeout'],
'custom': ['temperature', 'top_p', 'max_tokens', 'presence_penalty', 'frequency_penalty', 'timeout'],
// Gemini类型
'gemini': ['temperature', 'topP', 'maxOutputTokens', 'topK', 'candidateCount', 'stopSequences']
};
```
### UI显示增强
- 显示当前提供商类型
- 显示可选参数数量
- 彩色状态指示
- 自动过滤已配置参数
## 📋 配置示例
### OpenAI模型配置
```json
{
"name": "OpenAI GPT-4",
"provider": "openai",
"llmParams": {
"temperature": 0.3, // 低随机性,更确定的输出
"max_tokens": 4096, // 限制输出长度
"top_p": 0.8, // 核心采样
"presence_penalty": 0.1, // 鼓励新话题
"timeout": 90000 // 90秒超时
}
}
```
### DeepSeek模型配置
```json
{
"name": "DeepSeek Coder V3",
"provider": "deepseek",
"llmParams": {
"temperature": 0.1, // 代码生成需要低随机性
"max_tokens": 8192, // 较长的代码输出
"top_p": 0.95, // 平衡多样性和质量
"timeout": 120000 // 代码生成可能需要更长时间
}
}
```
### Gemini模型配置
```json
{
"name": "Gemini Pro",
"provider": "gemini",
"llmParams": {
"temperature": 0.8, // 创意任务高随机性
"maxOutputTokens": 2048, // 适中输出长度
"topP": 0.95, // 核心采样
"topK": 40, // Top-K采样
"candidateCount": 1, // 单个响应
"stopSequences": ["END", "STOP"] // 自定义停止词
}
}
```
### 自定义模型配置
```json
{
"name": "Custom LLaMA",
"provider": "custom",
"llmParams": {
"temperature": 0.7,
"max_tokens": 4096,
// 自定义参数示例
"repetition_penalty": 1.1,
"do_sample": true,
"pad_token_id": 0,
"eos_token_id": 2
}
}
```
## 🔍 验证与调试
### 参数验证API
```typescript
import { validateLLMParams } from '@prompt-optimizer/core';
const validation = validateLLMParams(llmParams, provider);
if (!validation.isValid) {
console.error('参数验证失败:', validation.errors);
validation.errors.forEach(error => {
console.error(`- ${error.parameterName}: ${error.message}`);
});
}
if (validation.warnings.length > 0) {
console.warn('参数警告:', validation.warnings);
validation.warnings.forEach(warning => {
console.warn(`- ${warning.parameterName}: ${warning.message}`);
});
}
```
### 测试每个参数
系统为每个参数提供独立的测试用例:
```typescript
// 测试temperature参数
await testParameter('temperature', 0.3, provider);
// 测试max_tokens参数
await testParameter('max_tokens', 100, provider);
// 测试组合参数
await testParameters({
temperature: 0.6,
max_tokens: 150,
top_p: 0.9
}, provider);
```
## ⚡ 最佳实践
### 1. 参数选择策略
```typescript
// 代码生成任务
const codingParams = {
temperature: 0.1, // 低随机性
max_tokens: 8192, // 长输出
top_p: 0.95 // 高质量采样
};
// 创意写作任务
const creativeParams = {
temperature: 0.8, // 高随机性
max_tokens: 2048, // 适中输出
top_p: 0.9, // 平衡采样
presence_penalty: 0.3 // 鼓励新想法
};
// 问答任务
const qaParams = {
temperature: 0.3, // 中等随机性
max_tokens: 1024, // 简洁回答
frequency_penalty: 0.1 // 避免重复
};
```
### 2. 渐进式调优
```typescript
// 第一步:基础配置
let params = {
temperature: 0.7
};
// 第二步:添加输出控制
params = {
...params,
max_tokens: 2048,
top_p: 0.9
};
// 第三步:精细调整
params = {
...params,
presence_penalty: 0.1,
frequency_penalty: 0.1
};
```
### 3. 性能优化
```typescript
// 快速响应场景
const fastParams = {
max_tokens: 512, // 限制输出长度
timeout: 30000 // 较短超时
};
// 高质量场景
const qualityParams = {
temperature: 0.2, // 低随机性
top_p: 0.8, // 精确采样
timeout: 120000 // 较长超时
};
```
## 🛠️ 故障排除
### 常见问题诊断
1. **参数不生效**
```typescript
// 检查参数名是否正确
console.log('支持的参数:', advancedParameterDefinitions
.filter(def => def.appliesToProviders.includes(provider))
.map(def => def.name));
```
2. **类型错误**
```typescript
// 确保参数类型正确
const temperature = parseFloat(userInput); // 确保是number
const maxTokens = parseInt(userInput, 10); // 确保是integer
```
3. **范围错误**
```typescript
// 检查参数范围
if (temperature < 0 || temperature > 2) {
throw new Error('temperature必须在0-2之间');
}
```
### 调试工具
1. **启用详细日志**
```typescript
// 在modelManager中启用调试
const debugMode = process.env.NODE_ENV === 'development';
if (debugMode) {
console.log('LLM参数配置:', llmParams);
console.log('当前provider:', provider);
}
```
2. **参数传递跟踪**
```typescript
// 查看实际传递的参数
console.log('传递给SDK的参数:', {
...completionConfig,
provider,
timestamp: new Date().toISOString()
});
```
## 📝 更新日志
### 2024.12.20 - 参数透明化更新
- ✅ 移除所有自动设置的默认值
- ✅ 改进参数类型自动过滤
- ✅ 优化UI显示和标签
- ✅ 增强测试覆盖率
- ✅ 添加参数组合测试
- ✅ 完善故障排除指南
### 核心改进
- **透明化原则**: 只传递用户明确配置的参数
- **智能分类**: 根据provider自动显示相关参数
- **UI优化**: 移除标签中的冗余提供商标识
- **测试完善**: 为每个参数添加独立测试用例
+34 -4
View File
@@ -340,10 +340,40 @@
### 4.4 模型管理流程
1. **模型配置管理**
- 模型配置更新
- 连接测试
- 配置验证
- 错误处理
- 模型配置更新: 用户可以更新模型的名称、基础URL、API密钥、可用模型列表、默认模型以及是否启用。
- **高级LLM参数 (`llmParams`)**:
- `ModelConfig` 接口包含一个 `llmParams?: Record<string, any>;` 字段。
- 此字段允许用户为每个模型配置提供一个灵活的键值对映射,用于指定特定于该LLM提供商SDK的参数。
- 用户可以添加其LLM SDK支持的任何参数。
- **示例**:
- **OpenAI/OpenAI兼容API (如 DeepSeek, Zhipu):**
```json
"llmParams": {
"temperature": 0.7,
"max_tokens": 4096,
"timeout": 60000, // 用于OpenAI客户端的请求超时 (毫秒)
"top_p": 0.9,
"frequency_penalty": 0.5
// ... 其他OpenAI支持的参数
}
```
- **Gemini:**
```json
"llmParams": {
"temperature": 0.8,
"maxOutputTokens": 2048, // 注意: Gemini使用maxOutputTokens
"topP": 0.95,
"topK": 40
// ... 其他Gemini支持的参数
}
```
- **`LLMService` 如何处理 `llmParams`**:
- 对于OpenAI兼容的API, `timeout` 值(如果提供)用于配置OpenAI JavaScript SDK客户端实例的超时设置。其余参数(如 `temperature`, `max_tokens`, `top_p` 等)会直接传递给 `chat.completions.create()` 方法。
- 对于Gemini, `temperature`, `maxOutputTokens`, `topP`, `topK` 等参数会包含在传递给 `model.startChat()` 的 `generationConfig` 对象中。
- 未被服务明确处理的参数(即非 `timeout` for OpenAI, 或非已知Gemini参数)通常会被安全地传递给相应SDK的请求中,如果SDK支持它们。
- 连接测试: 验证API密钥和基础URL是否正确,以及模型是否可用。
- 配置验证: 确保所有必填字段都已填写,并且格式正确。`llmParams` 字段(如果提供)必须是一个对象。
- 错误处理: 在配置不正确或连接失败时提供明确的错误信息。
2. **API密钥管理**
- 密钥设置与加密
-13
View File
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@prompt-optimizer/core": ["packages/core"],
"@prompt-optimizer/ui": ["packages/ui"],
"@prompt-optimizer/web": ["packages/web"],
"@prompt-optimizer/extension": ["packages/extension"]
}
},
"include": ["packages/*/src"],
"exclude": ["node_modules", "**/node_modules", "**/dist"]
}
+10
View File
@@ -20,6 +20,16 @@ export * from './services/llm/errors'
export { ModelManager, modelManager } from './services/model/manager'
export * from './services/model/types'
export * from './services/model/defaults'
export * from './services/model/advancedParameterDefinitions'
export {
validateLLMParams,
getSupportedParameters
} from './services/model/validation'
export type {
ValidationResult,
ValidationError as LLMValidationError,
ValidationWarning
} from './services/model/validation'
// 导出提示词服务相关
export { PromptService, createPromptService } from './services/prompt/service'
+4 -2
View File
@@ -156,7 +156,8 @@ export class DataManager {
provider: model.provider || existingModel.provider,
enabled: model.enabled !== undefined ? model.enabled : existingModel.enabled, // 优先使用导入的启用状态
...(model.apiKey !== undefined && { apiKey: model.apiKey }),
...(model.useVercelProxy !== undefined && { useVercelProxy: model.useVercelProxy })
...(model.useVercelProxy !== undefined && { useVercelProxy: model.useVercelProxy }),
...(model.llmParams !== undefined && { llmParams: model.llmParams })
};
await this.modelManagerInstance.updateModel(model.key, mergedConfig);
console.log(`模型 ${model.key} 已存在,已更新配置(使用导入的启用状态: ${mergedConfig.enabled})`);
@@ -170,7 +171,8 @@ export class DataManager {
provider: model.provider || 'custom',
enabled: model.enabled !== undefined ? model.enabled : false, // 使用导入的启用状态,默认为false
...(model.apiKey !== undefined && { apiKey: model.apiKey }),
...(model.useVercelProxy !== undefined && { useVercelProxy: model.useVercelProxy })
...(model.useVercelProxy !== undefined && { useVercelProxy: model.useVercelProxy }),
...(model.llmParams !== undefined && { llmParams: model.llmParams })
};
await this.modelManagerInstance.addModel(model.key, newModelConfig);
console.log(`已导入新模型 ${model.key}(启用状态: ${newModelConfig.enabled})`);
+100 -20
View File
@@ -79,20 +79,18 @@ export class LLMService implements ILLMService {
}
// 创建OpenAI实例配置
const defaultTimeout = isStream ? 90000 : 60000;
const timeout = modelConfig.llmParams?.timeout !== undefined
? modelConfig.llmParams.timeout
: defaultTimeout;
const config: any = {
apiKey: apiKey,
baseURL: finalBaseURL,
dangerouslyAllowBrowser: true,
timeout: 60000, // 设置60秒超时时间,文本生成可能需要较长时间
maxRetries: 3 // 设置重试次数
timeout: timeout, // Use the new timeout logic
maxRetries: isStream ? 2 : 3
};
// 为流式请求添加额外配置
if (isStream) {
config.timeout = 90000; // 流式请求使用更长的超时时间
config.maxRetries = 2; // 流式请求减少重试次数
}
const instance = new OpenAI(config);
return instance;
@@ -142,11 +140,19 @@ export class LLMService implements ILLMService {
content: msg.content
}));
const response = await openai.chat.completions.create({
const {
timeout, // Handled in getOpenAIInstance
model: llmParamsModel, // Avoid overriding main model
messages: llmParamsMessages, // Avoid overriding main messages
...restLlmParams
} = modelConfig.llmParams || {};
const completionConfig: any = {
model: modelConfig.defaultModel,
messages: formattedMessages,
temperature: 0.7
});
...restLlmParams // Spread other params from llmParams
};
const response = await openai.chat.completions.create(completionConfig);
return response.choices[0].message.content || '';
}
@@ -168,9 +174,15 @@ export class LLMService implements ILLMService {
const conversationMessages = messages.filter(msg => msg.role !== 'system');
// 创建聊天会话
const chat = model.startChat({
const generationConfig = this.buildGeminiGenerationConfig(modelConfig.llmParams);
const chatOptions: any = {
history: this.formatGeminiHistory(conversationMessages)
});
};
if (Object.keys(generationConfig).length > 0) {
chatOptions.generationConfig = generationConfig;
}
const chat = model.startChat(chatOptions);
// 获取最后一条用户消息
const lastUserMessage = conversationMessages.length > 0 &&
@@ -310,16 +322,28 @@ export class LLMService implements ILLMService {
try {
console.log('开始创建流式请求...');
const stream = await openai.chat.completions.create({
const {
timeout, // Handled in getOpenAIInstance
model: llmParamsModel, // Avoid overriding main model
messages: llmParamsMessages, // Avoid overriding main messages
stream: llmParamsStream, // Avoid overriding main stream flag
...restLlmParams
} = modelConfig.llmParams || {};
const completionConfig: any = {
model: modelConfig.defaultModel,
messages: formattedMessages,
temperature: 0.7,
stream: true
});
stream: true, // Essential for streaming
...restLlmParams // User-defined parameters from llmParams
};
// 直接使用流式响应,无需类型转换
const stream = await openai.chat.completions.create(completionConfig);
console.log('成功获取到流式响应');
for await (const chunk of stream) {
// 使用类型断言来确保TypeScript知道这是流式响应
for await (const chunk of stream as any) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
console.log('收到数据块:', {
@@ -363,9 +387,15 @@ export class LLMService implements ILLMService {
const conversationMessages = messages.filter(msg => msg.role !== 'system');
// 创建聊天会话
const chat = model.startChat({
const generationConfig = this.buildGeminiGenerationConfig(modelConfig.llmParams);
const chatOptions: any = {
history: this.formatGeminiHistory(conversationMessages)
});
};
if (Object.keys(generationConfig).length > 0) {
chatOptions.generationConfig = generationConfig;
}
const chat = model.startChat(chatOptions);
// 获取最后一条用户消息
const lastUserMessage = conversationMessages.length > 0 &&
@@ -588,6 +618,56 @@ export class LLMService implements ILLMService {
];
}
}
/**
* 构建Gemini生成配置
*
* 注意:此方法假设传入的 llmParams 已经通过 ModelManager.validateConfig()
* 中的 validateLLMParams 验证,确保安全性
*/
private buildGeminiGenerationConfig(llmParams: Record<string, any> = {}): any {
const {
temperature,
maxOutputTokens,
topP,
topK,
candidateCount,
stopSequences,
...otherParams
} = llmParams;
const generationConfig: any = {};
// 添加已知参数
if (temperature !== undefined) {
generationConfig.temperature = temperature;
}
if (maxOutputTokens !== undefined) {
generationConfig.maxOutputTokens = maxOutputTokens;
}
if (topP !== undefined) {
generationConfig.topP = topP;
}
if (topK !== undefined) {
generationConfig.topK = topK;
}
if (candidateCount !== undefined) {
generationConfig.candidateCount = candidateCount;
}
if (stopSequences !== undefined && Array.isArray(stopSequences)) {
generationConfig.stopSequences = stopSequences;
}
// 添加其他参数 (已在上层验证过安全性)
// 排除一些明显不属于 Gemini generationConfig 的参数
for (const [key, value] of Object.entries(otherParams)) {
if (!['timeout', 'model', 'messages', 'stream'].includes(key)) {
generationConfig[key] = value;
}
}
return generationConfig;
}
}
// 导出工厂函数
@@ -0,0 +1,178 @@
// packages/core/src/services/model/advancedParameterDefinitions.ts
export interface AdvancedParameterDefinition {
id: string; // Unique ID across all definitions, e.g., "openai_temperature"
name: string; // Actual parameter name used by the SDK, e.g., "temperature"
labelKey: string; // i18n key for UI label, e.g., "params.temperature.label"
descriptionKey: string; // i18n key for UI description, e.g., "params.temperature.description"
type: 'number' | 'string' | 'boolean' | 'integer';
defaultValue?: any;
minValue?: number;
maxValue?: number;
step?: number;
unit?: string; // e.g., "ms"
unitKey?: string; // i18n key for the unit, e.g., "params.tokens.unit"
appliesToProviders: string[]; // e.g., ["openai", "deepseek"] or ["gemini"]
// Optional: For string types with predefined choices
// allowedValues?: string[];
// allowedValuesLabelsKey?: string; // i18n key for labels of allowedValues (if they differ from values)
}
export const advancedParameterDefinitions: AdvancedParameterDefinition[] = [
// Common Parameters (can be mapped or used if name matches)
{
id: "common_temperature",
name: "temperature",
labelKey: "params.temperature.label",
descriptionKey: "params.temperature.description",
type: "number",
defaultValue: 0.7,
minValue: 0.0,
maxValue: 2.0, // Max for OpenAI; Gemini is often 0-1. UI might need to adjust range based on provider.
step: 0.1,
appliesToProviders: ["openai", "gemini", "deepseek", "custom", "zhipu", "siliconflow"]
},
{
id: "common_top_p",
name: "top_p", // OpenAI, Zhipu, SiliconFlow use top_p
labelKey: "params.top_p.label",
descriptionKey: "params.top_p.description",
type: "number",
defaultValue: 1.0,
minValue: 0.0,
maxValue: 1.0,
step: 0.01,
appliesToProviders: ["openai", "deepseek", "custom", "zhipu", "siliconflow"]
},
// OpenAI / OpenAI-Compatible Specific
{
id: "openai_max_tokens",
name: "max_tokens",
labelKey: "params.max_tokens.label",
descriptionKey: "params.max_tokens.description",
type: "integer",
defaultValue: 40000,
minValue: 1,
step: 1,
unitKey: "params.tokens.unit",
appliesToProviders: ["openai", "deepseek", "custom", "zhipu", "siliconflow"]
},
{
id: "openai_presence_penalty",
name: "presence_penalty",
labelKey: "params.presence_penalty.label",
descriptionKey: "params.presence_penalty.description",
type: "number",
defaultValue: 0,
minValue: -2.0,
maxValue: 2.0,
step: 0.1,
appliesToProviders: ["openai", "deepseek", "custom", "zhipu", "siliconflow"]
},
{
id: "openai_frequency_penalty",
name: "frequency_penalty",
labelKey: "params.frequency_penalty.label",
descriptionKey: "params.frequency_penalty.description",
type: "number",
defaultValue: 0,
minValue: -2.0,
maxValue: 2.0,
step: 0.1,
appliesToProviders: ["openai", "deepseek", "custom", "zhipu", "siliconflow"]
},
{
id: "openai_timeout", // This is a client configuration for OpenAI
name: "timeout",
labelKey: "params.timeout.label",
descriptionKey: "params.timeout.description_openai", // Specific description
type: "integer",
defaultValue: 60000,
minValue: 1000,
step: 1000,
unit: "ms",
appliesToProviders: ["openai", "deepseek", "custom", "zhipu", "siliconflow"]
},
// Gemini Specific
{
id: "gemini_maxOutputTokens",
name: "maxOutputTokens",
labelKey: "params.maxOutputTokens.label",
descriptionKey: "params.maxOutputTokens.description",
type: "integer",
defaultValue: 40000,
minValue: 1,
step: 1,
unitKey: "params.tokens.unit",
appliesToProviders: ["gemini"]
},
{
id: "gemini_topP", // Gemini uses "topP" (capital P)
name: "topP",
labelKey: "params.top_p.label", // Can share label if meaning is identical
descriptionKey: "params.top_p.description", // Can share description
type: "number",
defaultValue: 1.0, // Or Gemini's typical default if different
minValue: 0.0,
maxValue: 1.0,
step: 0.01,
appliesToProviders: ["gemini"]
},
{
id: "gemini_topK",
name: "topK",
labelKey: "params.top_k.label",
descriptionKey: "params.top_k.description",
type: "integer",
defaultValue: 1, // Gemini default, but often user wants to adjust
minValue: 1,
step: 1,
appliesToProviders: ["gemini"]
},
{
id: "gemini_candidateCount",
name: "candidateCount",
labelKey: "params.candidateCount.label",
descriptionKey: "params.candidateCount.description",
type: "integer",
defaultValue: 1,
minValue: 1,
maxValue: 8, // Check Gemini docs for actual max
step: 1,
appliesToProviders: ["gemini"]
},
{
id: "gemini_stopSequences",
name: "stopSequences", // Array of strings
labelKey: "params.stopSequences.label",
descriptionKey: "params.stopSequences.description",
type: "string", // Special handling: array of strings but UI input as comma-separated string
defaultValue: [], // Array of strings
appliesToProviders: ["gemini"]
},
// Add more definitions as needed for other parameters and providers.
// For example, Zhipu specific parameters, Groq, Anthropic etc.
];
// It might be useful to also export a map for easier lookup by id or name
// export const advancedParameterDefinitionsMapById: Record<string, AdvancedParameterDefinition> =
// advancedParameterDefinitions.reduce((acc, def) => {
// acc[def.id] = def;
// return acc;
// }, {} as Record<string, AdvancedParameterDefinition>);
// export const advancedParameterDefinitionsMapByName: Record<string, AdvancedParameterDefinition> =
// advancedParameterDefinitions.reduce((acc, def) => {
// acc[def.name] = def; // Note: 'name' might not be unique across all providers if we don't make it so
// return acc;
// }, {} as Record<string, AdvancedParameterDefinition>);
// Consider provider-specific name uniqueness if using name as a map key.
// For UI filtering, iterating the array and filtering by appliesToProviders is often sufficient.
+18 -6
View File
@@ -51,7 +51,9 @@ export const defaultModels: Record<string, ModelConfig> = {
defaultModel: 'gpt-3.5-turbo',
apiKey: OPENAI_API_KEY,
enabled: !!OPENAI_API_KEY,
provider: 'openai'
provider: 'openai',
llmParams: {
}
},
gemini: {
name: 'Gemini',
@@ -60,7 +62,9 @@ export const defaultModels: Record<string, ModelConfig> = {
defaultModel: 'gemini-2.0-flash',
apiKey: GEMINI_API_KEY,
enabled: !!GEMINI_API_KEY,
provider: 'gemini'
provider: 'gemini',
llmParams: {
}
},
deepseek: {
name: 'DeepSeek',
@@ -69,7 +73,9 @@ export const defaultModels: Record<string, ModelConfig> = {
defaultModel: 'deepseek-chat',
apiKey: DEEPSEEK_API_KEY,
enabled: !!DEEPSEEK_API_KEY,
provider: 'deepseek'
provider: 'deepseek',
llmParams: {
}
},
siliconflow: {
name: 'SiliconFlow',
@@ -78,7 +84,9 @@ export const defaultModels: Record<string, ModelConfig> = {
defaultModel: 'Pro/deepseek-ai/DeepSeek-V3',
apiKey: SILICONFLOW_API_KEY,
enabled: !!SILICONFLOW_API_KEY,
provider: 'siliconflow'
provider: 'siliconflow',
llmParams: {
}
},
zhipu: {
name: 'Zhipu',
@@ -87,7 +95,9 @@ export const defaultModels: Record<string, ModelConfig> = {
defaultModel: 'glm-4-flash',
apiKey: ZHIPU_API_KEY,
enabled: !!ZHIPU_API_KEY,
provider: 'zhipu'
provider: 'zhipu',
llmParams: {
}
},
custom: {
name: 'Custom',
@@ -96,6 +106,8 @@ export const defaultModels: Record<string, ModelConfig> = {
defaultModel: CUSTOM_API_MODEL,
apiKey: CUSTOM_API_KEY,
enabled: !!CUSTOM_API_KEY,
provider: 'custom'
provider: 'custom',
llmParams: {
}
}
};
+76 -7
View File
@@ -4,6 +4,7 @@ import { StorageFactory } from '../storage/factory';
import { StorageAdapter } from '../storage/adapter';
import { defaultModels } from './defaults';
import { ModelConfigError } from '../llm/errors';
import { validateLLMParams } from './validation';
/**
* 模型管理器实现
@@ -36,10 +37,45 @@ export class ModelManager implements IModelManager {
let hasChanges = false;
Object.entries(defaultModels).forEach(([key, config]) => {
if (!this.models[key]) {
this.models[key] = {
...config
this.models[key] = {
...config,
// Deep copy llmParams to avoid reference sharing
...(config.llmParams && { llmParams: { ...config.llmParams } })
};
hasChanges = true;
} else { // Model exists in storage, check for llmParams updates
let modelUpdated = false;
if (config.llmParams) { // If default config has llmParams
if (!this.models[key].llmParams) { // If stored model has no llmParams
this.models[key].llmParams = { ...config.llmParams };
modelUpdated = true;
} else { // Stored model has llmParams, merge new default keys
for (const paramKey in config.llmParams) {
if (this.models[key].llmParams[paramKey] === undefined && config.llmParams.hasOwnProperty(paramKey)) {
this.models[key].llmParams[paramKey] = config.llmParams[paramKey];
modelUpdated = true;
}
}
}
}
// Ensure llmParams is an object if it was created/modified
if (this.models[key].llmParams && (typeof this.models[key].llmParams !== 'object' || this.models[key].llmParams === null)) {
this.models[key].llmParams = {}; // Initialize to empty object if invalid
modelUpdated = true;
}
// Remove old top-level properties if they exist on stored model
const oldParams = ['maxTokens', 'temperature', 'timeout'];
for (const oldParam of oldParams) {
if (this.models[key].hasOwnProperty(oldParam)) {
delete (this.models[key] as any)[oldParam];
modelUpdated = true;
}
}
if (modelUpdated) {
hasChanges = true;
}
}
});
@@ -104,13 +140,21 @@ export class ModelManager implements IModelManager {
}
return {
...models,
[key]: { ...config }
[key]: {
...config,
// Deep copy llmParams to avoid reference sharing
...(config.llmParams && { llmParams: { ...config.llmParams } })
}
};
}
);
// 更新内存状态
this.models[key] = { ...config };
this.models[key] = {
...config,
// Deep copy llmParams to avoid reference sharing
...(config.llmParams && { llmParams: { ...config.llmParams } })
};
}
/**
@@ -130,7 +174,11 @@ export class ModelManager implements IModelManager {
throw new ModelConfigError(`模型 ${key} 不存在`);
}
// 如果是内置模型但尚未配置,创建初始配置
models[key] = { ...defaultModels[key] };
models[key] = {
...defaultModels[key],
// Deep copy llmParams to avoid reference sharing
...(defaultModels[key].llmParams && { llmParams: { ...defaultModels[key].llmParams } })
};
}
// 合并配置时保留原有 enabled 状态
@@ -138,16 +186,19 @@ export class ModelManager implements IModelManager {
...models[key],
...config,
// 确保 enabled 属性存在
enabled: config.enabled !== undefined ? config.enabled : models[key].enabled
enabled: config.enabled !== undefined ? config.enabled : models[key].enabled,
// Deep copy llmParams to avoid reference sharing
...(config.llmParams && { llmParams: { ...config.llmParams } })
};
// 如果更新了关键字段或尝试启用模型,需要验证配置
// 如果更新了关键字段, 尝试启用模型, 或者更新了llmParams,需要验证配置
if (
config.name !== undefined ||
config.baseURL !== undefined ||
config.models !== undefined ||
config.defaultModel !== undefined ||
config.apiKey !== undefined ||
config.llmParams !== undefined || // Added llmParams as a trigger
config.enabled
) {
this.validateConfig(updatedConfig);
@@ -268,6 +319,24 @@ export class ModelManager implements IModelManager {
errors.push('默认模型必须在模型列表中');
}
// Validate llmParams structure
if (config.llmParams !== undefined && (typeof config.llmParams !== 'object' || config.llmParams === null || Array.isArray(config.llmParams))) {
errors.push('llmParams必须是一个对象');
}
// Validate llmParams content for security and correctness
if (config.llmParams && typeof config.llmParams === 'object') {
const provider = config.provider || 'openai'; // Default to openai provider for validation
const validation = validateLLMParams(config.llmParams, provider);
if (!validation.isValid) {
const paramErrors = validation.errors.map(error =>
`参数 ${error.parameterName}: ${error.message}`
);
errors.push(...paramErrors);
}
}
if (errors.length > 0) {
throw new ModelConfigError('无效的模型配置:' + errors.join('、'));
}
@@ -18,6 +18,8 @@ export interface ModelConfig {
provider: 'deepseek' | 'gemini' | 'custom' | 'zhipu' | string;
/** 是否使用Vercel代理 */
useVercelProxy?: boolean;
/** LLM特定参数 */
llmParams?: Record<string, any>;
}
/**
@@ -0,0 +1,194 @@
import { advancedParameterDefinitions, AdvancedParameterDefinition } from './advancedParameterDefinitions';
// 危险参数黑名单
const DANGEROUS_PARAMS = [
'eval', 'exec', 'function', 'script', 'code',
'apiKey', 'api_key', 'secret', 'password', 'credentials',
'authorization', 'baseURL', 'base_url', 'endpoint', 'url',
'__proto__', 'constructor', 'prototype', 'require', 'import'
];
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
parameterName: string;
parameterValue: any;
message: string;
expectedType?: string;
expectedRange?: string;
}
export interface ValidationWarning {
parameterName: string;
parameterValue: any;
message: string;
}
/**
* 检查参数是否在危险黑名单中
*/
function isDangerousParameter(paramName: string): boolean {
return DANGEROUS_PARAMS.some(dangerous =>
paramName.toLowerCase().includes(dangerous.toLowerCase())
);
}
/**
* 验证llmParams中的已知参数(增强安全版本)
*/
export function validateLLMParams(
llmParams: Record<string, any> | undefined,
provider: string
): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: []
};
if (!llmParams) {
return result;
}
// 获取该提供商支持的参数定义
const supportedParams = advancedParameterDefinitions.filter(
def => def.appliesToProviders.includes(provider)
);
// 验证每个已知参数
for (const [paramName, paramValue] of Object.entries(llmParams)) {
// 首先检查危险参数
if (isDangerousParameter(paramName)) {
result.isValid = false;
result.errors.push({
parameterName: paramName,
parameterValue: paramValue,
message: `Parameter '${paramName}' is potentially dangerous and not allowed for security reasons.`
});
continue;
}
const paramDef = supportedParams.find(def => def.name === paramName);
if (!paramDef) {
// 未知参数警告 - 统一使用黑名单机制
// 对于个人化前端应用,未知参数不应该导致验证失败,只产生警告
result.warnings.push({
parameterName: paramName,
parameterValue: paramValue,
message: `Parameter '${paramName}' is not defined in parameter specifications for ${provider}. It will be passed to the SDK but may not be supported or could cause issues.`
});
continue;
}
// 验证参数类型和范围
const validation = validateSingleParameter(paramValue, paramDef);
if (!validation.isValid) {
result.isValid = false;
result.errors.push({
parameterName: paramName,
parameterValue: paramValue,
message: validation.message!,
expectedType: paramDef.type,
expectedRange: getExpectedRange(paramDef)
});
}
}
return result;
}
function validateSingleParameter(
value: any,
def: AdvancedParameterDefinition
): { isValid: boolean; message?: string } {
// Special validation: stopSequences should be string array (although type is defined as string, but actually is array)
if (def.name === 'stopSequences') {
if (!Array.isArray(value)) {
return {
isValid: false,
message: `Parameter 'stopSequences' should be a string array, but received ${typeof value}`
};
}
if (!value.every(item => typeof item === 'string')) {
return {
isValid: false,
message: `Parameter 'stopSequences' array should only contain strings`
};
}
// Skip regular type check for stopSequences
return { isValid: true };
}
// Type validation
if (!validateType(value, def.type)) {
return {
isValid: false,
message: `Parameter '${def.name}' should be of type ${def.type}, but received ${typeof value}`
};
}
// Numeric range validation
if (def.type === 'number' || def.type === 'integer') {
if (def.minValue !== undefined && value < def.minValue) {
return {
isValid: false,
message: `Parameter '${def.name}' value ${value} is less than minimum value ${def.minValue}`
};
}
if (def.maxValue !== undefined && value > def.maxValue) {
return {
isValid: false,
message: `Parameter '${def.name}' value ${value} is greater than maximum value ${def.maxValue}`
};
}
}
return { isValid: true };
}
function validateType(value: any, expectedType: string): boolean {
switch (expectedType) {
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'integer':
return typeof value === 'number' && Number.isInteger(value);
case 'string':
return typeof value === 'string';
case 'boolean':
return typeof value === 'boolean';
default:
return true;
}
}
function getExpectedRange(def: AdvancedParameterDefinition): string {
if (def.type === 'number' || def.type === 'integer') {
if (def.minValue !== undefined && def.maxValue !== undefined) {
return `${def.minValue} - ${def.maxValue}`;
} else if (def.minValue !== undefined) {
return `>= ${def.minValue}`;
} else if (def.maxValue !== undefined) {
return `<= ${def.maxValue}`;
}
}
return '';
}
/**
* 获取提供商支持的参数列表
*/
export function getSupportedParameters(provider: string): AdvancedParameterDefinition[] {
return advancedParameterDefinitions.filter(
def => def.appliesToProviders.includes(provider)
);
}
@@ -1,4 +1,4 @@
import { createLLMService, ModelManager } from '../../../src/index.js';
import { createLLMService, ModelManager, LocalStorageProvider } from '../../../src/index.js';
import { expect, describe, it, beforeEach, beforeAll } from 'vitest';
import dotenv from 'dotenv';
import path from 'path';
@@ -19,7 +19,8 @@ describe('DeepSeek API 测试', () => {
}
it('应该能正确调用 DeepSeek API', async () => {
const modelManager = new ModelManager();
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
// 更新 DeepSeek 配置
@@ -39,7 +40,8 @@ describe('DeepSeek API 测试', () => {
}, 25000);
it('应该能正确处理多轮对话', async () => {
const modelManager = new ModelManager();
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
// 更新 DeepSeek 配置
@@ -0,0 +1,203 @@
import { createLLMService, ModelManager, LocalStorageProvider } from '../../../src/index.js';
import { expect, describe, it, beforeEach, beforeAll } from 'vitest';
import dotenv from 'dotenv';
import path from 'path';
// 加载环境变量
beforeAll(() => {
dotenv.config({ path: path.resolve(process.cwd(), '.env.local') });
});
describe('OpenAI API 真实连接测试', () => {
// 检查OpenAI兼容的环境变量(任何一个存在就可以运行测试)
const openaiCompatibleKeys = [
'OPENAI_API_KEY', 'VITE_OPENAI_API_KEY',
'DEEPSEEK_API_KEY', 'VITE_DEEPSEEK_API_KEY',
'SILICONFLOW_API_KEY', 'VITE_SILICONFLOW_API_KEY',
'ZHIPU_API_KEY', 'VITE_ZHIPU_API_KEY',
'CUSTOM_API_KEY', 'VITE_CUSTOM_API_KEY'
];
const availableKeys = openaiCompatibleKeys.filter(key =>
process.env[key] && process.env[key].trim()
);
if (availableKeys.length === 0) {
console.log('跳过 OpenAI 真实API测试:未设置任何 OpenAI 兼容的 API 密钥');
it.skip('应该能正确调用 OpenAI 兼容的 API', () => {});
it.skip('应该能正确处理多轮对话', () => {});
it.skip('应该能正确使用高级参数', () => {});
return;
}
// 选择第一个可用的密钥和对应的配置
const getModelConfig = () => {
if (process.env.OPENAI_API_KEY || process.env.VITE_OPENAI_API_KEY) {
return {
key: 'openai',
apiKey: process.env.OPENAI_API_KEY || process.env.VITE_OPENAI_API_KEY,
baseURL: 'https://api.openai.com/v1',
defaultModel: 'gpt-3.5-turbo'
};
}
if (process.env.DEEPSEEK_API_KEY || process.env.VITE_DEEPSEEK_API_KEY) {
return {
key: 'deepseek',
apiKey: process.env.DEEPSEEK_API_KEY || process.env.VITE_DEEPSEEK_API_KEY,
baseURL: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat'
};
}
if (process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY) {
return {
key: 'siliconflow',
apiKey: process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY,
baseURL: 'https://api.siliconflow.cn/v1',
defaultModel: 'Pro/deepseek-ai/DeepSeek-V3'
};
}
if (process.env.ZHIPU_API_KEY || process.env.VITE_ZHIPU_API_KEY) {
return {
key: 'zhipu',
apiKey: process.env.ZHIPU_API_KEY || process.env.VITE_ZHIPU_API_KEY,
baseURL: 'https://open.bigmodel.cn/api/paas/v4',
defaultModel: 'glm-4-flash'
};
}
if (process.env.CUSTOM_API_KEY || process.env.VITE_CUSTOM_API_KEY) {
const baseURL = process.env.CUSTOM_API_BASE_URL || process.env.VITE_CUSTOM_API_BASE_URL;
const model = process.env.CUSTOM_API_MODEL || process.env.VITE_CUSTOM_API_MODEL;
// 只有当baseURL和model都有值时才返回custom配置
if (baseURL && model) {
return {
key: 'custom',
apiKey: process.env.CUSTOM_API_KEY || process.env.VITE_CUSTOM_API_KEY,
baseURL: baseURL,
defaultModel: model
};
}
}
return null;
};
const modelConfig = getModelConfig();
if (!modelConfig) {
console.log('跳过 OpenAI 真实API测试:无有效的模型配置');
it.skip('应该能正确调用 OpenAI 兼容的 API', () => {});
it.skip('应该能正确处理多轮对话', () => {});
it.skip('应该能正确使用高级参数', () => {});
return;
}
console.log(`使用 ${modelConfig.key} 进行 OpenAI 兼容 API 测试,模型: ${modelConfig.defaultModel}`);
it('应该能正确调用 OpenAI 兼容的 API', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
try {
// 更新模型配置
await modelManager.updateModel(modelConfig.key, {
apiKey: modelConfig.apiKey,
baseURL: modelConfig.baseURL,
defaultModel: modelConfig.defaultModel,
enabled: true,
provider: 'openai' // 所有兼容提供商都使用openai提供商类型
});
const messages = [
{ role: 'user', content: '你好,请用一句话介绍你自己' }
];
const response = await llmService.sendMessage(messages, modelConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
} catch (error) {
console.error(`API调用失败 (${modelConfig.key}):`, error.message);
// 如果是400错误,可能是配置问题,跳过测试
if (error.message.includes('400')) {
console.log(`跳过测试:${modelConfig.key} API配置可能有问题`);
return;
}
throw error;
}
}, 30000);
it('应该能正确处理多轮对话', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
try {
// 更新模型配置
await modelManager.updateModel(modelConfig.key, {
apiKey: modelConfig.apiKey,
baseURL: modelConfig.baseURL,
defaultModel: modelConfig.defaultModel,
enabled: true,
provider: 'openai'
});
const messages = [
{ role: 'user', content: '你好,我们来玩个游戏' },
{ role: 'assistant', content: '好啊,你想玩什么游戏?' },
{ role: 'user', content: '我们来玩猜数字游戏,1到100之间' }
];
const response = await llmService.sendMessage(messages, modelConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
} catch (error) {
console.error(`多轮对话测试失败 (${modelConfig.key}):`, error.message);
if (error.message.includes('400')) {
console.log(`跳过测试:${modelConfig.key} API配置可能有问题`);
return;
}
throw error;
}
}, 30000);
it('应该能正确使用高级参数', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
try {
// 更新模型配置,包含高级参数
await modelManager.updateModel(modelConfig.key, {
apiKey: modelConfig.apiKey,
baseURL: modelConfig.baseURL,
defaultModel: modelConfig.defaultModel,
enabled: true,
provider: 'openai',
llmParams: {
temperature: 0.3,
max_tokens: 100
}
});
const messages = [
{ role: 'user', content: '请用一句话回答:什么是人工智能?' }
];
const response = await llmService.sendMessage(messages, modelConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
// 由于设置了max_tokens=100,响应应该相对较短
expect(response.length).toBeLessThan(500);
} catch (error) {
console.error(`高级参数测试失败 (${modelConfig.key}):`, error.message);
if (error.message.includes('400')) {
console.log(`跳过测试:${modelConfig.key} API配置可能有问题`);
return;
}
throw error;
}
}, 30000);
});
-90
View File
@@ -1,90 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { LLMService, ModelManager, RequestConfigError, APIError } from '../src';
import { createMockStorage } from './mocks/mockStorage';
describe('LLMService', () => {
let llmService;
let modelManager;
const testProvider = 'test-provider';
const mockModelConfig = {
name: 'Test Model',
provider: 'openai',
apiKey: 'test-key',
baseURL: 'https://api.test.com',
defaultModel: 'test-model',
enabled: true,
models: ['test-model']
};
beforeEach(() => {
const mockStorage = createMockStorage();
// 模拟getAllModels返回空数组
mockStorage.getItem.mockResolvedValue(null);
modelManager = new ModelManager(mockStorage);
llmService = new LLMService(modelManager);
// 模拟getAllModels方法
vi.spyOn(modelManager, 'getAllModels').mockResolvedValue([]);
});
describe('消息验证', () => {
it('应该正确验证消息格式', () => {
const validMessages = [
{ role: 'system', content: 'test' },
{ role: 'user', content: 'test' }
];
expect(() => llmService['validateMessages'](validMessages)).not.toThrow();
});
it('当消息格式无效时应抛出错误', () => {
const invalidMessages = [
{ role: 'invalid', content: 'test' }
];
expect(() => llmService['validateMessages'](invalidMessages))
.toThrow('不支持的消息类型: invalid');
});
});
describe('模型配置验证', () => {
it('应该正确验证模型配置', () => {
expect(() => llmService['validateModelConfig'](mockModelConfig)).not.toThrow();
});
it('当模型配置无效时应抛出错误', () => {
const invalidConfig = { ...mockModelConfig, apiKey: '' };
expect(() => llmService['validateModelConfig'](invalidConfig))
.toThrow('API密钥不能为空');
});
it('当模型未启用时应抛出错误', () => {
const disabledConfig = { ...mockModelConfig, enabled: false };
expect(() => llmService['validateModelConfig'](disabledConfig))
.toThrow('模型未启用');
});
});
describe('发送消息', () => {
it('当提供商不存在时应抛出错误', async () => {
vi.spyOn(modelManager, 'getModel').mockResolvedValue(undefined);
const messages = [
{ role: 'system', content: 'test' }
];
await expect(llmService.sendMessage(messages, 'non-existent-provider'))
.rejects
.toThrow(RequestConfigError);
});
it('当消息格式无效时应抛出错误', async () => {
vi.spyOn(modelManager, 'getModel').mockResolvedValue(mockModelConfig);
const invalidMessages = [
{ role: 'invalid', content: 'test' }
];
await expect(llmService.sendMessage(invalidMessages, testProvider))
.rejects
.toThrow(RequestConfigError);
});
});
});
+185 -22
View File
@@ -295,6 +295,191 @@ describe('DataManager', () => {
expect(mockTemplateManager.deleteTemplate).toHaveBeenCalledWith(id);
});
});
it('should handle partial UI setting import failures gracefully', async () => {
const partialFailData = {
userSettings: {
'theme-id': 'dark',
'preferred-language': 'en-US',
'app:selected-optimize-model': 'model1'
}
};
// 模拟第二个设置项失败
mockStorage.setItem = vi.fn()
.mockResolvedValueOnce(undefined) // 第一个成功
.mockRejectedValueOnce(new Error('Storage quota exceeded')) // 第二个失败
.mockResolvedValueOnce(undefined); // 第三个成功
// 不应该抛出异常,而是继续导入其他设置
await expect(dataManager.importAllData(JSON.stringify(partialFailData))).resolves.not.toThrow();
// 验证失败的设置被记录但不影响其他设置的导入
expect(mockStorage.setItem).toHaveBeenCalledTimes(3);
});
it('should correctly import and export models with llmParams', async () => {
// 测试包含 llmParams 的模型配置
const modelsWithLlmParams = [
{
key: 'openai',
name: 'OpenAI Model',
provider: 'openai',
enabled: true,
baseURL: 'https://api.openai.com/v1',
models: ['gpt-3.5-turbo'],
defaultModel: 'gpt-3.5-turbo',
llmParams: {
temperature: 0.7,
max_tokens: 4096,
timeout: 30000
}
},
{
key: 'gemini',
name: 'Gemini Model',
provider: 'gemini',
enabled: false,
baseURL: 'https://generativelanguage.googleapis.com/v1beta',
models: ['gemini-2.0-flash'],
defaultModel: 'gemini-2.0-flash',
llmParams: {
temperature: 0.8,
maxOutputTokens: 2048,
topP: 0.95,
topK: 40
}
}
];
// 设置导出模拟
mockModelManager.getAllModels.mockResolvedValue(modelsWithLlmParams);
// 导出数据
const exportedData = await dataManager.exportAllData();
const parsedData = JSON.parse(exportedData);
// 验证导出的模型包含 llmParams
expect(parsedData.models).toHaveLength(2);
expect(parsedData.models[0].llmParams).toEqual({
temperature: 0.7,
max_tokens: 4096,
timeout: 30000
});
expect(parsedData.models[1].llmParams).toEqual({
temperature: 0.8,
maxOutputTokens: 2048,
topP: 0.95,
topK: 40
});
// 重置模拟
vi.clearAllMocks();
// 模拟getModel返回空(新模型)
mockModelManager.getModel.mockResolvedValue(null);
// 导入数据
await dataManager.importAllData(exportedData);
// 验证导入时 llmParams 被正确传递
expect(mockModelManager.addModel).toHaveBeenCalledTimes(2);
const firstModelCall = mockModelManager.addModel.mock.calls[0];
const secondModelCall = mockModelManager.addModel.mock.calls[1];
expect(firstModelCall[1].llmParams).toEqual({
temperature: 0.7,
max_tokens: 4096,
timeout: 30000
});
expect(secondModelCall[1].llmParams).toEqual({
temperature: 0.8,
maxOutputTokens: 2048,
topP: 0.95,
topK: 40
});
});
it('should handle models without llmParams during import', async () => {
// 测试不包含 llmParams 的模型(向后兼容性)
const modelsWithoutLlmParams = {
models: [
{
key: 'legacy-model',
name: 'Legacy Model',
provider: 'custom',
enabled: true,
baseURL: 'https://api.example.com/v1',
models: ['legacy-model'],
defaultModel: 'legacy-model'
// 没有 llmParams 字段
}
]
};
// 模拟getModel返回空(新模型)
mockModelManager.getModel.mockResolvedValue(null);
// 导入数据
await dataManager.importAllData(JSON.stringify(modelsWithoutLlmParams));
// 验证导入成功
expect(mockModelManager.addModel).toHaveBeenCalledTimes(1);
const modelCall = mockModelManager.addModel.mock.calls[0];
// 验证没有 llmParams 字段(undefined 不会被包含在配置中)
expect(modelCall[1]).not.toHaveProperty('llmParams');
});
it('should update existing models with llmParams', async () => {
// 测试更新现有模型时包含 llmParams
const existingModel = {
name: 'Existing Model',
provider: 'openai',
enabled: false,
baseURL: 'https://api.openai.com/v1',
models: ['gpt-3.5-turbo'],
defaultModel: 'gpt-3.5-turbo'
// 现有模型没有 llmParams
};
const updatedModelData = {
models: [{
key: 'existing-model',
name: 'Updated Model',
provider: 'openai',
enabled: true,
baseURL: 'https://api.openai.com/v1',
models: ['gpt-4'],
defaultModel: 'gpt-4',
llmParams: {
temperature: 0.5,
max_tokens: 2048
}
}]
};
// 模拟现有模型
mockModelManager.getModel.mockResolvedValue(existingModel);
// 导入数据
await dataManager.importAllData(JSON.stringify(updatedModelData));
// 验证调用了updateModel而不是addModel
expect(mockModelManager.updateModel).toHaveBeenCalledTimes(1);
expect(mockModelManager.addModel).not.toHaveBeenCalled();
const updateCall = mockModelManager.updateModel.mock.calls[0];
// 验证 llmParams 被包含在更新配置中
expect(updateCall[1].llmParams).toEqual({
temperature: 0.5,
max_tokens: 2048
});
});
});
describe('UI配置导入安全性测试', () => {
@@ -341,27 +526,5 @@ describe('DataManager', () => {
expect(mockStorage.setItem).not.toHaveBeenCalledWith('app:selected-optimize-model', expect.any(String));
expect(mockStorage.setItem).not.toHaveBeenCalledWith('app:selected-test-model', expect.any(String));
});
it('should handle partial UI setting import failures gracefully', async () => {
const partialFailData = {
userSettings: {
'theme-id': 'dark',
'preferred-language': 'en-US',
'app:selected-optimize-model': 'model1'
}
};
// 模拟第二个设置项失败
mockStorage.setItem = vi.fn()
.mockResolvedValueOnce(undefined) // 第一个成功
.mockRejectedValueOnce(new Error('Storage quota exceeded')) // 第二个失败
.mockResolvedValueOnce(undefined); // 第三个成功
// 不应该抛出异常,而是继续导入其他设置
await expect(dataManager.importAllData(JSON.stringify(partialFailData))).resolves.not.toThrow();
// 验证失败的设置被记录但不影响其他设置的导入
expect(mockStorage.setItem).toHaveBeenCalledTimes(3);
});
});
});
@@ -0,0 +1,618 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { createLLMService, ModelManager, LocalStorageProvider } from '../../../src/index.js';
import { validateLLMParams } from '../../../src/services/model/validation';
import type { ModelConfig } from '../../../src/services/model/types';
import dotenv from 'dotenv';
import path from 'path';
// Load environment variables
beforeAll(() => {
dotenv.config({ path: path.resolve(process.cwd(), '.env.local') });
});
describe('LLM Parameters (llmParams) Functionality', () => {
// Check for available API keys
const openaiCompatibleKeys = [
'OPENAI_API_KEY', 'VITE_OPENAI_API_KEY',
'DEEPSEEK_API_KEY', 'VITE_DEEPSEEK_API_KEY',
'SILICONFLOW_API_KEY', 'VITE_SILICONFLOW_API_KEY',
'ZHIPU_API_KEY', 'VITE_ZHIPU_API_KEY',
'CUSTOM_API_KEY', 'VITE_CUSTOM_API_KEY'
];
const geminiKeys = [
'GEMINI_API_KEY', 'VITE_GEMINI_API_KEY'
];
const hasOpenAICompatibleKey = openaiCompatibleKeys.some(key =>
process.env[key] && process.env[key].trim()
);
const hasGeminiKey = geminiKeys.some(key =>
process.env[key] && process.env[key].trim()
);
// Configuration interface
interface ProviderConfig {
key: string;
apiKey: string;
baseURL: string;
defaultModel: string;
provider: string;
}
// Get all available OpenAI compatible configurations
const getAvailableOpenAICompatibleConfigs = (): ProviderConfig[] => {
const configs: ProviderConfig[] = [];
if (process.env.OPENAI_API_KEY || process.env.VITE_OPENAI_API_KEY) {
configs.push({
key: 'openai',
apiKey: (process.env.OPENAI_API_KEY || process.env.VITE_OPENAI_API_KEY)!,
baseURL: 'https://api.openai.com/v1',
defaultModel: 'gpt-3.5-turbo',
provider: 'openai'
});
}
if (process.env.DEEPSEEK_API_KEY || process.env.VITE_DEEPSEEK_API_KEY) {
configs.push({
key: 'deepseek',
apiKey: (process.env.DEEPSEEK_API_KEY || process.env.VITE_DEEPSEEK_API_KEY)!,
baseURL: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat',
provider: 'deepseek'
});
}
if (process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY) {
configs.push({
key: 'siliconflow',
apiKey: (process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY)!,
baseURL: 'https://api.siliconflow.cn/v1',
defaultModel: 'Pro/deepseek-ai/DeepSeek-V3',
provider: 'siliconflow'
});
}
if (process.env.ZHIPU_API_KEY || process.env.VITE_ZHIPU_API_KEY) {
configs.push({
key: 'zhipu',
apiKey: (process.env.ZHIPU_API_KEY || process.env.VITE_ZHIPU_API_KEY)!,
baseURL: 'https://open.bigmodel.cn/api/paas/v4',
defaultModel: 'glm-4-flash',
provider: 'zhipu'
});
}
if (process.env.CUSTOM_API_KEY || process.env.VITE_CUSTOM_API_KEY) {
configs.push({
key: 'custom',
apiKey: (process.env.CUSTOM_API_KEY || process.env.VITE_CUSTOM_API_KEY)!,
baseURL: (process.env.CUSTOM_API_BASE_URL || process.env.VITE_CUSTOM_API_BASE_URL)!,
defaultModel: (process.env.CUSTOM_API_MODEL || process.env.VITE_CUSTOM_API_MODEL)!,
provider: 'custom'
});
}
return configs;
};
// Get OpenAI compatible configuration (for backward compatibility)
const getOpenAICompatibleConfig = () => {
const configs = getAvailableOpenAICompatibleConfigs();
return configs.length > 0 ? configs[0] : null;
};
// Get Gemini configuration
const getGeminiConfig = () => {
if (process.env.GEMINI_API_KEY || process.env.VITE_GEMINI_API_KEY) {
return {
key: 'gemini', // Use existing model key
apiKey: process.env.GEMINI_API_KEY || process.env.VITE_GEMINI_API_KEY,
baseURL: 'https://generativelanguage.googleapis.com/v1beta',
defaultModel: 'gemini-2.0-flash',
provider: 'gemini'
};
}
return null;
};
describe('OpenAI Compatible Providers', () => {
const openaiConfig = getOpenAICompatibleConfig();
if (!hasOpenAICompatibleKey || !openaiConfig) {
console.log('Skipping OpenAI Compatible tests: No API key available');
it.skip('should handle llmParams for OpenAI compatible providers', () => {});
return;
}
it('should use custom parameters from llmParams', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
// Configure model with llmParams
await modelManager.updateModel(openaiConfig.key, {
name: 'Test OpenAI Compatible',
apiKey: openaiConfig.apiKey,
baseURL: openaiConfig.baseURL,
defaultModel: openaiConfig.defaultModel,
enabled: true,
provider: openaiConfig.provider,
models: [openaiConfig.defaultModel],
llmParams: {
temperature: 0.1, // Very low temperature for predictable output
max_tokens: 50 // Short response
}
});
const messages = [
{ role: 'user' as const, content: 'Say exactly: "Hello World"' }
];
const response = await llmService.sendMessage(messages, openaiConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
// With low temperature and specific instruction, response should be short and focused
expect(response.length).toBeLessThan(200);
}, 30000);
it('should handle timeout parameter for OpenAI compatible providers', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
// Configure model with custom timeout
await modelManager.updateModel(openaiConfig.key, {
name: 'Test OpenAI Compatible',
apiKey: openaiConfig.apiKey,
baseURL: openaiConfig.baseURL,
defaultModel: openaiConfig.defaultModel,
enabled: true,
provider: openaiConfig.provider,
models: [openaiConfig.defaultModel],
llmParams: {
timeout: 30000, // 30 seconds timeout
temperature: 0.5
}
});
const messages = [
{ role: 'user' as const, content: 'Hello' }
];
const response = await llmService.sendMessage(messages, openaiConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 35000);
});
describe('Gemini Provider', () => {
const geminiConfig = getGeminiConfig();
if (!hasGeminiKey || !geminiConfig) {
console.log('Skipping Gemini tests: No GEMINI_API_KEY available');
it.skip('should handle llmParams for Gemini provider', () => {});
return;
}
it('should use Gemini-specific parameters from llmParams', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
// Configure Gemini model with llmParams
await modelManager.updateModel(geminiConfig.key, {
name: 'Test Gemini',
apiKey: geminiConfig.apiKey,
baseURL: geminiConfig.baseURL,
defaultModel: geminiConfig.defaultModel,
enabled: true,
provider: geminiConfig.provider,
models: [geminiConfig.defaultModel],
llmParams: {
temperature: 0.2,
maxOutputTokens: 100,
topP: 0.8,
topK: 20
}
});
const messages = [
{ role: 'user' as const, content: 'Tell me a very short fact about AI' }
];
const response = await llmService.sendMessage(messages, geminiConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
// With maxOutputTokens=100, response should be relatively short
expect(response.length).toBeLessThan(500);
}, 30000);
});
describe('Parameter Validation', () => {
it('should validate OpenAI parameters correctly', () => {
const validParams = {
temperature: 0.7,
max_tokens: 2048,
timeout: 60000
};
const result = validateLLMParams(validParams, 'openai');
// Debug: print validation results if test fails
if (!result.isValid) {
console.log('OpenAI validation failed:', JSON.stringify(result, null, 2));
}
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should detect invalid parameter types', () => {
const invalidParams = {
temperature: 'invalid', // should be number
max_tokens: 2048.5 // should be integer
};
const result = validateLLMParams(invalidParams, 'openai');
expect(result.isValid).toBe(false);
expect(result.errors).toHaveLength(2);
});
it('should detect out-of-range parameter values', () => {
const outOfRangeParams = {
temperature: 3.0, // exceeds maximum 2.0
presence_penalty: -3.0 // below minimum -2.0
};
const result = validateLLMParams(outOfRangeParams, 'openai');
expect(result.isValid).toBe(false);
expect(result.errors).toHaveLength(2);
});
it('should warn about unknown parameters', () => {
const unknownParams = {
temperature: 0.7,
unknown_param: 'value'
};
const result = validateLLMParams(unknownParams, 'openai');
expect(result.isValid).toBe(true);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].parameterName).toBe('unknown_param');
});
it('should validate Gemini-specific parameters', () => {
const geminiParams = {
temperature: 0.8,
maxOutputTokens: 2048,
topK: 40,
stopSequences: ['END', 'STOP']
};
const result = validateLLMParams(geminiParams, 'gemini');
// Debug: print validation results if test fails
if (!result.isValid) {
console.log('Gemini validation failed:', JSON.stringify(result, null, 2));
}
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should validate stopSequences array correctly', () => {
const invalidStopSequences = {
stopSequences: 'should_be_array'
};
const result = validateLLMParams(invalidStopSequences, 'gemini');
expect(result.isValid).toBe(false);
expect(result.errors[0].parameterName).toBe('stopSequences');
});
it('should filter unsafe parameters in Gemini configuration', () => {
// 这里我们测试的是参数验证,虽然buildGeminiGenerationConfig是私有方法
// 但我们可以通过集成测试来验证它的行为
const unsafeParams = {
temperature: 0.8,
maxOutputTokens: 2048,
// 这些参数应该被警告或过滤
dangerousParam: 'malicious_value',
__proto__: 'attack',
eval: 'dangerous_code'
};
const result = validateLLMParams(unsafeParams, 'gemini');
// 验证不安全的参数被拒绝
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings.some(w => w.parameterName === 'dangerousParam')).toBe(true);
});
});
describe('Individual Parameter Tests', () => {
const openaiCompatibleConfigs = getAvailableOpenAICompatibleConfigs();
const geminiConfig = getGeminiConfig();
// Temperature parameter tests
describe('Temperature Parameter', () => {
// Test for all OpenAI compatible providers
openaiCompatibleConfigs.forEach((config) => {
it(`should accept valid temperature for ${config.provider} provider`, async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(config.key, {
name: `Test ${config.provider} Temperature`,
apiKey: config.apiKey,
baseURL: config.baseURL,
defaultModel: config.defaultModel,
enabled: true,
provider: config.provider,
models: [config.defaultModel],
llmParams: {
temperature: 0.3
}
});
const messages = [{ role: 'user' as const, content: 'Hello' }];
const response = await llmService.sendMessage(messages, config.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 30000);
});
});
// Top P parameter tests
describe('Top P Parameter', () => {
// Test for all OpenAI compatible providers
openaiCompatibleConfigs.forEach((config) => {
it(`should accept valid top_p for ${config.provider} provider`, async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(config.key, {
name: `Test ${config.provider} Top P`,
apiKey: config.apiKey,
baseURL: config.baseURL,
defaultModel: config.defaultModel,
enabled: true,
provider: config.provider,
models: [config.defaultModel],
llmParams: {
top_p: 0.9
}
});
const messages = [{ role: 'user' as const, content: 'Hello' }];
const response = await llmService.sendMessage(messages, config.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 30000);
});
});
// Max Tokens parameter tests (OpenAI compatible)
describe('Max Tokens Parameter', () => {
// Test for all OpenAI compatible providers
openaiCompatibleConfigs.forEach((config) => {
it(`should accept valid max_tokens for ${config.provider} provider`, async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(config.key, {
name: `Test ${config.provider} Max Tokens`,
apiKey: config.apiKey,
baseURL: config.baseURL,
defaultModel: config.defaultModel,
enabled: true,
provider: config.provider,
models: [config.defaultModel],
llmParams: {
max_tokens: 100
}
});
const messages = [{ role: 'user' as const, content: 'Tell me a short fact' }];
const response = await llmService.sendMessage(messages, config.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 30000);
});
});
// Frequency Penalty parameter tests
describe('Frequency Penalty Parameter', () => {
// Test for all OpenAI compatible providers
openaiCompatibleConfigs.forEach((config) => {
it(`should accept valid frequency_penalty for ${config.provider} provider`, async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(config.key, {
name: `Test ${config.provider} Frequency Penalty`,
apiKey: config.apiKey,
baseURL: config.baseURL,
defaultModel: config.defaultModel,
enabled: true,
provider: config.provider,
models: [config.defaultModel],
llmParams: {
frequency_penalty: 0.3
}
});
const messages = [{ role: 'user' as const, content: 'Hello' }];
const response = await llmService.sendMessage(messages, config.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 20000);
});
});
// Gemini specific parameters
describe('Gemini Specific Parameters', () => {
if (hasGeminiKey && geminiConfig) {
it('should accept valid maxOutputTokens for Gemini provider', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(geminiConfig.key, {
name: 'Test Gemini Max Output Tokens',
apiKey: geminiConfig.apiKey,
baseURL: geminiConfig.baseURL,
defaultModel: geminiConfig.defaultModel,
enabled: true,
provider: geminiConfig.provider,
models: [geminiConfig.defaultModel],
llmParams: {
maxOutputTokens: 200
}
});
const messages = [{ role: 'user' as const, content: 'Tell me about AI' }];
const response = await llmService.sendMessage(messages, geminiConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 30000);
it('should accept valid candidateCount for Gemini provider', async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(geminiConfig.key, {
name: 'Test Gemini Candidate Count',
apiKey: geminiConfig.apiKey,
baseURL: geminiConfig.baseURL,
defaultModel: geminiConfig.defaultModel,
enabled: true,
provider: geminiConfig.provider,
models: [geminiConfig.defaultModel],
llmParams: {
candidateCount: 1
}
});
const messages = [{ role: 'user' as const, content: 'Hello' }];
const response = await llmService.sendMessage(messages, geminiConfig.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 30000);
}
});
// Combined parameters tests
describe('Combined Parameters', () => {
// Test for all OpenAI compatible providers
openaiCompatibleConfigs.forEach((config) => {
it(`should handle multiple parameters for ${config.provider} provider`, async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(config.key, {
name: `Test ${config.provider} Combined`,
apiKey: config.apiKey,
baseURL: config.baseURL,
defaultModel: config.defaultModel,
enabled: true,
provider: config.provider,
models: [config.defaultModel],
llmParams: {
temperature: 0.6,
max_tokens: 150,
top_p: 0.9,
presence_penalty: 0.2,
frequency_penalty: 0.1,
timeout: 25000
}
});
const messages = [{ role: 'user' as const, content: 'Tell me a short story' }];
const response = await llmService.sendMessage(messages, config.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
}, 30000);
});
});
});
describe('Edge Cases', () => {
const openaiCompatibleConfigs = getAvailableOpenAICompatibleConfigs();
it('should handle missing llmParams gracefully', () => {
const result = validateLLMParams(undefined, 'openai');
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
it('should handle empty llmParams object', () => {
const result = validateLLMParams({}, 'openai');
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
// Test that no default values are set when parameters are not provided
describe('No Default Values', () => {
// Test for all OpenAI compatible providers
openaiCompatibleConfigs.forEach((config) => {
it(`should not set default values when not provided for ${config.provider}`, async () => {
const storage = new LocalStorageProvider();
const modelManager = new ModelManager(storage);
const llmService = createLLMService(modelManager);
await modelManager.updateModel(config.key, {
name: `Test ${config.provider} No Defaults`,
apiKey: config.apiKey,
baseURL: config.baseURL,
defaultModel: config.defaultModel,
enabled: true,
provider: config.provider,
models: [config.defaultModel],
// No llmParams provided - testing parameter transparency
});
const messages = [{ role: 'user' as const, content: 'Hello' }];
const response = await llmService.sendMessage(messages, config.key);
expect(response).toBeDefined();
expect(typeof response).toBe('string');
expect(response.length).toBeGreaterThan(0);
// Should work fine without any default values being set
}, 30000);
});
});
});
});
+145 -6
View File
@@ -162,14 +162,153 @@ describe('ModelManager', () => {
it('should return only enabled models', async () => {
const enabledModel = createModelConfig('EnabledModel', true);
const disabledModel = createModelConfig('DisabledModel', false);
await modelManager.addModel('enabledKey', enabledModel);
await modelManager.addModel('disabledKey', disabledModel);
await modelManager.addModel('enabled', enabledModel);
await modelManager.addModel('disabled', disabledModel);
const enabledModels = await modelManager.getEnabledModels();
expect(enabledModels).toHaveLength(1);
expect(enabledModels[0].key).toBe('enabled');
});
});
describe('llmParams deep copy', () => {
it('should deep copy llmParams to avoid reference sharing when adding models', async () => {
const originalLlmParams = {
temperature: 0.7,
max_tokens: 4096
};
expect(enabledModels.some(m => m.key === 'enabledKey')).toBe(true);
expect(enabledModels.some(m => m.key === 'disabledKey')).toBe(false);
const modelConfig = createModelConfig('TestModel', true, 'test_key', ['model1'], 'model1', 'openai');
modelConfig.llmParams = originalLlmParams;
await modelManager.addModel('test-model', modelConfig);
// Modify the original llmParams
originalLlmParams.temperature = 0.9;
originalLlmParams.max_tokens = 2048;
// Get the stored model and verify it wasn't affected
const storedModel = await modelManager.getModel('test-model');
expect(storedModel?.llmParams?.temperature).toBe(0.7);
expect(storedModel?.llmParams?.max_tokens).toBe(4096);
});
it('should deep copy llmParams when updating models', async () => {
const initialModel = createModelConfig('TestModel', true);
await modelManager.addModel('test-model', initialModel);
const updateLlmParams = {
temperature: 0.5,
top_p: 0.9
};
await modelManager.updateModel('test-model', {
llmParams: updateLlmParams
});
// Modify the original update params
updateLlmParams.temperature = 1.0;
updateLlmParams.top_p = 0.5;
// Get the stored model and verify it wasn't affected
const storedModel = await modelManager.getModel('test-model');
expect(storedModel?.llmParams?.temperature).toBe(0.5);
expect(storedModel?.llmParams?.top_p).toBe(0.9);
});
it('should handle undefined llmParams gracefully', async () => {
const modelConfig = createModelConfig('TestModel', true);
// Explicitly set llmParams to undefined
modelConfig.llmParams = undefined;
await modelManager.addModel('test-model', modelConfig);
const storedModel = await modelManager.getModel('test-model');
expect(storedModel?.llmParams).toBeUndefined();
});
});
describe('llmParams security validation', () => {
it('should reject dangerous parameters when adding models', async () => {
const modelWithDangerousParams = createModelConfig('DangerousModel', true, 'test_key', ['model1'], 'model1', 'openai');
modelWithDangerousParams.llmParams = {
temperature: 0.7,
__proto__: { malicious: 'code' }, // Dangerous parameter
constructor: function() { return 'hack'; } // Another dangerous parameter
};
await expect(modelManager.addModel('dangerous-model', modelWithDangerousParams))
.rejects.toThrow(ModelConfigError);
});
it('should reject invalid parameter types when adding models', async () => {
const modelWithInvalidTypes = createModelConfig('InvalidModel', true, 'test_key', ['model1'], 'model1', 'openai');
modelWithInvalidTypes.llmParams = {
temperature: 'invalid_string', // Should be number
max_tokens: 1024.5 // Should be integer
};
await expect(modelManager.addModel('invalid-model', modelWithInvalidTypes))
.rejects.toThrow(ModelConfigError);
});
it('should reject out-of-range parameters when adding models', async () => {
const modelWithOutOfRangeParams = createModelConfig('OutOfRangeModel', true, 'test_key', ['model1'], 'model1', 'openai');
modelWithOutOfRangeParams.llmParams = {
temperature: 5.0, // Exceeds maximum 2.0
presence_penalty: -3.0 // Below minimum -2.0
};
await expect(modelManager.addModel('out-of-range-model', modelWithOutOfRangeParams))
.rejects.toThrow(ModelConfigError);
});
it('should accept valid parameters when adding models', async () => {
const modelWithValidParams = createModelConfig('ValidModel', true, 'test_key', ['model1'], 'model1', 'openai');
modelWithValidParams.llmParams = {
temperature: 0.7,
max_tokens: 2048,
top_p: 0.9,
presence_penalty: 0.1,
frequency_penalty: 0.1
};
await expect(modelManager.addModel('valid-model', modelWithValidParams))
.resolves.not.toThrow();
const storedModel = await modelManager.getModel('valid-model');
expect(storedModel?.llmParams).toEqual(modelWithValidParams.llmParams);
});
it('should validate llmParams when updating models', async () => {
const initialModel = createModelConfig('TestModel', true, 'test_key', ['model1'], 'model1', 'openai');
await modelManager.addModel('test-model', initialModel);
// Try to update with dangerous parameters
await expect(modelManager.updateModel('test-model', {
llmParams: {
temperature: 0.5,
eval: 'malicious_code()' // Dangerous parameter
}
})).rejects.toThrow(ModelConfigError);
});
it('should validate provider-specific parameters', async () => {
const geminiModel = createModelConfig('GeminiModel', true, 'test_key', ['gemini-pro'], 'gemini-pro', 'gemini');
geminiModel.llmParams = {
temperature: 0.8,
maxOutputTokens: 2048,
topK: 40,
topP: 0.9,
stopSequences: ['END', 'STOP']
};
await expect(modelManager.addModel('gemini-model', geminiModel))
.resolves.not.toThrow();
const storedModel = await modelManager.getModel('gemini-model');
expect(storedModel?.llmParams).toEqual(geminiModel.llmParams);
});
});
});
+454 -15
View File
@@ -105,10 +105,10 @@
<Teleport to="body">
<!-- 编辑模型弹窗 -->
<div v-if="isEditing"
class="fixed inset-0 z-[60] flex items-center justify-center overflow-y-auto">
class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="relative theme-manager-container w-full max-w-2xl m-4 z-10"
<div class="relative theme-manager-container w-full max-w-2xl max-h-[90vh] overflow-y-auto z-10"
@click.stop>
<div class="p-6 space-y-6">
<div class="flex items-center justify-between">
@@ -167,6 +167,116 @@
<span class="cursor-help ml-1" :title="t('modelManager.useVercelProxyHint')">?</span>
</label>
</div>
<!-- Advanced Parameters Section -->
<div class="pt-4 mt-4 border-t theme-manager-border">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="text-md font-semibold theme-manager-text">
{{ t('modelManager.advancedParameters.title') }}
</h4>
<p class="text-xs theme-manager-text-secondary mt-1">
{{ t('modelManager.advancedParameters.currentProvider') }}: <span class="font-medium text-purple-600">{{ currentProviderType === 'custom' ? t('modelManager.advancedParameters.customProvider') : currentProviderType.toUpperCase() }}</span>
<span v-if="availableLLMParamDefinitions.length > 0"> ({{ availableLLMParamDefinitions.length }}{{ t('modelManager.advancedParameters.availableParams') }})</span>
<span v-else class="text-yellow-600"> ({{ t('modelManager.advancedParameters.noAvailableParams') }})</span>
</p>
</div>
<!-- 参数选择器 -->
<select v-model="selectedNewLLMParamId"
@change="handleQuickAddParam"
class="theme-manager-input text-sm py-1.5 px-3 min-w-[180px] max-w-[220px]">
<option disabled value="">{{ t('modelManager.advancedParameters.select') }}</option>
<option v-for="paramDef in availableLLMParamDefinitions" :key="paramDef.id" :value="paramDef.id">
{{ paramDef.labelKey ? t(paramDef.labelKey) : paramDef.name }}
</option>
<option value="custom">{{ t('modelManager.advancedParameters.custom') }}</option>
</select>
</div>
<div v-if="Object.keys(currentLLMParams || {}).length === 0" class="text-sm theme-manager-text-secondary mb-3">
{{ t('modelManager.advancedParameters.noParamsConfigured') }}
</div>
<!-- 参数显示 -->
<div v-for="(value, key) in currentLLMParams" :key="key" class="mb-4">
<div class="p-3 theme-manager-card rounded-lg border theme-manager-border">
<!-- 参数标题行 -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium theme-manager-text">
{{ getParamMetadata(key)?.label || key }}
</span>
<span v-if="!getParamMetadata(key)" class="px-2 py-0.5 text-xs theme-manager-tag rounded">
{{ t('modelManager.advancedParameters.customParam') }}
</span>
</div>
<button @click="removeLLMParam(key)" type="button"
class="w-6 h-6 text-red-500 hover:text-white hover:bg-red-500 rounded text-xs flex items-center justify-center transition-colors">
×
</button>
</div>
<!-- 参数描述 -->
<p v-if="getParamMetadata(key)?.description" class="text-xs theme-manager-text-secondary mb-2">
{{ getParamMetadata(key)?.description }}
</p>
<!-- 输入字段 -->
<div class="w-full">
<!-- Boolean 类型 -->
<template v-if="getParamMetadata(key)?.type === 'boolean'">
<label class="flex items-center cursor-pointer">
<input v-model="currentLLMParams[key]"
type="checkbox"
:id="`llmparam-${isEditing ? 'edit' : 'add'}-${key}`"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
<span class="ml-2 text-sm theme-manager-text">
{{ currentLLMParams[key] ? t('common.enabled') : t('common.disabled') }}
</span>
</label>
</template>
<!-- Number/Integer 类型 -->
<template v-else-if="getParamMetadata(key)?.type === 'number' || (getParamMetadata(key)?.type === 'integer' && getParamMetadata(key)?.name !== 'stopSequences')">
<div class="space-y-2">
<input v-model.number="currentLLMParams[key]"
type="number"
:min="getParamMetadata(key)?.minValue"
:max="getParamMetadata(key)?.maxValue"
:step="getParamMetadata(key)?.step"
class="theme-manager-input w-full text-sm"
:class="{ 'border-red-500': isParamInvalid(key, currentLLMParams[key]) }"
:placeholder="getParamMetadata(key)?.defaultValue !== undefined ? String(getParamMetadata(key)?.defaultValue) : ''" />
<!-- 范围提示 -->
<div v-if="getParamMetadata(key)?.minValue !== undefined && getParamMetadata(key)?.maxValue !== undefined"
class="text-xs theme-manager-text-secondary">
范围: {{ getParamMetadata(key)?.minValue }} - {{ getParamMetadata(key)?.maxValue }}{{ getParamMetadata(key)?.unit || '' }}
</div>
<!-- 错误提示 -->
<div v-if="isParamInvalid(key, currentLLMParams[key])" class="text-red-500 text-xs">
{{ getParamValidationMessage(key, currentLLMParams[key]) }}
</div>
</div>
</template>
<!-- String 类型 -->
<template v-else>
<input v-model="currentLLMParams[key]"
type="text"
class="theme-manager-input w-full text-sm"
:placeholder="getParamMetadata(key)?.name === 'stopSequences' ? t('modelManager.advancedParameters.stopSequencesPlaceholder') : (getParamMetadata(key)?.defaultValue !== undefined ? String(getParamMetadata(key)?.defaultValue) : '')" />
<p v-if="getParamMetadata(key)?.name === 'stopSequences'" class="text-xs theme-manager-text-secondary mt-1">
{{ t('modelManager.advancedParameters.stopSequencesPlaceholder') }}
</p>
</template>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@@ -188,10 +298,10 @@
</div>
<!-- 添加模型弹窗 -->
<div v-if="showAddForm" class="fixed inset-0 z-[60] flex items-center justify-center overflow-y-auto">
<div v-if="showAddForm" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="relative theme-manager-container w-full max-w-2xl m-4 z-10" @click.stop>
<div class="relative theme-manager-container w-full max-w-2xl max-h-[90vh] overflow-y-auto z-10" @click.stop>
<div class="p-6 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold theme-manager-text">{{ t('modelManager.addModel') }}</h3>
@@ -250,6 +360,115 @@
<span class="cursor-help ml-1" :title="t('modelManager.useVercelProxyHint')">?</span>
</label>
</div>
<!-- Advanced Parameters Section FOR ADD MODEL -->
<div class="pt-4 mt-4 border-t theme-manager-border">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="text-md font-semibold theme-manager-text">
{{ t('modelManager.advancedParameters.title') }}
</h4>
<p class="text-xs theme-manager-text-secondary mt-1">
{{ t('modelManager.advancedParameters.currentProvider') }}: <span class="font-medium text-purple-600">{{ currentProviderType === 'custom' ? t('modelManager.advancedParameters.customProvider') : currentProviderType.toUpperCase() }}</span>
<span v-if="availableLLMParamDefinitions.length > 0"> ({{ availableLLMParamDefinitions.length }}{{ t('modelManager.advancedParameters.availableParams') }})</span>
<span v-else class="text-yellow-600"> ({{ t('modelManager.advancedParameters.noAvailableParams') }})</span>
</p>
</div>
<!-- 参数选择器 -->
<select v-model="selectedNewLLMParamId"
@change="handleQuickAddParam"
class="theme-manager-input text-sm py-1.5 px-3 min-w-[180px] max-w-[220px]">
<option disabled value="">{{ t('modelManager.advancedParameters.select') }}</option>
<option v-for="paramDef in availableLLMParamDefinitions" :key="paramDef.id" :value="paramDef.id">
{{ paramDef.labelKey ? t(paramDef.labelKey) : paramDef.name }}
</option>
<option value="custom">{{ t('modelManager.advancedParameters.custom') }}</option>
</select>
</div>
<div v-if="Object.keys(currentLLMParams || {}).length === 0" class="text-sm theme-manager-text-secondary mb-3">
{{ t('modelManager.advancedParameters.noParamsConfigured') }}
</div>
<!-- 参数显示 -->
<div v-for="(value, key) in currentLLMParams" :key="key" class="mb-4">
<div class="p-3 theme-manager-card rounded-lg border theme-manager-border">
<!-- 参数标题行 -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium theme-manager-text">
{{ getParamMetadata(key)?.label || key }}
</span>
<span v-if="!getParamMetadata(key)" class="px-2 py-0.5 text-xs theme-manager-tag rounded">
{{ t('modelManager.advancedParameters.customParam') }}
</span>
</div>
<button @click="removeLLMParam(key)" type="button"
class="w-6 h-6 text-red-500 hover:text-white hover:bg-red-500 rounded text-xs flex items-center justify-center transition-colors">
×
</button>
</div>
<!-- 参数描述 -->
<p v-if="getParamMetadata(key)?.description" class="text-xs theme-manager-text-secondary mb-2">
{{ getParamMetadata(key)?.description }}
</p>
<!-- 输入字段 -->
<div class="w-full">
<!-- Boolean 类型 -->
<template v-if="getParamMetadata(key)?.type === 'boolean'">
<label class="flex items-center cursor-pointer">
<input v-model="currentLLMParams[key]"
type="checkbox"
:id="`llmparam-${isEditing ? 'edit' : 'add'}-${key}`"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
<span class="ml-2 text-sm theme-manager-text">
{{ currentLLMParams[key] ? t('common.enabled') : t('common.disabled') }}
</span>
</label>
</template>
<!-- Number/Integer 类型 -->
<template v-else-if="getParamMetadata(key)?.type === 'number' || (getParamMetadata(key)?.type === 'integer' && getParamMetadata(key)?.name !== 'stopSequences')">
<div class="space-y-2">
<input v-model.number="currentLLMParams[key]"
type="number"
:min="getParamMetadata(key)?.minValue"
:max="getParamMetadata(key)?.maxValue"
:step="getParamMetadata(key)?.step"
class="theme-manager-input w-full text-sm"
:class="{ 'border-red-500': isParamInvalid(key, currentLLMParams[key]) }"
:placeholder="getParamMetadata(key)?.defaultValue !== undefined ? String(getParamMetadata(key)?.defaultValue) : ''" />
<!-- 范围提示 -->
<div v-if="getParamMetadata(key)?.minValue !== undefined && getParamMetadata(key)?.maxValue !== undefined"
class="text-xs theme-manager-text-secondary">
范围: {{ getParamMetadata(key)?.minValue }} - {{ getParamMetadata(key)?.maxValue }}{{ getParamMetadata(key)?.unit || '' }}
</div>
<!-- 错误提示 -->
<div v-if="isParamInvalid(key, currentLLMParams[key])" class="text-red-500 text-xs">
{{ getParamValidationMessage(key, currentLLMParams[key]) }}
</div>
</div>
</template>
<!-- String 类型 -->
<template v-else>
<input v-model="currentLLMParams[key]"
type="text"
class="theme-manager-input w-full text-sm"
:placeholder="getParamMetadata(key)?.name === 'stopSequences' ? t('modelManager.advancedParameters.stopSequencesPlaceholder') : (getParamMetadata(key)?.defaultValue !== undefined ? String(getParamMetadata(key)?.defaultValue) : '')" />
<p v-if="getParamMetadata(key)?.name === 'stopSequences'" class="text-xs theme-manager-text-secondary mt-1">
{{ t('modelManager.advancedParameters.stopSequencesPlaceholder') }}
</p>
</template>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showAddForm = false" class="theme-manager-button-secondary">
{{ t('common.cancel') }}
@@ -269,13 +488,14 @@
</template>
<script setup>
import { ref, onMounted, defineEmits, watch } from 'vue';
import { ref, onMounted, defineEmits, watch, computed } from 'vue'; // Added computed
import { useI18n } from 'vue-i18n';
import {
modelManager,
createLLMService,
checkVercelApiAvailability,
resetVercelStatusCache
resetVercelStatusCache,
advancedParameterDefinitions // NEW IMPORT
} from '@prompt-optimizer/core';
import { useToast } from '../composables/useToast';
import InputWithSelect from './InputWithSelect.vue'
@@ -293,6 +513,9 @@ const isLoadingModels = ref(false);
const testingConnections = ref({});
// 是否支持Vercel代理
const vercelProxyAvailable = ref(false);
// For Advanced Parameters UI
const selectedNewLLMParamId = ref(''); // Stores ID of param selected from dropdown
const customLLMParam = ref({ key: '', value: '' });
// 数据状态
const models = ref([]);
@@ -306,7 +529,9 @@ const newModel = ref({
baseURL: '',
defaultModel: '',
apiKey: '',
useVercelProxy: false
useVercelProxy: false,
provider: 'custom',
llmParams: {}
});
// =============== 初始化和辅助函数 ===============
@@ -465,10 +690,10 @@ const editModel = async (key) => {
apiKey: maskedApiKey,
displayMaskedKey: true,
originalApiKey: model.apiKey,
useVercelProxy: model.useVercelProxy,
provider: model.provider,
useVercelProxy: model.useVercelProxy === undefined ? false : model.useVercelProxy, // Ensure default
provider: model.provider || 'custom', // Ensure provider is set
enabled: model.enabled,
// 其他需要的字段...
llmParams: model.llmParams ? JSON.parse(JSON.stringify(model.llmParams)) : {} // Deep copy llmParams
};
// 初始化模型选项
@@ -652,7 +877,7 @@ const handleFetchNewModels = async () => {
const customConfig = {
baseURL: baseURL,
apiKey: apiKey,
provider: 'custom', // 新模型默认为自定义提供商
provider: currentProviderType.value || 'custom',
useVercelProxy: newModel.value.useVercelProxy
};
@@ -710,7 +935,8 @@ const saveEdit = async () => {
provider: editingModel.value.provider || 'custom',
enabled: editingModel.value.enabled !== undefined
? editingModel.value.enabled
: true
: true,
llmParams: editingModel.value.llmParams || {}
};
// 直接更新原始模型
@@ -744,8 +970,9 @@ const addCustomModel = async () => {
defaultModel: newModel.value.defaultModel,
apiKey: newModel.value.apiKey,
enabled: true,
provider: 'custom',
useVercelProxy: newModel.value.useVercelProxy
provider: currentProviderType.value || 'custom',
useVercelProxy: newModel.value.useVercelProxy,
llmParams: newModel.value.llmParams || {}
}
await modelManager.addModel(newModel.value.key, config)
@@ -759,7 +986,9 @@ const addCustomModel = async () => {
baseURL: '',
defaultModel: '',
apiKey: '',
useVercelProxy: false
useVercelProxy: false,
provider: 'custom',
llmParams: {}
}
toast.success('模型添加成功')
} catch (error) {
@@ -777,6 +1006,208 @@ watch(() => editingModel.value?.apiKey, (newValue) => {
}
});
// =============== Advanced Parameters Computed Properties ===============
const currentLLMParams = computed(() => {
return isEditing.value ? (editingModel.value?.llmParams || {}) : newModel.value.llmParams;
});
const currentProviderType = computed(() => {
if (isEditing.value) {
return editingModel.value?.provider || 'custom';
}
// For new models, derive from key if it matches a known provider, else default to 'custom'
// This helps in suggesting relevant advanced parameters early.
const knownProviders = ['openai', 'gemini', 'deepseek', 'zhipu', 'siliconflow'];
if (newModel.value.key && knownProviders.includes(newModel.value.key.toLowerCase())) {
return newModel.value.key.toLowerCase();
}
return newModel.value.provider || 'custom';
});
const getParamMetadata = (paramName) => {
if (!advancedParameterDefinitions) return null;
// Ensure currentProviderType.value is valid before using in .includes()
const provider = currentProviderType.value || 'custom';
const definition = advancedParameterDefinitions.find(def => def.name === paramName && def.appliesToProviders.includes(provider));
if (definition) {
return {
...definition,
label: definition.labelKey ? t(definition.labelKey) : definition.name,
description: definition.descriptionKey ? t(definition.descriptionKey) : `(${paramName})`, // Fallback description
unit: definition.unitKey ? t(definition.unitKey) : (definition.unit || '')
};
}
return null; // For custom params or if not found
};
const availableLLMParamDefinitions = computed(() => {
if (!advancedParameterDefinitions) return [];
const currentParams = currentLLMParams.value || {};
const provider = currentProviderType.value || 'custom';
return advancedParameterDefinitions.filter(def =>
def.appliesToProviders.includes(provider) &&
!Object.keys(currentParams).includes(def.name)
);
});
const removeLLMParam = (paramKey) => {
if (currentLLMParams.value) {
delete currentLLMParams.value[paramKey];
// Vue 3 might need a bit more help for reactivity on nested objects if not using Vue.set or Vue.delete
// However, direct deletion and assignment for the whole llmParams object during save should be fine.
// For local reactivity within the form, this should work.
}
};
const quickAddLLMParam = () => {
const paramsObject = currentLLMParams.value;
if (!paramsObject) return; // Should not happen if initialized correctly
if (selectedNewLLMParamId.value === 'custom') {
if (customLLMParam.value.key && !paramsObject[customLLMParam.value.key]) {
// For custom params, initially store value as string.
// Type conversion can be attempted by user or upon processing if needed.
paramsObject[customLLMParam.value.key] = customLLMParam.value.value;
}
customLLMParam.value = { key: '', value: '' }; // Reset custom input
} else {
const definition = advancedParameterDefinitions.find(def => def.id === selectedNewLLMParamId.value);
if (definition && !paramsObject[definition.name]) {
let val = definition.defaultValue;
// Basic type handling for default values
if (definition.type === 'boolean') {
val = (val === undefined) ? false : Boolean(val);
} else if (definition.type === 'integer' && val !== undefined) {
val = parseInt(String(val), 10);
} else if (definition.type === 'number' && val !== undefined) {
val = parseFloat(String(val));
} else if (definition.name === 'stopSequences') { // Special handling for stopSequences
val = Array.isArray(val) ? val : ( (typeof val === 'string' && val) ? val.split(',').map(s => s.trim()).filter(s => s) : [] );
}
// All other types, including string, or if val is undefined, will use val as is.
paramsObject[definition.name] = val;
}
}
selectedNewLLMParamId.value = ''; // Reset select
};
// 处理快速添加参数(选择后立即添加)
const handleQuickAddParam = () => {
if (selectedNewLLMParamId.value !== 'custom') {
quickAddLLMParam();
}
// 如果是custom,等待用户输入完成后再添加
};
// 处理自定义参数添加
const handleCustomParamAdd = () => {
if (customLLMParam.value.key && customLLMParam.value.value) {
quickAddLLMParam();
}
};
// 取消自定义参数输入
const cancelCustomParam = () => {
selectedNewLLMParamId.value = '';
customLLMParam.value = { key: '', value: '' };
};
// 验证参数是否有效
const isParamInvalid = (paramName, value) => {
const metadata = getParamMetadata(paramName);
// 如果是自定义参数(没有metadata),只做基础验证
if (!metadata) {
// 对自定义参数进行基础验证:不能为空且不能包含危险字符
if (value === undefined || value === null || value === '') return false;
// 检查是否包含潜在危险的参数名
const dangerousNames = ['eval', 'exec', 'script', '__proto__', 'constructor'];
if (dangerousNames.some(dangerous => paramName.toLowerCase().includes(dangerous))) {
return true;
}
return false;
}
if (value === undefined || value === null || value === '') return false;
if (metadata.type === 'number' || metadata.type === 'integer') {
const numValue = Number(value);
if (isNaN(numValue)) return true;
if (metadata.minValue !== undefined && numValue < metadata.minValue) return true;
if (metadata.maxValue !== undefined && numValue > metadata.maxValue) return true;
if (metadata.type === 'integer' && !Number.isInteger(numValue)) return true;
}
return false;
};
// 获取参数验证错误信息
const getParamValidationMessage = (paramName, value) => {
const metadata = getParamMetadata(paramName);
// 自定义参数的错误信息
if (!metadata) {
const dangerousNames = ['eval', 'exec', 'script', '__proto__', 'constructor'];
if (dangerousNames.some(dangerous => paramName.toLowerCase().includes(dangerous))) {
return t('modelManager.advancedParameters.validation.dangerousParam');
}
return '';
}
if (metadata.type === 'number' || metadata.type === 'integer') {
const numValue = Number(value);
if (isNaN(numValue)) {
return t('modelManager.advancedParameters.validation.invalidNumber', {
type: metadata.type === 'integer' ? t('common.integer') : t('common.number')
});
}
if (metadata.minValue !== undefined && numValue < metadata.minValue) {
return t('modelManager.advancedParameters.validation.belowMin', {
min: metadata.minValue
});
}
if (metadata.maxValue !== undefined && numValue > metadata.maxValue) {
return t('modelManager.advancedParameters.validation.aboveMax', {
max: metadata.maxValue
});
}
if (metadata.type === 'integer' && !Number.isInteger(numValue)) {
return t('modelManager.advancedParameters.validation.mustBeInteger');
}
}
return '';
};
watch(() => newModel.value.key, (newKey) => {
// If the key changes and implies a different provider type for a new model,
// it might be good to reset llmParams or re-evaluate defaults.
// For simplicity now, we can reset if the key change might imply a different context.
// This is a basic reset; more sophisticated logic could merge common params if desired.
const knownProviders = ['openai', 'gemini', 'deepseek', 'zhipu', 'siliconflow'];
let newProvider = 'custom';
if (newKey && knownProviders.includes(newKey.toLowerCase())) {
newProvider = newKey.toLowerCase();
}
if (newModel.value.provider !== newProvider) {
// If provider type effectively changes, reset llmParams.
// This helps if user types "openai", then changes mind to "gemini".
// The UI will then show relevant default params for "gemini".
newModel.value.llmParams = {};
newModel.value.provider = newProvider; // Update the provider field too
} else if (newModel.value.provider === 'custom' && newProvider === 'custom' && newModel.value.key !== '') {
// If still custom but key has changed, it's a new custom model, reset llmParams
newModel.value.llmParams = {};
}
});
// =============== 生命周期钩子 ===============
// 初始化
onMounted(() => {
@@ -797,4 +1228,12 @@ onMounted(() => {
opacity: 0;
transform: scale(0.95);
}
/* 文本截断样式 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+74
View File
@@ -19,6 +19,7 @@ export default {
enabled: 'Enabled',
disabled: 'Disabled',
add: 'Add',
remove: 'Remove',
title: 'Title',
description: 'Description',
lastModified: 'Last Modified',
@@ -40,6 +41,8 @@ export default {
selectFile: 'Select File',
exporting: 'Exporting...',
importing: 'Importing...',
number: 'Number',
integer: 'Integer',
},
nav: {
home: 'Home',
@@ -85,6 +88,32 @@ export default {
useVercelProxyHint: 'Using Vercel proxy can solve CORS issues, but may trigger risk control from some providers. Please use with caution',
addModel: 'Add',
// Advanced Parameters
advancedParameters: {
title: 'Advanced Parameters',
noParamsConfigured: 'No advanced parameters configured',
customParam: 'Custom',
add: 'Add Parameter',
select: 'Select a parameter',
selectTitle: 'Add Advanced Parameter',
custom: 'Custom Parameter',
customKeyPlaceholder: 'Enter parameter name',
customValuePlaceholder: 'Enter parameter value',
stopSequencesPlaceholder: 'Enter stop sequences (comma-separated)',
unitLabel: 'Unit',
currentProvider: 'Current Provider',
customProvider: 'Custom',
availableParams: 'available parameters',
noAvailableParams: 'no available parameters',
validation: {
dangerousParam: 'This parameter is considered dangerous and is not allowed',
invalidNumber: 'Must be a valid number',
belowMin: 'Value cannot be less than {min}',
aboveMax: 'Value cannot be greater than {max}',
mustBeInteger: 'Must be an integer'
}
},
// Placeholders
modelKeyPlaceholder: 'Enter model key',
displayNamePlaceholder: 'Enter display name',
@@ -397,5 +426,50 @@ export default {
successWithRefresh: 'Data imported successfully, page will refresh to update data'
},
warning: 'Importing data will overwrite existing history records, model configurations, custom templates and all user settings (including theme, language preferences, etc.). Please ensure you have backed up important data.'
},
params: {
"temperature": {
"label": "Temperature",
"description": "Controls randomness: Lower values (e.g., 0.2) make the output more focused and deterministic, while higher values (e.g., 0.8) make it more random."
},
"top_p": {
"label": "Top P",
"description": "Nucleus sampling. Considers tokens with top P probability mass. E.g., 0.1 means only tokens comprising the top 10% probability mass are considered."
},
"max_tokens": {
"label": "Max Tokens",
"description": "Maximum number of tokens to generate in the completion."
},
"presence_penalty": {
"label": "Presence Penalty",
"description": "Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics."
},
"frequency_penalty": {
"label": "Frequency Penalty",
"description": "Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim."
},
"timeout": {
"label": "Timeout (ms)",
"description_openai": "Request timeout in milliseconds for the OpenAI client connection."
},
"maxOutputTokens": {
"label": "Max Output Tokens",
"description": "Maximum number of tokens the model can output in a single response."
},
"top_k": {
"label": "Top K",
"description": "Filters the next token choices to the K most likely tokens. Helps to reduce nonsensical token generation."
},
"candidateCount": {
"label": "Candidate Count",
"description": "Number of generated responses to return. Must be between 1 and 8."
},
"stopSequences": {
"label": "Stop Sequences",
"description": "Custom strings that will stop output generation if encountered. Specify multiple sequences separated by commas."
},
"tokens": {
"unit": "tokens"
}
}
};
+75 -1
View File
@@ -19,6 +19,7 @@ export default {
enabled: '已启用',
disabled: '已禁用',
add: '添加',
remove: '移除',
title: '标题',
description: '描述',
lastModified: '最后修改',
@@ -40,6 +41,8 @@ export default {
selectFile: '选择文件',
exporting: '导出中...',
importing: '导入中...',
number: '数字',
integer: '整数',
},
nav: {
home: '首页',
@@ -82,9 +85,35 @@ export default {
clickToFetchModels: '点击箭头获取模型列表',
apiKey: 'API密钥',
useVercelProxy: '使用Vercel代理',
useVercelProxyHint: '使用Vercel代理可以解决跨域问题,但可能触发部分厂商风控,请酌情使用',
useVercelProxyHint: '使用Vercel代理可以解决跨域问题,但可能触发某些提供商的风控,请谨慎使用',
addModel: '添加',
// 高级参数
advancedParameters: {
title: '高级参数',
noParamsConfigured: '未配置高级参数',
customParam: '自定义',
add: '添加参数',
select: '选择参数',
selectTitle: '添加高级参数',
custom: '自定义参数',
customKeyPlaceholder: '输入参数名称',
customValuePlaceholder: '输入参数值',
stopSequencesPlaceholder: '输入停止序列(逗号分隔)',
unitLabel: '单位',
currentProvider: '当前提供商',
customProvider: '自定义',
availableParams: '个可选参数',
noAvailableParams: '无可选参数',
validation: {
dangerousParam: '此参数名称包含潜在危险字符,不允许使用',
invalidNumber: '参数值必须是有效的{type}',
belowMin: '参数值不能小于 {min}',
aboveMax: '参数值不能大于 {max}',
mustBeInteger: '参数值必须是整数'
}
},
// 占位符
modelKeyPlaceholder: '请输入模型标识',
displayNamePlaceholder: '请输入显示名称',
@@ -397,5 +426,50 @@ export default {
successWithRefresh: '数据导入成功,页面将刷新以更新数据'
},
warning: '导入数据将覆盖现有的历史记录、模型配置、自定义提示词和所有用户设置(包括主题、语言偏好等),请确保已备份重要数据。'
},
params: {
"temperature": {
"label": "温度 (Temperature)",
"description": "控制随机性:较低的值(例如0.2)使输出更集中和确定,较高的值(例如0.8)使其更随机。"
},
"top_p": {
"label": "Top P (核心采样)",
"description": "核心采样。仅考虑累积概率达到Top P阈值的Token。例如,0.1表示仅考虑构成最高10%概率质量的Token。"
},
"max_tokens": {
"label": "最大Token数",
"description": "在补全中生成的最大Token数量。"
},
"presence_penalty": {
"label": "存在惩罚 (Presence Penalty)",
"description": "介于-2.0和2.0之间的数字。正值会根据新Token是否已在文本中出现来惩罚它们,增加模型谈论新主题的可能性。"
},
"frequency_penalty": {
"label": "频率惩罚 (Frequency Penalty)",
"description": "介于-2.0和2.0之间的数字。正值会根据新Token在文本中已出现的频率来惩罚它们,降低模型逐字重复相同行的可能性。"
},
"timeout": {
"label": "超时时间 (毫秒)",
"description_openai": "OpenAI客户端连接的请求超时时间(毫秒)。"
},
"maxOutputTokens": {
"label": "最大输出Token数",
"description": "模型在单个响应中可以输出的最大Token数。"
},
"top_k": {
"label": "Top K (K选顶)",
"description": "将下一个Token的选择范围限制为K个最可能的Token。有助于减少无意义Token的生成。"
},
"candidateCount": {
"label": "候选数量",
"description": "返回的生成响应数量。必须介于1和8之间。"
},
"stopSequences": {
"label": "停止序列",
"description": "遇到时将停止输出生成的自定义字符串。用逗号分隔多个序列。"
},
"tokens": {
"unit": "令牌"
}
}
};