diff --git a/packages/core/src/agent/config-loader.ts b/packages/core/src/agent/config-loader.ts index 74755f8..00c989a 100644 --- a/packages/core/src/agent/config-loader.ts +++ b/packages/core/src/agent/config-loader.ts @@ -1,67 +1,39 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; import type { AgentConfigFile } from './types.js'; /** - * 配置文件搜索路径 + * 全局配置目录 */ -const CONFIG_PATHS = [ - '.ai-assist/agents.yaml', - '.ai-assist/agents.yml', - '.ai-assist/agents.json', - '.ai-assist.yaml', - '.ai-assist.yml', -]; - -/** - * 解析 YAML 内容 - */ -async function parseYaml(content: string): Promise { - try { - const yaml = await import('js-yaml'); - return yaml.load(content); - } catch { - console.warn('解析 YAML 配置文件失败'); - return null; - } -} +const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant'); /** * 加载用户自定义 Agent 配置 - * @param workdir 工作目录 + * 从 ~/.ai-terminal-assistant/agents.json 读取 + * + * @param _workdir 工作目录(保留参数以兼容现有调用,但不再使用) * @returns 配置对象或 null */ -export async function loadAgentConfig(workdir: string): Promise { - for (const configPath of CONFIG_PATHS) { - const fullPath = path.join(workdir, configPath); +export async function loadAgentConfig(_workdir: string): Promise { + const configPath = path.join(GLOBAL_CONFIG_DIR, 'agents.json'); - try { - if (!fs.existsSync(fullPath)) { - continue; - } - - const content = await fs.promises.readFile(fullPath, 'utf-8'); - - let config: unknown; - - if (configPath.endsWith('.json')) { - config = JSON.parse(content); - } else if (configPath.endsWith('.yaml') || configPath.endsWith('.yml')) { - config = await parseYaml(content); - if (!config) continue; - } else { - continue; - } - - // 验证配置格式 - if (isValidAgentConfig(config)) { - return config; - } else { - console.warn(`Agent 配置格式无效: ${fullPath}`); - } - } catch (error) { - console.warn(`加载 Agent 配置失败: ${fullPath}`, error); + try { + if (!fs.existsSync(configPath)) { + return null; } + + const content = await fs.promises.readFile(configPath, 'utf-8'); + const config = JSON.parse(content); + + // 验证配置格式 + if (isValidAgentConfig(config)) { + return config; + } else { + console.warn(`Agent 配置格式无效: ${configPath}`); + } + } catch (error) { + console.warn(`加载 Agent 配置失败: ${configPath}`, error); } return null; @@ -92,39 +64,24 @@ function isValidAgentConfig(config: unknown): config is AgentConfigFile { /** * 保存 Agent 配置到文件 - * @param workdir 工作目录 + * 保存到 ~/.ai-terminal-assistant/agents.json + * + * @param _workdir 工作目录(保留参数以兼容现有调用,但不再使用) * @param config 配置对象 - * @param format 文件格式 + * @param _format 文件格式(保留参数以兼容现有调用,但只使用 json) */ export async function saveAgentConfig( - workdir: string, + _workdir: string, config: AgentConfigFile, - format: 'json' | 'yaml' = 'json' + _format: 'json' | 'yaml' = 'json' ): Promise { - const dir = path.join(workdir, '.ai-assist'); - // 确保目录存在 - if (!fs.existsSync(dir)) { - await fs.promises.mkdir(dir, { recursive: true }); + if (!fs.existsSync(GLOBAL_CONFIG_DIR)) { + await fs.promises.mkdir(GLOBAL_CONFIG_DIR, { recursive: true }); } - const filename = format === 'json' ? 'agents.json' : 'agents.yaml'; - const fullPath = path.join(dir, filename); - - let content: string; - - if (format === 'json') { - content = JSON.stringify(config, null, 2); - } else { - try { - const yaml = await import('js-yaml'); - content = yaml.dump(config, { indent: 2, lineWidth: 120 }); - } catch { - // 回退到 JSON - content = JSON.stringify(config, null, 2); - console.warn('保存 YAML 失败,已保存为 JSON 格式'); - } - } + const fullPath = path.join(GLOBAL_CONFIG_DIR, 'agents.json'); + const content = JSON.stringify(config, null, 2); await fs.promises.writeFile(fullPath, content, 'utf-8'); } diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index e36b569..c15b912 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -109,16 +109,26 @@ export interface AgentInfo { maxSteps?: number; } +/** + * Agent 配置文件默认配置 + */ +export interface AgentConfigFileDefaults { + /** 最大执行步数 */ + maxSteps?: number; + /** 默认模型配置(全局 Provider/Model 选择) */ + model?: AgentModelConfig; + /** Vision 模型配置(用于图片理解) */ + vision?: AgentModelConfig; + /** 默认权限配置 */ + permission?: AgentPermission; +} + /** * Agent 配置文件格式(用户自定义) */ export interface AgentConfigFile { /** 全局默认配置 */ - defaults?: { - maxSteps?: number; - model?: AgentModelConfig; - permission?: AgentPermission; - }; + defaults?: AgentConfigFileDefaults; /** Agent 定义 */ agents?: Record>; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c28b0e1..bff5667 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,7 +10,7 @@ export { } from './core/doom-loop.js'; export type { DoomLoopDetector } from './core/doom-loop.js'; export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js'; -export { loadConfig, saveConfig, getConfig, loadVisionConfig, ConfigurationError } from './utils/config.js'; +export { loadConfig, loadVisionConfig, buildConfigFromModelConfig, ConfigurationError } from './utils/config.js'; export type { VisionConfig } from './utils/config.js'; // Context compression diff --git a/packages/core/src/tools/web/web_extract.ts b/packages/core/src/tools/web/web_extract.ts index d123c96..f435d5f 100644 --- a/packages/core/src/tools/web/web_extract.ts +++ b/packages/core/src/tools/web/web_extract.ts @@ -2,7 +2,6 @@ import { tavily } from '@tavily/core'; import type { ToolResult } from '../../types/index.js'; import type { ToolWithMetadata } from '../types.js'; import { loadDescription } from '../load_description.js'; -import { getConfig } from '../../utils/config.js'; import { getPermissionManager } from '../../permission/index.js'; export const webExtractTool: ToolWithMetadata = { @@ -73,15 +72,14 @@ export const webExtractTool: ToolWithMetadata = { }; } - // 获取 Tavily API Key - const config = getConfig(); - const apiKey = process.env.TAVILY_API_KEY || config.tavilyApiKey; + // 获取 Tavily API Key(从环境变量) + const apiKey = process.env.TAVILY_API_KEY; if (!apiKey) { return { success: false, output: '', - error: '未配置 Tavily API Key。请设置环境变量 TAVILY_API_KEY 或在配置文件中添加 tavilyApiKey。', + error: '未配置 Tavily API Key。请设置环境变量 TAVILY_API_KEY。', }; } diff --git a/packages/core/src/tools/web/web_search.ts b/packages/core/src/tools/web/web_search.ts index 05e51fc..aaadef2 100644 --- a/packages/core/src/tools/web/web_search.ts +++ b/packages/core/src/tools/web/web_search.ts @@ -2,7 +2,6 @@ import { tavily } from '@tavily/core'; import type { ToolResult } from '../../types/index.js'; import type { ToolWithMetadata } from '../types.js'; import { loadDescription } from '../load_description.js'; -import { getConfig } from '../../utils/config.js'; import { getPermissionManager } from '../../permission/index.js'; export const webSearchTool: ToolWithMetadata = { @@ -75,15 +74,14 @@ export const webSearchTool: ToolWithMetadata = { }; } - // 获取 Tavily API Key - const config = getConfig(); - const apiKey = process.env.TAVILY_API_KEY || config.tavilyApiKey; + // 获取 Tavily API Key(从环境变量) + const apiKey = process.env.TAVILY_API_KEY; if (!apiKey) { return { success: false, output: '', - error: '未配置 Tavily API Key。请设置环境变量 TAVILY_API_KEY 或在配置文件中添加 tavilyApiKey。', + error: '未配置 Tavily API Key。请设置环境变量 TAVILY_API_KEY。', }; } diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index c31a9db..eeba446 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -1,8 +1,14 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; +/** + * Agent 配置加载 + * + * 从 agents.json 的 defaults.model 读取全局模型配置 + * 从 providers.json 读取 API Key 和 baseUrl + */ + import type { AgentConfig, ProviderType } from '../types/index.js'; import { providerRegistry, resolveApiKey } from '../provider/index.js'; +import { agentRegistry } from '../agent/registry.js'; +import type { AgentModelConfig } from '../agent/types.js'; /** * 配置错误异常 @@ -21,32 +27,6 @@ export class ConfigurationError extends Error { } } -const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant'); -const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); - -interface StoredConfig { - provider?: ProviderType; - model?: string; - maxTokens?: number; - tavilyApiKey?: string; - /** 自定义 API 基础 URL(用于 OpenAI 兼容服务,如阿里云百炼) */ - baseUrl?: string; - // Vision 配置 - visionProvider?: ProviderType; - visionModel?: string; - /** Vision 专用的 Base URL(用于 OpenAI 兼容的 Vision 服务) */ - visionBaseUrl?: string; -} - -// Vision 配置接口 -export interface VisionConfig { - provider: ProviderType; - apiKey: string; - model: string; - /** 自定义 Base URL(用于 OpenAI 兼容的 Vision 服务) */ - baseUrl?: string; -} - // 默认模型配置 const DEFAULT_MODELS: Record = { anthropic: 'claude-sonnet-4-20250514', @@ -54,13 +34,6 @@ const DEFAULT_MODELS: Record = { openai: 'gpt-4o', }; -// 默认 Vision 模型(需要支持图片理解) -const DEFAULT_VISION_MODELS: Record = { - anthropic: 'claude-sonnet-4-20250514', - deepseek: 'deepseek-chat', // DeepSeek 暂不支持 vision,占位用 - openai: 'gpt-4o', -}; - // 默认系统提示词 const DEFAULT_SYSTEM_PROMPT = `你是一个运行在终端中的 AI 编程助手。你可以帮助用户: - 读取和写入文件 @@ -81,116 +54,159 @@ const DEFAULT_SYSTEM_PROMPT = `你是一个运行在终端中的 AI 编程助手 当前工作目录: ${process.cwd()} 操作系统: ${process.platform}`; -// 获取原始配置(包含所有字段) -export function getConfig(): StoredConfig { - if (fs.existsSync(CONFIG_FILE)) { - try { - const content = fs.readFileSync(CONFIG_FILE, 'utf-8'); - return JSON.parse(content); - } catch { - return {}; +/** + * 查找第一个已配置 API Key 的 Provider + * 作为未配置 defaults.model 时的 fallback + */ +function findFirstConfiguredProvider(): ProviderType | null { + const providers: ProviderType[] = ['anthropic', 'openai', 'deepseek']; + + for (const providerId of providers) { + const config = providerRegistry.getConfig(providerId); + const apiKey = resolveApiKey(config); + if (apiKey) { + return providerId; } } - return {}; + + return null; } -// 加载配置 +/** + * 加载 Agent 配置 + * + * 配置来源优先级: + * 1. agents.json 的 defaults.model(用户配置的全局默认模型) + * 2. 第一个已配置 API Key 的 Provider(fallback) + * 3. 抛出 ConfigurationError(无可用配置) + */ export function loadConfig(): AgentConfig { - // 从配置文件读取 - const storedConfig = getConfig(); + // 1. 从 AgentRegistry 获取 defaults.model 配置 + const globalConfig = agentRegistry.getGlobalConfig(); + const modelConfig = globalConfig?.model; - // 检查是否配置了 provider - const hasProviderConfig = !!storedConfig.provider; - const finalProvider = storedConfig.provider || 'anthropic'; + // 2. 确定 provider(优先使用配置,否则使用第一个有 API Key 的 provider) + let provider: ProviderType; + let model: string; - // 通过 ProviderRegistry 获取 API Key - const providerConfig = providerRegistry.getConfig(finalProvider); - const finalApiKey = resolveApiKey(providerConfig); - - if (!finalApiKey) { - // 根据是否已选择 provider 给出不同的提示 - const message = hasProviderConfig - ? `请在 Providers 面板配置 ${finalProvider} 的 API Key` - : '请先在 Providers 面板选择并配置一个模型提供商'; - throw new ConfigurationError(message, finalProvider, 'apiKey'); + if (modelConfig?.provider) { + // 用户配置了 defaults.model.provider + provider = modelConfig.provider; + model = modelConfig.model || DEFAULT_MODELS[provider]; + } else { + // 未配置,尝试找第一个有 API Key 的 provider + const fallbackProvider = findFirstConfiguredProvider(); + if (!fallbackProvider) { + throw new ConfigurationError( + '请先在 Providers 面板配置一个模型提供商的 API Key', + 'unknown', + 'apiKey' + ); + } + provider = fallbackProvider; + model = DEFAULT_MODELS[provider]; } - // 确定模型 - const finalModel = storedConfig.model || DEFAULT_MODELS[finalProvider]; + // 3. 从 ProviderRegistry 获取 API Key 和 baseUrl + const providerConfig = providerRegistry.getConfig(provider); + const apiKey = resolveApiKey(providerConfig); - // 确定 baseUrl - const finalBaseUrl = storedConfig.baseUrl || providerConfig?.baseUrl; + if (!apiKey) { + throw new ConfigurationError( + `请在 Providers 面板配置 ${provider} 的 API Key`, + provider, + 'apiKey' + ); + } - // 获取模型的 contextWindow(从 ProviderRegistry 查询) - const modelInfo = providerRegistry.getModelInfo(finalProvider, finalModel); + // 4. 获取模型的 contextWindow + const modelInfo = providerRegistry.getModelInfo(provider, model); const contextWindow = modelInfo?.contextWindow; + // 5. 获取 maxTokens(从 modelConfig 或默认值) + const maxTokens = modelConfig?.maxTokens || 4096; + return { - provider: finalProvider, - apiKey: finalApiKey, - model: finalModel, - maxTokens: storedConfig.maxTokens || 4096, + provider, + apiKey, + model, + maxTokens, systemPrompt: DEFAULT_SYSTEM_PROMPT, - baseUrl: finalBaseUrl, + baseUrl: providerConfig?.baseUrl, contextWindow, }; } +// Vision 配置接口 +export interface VisionConfig { + provider: ProviderType; + apiKey: string; + model: string; + baseUrl?: string; +} + /** * 加载 Vision 配置 + * + * 从 agents.json 的 defaults.vision 读取配置 * Vision 用于图片理解,当主模型不支持 vision 时使用 - * 通过 ProviderRegistry 获取 API Key */ export function loadVisionConfig(): VisionConfig | null { - // 从配置文件读取 - const storedConfig = getConfig(); + // 从 AgentRegistry 获取 defaults.vision 配置 + const globalConfig = agentRegistry.getGlobalConfig(); + const visionConfig = globalConfig?.vision; - // 确定 vision provider(默认使用 anthropic,因为 Claude 支持 vision) - const finalProvider = storedConfig.visionProvider || 'anthropic'; - - // 通过 ProviderRegistry 获取 API Key - const providerConfig = providerRegistry.getConfig(finalProvider); - const finalApiKey = resolveApiKey(providerConfig); - - // 如果没有 API Key,返回 null - if (!finalApiKey) { + if (!visionConfig?.provider) { + // 未配置 vision,返回 null return null; } - // 确定模型 - const finalModel = storedConfig.visionModel || DEFAULT_VISION_MODELS[finalProvider]; + // 从 ProviderRegistry 获取 API Key + const providerConfig = providerRegistry.getConfig(visionConfig.provider); + const apiKey = resolveApiKey(providerConfig); - // 确定 baseUrl - const finalBaseUrl = storedConfig.visionBaseUrl || providerConfig?.baseUrl; + if (!apiKey) { + // 没有 API Key,返回 null + return null; + } + + // 确定模型(使用配置的或默认值) + const model = visionConfig.model || DEFAULT_MODELS[visionConfig.provider]; return { - provider: finalProvider, - apiKey: finalApiKey, - model: finalModel, - baseUrl: finalBaseUrl, + provider: visionConfig.provider, + apiKey, + model, + baseUrl: providerConfig?.baseUrl, }; } -// 保存配置 -export function saveConfig(config: Partial): void { - // 确保目录存在 - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); +/** + * 从 AgentModelConfig 构建完整配置 + * 用于 Task 工具动态构建 Agent 配置 + */ +export function buildConfigFromModelConfig(modelConfig: AgentModelConfig): AgentConfig | null { + if (!modelConfig.provider) { + return null; } - // 读取现有配置 - let existingConfig: StoredConfig = {}; - if (fs.existsSync(CONFIG_FILE)) { - try { - const content = fs.readFileSync(CONFIG_FILE, 'utf-8'); - existingConfig = JSON.parse(content); - } catch { - // 忽略 - } + const providerConfig = providerRegistry.getConfig(modelConfig.provider); + const apiKey = resolveApiKey(providerConfig); + + if (!apiKey) { + return null; } - // 合并并保存 - const newConfig = { ...existingConfig, ...config }; - fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2)); + const model = modelConfig.model || DEFAULT_MODELS[modelConfig.provider]; + const modelInfo = providerRegistry.getModelInfo(modelConfig.provider, model); + + return { + provider: modelConfig.provider, + apiKey, + model, + maxTokens: modelConfig.maxTokens || 4096, + systemPrompt: DEFAULT_SYSTEM_PROMPT, + baseUrl: providerConfig?.baseUrl, + contextWindow: modelInfo?.contextWindow, + }; } - diff --git a/packages/core/tests/unit/tools/web/web_extract.test.ts b/packages/core/tests/unit/tools/web/web_extract.test.ts index 8d4b306..ba9c6f1 100644 --- a/packages/core/tests/unit/tools/web/web_extract.test.ts +++ b/packages/core/tests/unit/tools/web/web_extract.test.ts @@ -8,12 +8,8 @@ vi.mock('@tavily/core', () => ({ })), })); -// Mock config -vi.mock('../../../../src/utils/config.js', () => ({ - getConfig: vi.fn(() => ({ - tavilyApiKey: 'test-api-key', - })), -})); +// Mock environment variable for Tavily API Key +vi.stubEnv('TAVILY_API_KEY', 'test-api-key'); // Mock permission manager vi.mock('../../../../src/permission/index.js', () => ({ @@ -32,7 +28,6 @@ vi.mock('../../../../src/tools/load_description.js', () => ({ import { webExtractTool } from '../../../../src/tools/web/web_extract.js'; import { getPermissionManager } from '../../../../src/permission/index.js'; -import { getConfig } from '../../../../src/utils/config.js'; describe('webExtractTool - 网页内容提取工具', () => { beforeEach(() => { @@ -198,9 +193,7 @@ describe('webExtractTool - 网页内容提取工具', () => { }); it('无 API Key 返回错误', async () => { - vi.mocked(getConfig).mockReturnValue({} as any); - const originalEnv = process.env.TAVILY_API_KEY; - delete process.env.TAVILY_API_KEY; + vi.unstubAllEnvs(); const result = await webExtractTool.execute({ urls: ['https://example.com'], @@ -209,7 +202,7 @@ describe('webExtractTool - 网页内容提取工具', () => { expect(result.success).toBe(false); expect(result.error).toContain('未配置 Tavily API Key'); - process.env.TAVILY_API_KEY = originalEnv; + vi.stubEnv('TAVILY_API_KEY', 'test-api-key'); }); it('权限被拒绝时返回错误', async () => { diff --git a/packages/core/tests/unit/tools/web/web_search.test.ts b/packages/core/tests/unit/tools/web/web_search.test.ts index b0ae5ed..7cdccfa 100644 --- a/packages/core/tests/unit/tools/web/web_search.test.ts +++ b/packages/core/tests/unit/tools/web/web_search.test.ts @@ -8,12 +8,8 @@ vi.mock('@tavily/core', () => ({ })), })); -// Mock config -vi.mock('../../../../src/utils/config.js', () => ({ - getConfig: vi.fn(() => ({ - tavilyApiKey: 'test-api-key', - })), -})); +// Mock environment variable for Tavily API Key +vi.stubEnv('TAVILY_API_KEY', 'test-api-key'); // Mock permission manager vi.mock('../../../../src/permission/index.js', () => ({ @@ -32,7 +28,6 @@ vi.mock('../../../../src/tools/load_description.js', () => ({ import { webSearchTool } from '../../../../src/tools/web/web_search.js'; import { getPermissionManager } from '../../../../src/permission/index.js'; -import { getConfig } from '../../../../src/utils/config.js'; describe('webSearchTool - 网络搜索工具', () => { beforeEach(() => { @@ -131,16 +126,14 @@ describe('webSearchTool - 网络搜索工具', () => { }); it('无 API Key 返回错误', async () => { - vi.mocked(getConfig).mockReturnValue({} as any); - const originalEnv = process.env.TAVILY_API_KEY; - delete process.env.TAVILY_API_KEY; + vi.unstubAllEnvs(); const result = await webSearchTool.execute({ query: 'test' }); expect(result.success).toBe(false); expect(result.error).toContain('未配置 Tavily API Key'); - process.env.TAVILY_API_KEY = originalEnv; + vi.stubEnv('TAVILY_API_KEY', 'test-api-key'); }); it('权限被拒绝时返回错误', async () => { diff --git a/packages/core/tests/unit/utils/config-extended.test.ts b/packages/core/tests/unit/utils/config-extended.test.ts deleted file mode 100644 index 88280ee..0000000 --- a/packages/core/tests/unit/utils/config-extended.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -// Mock fs module -vi.mock('fs', () => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), -})); - -// Mock provider registry -vi.mock('../../../src/provider/index.js', () => ({ - providerRegistry: { - getInfo: vi.fn(), - getConfig: vi.fn(), - }, - resolveApiKey: vi.fn(), -})); - -// Mock process.exit -const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); -}); - -// Mock console.error -const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - -describe('Config - 配置管理扩展测试', () => { - const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant'); - const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - mockExit.mockClear(); - mockConsoleError.mockClear(); - }); - - describe('getConfig - 获取原始配置', () => { - it('配置文件不存在返回空对象', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { getConfig } = await import('../../../src/utils/config.js'); - - const config = getConfig(); - expect(config).toEqual({}); - }); - - it('配置文件存在返回解析后的内容', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - model: 'claude-sonnet-4-20250514', - })); - - vi.resetModules(); - const { getConfig } = await import('../../../src/utils/config.js'); - - const config = getConfig(); - expect(config.provider).toBe('anthropic'); - expect(config.model).toBe('claude-sonnet-4-20250514'); - }); - - it('配置文件解析错误返回空对象', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('invalid json'); - - vi.resetModules(); - const { getConfig } = await import('../../../src/utils/config.js'); - - const config = getConfig(); - expect(config).toEqual({}); - }); - }); - - describe('loadConfig - 加载配置', () => { - it('通过 ProviderRegistry 获取 API Key', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - })); - - vi.resetModules(); - const { resolveApiKey } = await import('../../../src/provider/index.js'); - const { loadConfig } = await import('../../../src/utils/config.js'); - - vi.mocked(resolveApiKey).mockReturnValue('resolved-api-key'); - - const config = loadConfig(); - expect(config.apiKey).toBe('resolved-api-key'); - }); - - it('使用配置文件中的 provider', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'deepseek', - })); - - vi.resetModules(); - const { resolveApiKey } = await import('../../../src/provider/index.js'); - const { loadConfig } = await import('../../../src/utils/config.js'); - - vi.mocked(resolveApiKey).mockReturnValue('deepseek-key'); - - const config = loadConfig(); - expect(config.provider).toBe('deepseek'); - expect(config.apiKey).toBe('deepseek-key'); - }); - - it('使用默认模型', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { resolveApiKey } = await import('../../../src/provider/index.js'); - const { loadConfig } = await import('../../../src/utils/config.js'); - - vi.mocked(resolveApiKey).mockReturnValue('test-key'); - - const config = loadConfig(); - expect(config.model).toBe('claude-sonnet-4-20250514'); - }); - - it('无 API Key 时退出程序', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { resolveApiKey } = await import('../../../src/provider/index.js'); - const { loadConfig } = await import('../../../src/utils/config.js'); - - vi.mocked(resolveApiKey).mockReturnValue(undefined); - - expect(() => loadConfig()).toThrow('process.exit called'); - expect(mockConsoleError).toHaveBeenCalled(); - }); - }); - - describe('loadVisionConfig - 加载 Vision 配置', () => { - it('返回 null 当没有 API Key', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { resolveApiKey } = await import('../../../src/provider/index.js'); - const { loadVisionConfig } = await import('../../../src/utils/config.js'); - - vi.mocked(resolveApiKey).mockReturnValue(undefined); - - const config = loadVisionConfig(); - expect(config).toBeNull(); - }); - - it('从配置文件获取 Vision 设置', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - visionProvider: 'openai', - visionModel: 'gpt-4o', - visionBaseUrl: 'https://config-vision.example.com', - })); - - vi.resetModules(); - const { resolveApiKey } = await import('../../../src/provider/index.js'); - const { loadVisionConfig } = await import('../../../src/utils/config.js'); - - vi.mocked(resolveApiKey).mockReturnValue('config-vision-key'); - - const config = loadVisionConfig(); - expect(config).not.toBeNull(); - expect(config!.provider).toBe('openai'); - expect(config!.model).toBe('gpt-4o'); - expect(config!.apiKey).toBe('config-vision-key'); - }); - - it('默认使用 anthropic provider', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { resolveApiKey } = await import('../../../src/provider/index.js'); - const { loadVisionConfig } = await import('../../../src/utils/config.js'); - - vi.mocked(resolveApiKey).mockReturnValue('anthropic-key'); - - const config = loadVisionConfig(); - expect(config).not.toBeNull(); - expect(config!.provider).toBe('anthropic'); - }); - }); - - describe('saveConfig - 保存配置', () => { - it('创建配置目录(如果不存在)', async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - vi.resetModules(); - const { saveConfig } = await import('../../../src/utils/config.js'); - - saveConfig({ provider: 'anthropic' }); - - expect(fs.mkdirSync).toHaveBeenCalledWith(CONFIG_DIR, { recursive: true }); - }); - - it('合并现有配置', async () => { - vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - })); - - vi.resetModules(); - const { saveConfig } = await import('../../../src/utils/config.js'); - - saveConfig({ model: 'claude-opus-4-20250514' }); - - expect(fs.writeFileSync).toHaveBeenCalledWith( - CONFIG_FILE, - expect.stringContaining('"model": "claude-opus-4-20250514"') - ); - expect(fs.writeFileSync).toHaveBeenCalledWith( - CONFIG_FILE, - expect.stringContaining('"provider": "anthropic"') - ); - }); - - it('覆盖相同字段', async () => { - vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - model: 'old-model', - })); - - vi.resetModules(); - const { saveConfig } = await import('../../../src/utils/config.js'); - - saveConfig({ model: 'new-model' }); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const savedConfig = JSON.parse(writeCall[1] as string); - expect(savedConfig.model).toBe('new-model'); - }); - }); -}); diff --git a/packages/core/tests/unit/utils/config.test.ts b/packages/core/tests/unit/utils/config.test.ts index 0377985..89c7ea7 100644 --- a/packages/core/tests/unit/utils/config.test.ts +++ b/packages/core/tests/unit/utils/config.test.ts @@ -1,108 +1,103 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -// Mock fs -vi.mock('fs', () => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), -})); - // Mock provider registry vi.mock('../../../src/provider/index.js', () => ({ providerRegistry: { getInfo: vi.fn(), getConfig: vi.fn(), + getModelInfo: vi.fn(), }, resolveApiKey: vi.fn(), })); -import * as fs from 'fs'; +// Mock agent registry +vi.mock('../../../src/agent/registry.js', () => ({ + agentRegistry: { + getGlobalConfig: vi.fn(), + }, +})); + import { providerRegistry, resolveApiKey } from '../../../src/provider/index.js'; -import { getConfig, loadConfig, saveConfig, loadVisionConfig } from '../../../src/utils/config.js'; +import { agentRegistry } from '../../../src/agent/registry.js'; +import { loadConfig, loadVisionConfig, ConfigurationError } from '../../../src/utils/config.js'; describe('Config - 配置管理', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('getConfig - 获取原始配置', () => { - it('配置文件存在时返回内容', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - model: 'claude-3-opus', - })); - - const config = getConfig(); - - expect(config.provider).toBe('anthropic'); - expect(config.model).toBe('claude-3-opus'); - }); - - it('配置文件不存在时返回空对象', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - const config = getConfig(); - - expect(config).toEqual({}); - }); - - it('配置文件解析错误时返回空对象', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('invalid json'); - - const config = getConfig(); - - expect(config).toEqual({}); - }); - }); - describe('loadConfig - 加载完整配置', () => { - it('通过 ProviderRegistry 获取 Anthropic 配置', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - vi.mocked(providerRegistry.getConfig).mockReturnValue(undefined); - vi.mocked(resolveApiKey).mockReturnValue('resolved-anthropic-key'); + it('从 AgentRegistry defaults.model 获取配置', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + model: { + provider: 'openai', + model: 'gpt-4o', + maxTokens: 8192, + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ + id: 'openai', + baseUrl: 'https://api.openai.com/v1', + }); + vi.mocked(resolveApiKey).mockReturnValue('openai-api-key'); const config = loadConfig(); - expect(config.provider).toBe('anthropic'); - expect(config.apiKey).toBe('resolved-anthropic-key'); - expect(config.model).toBe('claude-sonnet-4-20250514'); - }); - - it('从配置文件获取 DeepSeek provider', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'deepseek', - })); - vi.mocked(providerRegistry.getConfig).mockReturnValue(undefined); - vi.mocked(resolveApiKey).mockReturnValue('resolved-deepseek-key'); - - const config = loadConfig(); - - expect(config.provider).toBe('deepseek'); - expect(config.apiKey).toBe('resolved-deepseek-key'); - expect(config.model).toBe('deepseek-chat'); - }); - - it('配置文件中的 model 和 maxTokens', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - model: 'custom-model', - maxTokens: 8192, - })); - vi.mocked(providerRegistry.getConfig).mockReturnValue(undefined); - vi.mocked(resolveApiKey).mockReturnValue('test-key'); - - const config = loadConfig(); - - expect(config.model).toBe('custom-model'); + expect(config.provider).toBe('openai'); + expect(config.model).toBe('gpt-4o'); + expect(config.apiKey).toBe('openai-api-key'); expect(config.maxTokens).toBe(8192); + expect(config.baseUrl).toBe('https://api.openai.com/v1'); + }); + + it('未配置 defaults.model 时使用第一个有 API Key 的 provider', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue(null); + // anthropic 没有 API Key + vi.mocked(providerRegistry.getConfig).mockImplementation((id) => { + if (id === 'openai') { + return { id: 'openai' }; + } + return undefined; + }); + vi.mocked(resolveApiKey).mockImplementation((config) => { + if (config?.id === 'openai') { + return 'openai-fallback-key'; + } + return undefined; + }); + + const config = loadConfig(); + + expect(config.provider).toBe('openai'); + expect(config.apiKey).toBe('openai-fallback-key'); + expect(config.model).toBe('gpt-4o'); // 默认模型 + }); + + it('没有任何 provider 配置 API Key 时抛出错误', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue(null); + vi.mocked(providerRegistry.getConfig).mockReturnValue(undefined); + vi.mocked(resolveApiKey).mockReturnValue(undefined); + + expect(() => loadConfig()).toThrow(ConfigurationError); + expect(() => loadConfig()).toThrow('请先在 Providers 面板配置一个模型提供商的 API Key'); + }); + + it('配置了 provider 但没有 API Key 时抛出错误', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + model: { + provider: 'deepseek', + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ id: 'deepseek' }); + vi.mocked(resolveApiKey).mockReturnValue(undefined); + + expect(() => loadConfig()).toThrow(ConfigurationError); + expect(() => loadConfig()).toThrow('请在 Providers 面板配置 deepseek 的 API Key'); }); it('包含系统提示词', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue(null); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ id: 'anthropic' }); vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); @@ -112,7 +107,12 @@ describe('Config - 配置管理', () => { }); it('默认 maxTokens 为 4096', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + model: { + provider: 'anthropic', + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ id: 'anthropic' }); vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); @@ -120,11 +120,16 @@ describe('Config - 配置管理', () => { expect(config.maxTokens).toBe(4096); }); - it('从配置文件获取 baseUrl', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + it('从 ProviderConfig 获取 baseUrl', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + model: { + provider: 'openai', + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ + id: 'openai', baseUrl: 'https://custom.api.com/v1', - })); + }); vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); @@ -132,67 +137,44 @@ describe('Config - 配置管理', () => { expect(config.baseUrl).toBe('https://custom.api.com/v1'); }); - it('从 ProviderConfig 获取 baseUrl', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - vi.mocked(providerRegistry.getConfig).mockReturnValue({ - id: 'anthropic', - baseUrl: 'https://provider-config.api.com/v1', + it('获取模型的 contextWindow', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + model: { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ id: 'anthropic' }); + vi.mocked(providerRegistry.getModelInfo).mockReturnValue({ + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + contextWindow: 200000, }); vi.mocked(resolveApiKey).mockReturnValue('test-key'); const config = loadConfig(); - expect(config.baseUrl).toBe('https://provider-config.api.com/v1'); - }); - }); - - describe('saveConfig - 保存配置', () => { - it('创建目录并保存配置', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - saveConfig({ provider: 'anthropic' }); - - expect(fs.mkdirSync).toHaveBeenCalledWith( - expect.any(String), - { recursive: true } - ); - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('anthropic') - ); - }); - - it('合并现有配置', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - provider: 'anthropic', - model: 'old-model', - })); - - saveConfig({ model: 'new-model' }); - - const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]; - const savedConfig = JSON.parse(writeCall[1] as string); - - expect(savedConfig.provider).toBe('anthropic'); - expect(savedConfig.model).toBe('new-model'); - }); - - it('目录已存在时不重新创建', () => { - vi.mocked(fs.existsSync) - .mockReturnValueOnce(true) // 目录存在 - .mockReturnValueOnce(true); // 配置文件存在 - vi.mocked(fs.readFileSync).mockReturnValue('{}'); - - saveConfig({ provider: 'test' }); - - expect(fs.mkdirSync).not.toHaveBeenCalled(); + expect(config.contextWindow).toBe(200000); }); }); describe('loadVisionConfig - Vision 配置', () => { - it('返回 null 当没有配置 Vision API Key', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + it('返回 null 当没有配置 defaults.vision', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue(null); + + const visionConfig = loadVisionConfig(); + + expect(visionConfig).toBeNull(); + }); + + it('返回 null 当 vision provider 没有 API Key', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + vision: { + provider: 'openai', + model: 'gpt-4o', + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ id: 'openai' }); vi.mocked(resolveApiKey).mockReturnValue(undefined); const visionConfig = loadVisionConfig(); @@ -200,12 +182,17 @@ describe('Config - 配置管理', () => { expect(visionConfig).toBeNull(); }); - it('通过 ProviderRegistry 获取 Vision 配置', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - visionProvider: 'openai', - visionModel: 'gpt-4-vision', - })); + it('从 defaults.vision 获取 Vision 配置', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + vision: { + provider: 'openai', + model: 'gpt-4o', + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ + id: 'openai', + baseUrl: 'https://api.openai.com/v1', + }); vi.mocked(resolveApiKey).mockReturnValue('vision-api-key'); const visionConfig = loadVisionConfig(); @@ -213,33 +200,17 @@ describe('Config - 配置管理', () => { expect(visionConfig).not.toBeNull(); expect(visionConfig?.provider).toBe('openai'); expect(visionConfig?.apiKey).toBe('vision-api-key'); - expect(visionConfig?.model).toBe('gpt-4-vision'); + expect(visionConfig?.model).toBe('gpt-4o'); + expect(visionConfig?.baseUrl).toBe('https://api.openai.com/v1'); }); - it('默认使用 anthropic provider', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - vi.mocked(resolveApiKey).mockReturnValue('anthropic-key'); - - const visionConfig = loadVisionConfig(); - - expect(visionConfig).not.toBeNull(); - expect(visionConfig?.provider).toBe('anthropic'); - }); - - it('Vision baseUrl 配置', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ - visionBaseUrl: 'https://vision.api.com/v1', - })); - vi.mocked(resolveApiKey).mockReturnValue('vision-key'); - - const visionConfig = loadVisionConfig(); - - expect(visionConfig?.baseUrl).toBe('https://vision.api.com/v1'); - }); - - it('使用默认 Vision 模型', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + it('使用默认模型当 vision.model 未指定', () => { + vi.mocked(agentRegistry.getGlobalConfig).mockReturnValue({ + vision: { + provider: 'anthropic', + }, + }); + vi.mocked(providerRegistry.getConfig).mockReturnValue({ id: 'anthropic' }); vi.mocked(resolveApiKey).mockReturnValue('test-key'); const visionConfig = loadVisionConfig(); @@ -247,4 +218,15 @@ describe('Config - 配置管理', () => { expect(visionConfig?.model).toBe('claude-sonnet-4-20250514'); }); }); + + describe('ConfigurationError', () => { + it('包含 provider 和 missingKey 信息', () => { + const error = new ConfigurationError('Test error', 'openai', 'apiKey'); + + expect(error.name).toBe('ConfigurationError'); + expect(error.message).toBe('Test error'); + expect(error.provider).toBe('openai'); + expect(error.missingKey).toBe('apiKey'); + }); + }); }); diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index a872569..91af6ec 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -12,6 +12,7 @@ import { getSessionManager } from '../session/manager.js'; import { broadcastToSession } from '../ws.js'; import { emitStatusEvent, emitLogEvent } from '../sse.js'; import { createServerPermissionCallback, setSessionAutoApprove } from '../permission/handler.js'; +import { getConfig } from '../routes/config.js'; // ============================================================================ // Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型) @@ -220,18 +221,22 @@ export async function initCore(): Promise { return false; } + // 获取服务器配置的工作目录 + const config = getConfig(); + const workdir = config.workdir; + // 初始化 ProviderRegistry(加载用户配置) const providerRegistry = core.getProviderRegistry(); if (!providerRegistry.isInitialized()) { - await providerRegistry.init(); - console.log('[Agent] ProviderRegistry initialized'); + await providerRegistry.init(workdir); + console.log('[Agent] ProviderRegistry initialized with workdir:', workdir); } // 初始化 AgentRegistry(加载用户自定义 Agent 配置) const agentRegistry = core.agentRegistry; if (!agentRegistry.isInitialized()) { - await agentRegistry.init(process.cwd()); - console.log('[Agent] AgentRegistry initialized'); + await agentRegistry.init(workdir); + console.log('[Agent] AgentRegistry initialized with workdir:', workdir); } coreModule = core; diff --git a/packages/server/src/routes/providers.ts b/packages/server/src/routes/providers.ts index 05f393b..98cb4dd 100644 --- a/packages/server/src/routes/providers.ts +++ b/packages/server/src/routes/providers.ts @@ -5,6 +5,7 @@ */ import { Hono } from 'hono'; +import { getConfig } from './config.js'; // Types from core - dynamically import to avoid build dependency interface ProviderListItem { @@ -246,7 +247,7 @@ providersRouter.post('/', async (c) => { const registry = core.getProviderRegistry(); registry.registerCustom(body); - await registry.saveConfig(); + await registry.saveConfig(getConfig().workdir); return c.json({ success: true, @@ -284,7 +285,7 @@ providersRouter.put('/:id', async (c) => { } registry.setConfig(id, { ...body, id }); - await registry.saveConfig(); + await registry.saveConfig(getConfig().workdir); return c.json({ success: true, @@ -320,7 +321,7 @@ providersRouter.delete('/:id', async (c) => { return c.json({ success: false, error: `Provider not found: ${id}` }, 404); } - await registry.saveConfig(); + await registry.saveConfig(getConfig().workdir); return c.json({ success: true, @@ -364,7 +365,7 @@ providersRouter.post('/:id/models', async (c) => { const registry = core.getProviderRegistry(); registry.addCustomModel(providerId, body); - await registry.saveConfig(); + await registry.saveConfig(getConfig().workdir); return c.json({ success: true, @@ -407,7 +408,7 @@ providersRouter.delete('/:id/models/:modelId', async (c) => { ); } - await registry.saveConfig(); + await registry.saveConfig(getConfig().workdir); return c.json({ success: true, diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 659e587..a0eeb01 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -496,6 +496,8 @@ export interface AgentDefaults { maxSteps?: number; /** 模型配置 */ model?: AgentModelConfig; + /** Vision 模型配置(用于图片理解) */ + vision?: AgentModelConfig; /** 权限配置 */ permission?: AgentPermission; } diff --git a/packages/ui/src/components/AgentDefaultsEditor.tsx b/packages/ui/src/components/AgentDefaultsEditor.tsx index b20bd20..ca02cde 100644 --- a/packages/ui/src/components/AgentDefaultsEditor.tsx +++ b/packages/ui/src/components/AgentDefaultsEditor.tsx @@ -44,6 +44,10 @@ export function AgentDefaultsEditor({ const [temperature, setTemperature] = useState(undefined); const [maxTokens, setMaxTokens] = useState(undefined); + // Vision 模型配置 + const [visionProvider, setVisionProvider] = useState(''); + const [visionModel, setVisionModel] = useState(''); + // UI 状态 const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -66,6 +70,11 @@ export function AgentDefaultsEditor({ setTemperature(defaults.model.temperature); setMaxTokens(defaults.model.maxTokens); } + + if (defaults.vision) { + setVisionProvider(defaults.vision.provider || ''); + setVisionModel(defaults.vision.model || ''); + } } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load defaults'); @@ -103,6 +112,18 @@ export function AgentDefaultsEditor({ defaults.model = model; } + // Vision 配置 + const vision: AgentModelConfig = {}; + if (visionProvider) { + vision.provider = visionProvider as 'anthropic' | 'deepseek' | 'openai'; + } + if (visionModel) { + vision.model = visionModel; + } + if (Object.keys(vision).length > 0) { + defaults.vision = vision; + } + return defaults; }; @@ -225,6 +246,9 @@ export function AgentDefaultsEditor({ {/* Model Configuration */}

Default Model

+

+ The main model used for all conversations +

{/* Provider */}
@@ -287,6 +311,41 @@ export function AgentDefaultsEditor({
+ {/* Vision Model Configuration */} +
+

Vision Model

+

+ Used for image understanding when the default model doesn't support vision +

+
+ {/* Vision Provider */} +
+ + +
+ + {/* Vision Model */} +
+ + setVisionModel(e.target.value)} + placeholder="gpt-4o" + className="w-full px-3 py-2 bg-surface-base border border-line rounded-lg text-sm focus:outline-none focus:border-primary-500" + /> +
+
+
+ {/* Info */}