diff --git a/crates/zclaw-kernel/src/kernel.rs b/crates/zclaw-kernel/src/kernel.rs index 572ad14..975c0dc 100644 --- a/crates/zclaw-kernel/src/kernel.rs +++ b/crates/zclaw-kernel/src/kernel.rs @@ -109,20 +109,36 @@ impl Kernel { /// Send a message to an agent pub async fn send_message(&self, agent_id: &AgentId, message: String) -> Result { - let _agent = self.registry.get(agent_id) + let agent_config = self.registry.get(agent_id) .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?; // Create or get session let session_id = self.memory.create_session(agent_id).await?; - // Create agent loop + // Always use Kernel's current model configuration + // This ensures user's "模型与 API" settings are respected + let model = self.config.model().to_string(); + + eprintln!("[Kernel] send_message: using model={} from kernel config", model); + + // Create agent loop with model configuration let tools = self.create_tool_registry(); let loop_runner = AgentLoop::new( *agent_id, self.driver.clone(), tools, self.memory.clone(), - ); + ) + .with_model(&model) + .with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens())) + .with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature())); + + // Add system prompt if configured + let loop_runner = if let Some(ref prompt) = agent_config.system_prompt { + loop_runner.with_system_prompt(prompt) + } else { + loop_runner + }; // Run the loop let result = loop_runner.run(session_id, message).await?; @@ -140,20 +156,36 @@ impl Kernel { agent_id: &AgentId, message: String, ) -> Result> { - let _agent = self.registry.get(agent_id) + let agent_config = self.registry.get(agent_id) .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?; // Create session let session_id = self.memory.create_session(agent_id).await?; - // Create agent loop + // Always use Kernel's current model configuration + // This ensures user's "模型与 API" settings are respected + let model = self.config.model().to_string(); + + eprintln!("[Kernel] send_message_stream: using model={} from kernel config", model); + + // Create agent loop with model configuration let tools = self.create_tool_registry(); let loop_runner = AgentLoop::new( *agent_id, self.driver.clone(), tools, self.memory.clone(), - ); + ) + .with_model(&model) + .with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens())) + .with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature())); + + // Add system prompt if configured + let loop_runner = if let Some(ref prompt) = agent_config.system_prompt { + loop_runner.with_system_prompt(prompt) + } else { + loop_runner + }; // Run with streaming loop_runner.run_streaming(session_id, message).await @@ -169,6 +201,11 @@ impl Kernel { self.events.publish(Event::KernelShutdown); Ok(()) } + + /// Get the kernel configuration + pub fn config(&self) -> &KernelConfig { + &self.config + } } /// Response from sending a message diff --git a/crates/zclaw-runtime/src/driver/openai.rs b/crates/zclaw-runtime/src/driver/openai.rs index fa50d7a..0926220 100644 --- a/crates/zclaw-runtime/src/driver/openai.rs +++ b/crates/zclaw-runtime/src/driver/openai.rs @@ -18,7 +18,11 @@ pub struct OpenAiDriver { impl OpenAiDriver { pub fn new(api_key: SecretString) -> Self { Self { - client: Client::new(), + client: Client::builder() + .user_agent(crate::USER_AGENT) + .http1_only() + .build() + .unwrap_or_else(|_| Client::new()), api_key, base_url: "https://api.openai.com/v1".to_string(), } @@ -26,7 +30,11 @@ impl OpenAiDriver { pub fn with_base_url(api_key: SecretString, base_url: String) -> Self { Self { - client: Client::new(), + client: Client::builder() + .user_agent(crate::USER_AGENT) + .http1_only() + .build() + .unwrap_or_else(|_| Client::new()), api_key, base_url, } @@ -46,10 +54,16 @@ impl LlmDriver for OpenAiDriver { async fn complete(&self, request: CompletionRequest) -> Result { let api_request = self.build_api_request(&request); + // Debug: log the request details + let url = format!("{}/chat/completions", self.base_url); + let request_body = serde_json::to_string(&api_request).unwrap_or_default(); + eprintln!("[OpenAiDriver] Sending request to: {}", url); + eprintln!("[OpenAiDriver] Request body: {}", request_body); + let response = self.client - .post(format!("{}/chat/completions", self.base_url)) + .post(&url) .header("Authorization", format!("Bearer {}", self.api_key.expose_secret())) - .header("Content-Type", "application/json") + .header("Accept", "*/*") .json(&api_request) .send() .await @@ -58,9 +72,12 @@ impl LlmDriver for OpenAiDriver { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); + eprintln!("[OpenAiDriver] API error {}: {}", status, body); return Err(ZclawError::LlmError(format!("API error {}: {}", status, body))); } + eprintln!("[OpenAiDriver] Response status: {}", response.status()); + let api_response: OpenAiResponse = response .json() .await @@ -71,7 +88,21 @@ impl LlmDriver for OpenAiDriver { } impl OpenAiDriver { + /// Check if this is a Coding Plan endpoint (requires coding context) + fn is_coding_plan_endpoint(&self) -> bool { + self.base_url.contains("coding.dashscope") || + self.base_url.contains("coding/paas") || + self.base_url.contains("api.kimi.com/coding") + } + fn build_api_request(&self, request: &CompletionRequest) -> OpenAiRequest { + // For Coding Plan endpoints, auto-add a coding assistant system prompt if not provided + let system_prompt = if request.system.is_none() && self.is_coding_plan_endpoint() { + Some("你是一个专业的编程助手,可以帮助用户解决编程问题、写代码、调试等。".to_string()) + } else { + request.system.clone() + }; + let messages: Vec = request.messages .iter() .filter_map(|msg| match msg { @@ -116,7 +147,7 @@ impl OpenAiDriver { // Add system prompt if provided let mut messages = messages; - if let Some(system) = &request.system { + if let Some(system) = &system_prompt { messages.insert(0, OpenAiMessage { role: "system".to_string(), content: Some(system.clone()), @@ -137,7 +168,7 @@ impl OpenAiDriver { .collect(); OpenAiRequest { - model: request.model.clone(), + model: request.model.clone(), // Use model ID directly without any transformation messages, max_tokens: request.max_tokens, temperature: request.temperature, @@ -256,38 +287,50 @@ struct FunctionDef { parameters: serde_json::Value, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] struct OpenAiResponse { + #[serde(default)] choices: Vec, + #[serde(default)] usage: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] struct OpenAiChoice { + #[serde(default)] message: OpenAiResponseMessage, + #[serde(default)] finish_reason: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] struct OpenAiResponseMessage { + #[serde(default)] content: Option, + #[serde(default)] tool_calls: Option>, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] struct OpenAiToolCallResponse { + #[serde(default)] id: String, + #[serde(default)] function: FunctionCallResponse, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] struct FunctionCallResponse { + #[serde(default)] name: String, + #[serde(default)] arguments: String, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] struct OpenAiUsage { + #[serde(default)] prompt_tokens: u32, + #[serde(default)] completion_tokens: u32, } diff --git a/crates/zclaw-runtime/src/lib.rs b/crates/zclaw-runtime/src/lib.rs index 944d53a..dcbe623 100644 --- a/crates/zclaw-runtime/src/lib.rs +++ b/crates/zclaw-runtime/src/lib.rs @@ -2,6 +2,10 @@ //! //! LLM drivers, tool system, and agent loop implementation. +/// Default User-Agent header sent with all outgoing HTTP requests. +/// Some LLM providers (e.g. Moonshot, Qwen, DashScope Coding Plan) reject requests without one. +pub const USER_AGENT: &str = "ZCLAW/0.2.0"; + pub mod driver; pub mod tool; pub mod loop_runner; diff --git a/desktop/src-tauri/src/kernel_commands.rs b/desktop/src-tauri/src/kernel_commands.rs index 767b87e..ea4c142 100644 --- a/desktop/src-tauri/src/kernel_commands.rs +++ b/desktop/src-tauri/src/kernel_commands.rs @@ -39,8 +39,8 @@ pub struct CreateAgentRequest { pub temperature: f32, } -fn default_provider() -> String { "anthropic".to_string() } -fn default_model() -> String { "claude-sonnet-4-20250514".to_string() } +fn default_provider() -> String { "openai".to_string() } +fn default_model() -> String { "gpt-4o-mini".to_string() } fn default_max_tokens() -> u32 { 4096 } fn default_temperature() -> f32 { 0.7 } @@ -79,30 +79,120 @@ pub struct KernelStatusResponse { pub initialized: bool, pub agent_count: usize, pub database_url: Option, - pub default_provider: Option, - pub default_model: Option, + pub base_url: Option, + pub model: Option, } +/// Kernel configuration request +/// +/// Simple configuration: base_url + api_key + model +/// Model ID is passed directly to the API without any transformation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KernelConfigRequest { + /// LLM provider (for preset URLs): anthropic, openai, zhipu, kimi, qwen, deepseek, local, custom + #[serde(default = "default_kernel_provider")] + pub provider: String, + /// Model identifier - passed directly to the API + #[serde(default = "default_kernel_model")] + pub model: String, + /// API key + pub api_key: Option, + /// Base URL (optional, uses provider default if not specified) + pub base_url: Option, + /// API protocol: openai or anthropic + #[serde(default = "default_api_protocol")] + pub api_protocol: String, +} + +fn default_api_protocol() -> String { "openai".to_string() } +fn default_kernel_provider() -> String { "openai".to_string() } +fn default_kernel_model() -> String { "gpt-4o-mini".to_string() } + /// Initialize the internal ZCLAW Kernel +/// +/// If kernel already exists with the same config, returns existing status. +/// If config changed, reboots kernel with new config. #[tauri::command] pub async fn kernel_init( state: State<'_, KernelState>, + config_request: Option, ) -> Result { let mut kernel_lock = state.lock().await; - if kernel_lock.is_some() { - let kernel = kernel_lock.as_ref().unwrap(); - return Ok(KernelStatusResponse { - initialized: true, - agent_count: kernel.list_agents().len(), - database_url: None, - default_provider: Some("anthropic".to_string()), - default_model: Some("claude-sonnet-4-20250514".to_string()), - }); + eprintln!("[kernel_init] Called with config_request: {:?}", config_request); + + // Check if we need to reboot kernel with new config + if let Some(kernel) = kernel_lock.as_ref() { + // Get current config from kernel + let current_config = kernel.config(); + eprintln!("[kernel_init] Current kernel config: model={}, base_url={}", + current_config.llm.model, current_config.llm.base_url); + + // Check if config changed + let config_changed = if let Some(ref req) = config_request { + let default_base_url = zclaw_kernel::config::KernelConfig::from_provider( + &req.provider, "", &req.model, None, &req.api_protocol + ).llm.base_url; + let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone()); + + eprintln!("[kernel_init] Request config: model={}, base_url={}", req.model, request_base_url); + eprintln!("[kernel_init] Comparing: current.model={} vs req.model={}, current.base_url={} vs req.base_url={}", + current_config.llm.model, req.model, current_config.llm.base_url, request_base_url); + + let changed = current_config.llm.model != req.model || + current_config.llm.base_url != request_base_url; + eprintln!("[kernel_init] Config changed: {}", changed); + changed + } else { + false + }; + + if !config_changed { + // Same config, return existing status + eprintln!("[kernel_init] Config unchanged, reusing existing kernel"); + return Ok(KernelStatusResponse { + initialized: true, + agent_count: kernel.list_agents().len(), + database_url: None, + base_url: Some(current_config.llm.base_url.clone()), + model: Some(current_config.llm.model.clone()), + }); + } + + // Config changed, need to reboot kernel + eprintln!("[kernel_init] Config changed, rebooting kernel..."); + + // Shutdown old kernel + if let Err(e) = kernel.shutdown().await { + eprintln!("[kernel_init] Warning: Failed to shutdown old kernel: {}", e); + } + *kernel_lock = None; } - // Load configuration - let config = zclaw_kernel::config::KernelConfig::default(); + // Build configuration from request + let config = if let Some(req) = &config_request { + let api_key = req.api_key.as_deref().unwrap_or(""); + let base_url = req.base_url.as_deref(); + + eprintln!("[kernel_init] Building config: provider={}, model={}, base_url={:?}, api_protocol={}", + req.provider, req.model, base_url, req.api_protocol); + + zclaw_kernel::config::KernelConfig::from_provider( + &req.provider, + api_key, + &req.model, + base_url, + &req.api_protocol, + ) + } else { + zclaw_kernel::config::KernelConfig::default() + }; + + let base_url = config.llm.base_url.clone(); + let model = config.llm.model.clone(); + + eprintln!("[kernel_init] Final config: model={}, base_url={}", model, base_url); // Boot kernel let kernel = Kernel::boot(config.clone()) @@ -113,12 +203,14 @@ pub async fn kernel_init( *kernel_lock = Some(kernel); + eprintln!("[kernel_init] Kernel booted successfully with new config"); + Ok(KernelStatusResponse { initialized: true, agent_count, database_url: Some(config.database_url), - default_provider: Some(config.default_provider), - default_model: Some(config.default_model), + base_url: Some(base_url), + model: Some(model), }) } @@ -134,15 +226,15 @@ pub async fn kernel_status( initialized: true, agent_count: kernel.list_agents().len(), database_url: None, - default_provider: Some("anthropic".to_string()), - default_model: Some("claude-sonnet-4-20250514".to_string()), + base_url: None, + model: None, }), None => Ok(KernelStatusResponse { initialized: false, agent_count: 0, database_url: None, - default_provider: None, - default_model: None, + base_url: None, + model: None, }), } } diff --git a/docs/knowledge-base/troubleshooting.md b/docs/knowledge-base/troubleshooting.md index a2c6397..4c17b6e 100644 --- a/docs/knowledge-base/troubleshooting.md +++ b/docs/knowledge-base/troubleshooting.md @@ -803,7 +803,204 @@ curl http://localhost:1420/api/agents --- -## 9. 相关文档 +## 9. 内核 LLM 响应问题 + +### 9.1 聊天显示"思考中..."但无响应 + +**症状**: 发送消息后,UI 显示"思考中..."状态,但永远不会收到 AI 响应 + +**根本原因**: `loop_runner.rs` 中的代码存在两个严重问题: + +1. **模型 ID 硬编码**: 使用固定的 `"claude-sonnet-4-20250514"` 而非用户配置的模型 +2. **响应被丢弃**: 返回硬编码的 `"Response placeholder"` 而非实际 LLM 响应内容 + +**问题代码** (`crates/zclaw-runtime/src/loop_runner.rs`): +```rust +// ❌ 错误 - 硬编码模型和响应 +let request = CompletionRequest { + model: "claude-sonnet-4-20250514".to_string(), // 硬编码! + // ... +}; + +// ... + +Ok(AgentLoopResult { + response: "Response placeholder".to_string(), // 丢弃真实响应! + // ... +}) +``` + +**修复方案**: + +1. **添加配置字段到 AgentLoop**: +```rust +pub struct AgentLoop { + // ... existing fields + model: String, + system_prompt: Option, + max_tokens: u32, + temperature: f32, +} + +impl AgentLoop { + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } + // ... other builder methods +} +``` + +2. **使用配置的模型**: +```rust +let request = CompletionRequest { + model: self.model.clone(), // 使用配置的模型 + // ... +}; +``` + +3. **提取实际响应内容**: +```rust +// 从 CompletionResponse.content 提取文本 +let response_text = response.content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.clone()), + ContentBlock::Thinking { thinking } => Some(format!("[思考] {}", thinking)), + ContentBlock::ToolUse { name, input, .. } => { + Some(format!("[工具调用] {}({})", name, serde_json::to_string(input).unwrap_or_default())) + } + }) + .collect::>() + .join("\n"); + +Ok(AgentLoopResult { + response: response_text, // 返回真实响应 + // ... +}) +``` + +4. **在 kernel.rs 中传递模型配置**: +```rust +pub async fn send_message(&self, agent_id: &AgentId, message: String) -> Result { + let agent_config = self.registry.get(agent_id)?; + + // 确定使用的模型:agent 配置优先,然后是 kernel 配置 + let model = if !agent_config.model.model.is_empty() { + &agent_config.model.model + } else { + &self.config.default_model + }; + + let loop_runner = AgentLoop::new(/* ... */) + .with_model(model) + .with_max_tokens(agent_config.max_tokens.unwrap_or(self.config.max_tokens)) + .with_temperature(agent_config.temperature.unwrap_or(self.config.temperature)); + + // ... +} +``` + +**影响范围**: +- `crates/zclaw-runtime/src/loop_runner.rs` - 核心修复 +- `crates/zclaw-kernel/src/kernel.rs` - 模型配置传递 + +**验证修复**: +1. 配置 Coding Plan API(如 `https://coding.dashscope.aliyuncs.com/v1`) +2. 发送消息 +3. 应该收到实际的 LLM 响应而非占位符 + +**特别说明**: 此问题影响所有 LLM 提供商,不仅限于 Coding Plan API。任何自定义模型配置都会被忽略。 + +### 9.2 Coding Plan API 配置流程 + +**支持的 Coding Plan 端点**: + +| 提供商 | Provider ID | Base URL | +|--------|-------------|----------| +| Kimi Coding Plan | `kimi-coding` | `https://api.kimi.com/coding/v1` | +| 百炼 Coding Plan | `qwen-coding` | `https://coding.dashscope.aliyuncs.com/v1` | +| 智谱 GLM Coding Plan | `zhipu-coding` | `https://open.bigmodel.cn/api/coding/paas/v4` | + +**配置流程**: + +1. **前端** (`ModelsAPI.tsx`): 用户选择 Provider,输入 API Key 和 Model ID +2. **存储** (`localStorage`): 保存为 `CustomModel` 对象 +3. **连接时** (`connectionStore.ts`): 从 localStorage 读取配置 +4. **传递给内核** (`kernel-client.ts`): 通过 `kernel_init` 命令传递 +5. **内核处理** (`kernel_commands.rs`): 根据 Provider 和 Base URL 创建驱动 + +**关键代码路径**: +``` +UI 配置 → localStorage → connectionStore.getDefaultModelConfig() + → kernelClient.setConfig() → invoke('kernel_init', { configRequest }) + → KernelConfig → create_driver() → OpenAiDriver::with_base_url() +``` + +**注意事项**: +- Coding Plan 使用 OpenAI 兼容协议 (`api_protocol: "openai"`) +- Base URL 必须包含完整路径(如 `/v1`) +- 未知 Provider 会走 fallback 逻辑,使用 `local_base_url` 作为自定义端点 + +### 9.3 更换模型配置后仍使用旧模型 + +**症状**: 在"模型与 API"页面切换模型后,对话仍然使用旧模型,API 请求中的 model 字段是旧的值 + +**示例日志**: +``` +[kernel_init] Final config: model=qwen3.5-plus, base_url=https://coding.dashscope.aliyuncs.com/v1 +[OpenAiDriver] Request body: {"model":"kimi-for-coding",...} # 旧模型! +``` + +**根本原因**: Agent 配置持久化在数据库中,其 `model` 字段优先于 Kernel 的配置 + +**问题代码** (`crates/zclaw-kernel/src/kernel.rs`): +```rust +// ❌ 错误 - Agent 的 model 优先于 Kernel 的 model +let model = if !agent_config.model.model.is_empty() { + agent_config.model.model.clone() // 持久化的旧值 +} else { + self.config.model().to_string() +}; +``` + +**问题分析**: + +1. Agent 配置在创建时保存到 SQLite 数据库 +2. Kernel 启动时从数据库恢复 Agent 配置 +3. `send_message` 中 Agent 的 model 配置优先于 Kernel 的当前配置 +4. 用户在"模型与 API"页面更改的是 Kernel 配置,不影响已持久化的 Agent 配置 + +**修复方案**: + +让 Kernel 的当前配置优先,确保用户的"模型与 API"设置生效: + +```rust +// ✅ 正确 - 始终使用 Kernel 的当前 model 配置 +let model = self.config.model().to_string(); + +eprintln!("[Kernel] send_message: using model={} from kernel config", model); +``` + +**影响范围**: +- `crates/zclaw-kernel/src/kernel.rs` - `send_message` 和 `send_message_stream` 方法 + +**设计决策**: + +ZCLAW 的设计是让用户在"模型与 API"页面设置全局模型,而不是为每个 Agent 单独设置。因此: +- Kernel 配置应该优先于 Agent 配置 +- Agent 配置主要用于存储 personality、system_prompt 等 +- model 配置应该由全局设置控制 + +**验证修复**: +1. 在"模型与 API"页面配置新模型 +2. 发送消息 +3. 检查终端日志,应显示 `using model=新模型 from kernel config` +4. 检查 API 请求体,`model` 字段应为新模型 + +--- + +## 10. 相关文档 - [OpenFang 配置指南](./openfang-configuration.md) - 配置文件位置、格式和最佳实践 - [Agent 和 LLM 提供商配置](./agent-provider-config.md) - Agent 管理和 Provider 配置 @@ -815,6 +1012,8 @@ curl http://localhost:1420/api/agents | 日期 | 变更 | |------|------| +| 2026-03-23 | 添加 9.3 节:更换模型配置后仍使用旧模型 - Agent 配置优先于 Kernel 配置导致的问题 | +| 2026-03-22 | 添加内核 LLM 响应问题:loop_runner.rs 硬编码模型和响应导致 Coding Plan API 不工作 | | 2026-03-20 | 添加端口配置问题:runtime-manifest.json 声明 4200 但实际运行 50051 | | 2026-03-18 | 添加记忆提取和图谱 UI 问题 | | 2026-03-18 | 添加刷新后对话丢失问题和 ChatArea 布局问题 |