Next.js SSR/hydration 与 SWR fetch-on-mount 存在根本冲突: hydration 卸载组件时 abort 的请求仍占用后端 DB 连接, retry 循环耗尽 PostgreSQL 连接池导致后端完全卡死。 admin-v2 使用 Vite + React + antd 纯 SPA 彻底消除此问题: - 12 页面全部完成(Login, Dashboard, Accounts, Providers, Models, API Keys, Usage, Relay, Config, Prompts, Logs, Agent Templates) - ProTable + ProForm + ProLayout 统一 UI 模式 - TanStack Query + Axios + Zustand 数据层 - JWT 自动刷新 + 401 重试机制 - 全部 18 网络请求 200 OK,零 ERR_ABORTED 同时更新 troubleshooting 第 13 节和 SaaS 平台文档。
68 KiB
故障排查指南
记录开发过程中遇到的问题、根因分析和解决方案。
1. 连接问题
1.1 WebSocket 连接失败
症状: WebSocket connection failed 或 Unexpected server response: 400
排查步骤:
# 1. 检查 ZCLAW 是否运行
curl http://127.0.0.1:50051/api/health
# 2. 检查端口是否正确
netstat -ano | findstr "50051"
# 3. 验证 Agent ID
curl http://127.0.0.1:50051/api/agents
常见原因:
| 原因 | 解决方案 |
|---|---|
| ZCLAW 未启动 | ./zclaw.exe start |
| 端口错误 | ZCLAW 使用 50051,不是 4200 |
| Agent ID 无效 | 使用 /api/agents 获取真实 UUID |
1.2 端口被占用
症状: Port 1420 is already in use
解决方案:
# Windows - 查找并终止进程
netstat -ano | findstr "1420"
taskkill /PID <PID> /F
# 或使用 PowerShell
Stop-Process -Id <PID> -Force
1.3 Vite 代理不工作
症状: 前端请求返回 404 或 CORS 错误
检查清单:
vite.config.ts中配置了/api代理ws: true已启用(WebSocket 需要)changeOrigin: true已设置- 重启 Vite 开发服务器
2. 聊天问题
2.1 LLM API Key 未配置
症状:
Missing API key: No LLM provider configured.
Set an API key (e.g. GROQ_API_KEY) and restart
根本原因: Agent 使用的 LLM 提供商没有配置 API Key
解决方案:
- 检查 Agent 使用的提供商:
curl -s http://127.0.0.1:50051/api/status | jq '.agents[] | {name, model_provider}'
- 配置对应的 API Key:
# 编辑 ~/.zclaw/.env
echo "ZHIPU_API_KEY=your_key" >> ~/.zclaw/.env
echo "BAILIAN_API_KEY=your_key" >> ~/.zclaw/.env
echo "GEMINI_API_KEY=your_key" >> ~/.zclaw/.env
- 重启 ZCLAW:
./zclaw.exe restart
快速解决: 使用已配置的 Agent
| Agent | 提供商 | 状态 |
|---|---|---|
| General Assistant | zhipu | 通常已配置 |
2.1.1 配置热重载限制(重要)
症状: 修改 config.toml 后,/api/config 和 /api/status 仍然返回旧配置
根本原因: ZCLAW 将配置持久化在 SQLite 数据库中,config.toml 只在启动时读取
验证问题:
# 检查 config.toml 内容
cat ~/.zclaw/config.toml
# 输出: provider = "zhipu"
# 检查 API 返回的配置
curl -s http://127.0.0.1:50051/api/config
# 输出: {"default_model":{"provider":"bailian",...}} # 不一致!
解决方案:
- 必须完全重启 ZCLAW(热重载
/api/config/reload不会更新持久化配置)
# 方法 1: 通过 API 关闭(然后手动重启)
curl -X POST http://127.0.0.1:50051/api/shutdown
# 方法 2: 使用 CLI
./zclaw.exe stop
./zclaw.exe start
- 验证配置已生效:
curl -s http://127.0.0.1:50051/api/status | grep -E "default_provider|default_model"
# 应输出: "default_provider":"zhipu"
配置文件位置:
| 文件 | 用途 |
|---|---|
~/.zclaw/config.toml |
主配置(启动时读取) |
~/.zclaw/.env |
API Key 环境变量 |
~/.zclaw/secrets.env |
敏感信息 |
~/.zclaw/data/zclaw.db |
SQLite 数据库(持久化配置) |
支持的 Provider:
| Provider | 环境变量 | 模型示例 |
|---|---|---|
| zhipu | ZHIPU_API_KEY |
glm-4-flash, GLM-4-Plus |
| bailian | BAILIAN_API_KEY |
qwen3.5-plus, qwen3-coder-next |
| gemini | GEMINI_API_KEY |
gemini-2.5-flash |
| deepseek | DEEPSEEK_API_KEY |
deepseek-chat |
| openai | OPENAI_API_KEY |
gpt-4, gpt-3.5-turbo |
2.2 Agent ID 获取失败导致无法对话
症状: Gateway 显示已连接,但发送消息无响应或报错 "No agent available"
根本原因: fetchDefaultAgentId() 使用错误的 API 端点
错误代码:
// ❌ 错误 - /api/status 不返回 agents 字段
const status = await this.restGet('/api/status');
if (status?.agents && status.agents.length > 0) { ... }
修复代码:
// ✅ 正确 - 使用 /api/agents 端点
const agents = await this.restGet<Array<{ id: string; name?: string; state?: string }>>('/api/agents');
if (agents && agents.length > 0) {
const runningAgent = agents.find(a => a.state === 'Running');
this.defaultAgentId = (runningAgent || agents[0]).id;
}
历史背景: 这个问题与 ZCLAW 的握手认证问题类似,但根因不同:
- ZCLAW: 需要
cli/cli/operator身份 + Ed25519 配对 - ZCLAW: 不需要认证握手,但需要正确的 Agent UUID
验证修复:
# 确认 /api/agents 返回数据
curl http://127.0.0.1:50051/api/agents
# 应返回: [{ "id": "uuid", "name": "...", "state": "Running" }]
2.3 流式响应不显示
症状: 消息发送后无响应或响应不完整
排查步骤:
- 确认 WebSocket 连接状态:
console.log(client.getState()); // 应为 'connected'
- 检查事件处理:
// 确保处理了 text_delta 事件
ws.on('message', (data) => {
const event = JSON.parse(data.toString());
if (event.type === 'text_delta') {
console.log('Delta:', event.content);
}
});
- 验证消息格式:
// ✅ 正确
{ type: 'message', content: 'Hello', session_id: 'xxx' }
// ❌ 错误
{ type: 'chat', message: { role: 'user', content: 'Hello' } }
2.3 消息格式错误
症状: WebSocket 连接成功,但发送消息后收到错误
根本原因: 使用了文档中的格式,而非实际格式
正确的消息格式:
{
"type": "message",
"content": "你的消息内容",
"session_id": "唯一会话ID"
}
3. 前端问题
3.1 Zustand 状态不更新
症状: UI 不反映状态变化
检查:
- 确保使用 selector:
// ✅ 正确 - 使用 selector
const messages = useChatStore((state) => state.messages);
// ❌ 错误 - 可能导致不必要的重渲染
const store = useChatStore();
const messages = store.messages;
- 检查 immer/persist 配置
3.2 切换 Agent 后对话消失
症状: 点击其他 Agent 后,之前的对话内容丢失
根本原因: setCurrentAgent 切换 Agent 时清空了 messages,但没有恢复该 Agent 之前的对话
解决方案:
修改 chatStore.ts 中的 setCurrentAgent 函数:
setCurrentAgent: (agent) =>
set((state) => {
if (state.currentAgent?.id === agent.id) {
return { currentAgent: agent };
}
// Save current conversation before switching
const conversations = upsertActiveConversation([...state.conversations], state);
// Try to find existing conversation for this agent
const agentConversation = conversations.find(c => c.agentId === agent.id);
if (agentConversation) {
// Restore the agent's previous conversation
return {
conversations,
currentAgent: agent,
messages: [...agentConversation.messages],
sessionKey: agentConversation.sessionKey,
currentConversationId: agentConversation.id,
};
}
// No existing conversation, start fresh
return {
conversations,
currentAgent: agent,
messages: [],
sessionKey: null,
currentConversationId: null,
};
}),
修改 partialize 配置以保存 currentAgentId:
partialize: (state) => ({
conversations: state.conversations,
currentModel: state.currentModel,
currentAgentId: state.currentAgent?.id, // 添加此行
currentConversationId: state.currentConversationId,
}),
添加 onRehydrateStorage 钩子恢复消息:
onRehydrateStorage: () => (state) => {
// Rehydrate Date objects from JSON strings
if (state?.conversations) {
for (const conv of state.conversations) {
conv.createdAt = new Date(conv.createdAt);
conv.updatedAt = new Date(conv.updatedAt);
for (const msg of conv.messages) {
msg.timestamp = new Date(msg.timestamp);
msg.streaming = false;
}
}
}
// Restore messages from current conversation if exists
if (state?.currentConversationId && state.conversations) {
const currentConv = state.conversations.find(c => c.id === state.currentConversationId);
if (currentConv) {
state.messages = [...currentConv.messages];
state.sessionKey = currentConv.sessionKey;
}
}
},
验证修复:
- 与 Agent A 对话
- 切换到 Agent B
- 切换回 Agent A → 对话应恢复
文件: desktop/src/store/chatStore.ts
3.3 流式消息累积错误
症状: 流式内容显示不正确或重复
解决方案:
onDelta: (delta: string) => {
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: m.content + delta } // 累积内容
: m
),
}));
}
4. Tauri 桌面端问题
4.1 Tauri 编译失败
常见错误:
- Rust 版本不兼容
- 依赖缺失
- Cargo.toml 配置错误
解决方案:
# 更新 Rust
rustup update
# 清理并重新构建
cd desktop/src-tauri
cargo clean
cargo build
4.2 Tauri 窗口白屏
原因: Vite 开发服务器未启动或连接失败
解决方案:
- 确保
pnpm dev在运行 - 检查
tauri.conf.json中的beforeDevCommand - 检查浏览器控制台错误
4.3 Tauri 热重载不工作
检查:
beforeDevCommand配置正确- 文件监听未超出限制(Linux:
fs.inotify.max_user_watches)
5. 调试技巧
5.1 启用详细日志
// gateway-client.ts
private log(level: string, message: string, data?: unknown) {
if (this.debug) {
console.log(`[GatewayClient:${level}]`, message, data || '');
}
}
5.2 WebSocket 抓包
# 使用 wscat 测试
npm install -g wscat
wscat -c ws://127.0.0.1:50051/api/agents/{agentId}/ws
5.3 检查 ZCLAW 状态
# 完整状态
curl -s http://127.0.0.1:50051/api/status | jq
# Agent 状态
curl -s http://127.0.0.1:50051/api/agents | jq '.[] | {id, name, state}'
# Hands 状态
curl -s http://127.0.0.1:50051/api/hands | jq '.[] | {id, name, requirements_met}'
6. 错误代码参考
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
Port 1420 is already in use |
Vite 服务器已运行 | 终止现有进程 |
Unexpected server response: 400 |
Agent ID 无效 | 使用真实 UUID |
Missing API key |
LLM 提供商未配置 | 配置 API Key |
Connection refused |
ZCLAW 未运行 | 启动服务 |
CORS error |
代理未配置 | 检查 vite.config.ts |
7. 新用户引导 (Onboarding)
7.1 首次使用引导流程
需求: 当用户第一次使用系统时,引导用户设置默认助手的人格信息(about me、you in my eye 等)。
实现方案:
-
检测首次使用 - 使用
useOnboardinghook 检查 localStorage:// desktop/src/lib/use-onboarding.ts const ONBOARDING_COMPLETED_KEY = 'zclaw-onboarding-completed'; const USER_PROFILE_KEY = 'zclaw-user-profile'; export function useOnboarding(): OnboardingState { // 检查 localStorage 是否有完成记录 // 返回 { isNeeded, isLoading, markCompleted, resetOnboarding } } -
引导向导 - 使用
AgentOnboardingWizard组件:// App.tsx 中的集成 if (showOnboarding) { return ( <AgentOnboardingWizard isOpen={true} onClose={() => { markCompleted({ userName: 'User', userRole: 'user' }); setShowOnboarding(false); }} onSuccess={(clone) => { markCompleted({ userName: clone.userName || 'User', userRole: clone.userRole, }); setCurrentAgent({ id: clone.id, name: clone.name, icon: clone.emoji || '🦞', // ... }); setShowOnboarding(false); }} /> ); } -
数据持久化 - 用户信息存储在 localStorage:
interface UserProfile { userName: string; userRole?: string; completedAt: string; }
文件位置:
| 文件 | 用途 |
|---|---|
desktop/src/lib/use-onboarding.ts |
引导状态管理 hook |
desktop/src/components/AgentOnboardingWizard.tsx |
5 步引导向导组件 |
desktop/src/App.tsx |
引导流程集成 |
引导步骤:
- 认识用户 - 收集用户名称和角色
- Agent 身份 - 设置助手名称、昵称、emoji
- 人格风格 - 选择沟通风格
- 使用场景 - 选择应用场景
- 工作环境 - 配置工作目录
7.2 Onboarding 创建 Agent 失败
症状: 首次使用引导完成后,点击"完成"按钮报错,Agent 创建失败
根本原因: Onboarding 应该更新现有的默认 Agent,而不是创建新的 Agent
错误代码:
// ❌ 错误 - 总是尝试创建新 Agent
const clone = await createClone(createOptions);
修复代码:
// ✅ 正确 - 如果存在现有 Agent 则更新
let clone: Clone | undefined;
if (clones && clones.length > 0) {
// 更新现有的默认 Agent
clone = await updateClone(clones[0].id, personalityUpdates);
} else {
// 没有现有 Agent 才创建新的
clone = await createClone(createOptions);
}
文件: desktop/src/components/AgentOnboardingWizard.tsx
验证修复:
- 清除 localStorage 中的 onboarding 标记
- 重新启动应用
- 完成引导流程 → 应该成功更新默认 Agent
3.4 刷新页面后对话内容丢失
症状: 切换 Tab 时对话正常保留,但按 F5 刷新页面后对话内容消失
根本原因: onComplete 回调中没有将当前对话保存到 conversations 数组,导致 persist 中间件无法持久化
问题分析:
Zustand 的 persist 中间件只保存 partialize 中指定的字段:
partialize: (state) => ({
conversations: state.conversations, // ← 从这里恢复
currentModel: state.currentModel,
currentAgentId: state.currentAgent?.id,
currentConversationId: state.currentConversationId,
}),
但 messages 数组只在内存中,刷新后丢失。恢复逻辑依赖 conversations 数组:
onRehydrateStorage: () => (state) => {
if (state?.currentConversationId && state.conversations) {
const currentConv = state.conversations.find(c => c.id === state.currentConversationId);
if (currentConv) {
state.messages = [...currentConv.messages]; // ← 从 conversations 恢复
}
}
},
问题: onComplete 回调中只更新了 messages,没有调用 upsertActiveConversation 保存到 conversations
修复代码:
onComplete: () => {
const state = get();
// Save conversation to persist across refresh
const conversations = upsertActiveConversation([...state.conversations], state);
const currentConvId = state.currentConversationId || conversations[0]?.id;
set({
isStreaming: false,
conversations, // ← 保存到 conversations 数组
currentConversationId: currentConvId, // ← 确保 ID 被设置
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false, runId } : m
),
});
// ... rest of the callback
},
文件: desktop/src/store/chatStore.ts
验证修复:
- 发送消息并获得 AI 回复
- 按 F5 刷新页面
- 对话内容应该保留
3.5 ChatArea 输入框布局错乱
症状: 对话过程中输入框被移动到页面顶部,而不是固定在底部
根本原因: ChatArea 组件返回的是 React Fragment (<>...</>),没有包裹在 flex 容器中
问题代码:
// ❌ 错误 - Fragment 没有 flex 布局
return (
<>
<div className="h-14 ...">Header</div>
<div className="flex-1 ...">Messages</div>
<div className="border-t ...">Input</div>
</>
);
修复代码:
// ✅ 正确 - 使用 flex 容器
return (
<div className="flex flex-col h-full">
<div className="h-14 flex-shrink-0 ...">Header</div>
<div className="flex-1 overflow-y-auto ...">Messages</div>
<div className="flex-shrink-0 ...">Input</div>
</div>
);
文件: desktop/src/components/ChatArea.tsx
布局原理:
┌─────────────────────────────────────┐
│ Header (h-14, flex-shrink-0) │ ← 固定高度
├─────────────────────────────────────┤
│ │
│ Messages (flex-1, overflow-y-auto) │ ← 占据剩余空间,可滚动
│ │
├─────────────────────────────────────┤
│ Input (flex-shrink-0) │ ← 固定在底部
└─────────────────────────────────────┘
7. 记忆系统问题
7.1 Memory Tab 为空,多轮对话后无记忆
症状: 经过多次对话后,右侧面板的"记忆"Tab 内容为空
根本原因: 多个配置问题导致记忆未被提取
问题分析:
- LLM 提取默认禁用:
useLLM: false导致只使用规则提取 - 提取阈值过高:
minMessagesForExtraction: 4短对话不会触发 - agentId 不一致:
MemoryPanel用'zclaw-main',MemoryGraph用'default' - Gateway 端点不存在:
GatewayLLMAdapter调用/api/llm/complete,ZCLAW 无此端点
修复方案:
// 1. 启用 LLM 提取 (memory-extractor.ts)
export const DEFAULT_EXTRACTION_CONFIG: ExtractionConfig = {
useLLM: true, // 从 false 改为 true
minMessagesForExtraction: 2, // 从 4 降低到 2
// ...
};
// 2. 统一 agentId fallback (MemoryGraph.tsx)
const agentId = currentAgent?.id || 'zclaw-main'; // 从 'default' 改为 'zclaw-main'
// 3. 修复 Gateway 适配器端点 (llm-service.ts)
// 使用 ZCLAW 的 /api/agents/{id}/message 端点
const response = await fetch(`/api/agents/${agentId}/message`, {
method: 'POST',
body: JSON.stringify({ message: fullPrompt, ... }),
});
文件:
desktop/src/lib/memory-extractor.tsdesktop/src/lib/llm-service.tsdesktop/src/components/MemoryGraph.tsx
验证修复:
- 打开浏览器控制台
- 进行至少 2 轮对话
- 查看日志:
[MemoryExtractor] Using LLM-powered semantic extraction - 检查 Memory Tab 是否显示提取的记忆
7.2 Memory Graph UI 与系统风格不一致
症状: 记忆图谱使用深色主题,与系统浅色主题不协调
根本原因: MemoryGraph.tsx 硬编码深色背景 #1a1a2e
修复方案:
// Canvas 背景 - 支持亮/暗双主题
ctx.fillStyle = '#f9fafb'; // gray-50 (浅色)
// 工具栏 - 添加 dark: 变体
<div className="bg-gray-100 dark:bg-gray-800/50">
// 图谱画布 - 添加 dark: 变体
<div className="bg-gray-50 dark:bg-gray-900">
文件: desktop/src/components/MemoryGraph.tsx
设计规范:
- 使用 Tailwind 的
dark:变体支持双主题 - 强调色使用
orange-500而非blue-600 - 文字颜色使用
gray-700 dark:text-gray-300
8. 端口配置问题
8.1 ZCLAW 端口不匹配导致 Network Error
症状: 创建 Agent 或其他 API 操作时报错 Failed to create agent: Network Error,控制台显示 POST http://localhost:1420/api/agents net::ERR_CONNECTION_REFUSED
根本原因: runtime-manifest.json 声明端口 4200,但实际 ZCLAW 运行在 50051 端口
正确配置:
| 配置位置 | 正确端口 |
|---|---|
runtime-manifest.json |
4200 (声明,但实际不使用) |
| 实际运行端口 | 50051 |
vite.config.ts 代理 |
50051 |
gateway-client.ts |
50051 |
解决方案:
- 更新
vite.config.ts:
proxy: {
'/api': {
target: 'http://127.0.0.1:50051', // 使用实际运行端口
// ...
},
}
- 更新
gateway-client.ts:
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
export const FALLBACK_GATEWAY_URLS = [
DEFAULT_GATEWAY_URL,
`${DEFAULT_WS_PROTOCOL}127.0.0.1:4200/ws`, // 保留作为备选
];
验证端口:
# 检查实际运行的端口
netstat -ano | findstr "50051"
netstat -ano | findstr "4200"
注意: runtime-manifest.json 中的端口声明与实际运行端口不一致,以实际监听端口为准。
涉及文件:
desktop/vite.config.ts- Vite 代理配置desktop/src/lib/gateway-client.ts- WebSocket 客户端默认 URLdesktop/src/components/Settings/General.tsx- 设置页面显示地址desktop/src/components/Settings/ModelsAPI.tsx- 模型 API 重连逻辑
排查流程:
- 先用
netstat确认实际监听端口 - 对比
runtime-manifest.json声明端口与实际端口 - 确保所有前端配置使用实际监听端口
- 重启 Vite 开发服务器
验证修复:
# 检查端口监听
netstat -ano | findstr "50051"
# 应显示 LISTENING
# 重启 Vite 后测试
curl http://localhost:1420/api/agents
# 应返回 JSON 数组而非 404/502
文件: 多个配置文件
9. 内核 LLM 响应问题
9.1 聊天显示"思考中..."但无响应
症状: 发送消息后,UI 显示"思考中..."状态,但永远不会收到 AI 响应
根本原因: loop_runner.rs 中的代码存在两个严重问题:
- 模型 ID 硬编码: 使用固定的
"claude-sonnet-4-20250514"而非用户配置的模型 - 响应被丢弃: 返回硬编码的
"Response placeholder"而非实际 LLM 响应内容
问题代码 (crates/zclaw-runtime/src/loop_runner.rs):
// ❌ 错误 - 硬编码模型和响应
let request = CompletionRequest {
model: "claude-sonnet-4-20250514".to_string(), // 硬编码!
// ...
};
// ...
Ok(AgentLoopResult {
response: "Response placeholder".to_string(), // 丢弃真实响应!
// ...
})
修复方案:
- 添加配置字段到 AgentLoop:
pub struct AgentLoop {
// ... existing fields
model: String,
system_prompt: Option<String>,
max_tokens: u32,
temperature: f32,
}
impl AgentLoop {
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
// ... other builder methods
}
- 使用配置的模型:
let request = CompletionRequest {
model: self.model.clone(), // 使用配置的模型
// ...
};
- 提取实际响应内容:
// 从 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::<Vec<_>>()
.join("\n");
Ok(AgentLoopResult {
response: response_text, // 返回真实响应
// ...
})
- 在 kernel.rs 中传递模型配置:
pub async fn send_message(&self, agent_id: &AgentId, message: String) -> Result<MessageResponse> {
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- 模型配置传递
验证修复:
- 配置 Coding Plan API(如
https://coding.dashscope.aliyuncs.com/v1) - 发送消息
- 应该收到实际的 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 |
配置流程:
- 前端 (
ModelsAPI.tsx): 用户选择 Provider,输入 API Key 和 Model ID - 存储 (
localStorage): 保存为CustomModel对象 - 连接时 (
connectionStore.ts): 从 localStorage 读取配置 - 传递给内核 (
kernel-client.ts): 通过kernel_init命令传递 - 内核处理 (
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):
// ❌ 错误 - Agent 的 model 优先于 Kernel 的 model
let model = if !agent_config.model.model.is_empty() {
agent_config.model.model.clone() // 持久化的旧值
} else {
self.config.model().to_string()
};
问题分析:
- Agent 配置在创建时保存到 SQLite 数据库
- Kernel 启动时从数据库恢复 Agent 配置
send_message中 Agent 的 model 配置优先于 Kernel 的当前配置- 用户在"模型与 API"页面更改的是 Kernel 配置,不影响已持久化的 Agent 配置
修复方案:
让 Kernel 的当前配置优先,确保用户的"模型与 API"设置生效:
// ✅ 正确 - 始终使用 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 配置应该由全局设置控制
验证修复:
- 在"模型与 API"页面配置新模型
- 发送消息
- 检查终端日志,应显示
using model=新模型 from kernel config - 检查 API 请求体,
model字段应为新模型
9.4 自我进化系统启动错误
问题:DateTime 类型不匹配导致编译失败
症状:
error[E0277]: cannot subtract `chrono::DateTime<FixedOffset>` from `chrono::DateTime<Utc>`
--> desktop\src-tauri\src\intelligence\heartbeat.rs:542:27
|
542 | let idle_hours = (now - last_time).num_hours();
| ^ no implementation for `chrono::DateTime<Utc> - chrono::DateTime<FixedOffset>`
根本原因: chrono::DateTime::parse_from_rfc3339() 返回 DateTime<FixedOffset>,但 chrono::Utc::now() 返回 DateTime<Utc>,两种类型不能直接相减。
解决方案:
将 DateTime<FixedOffset> 转换为 DateTime<Utc> 后再计算:
// 错误写法
let last_time = chrono::DateTime::parse_from_rfc3339(&last_interaction).ok()?;
let now = chrono::Utc::now();
let idle_hours = (now - last_time).num_hours(); // 编译错误!
// 正确写法
let last_time = chrono::DateTime::parse_from_rfc3339(&last_interaction)
.ok()?
.with_timezone(&chrono::Utc); // 转换为 UTC
let now = chrono::Utc::now();
let idle_hours = (now - last_time).num_hours(); // OK
相关文件:
desktop/src-tauri/src/intelligence/heartbeat.rs
问题:未使用的导入警告
症状:
warning: unused import: `Manager`
warning: unused import: `futures::StreamExt`
解决方案:
- 手动移除未使用的导入
- 或使用
cargo fix --lib -p <package> --allow-dirty自动修复
自动修复命令:
cargo fix --lib -p desktop --allow-dirty
cargo fix --lib -p zclaw-hands --allow-dirty
cargo fix --lib -p zclaw-runtime --allow-dirty
cargo fix --lib -p zclaw-kernel --allow-dirty
cargo fix --lib -p zclaw-protocols --allow-dirty
注意: dead_code 警告(未使用的字段、方法)不影响编译,可以保留供将来使用。
9.5 阿里云百炼 Coding Plan 工具调用 400 错误
症状:
- 普通对话正常,但需要调用 skill/tool 时返回 400 错误
- API 返回
function.arguments must be in JSON format - 或者响应为空,但显示有
output_tokens
根本原因: 多层问题叠加
-
流式模式不支持工具调用: 阿里云百炼 (DashScope) Coding Plan API 的限制:
"tools暂时无法与stream=True同时使用"
- 当同时启用
stream: true和tools时,API 行为异常 - 工具调用参数无法正确传输
- 当同时启用
-
响应解析优先级错误:
convert_response方法优先处理content字段,即使它是空字符串- 当 API 返回
content: Some("")和tool_calls: [...]时 - 代码错误地选择了空的 content,导致响应为空
- 当 API 返回
-
ToolUse 消息 JSON 序列化错误: 当
input为Null时serde_json::to_string(input)产生"null"字符串- API 要求
"{}"(空对象) 格式
问题分析:
工具调用的完整流程:
用户消息 → LLM 决定调用工具 → 返回 tool_calls → 执行工具 → 返回结果 → LLM 生成最终响应
在百炼 API 中,由于流式 + 工具不兼容:
stream=true + tools → API 行为异常 → tool_calls 参数丢失 → 空工具名/重复调用
修复方案:
- 检测不兼容的 Provider 并使用非流式模式 (
openai.rs:stream):
fn stream(&self, request: CompletionRequest) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
let has_tools = !request.tools.is_empty();
let needs_non_streaming = self.base_url.contains("dashscope") ||
self.base_url.contains("aliyuncs") ||
self.base_url.contains("bigmodel.cn");
if has_tools && needs_non_streaming {
eprintln!("[OpenAiDriver:stream] Provider detected that may not support streaming with tools, using non-streaming mode");
return self.stream_from_complete(request); // 使用非流式模式
}
// ... 正常流式逻辑
}
- 实现
stream_from_complete方法: 调用非流式 API,然后模拟流式输出
fn stream_from_complete(&self, request: CompletionRequest) -> Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
let mut complete_request = self.build_api_request(&request);
complete_request.stream = false; // 强制非流式
Box::pin(stream! {
// 1. 发送非流式请求
let response = client.execute(request).await?;
// 2. 解析响应
let api_response: OpenAiResponse = response.json().await?;
// 3. 转换为流式事件
for tool_call in tool_calls {
yield Ok(StreamChunk::ToolUseStart { id, name });
yield Ok(StreamChunk::ToolUseDelta { id, delta });
yield Ok(StreamChunk::ToolUseEnd { id, input });
}
// 4. 文本内容
yield Ok(StreamChunk::TextDelta { delta: content });
// 5. 完成
yield Ok(StreamChunk::Complete { ... });
})
}
- 修复响应解析优先级 (
convert_response):
let (content, stop_reason) = match choice {
Some(c) => {
let has_tool_calls = c.message.tool_calls.as_ref().map(|tc| !tc.is_empty()).unwrap_or(false);
let has_content = c.message.content.as_ref().map(|t| !t.is_empty()).unwrap_or(false);
let blocks = if has_tool_calls {
// ✅ 工具调用优先于空内容
tool_calls.iter().map(|tc| ContentBlock::ToolUse {
id: tc.id.clone(),
name: tc.function.name.clone(),
input: serde_json::from_str(&tc.function.arguments).unwrap_or(Value::Null),
}).collect()
} else if has_content {
// 非空文本内容
vec![ContentBlock::Text { text: c.message.content.as_ref().unwrap().clone() }]
} else {
vec![ContentBlock::Text { text: String::new() }]
};
// ...
}
};
- 修复 ToolUse 消息的 JSON 序列化:
zclaw_types::Message::ToolUse { id, tool, input } => {
let args = if input.is_null() {
"{}".to_string() // ✅ Null 转换为空对象
} else {
serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string())
};
// ...
}
影响范围:
crates/zclaw-runtime/src/driver/openai.rs- OpenAI 兼容驱动
已知的兼容性问题 Provider:
| Provider | Base URL 特征 | 问题 |
|---|---|---|
| 阿里云百炼 | dashscope.aliyuncs.com |
流式 + 工具不兼容 |
| 阿里云百炼 Coding Plan | coding.dashscope.aliyuncs.com |
流式 + 工具不兼容 |
| 智谱 GLM | bigmodel.cn |
可能存在同样问题 |
验证修复:
- 配置百炼 Coding Plan API (
https://coding.dashscope.aliyuncs.com/v1) - 发送需要调用 skill 的消息(如"查询腾讯财报")
- 应看到日志:
[OpenAiDriver:stream] Provider detected that may not support streaming with tools - 工具应正确执行,参数完整
调试日志示例:
[OpenAiDriver:stream] base_url=https://coding.dashscope.aliyuncs.com/v1, has_tools=true, needs_non_streaming=true
[OpenAiDriver:stream] Provider detected that may not support streaming with tools, using non-streaming mode
[OpenAiDriver] Non-streaming response received, tool_calls=1
[AgentLoop] ToolUseEnd: id=call_xxx, input={"skill_id":"finance-tracker","input":{...}}
9.6 日志截断导致 UTF-8 字符边界 Panic
症状:
- 会话一直卡在"思考中..."状态
- 终端显示 panic:
byte index 100 is not a char boundary; it is inside '务' (bytes 99..102)
错误信息:
thread 'tokio-rt-worker' panicked at crates\zclaw-runtime\src\driver\openai.rs:502:82:
byte index 100 is not a char boundary; it is inside '务' (bytes 99..102) of `你好!我是 **Agent Soul**...`
根本原因: 使用 &c[..100] 按字节截断 UTF-8 字符串用于日志输出
问题代码 (crates/zclaw-runtime/src/driver/openai.rs:502):
// ❌ 错误 - 按字节截断,可能切断多字节字符
choice.message.content.as_ref().map(|c| if c.len() > 100 { &c[..100] } else { c.as_str() })
问题分析:
Rust 字符串是 UTF-8 编码的:
- ASCII 字符:1 字节
- 中文字符:3 字节(如 '务' = bytes 99..102)
- 当截断位置正好落在多字节字符内部时,程序 panic
修复方案:
使用 floor_char_boundary() 找到最近的合法字符边界:
// ✅ 正确 - 使用 floor_char_boundary 确保不截断多字节字符
choice.message.content.as_ref().map(|c| {
if c.len() > 100 {
let end = c.floor_char_boundary(100); // 找到 <= 100 的最近字符边界
&c[..end]
} else {
c.as_str()
}
})
相关文件:
crates/zclaw-runtime/src/driver/openai.rs:502- 日志截断逻辑
验证修复:
- 启动应用
- 发送包含中文的消息
- 查看终端日志,应正常显示截断的内容
- 会话不应卡住
最佳实践:
Rust 中截断 UTF-8 字符串的正确方式:
| 方法 | 用途 |
|---|---|
s.floor_char_boundary(n) |
找到 <= n 的最近字符边界 |
s.ceil_char_boundary(n) |
找到 >= n 的最近字符边界 |
s.chars().take(n).collect() |
取前 n 个字符(创建新 String) |
注意: floor_char_boundary() 需要 Rust 1.65+
9.9 对话上下文丢失 — Agent 不记得上一轮说了什么
症状: 每轮对话 Agent 都像失忆一样,重复问相同的问题,完全不知道之前聊了什么。
用户: 我要制作小学数学启蒙课件
Agent: [问了一堆需求确认问题]
用户: 用 涵盖加减法
Agent: 你是需要我帮你创建关于加减法的内容吗? ← 完全忘了上一轮
根本原因: kernel.rs 的 send_message_stream_with_prompt() 每次调用都执行 self.memory.create_session(agent_id) 创建全新 session。前端虽然传了 session_id 用于事件路由,但 kernel 完全忽略这个 ID,导致 loop_runner 的 get_messages() 永远返回空数组,LLM 从未收到历史消息。
// kernel.rs 修复前 — 每次创建新 session
let session_id = self.memory.create_session(agent_id).await?;
// 修复后 — 复用已有 session
let session_id = match session_id_override {
Some(id) => {
let existing = self.memory.get_messages(&id).await;
match existing {
Ok(msgs) if !msgs.is_empty() => id,
_ => self.memory.create_session(agent_id).await?,
}
}
None => self.memory.create_session(agent_id).await?,
};
修复:
| 文件 | 改动 |
|---|---|
crates/zclaw-kernel/src/kernel.rs |
send_message_stream_with_prompt 新增 session_id_override 参数,存在且非空则复用 |
desktop/src-tauri/src/kernel_commands.rs |
解析前端 session_id 为 SessionId,传入 kernel |
相关流程:
- 前端
chatStore发送sessionId: "session_xxx"到 Tauri 命令 kernel_commands.rs解析为SessionId传给 kernel- kernel 复用已有 session →
loop_runner.get_messages()返回历史 - LLM 收到完整对话上下文
9.10 多轮工具调用 tool_call_id is not found 400 错误
症状: Agent 调用工具后,第二轮将工具结果发回 LLM 时报 400:
LLM error: API error 400 Bad Request: {"error":{"message":"tool_call_id is not found","type":"invalid_request_error"}}
根本原因: openai.rs 的 build_api_request() 有两个关键缺陷:
-
OpenAiMessage缺少tool_call_id字段: OpenAI 协议要求role: "tool"的消息必须携带tool_call_id来匹配对应的工具调用。当前代码用tool_call_id: _丢弃了这个值,且结构体中没有该字段。 -
连续的
ToolUse消息没有合并: 同一轮 LLM 响应的多个工具调用应该在同一个 assistant 消息中(tool_calls数组),而不是每个工具调用生成一个独立的 assistant 消息。
修复前的 API 请求格式(错误):
[
{ "role": "assistant", "tool_calls": [{ "id": "call_1", ... }] },
{ "role": "assistant", "tool_calls": [{ "id": "call_2", ... }] },
{ "role": "tool", "content": "result1" }, // ← 缺少 tool_call_id
{ "role": "tool", "content": "result2" } // ← 缺少 tool_call_id
]
修复后(正确):
[
{ "role": "assistant", "tool_calls": [{ "id": "call_1", ... }, { "id": "call_2", ... }] },
{ "role": "tool", "tool_call_id": "call_1", "content": "result1" },
{ "role": "tool", "tool_call_id": "call_2", "content": "result2" }
]
修复 (crates/zclaw-runtime/src/driver/openai.rs):
OpenAiMessage添加tool_call_id字段:
struct OpenAiMessage {
role: String,
content: Option<String>,
tool_calls: Option<Vec<OpenAiToolCall>>,
tool_call_id: Option<String>, // ← 新增
}
- 重写消息转换逻辑 — 合并连续
ToolUse消息 + 传递tool_call_id:
// ToolResult 消息现在正确传递 tool_call_id
zclaw_types::Message::ToolResult { tool_call_id, output, is_error, .. } => {
messages.push(OpenAiMessage {
role: "tool",
content: Some(output.to_string()),
tool_calls: None,
tool_call_id: Some(tool_call_id.clone()), // ← 传递 ID
});
}
// ToolUse 消息累积到同一个 assistant 消息中
zclaw_types::Message::ToolUse { id, tool, input } => {
pending_tool_calls.get_or_insert_with(Vec::new).push(...);
}
相关文件:
crates/zclaw-runtime/src/driver/openai.rs—OpenAiMessage结构体 +build_api_request()消息转换
适用范围: 所有 OpenAI 兼容提供商(Kimi、百炼、智谱、OpenAI 等)的多轮工具调用。
9.11 thinking 启用时工具调用 reasoning_content is missing 400 错误
症状: 使用 Kimi 等 thinking 模式 API 时,第二轮工具结果发回后报 400:
API error 400 Bad Request: {"error":{"message":"thinking is enabled but reasoning_content is missing in assistant tool call message at index 2"}}
根本原因: Kimi 要求包含工具调用的 assistant 消息必须携带 reasoning_content 字段。当前有两层缺陷:
-
loop_runner 未分离 reasoning 和 text:
ThinkingDelta和TextDelta混在iteration_text中(带[思考]前缀),且工具调用时不保存 Assistant 消息,导致reasoning_content完全丢失。 -
openai.rs 未传递
reasoning_content:OpenAiMessage缺少reasoning_content字段,且Message::Assistant.thinking被忽略(thinking: _)。
修复:
a) loop_runner — 分别追踪 reasoning 和 text (loop_runner.rs):
// ThinkingDelta 只追加到 reasoning_text,不混入 iteration_text
StreamChunk::ThinkingDelta { delta } => {
reasoning_text.push_str(delta);
}
// 工具调用前推 Assistant 消息(含 thinking)
messages.push(Message::assistant_with_thinking(&iteration_text, &reasoning_text));
// 然后推 ToolUse 消息
for (id, name, input) in &pending_tool_calls {
messages.push(Message::tool_use(id, ...));
}
b) openai.rs — 合并 [Assistant, ToolUse] 并传递 reasoning_content*:
OpenAiMessage新增reasoning_content: Option<String>字段build_api_request()检测[Assistant, ToolUse*]模式,合并为一个 assistant 消息,同时携带content、reasoning_content、tool_calls
修复后的 API 请求格式:
{
"role": "assistant",
"content": "text content",
"reasoning_content": "thinking content",
"tool_calls": [{ "id": "call_1", ... }]
}
相关文件:
crates/zclaw-runtime/src/loop_runner.rs— 流式/非流式路径的 reasoning 分离crates/zclaw-runtime/src/driver/openai.rs—OpenAiMessage+build_api_request()合并逻辑
适用范围: 所有启用 thinking/reasoning 的 OpenAI 兼容提供商(Kimi、百炼、DeepSeek 等)。
10. 技能系统问题
10.1 Agent 无法调用合适的技能
症状: 用户发送消息(如"查询某公司财报"),Agent 没有调用相关技能,只是直接回复文本
根本原因:
- 系统提示词缺少技能列表: LLM 不知道有哪些技能可用
- SkillManifest 缺少 triggers 字段: 触发词无法传递给 LLM
- 技能触发词覆盖不足: "财报" 无法匹配 "财务报告"
问题分析:
Agent 调用技能的完整链路:
用户消息 → LLM → 选择 execute_skill 工具 → 传入 skill_id → 执行技能
如果 LLM 不知道有哪些 skill_id 可用,就无法主动调用。
修复方案:
- 在系统提示词中注入技能列表 (
kernel.rs):
/// Build a system prompt with skill information injected
fn build_system_prompt_with_skills(&self, base_prompt: Option<&String>) -> String {
let skills = futures::executor::block_on(self.skills.list());
let mut prompt = base_prompt
.map(|p| p.clone())
.unwrap_or_else(|| "You are a helpful AI assistant.".to_string());
if !skills.is_empty() {
prompt.push_str("\n\n## Available Skills\n\n");
prompt.push_str("Use the `execute_skill` tool with the skill_id to invoke them:\n\n");
for skill in skills {
prompt.push_str(&format!(
"- **{}**: {}",
skill.id.as_str(),
skill.description
));
if !skill.triggers.is_empty() {
prompt.push_str(&format!(
" (Triggers: {})",
skill.triggers.join(", ")
));
}
prompt.push('\n');
}
}
prompt
}
- 添加 triggers 字段到 SkillManifest (
skill.rs):
pub struct SkillManifest {
// ... existing fields
/// Trigger words for skill activation
#[serde(default)]
pub triggers: Vec<String>,
}
- 解析 SKILL.md 中的 triggers (
loader.rs):
// Parse triggers list in frontmatter
if in_triggers_list && line.starts_with("- ") {
triggers.push(line[2..].trim().trim_matches('"').to_string());
continue;
}
- 添加常见触发词 (
skills/finance-tracker/SKILL.md):
triggers:
- "财务分析"
- "财报" # 新增
- "财务数据" # 新增
- "盈利"
- "营收"
- "利润"
影响范围:
crates/zclaw-kernel/src/kernel.rs- 系统提示词构建crates/zclaw-skills/src/skill.rs- SkillManifest 结构crates/zclaw-skills/src/loader.rs- SKILL.md 解析skills/*/SKILL.md- 技能定义文件
验证修复:
- 重启应用
- 发送"查询腾讯财报"
- Agent 应该调用
execute_skill工具,传入skill_id: "finance-tracker"
10.2 skills_dir: None 导致技能系统完全失效
症状:
- Agent 无法调用任何技能,总是直接回复文本
skills.list()返回空列表- 系统提示词中没有任何技能信息
根本原因: KernelConfig::from_provider() 方法中 skills_dir 被硬编码为 None
问题代码 (crates/zclaw-kernel/src/config.rs:337):
// ❌ 错误 - from_provider() 中硬编码为 None
pub fn from_provider(
provider: &str,
api_key: &str,
model: &str,
base_url: Option<&str>,
api_protocol: &str,
) -> Self {
let llm = match provider {
// ... provider matching logic
};
Self {
database_url: default_database_url(),
llm,
skills_dir: None, // ← 硬编码!导致技能永不加载
}
}
影响分析:
Tauri 初始化 Kernel 时使用 from_provider() 创建配置:
kernel_init → KernelConfig::from_provider() → skills_dir: None
→ Kernel::boot() → skills_dir 不存在,跳过扫描
→ skills.list() 返回空列表
→ 系统提示词中无技能信息
→ LLM 不知道有 execute_skill 工具可用
修复方案:
// ✅ 正确 - 使用默认技能目录
Self {
database_url: default_database_url(),
llm,
skills_dir: default_skills_dir(), // 使用 ./skills 目录
}
修复代码 (config.rs:161-165):
fn default_skills_dir() -> Option<std::path::PathBuf> {
std::env::current_dir()
.ok()
.map(|cwd| cwd.join("skills"))
}
相关文件:
crates/zclaw-kernel/src/config.rs:337- 修复位置crates/zclaw-kernel/src/kernel.rs:79-83- 技能目录扫描逻辑
验证修复:
- 启动应用,查看终端日志
- 应看到
[Kernel] Scanning skills directory: ./skills - 发送 "查询腾讯财报"
- Agent 应调用
execute_skill("finance-tracker", {...})
已知限制:
default_skills_dir() 依赖 current_dir(),如果工作目录不同可能失效。更可靠的方案是使用可执行文件目录:
// 建议改进
fn default_skills_dir() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|p| p.join("skills")))
.or_else(|| std::env::current_dir().ok().map(|cwd| cwd.join("skills")))
}
10.3 技能页面显示"暂无技能"但技能目录存在
症状:
- 技能市场显示 "暂无技能" 和 "0 技能"
- 控制台日志显示
[skill_list] Found 0 skills - 技能目录
G:\ZClaw_zclaw\skills存在且包含 70+ 个 SKILL.md 文件
根本原因: 多层问题叠加
-
技能目录路径解析失败: Tauri dev 模式下
current_exe()和current_dir()返回意外路径current_dir()可能返回desktop/src-tauri而非项目根目录current_exe()可能返回 Tauri CLI 或 node.exe 而非编译后的 exe
-
SkillRegistry.async 上下文使用 blocking_write(): 在 tokio 异步运行时中调用
blocking_write()导致 panicthread 'tokio-rt-worker' panicked at registry.rs:86:38: Cannot block the current thread from within a runtime.
问题代码 (crates/zclaw-skills/src/registry.rs):
// ❌ 错误 - 在 async 函数调用的 sync 函数中使用 blocking_write
pub async fn add_skill_dir(&self, dir: PathBuf) -> Result<()> {
// ...
for skill_path in skill_paths {
self.load_skill_from_dir(&skill_path)?; // 调用 sync 函数
}
}
fn load_skill_from_dir(&self, dir: &PathBuf) -> Result<()> {
// ...
let mut skills = self.skills.blocking_write(); // 在 async 上下文中 panic!
}
修复方案:
- 使用编译时路径作为技能目录备选 (
config.rs:default_skills_dir):
fn default_skills_dir() -> Option<std::path::PathBuf> {
// 1. 环境变量
if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") {
return Some(PathBuf::from(dir));
}
// 2. 编译时路径 - CARGO_MANIFEST_DIR 是 crates/zclaw-kernel
// 向上两级找到 workspace root
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) {
let workspace_skills = workspace_root.join("skills");
if workspace_skills.exists() {
return Some(workspace_skills);
}
}
// 3. 当前工作目录及向上搜索
// ... 其他备选方案
}
- 将 load_skill_from_dir 改为 async (
registry.rs):
// ✅ 正确 - 使用 async write
async fn load_skill_from_dir(&self, dir: &PathBuf) -> Result<()> {
// ... 解析 SKILL.md
// 使用 async write 而非 blocking_write
let mut skills = self.skills.write().await;
let mut manifests = self.manifests.write().await;
skills.insert(manifest.id.clone(), skill);
manifests.insert(manifest.id.clone(), manifest);
Ok(())
}
调试日志示例 (修复后):
[default_skills_dir] CARGO_MANIFEST_DIR: G:\ZClaw_zclaw\crates\zclaw-kernel
[default_skills_dir] Workspace skills: G:\ZClaw_zclaw\skills (exists: true)
[kernel_init] Skills directory: G:\ZClaw_zclaw\skills (exists: true)
[skill_list] Found 77 skills
影响范围:
crates/zclaw-kernel/src/config.rs- default_skills_dir() 函数crates/zclaw-skills/src/registry.rs- load_skill_from_dir() 函数desktop/src-tauri/src/kernel_commands.rs- SkillInfoResponse 结构体(添加 triggers 和 category 字段)
前端配套修改:
desktop/src-tauri/src/kernel_commands.rs: 添加triggers: Vec<String>和category: Option<String>字段desktop/src/lib/kernel-client.ts: 更新listSkills()返回类型desktop/src/store/configStore.ts: 更新createConfigClientFromKernel中的字段映射desktop/src/lib/skill-adapter.ts: 更新extractTriggers和extractCapabilities
验证修复:
- 启动应用,查看终端日志
- 应看到
[kernel_init] Skills directory: ... (exists: true) - 技能市场应显示 77 个技能
- 点击技能可展开查看详情
技能目录发现优先级:
ZCLAW_SKILLS_DIR环境变量CARGO_MANIFEST_DIR/../skills (编译时路径)current_dir()/skills 及向上搜索current_exe()/skills 及向上搜索- 回退到
current_dir()/skills
10.4 Pipeline YAML 解析失败 - 类型不匹配
症状:
- Pipeline 列表显示为空(Found 0 pipelines)
- 后端调试日志显示扫描目录成功但没有找到任何 Pipeline
- 没有明显的错误消息
根本原因: YAML 文件中的字段类型与 Rust 类型定义不匹配
问题分析:
-
FileExport action formats 字段类型不匹配:
- Rust 定义:
formats: Vec<ExportFormat>(枚举数组) - YAML 写法:
formats: ${inputs.export_formats}(表达式字符串) - serde_yaml 无法将字符串解析为枚举数组,静默失败
- Rust 定义:
-
InputType serde rename_all 配置错误:
- YAML 使用
multi-select(kebab-case) - Rust serde 配置
rename_all = "snake_case" - 期望
multi_select但收到multi-select,解析失败
- YAML 使用
修复方案:
- 将 formats 字段改为 String 类型 (
types.rs):
FileExport {
formats: String, // 从 Vec<ExportFormat> 改为 String
input: String,
output_dir: Option<String>,
}
- 在运行时解析 formats 表达式 (
executor.rs):
let resolved_formats = context.resolve(formats)?;
let format_strings: Vec<String> = if resolved_formats.is_array() {
resolved_formats.as_array()?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
} else if resolved_formats.is_string() {
// 尝试解析为 JSON 数组
serde_json::from_str(s).unwrap_or_else(|_| vec![s.to_string()])
} else {
return Err(...);
};
// 转换为 ExportFormat 枚举
let export_formats: Vec<ExportFormat> = format_strings
.iter()
.filter_map(|s| match s.to_lowercase().as_str() {
"pptx" => Some(ExportFormat::Pptx),
"html" => Some(ExportFormat::Html),
"pdf" => Some(ExportFormat::Pdf),
"markdown" | "md" => Some(ExportFormat::Markdown),
"json" => Some(ExportFormat::Json),
_ => None,
})
.collect();
- 修正 InputType serde 配置 (
types.rs):
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")] // 从 snake_case 改为 kebab-case
pub enum InputType {
#[default]
String,
Number,
Boolean,
Select,
MultiSelect, // YAML 中写 multi-select
File,
Text,
}
影响范围:
crates/zclaw-pipeline/src/types.rs- InputType serde, FileExport formatscrates/zclaw-pipeline/src/executor.rs- 运行时解析 formatspipelines/**/*.yaml- 确保使用multi-select而非multi_select
验证修复:
[DEBUG pipeline_list] Found 5 pipelines
[DEBUG pipeline_list] Pipeline: classroom-generator -> category: education, industry: 'education'
最佳实践:
- YAML 中的表达式(如
${inputs.xxx})应该定义为 String 类型 - 在运行时通过 ExecutionContext.resolve() 解析表达式
- 使用
kebab-case命名风格更符合 YAML 惯例
11. 开发调试问题
11.1 Web 端无法连接后端进行调试
症状:
- Web 端 (
pnpm dev) 无法连接后端 Gateway - 只能通过 Tauri 桌面端调试,效率低下
- Vite 代理配置正确但仍然无法连接
根本原因: Tauri 应用在开发模式下不暴露 API 端口给外部
解决方案: 使用开发模式服务器
ZCLAW 提供了可选的开发模式 HTTP/WebSocket 服务器,通过 feature flag 控制:
启动方式:
# 启动带开发服务器的 Tauri
pnpm tauri:dev:web
# 常规开发模式(无服务器)
pnpm tauri:dev
技术实现:
- 服务器代码:
desktop/src-tauri/src/dev_server.rs - Feature flag:
dev-server - 端口:
localhost:50051(与生产 Gateway 相同) - 协议: HTTP + WebSocket
安全设计:
- Feature Flag 控制: 只有显式启用
dev-serverfeature 才会编译服务器代码 - 仅 localhost 绑定: 不暴露到外部网络
- CORS 白名单: 只允许 Vite 开发服务器端口 (1420, 5173)
- 生产构建排除:
tauri build不会包含服务器代码
API 端点:
| 端点 | 方法 | 说明 |
|---|---|---|
/health |
GET | 健康检查 |
/ws |
GET | WebSocket 连接 |
/api/kernel/status |
GET | 内核状态 |
/api/agents |
GET | Agent 列表 |
/api/skills |
GET | 技能列表 |
/api/hands |
GET | Hands 列表 |
/api/pipelines |
GET | Pipeline 列表 |
/api/rpc |
POST | JSON-RPC 调用 |
验证服务器运行:
curl http://localhost:50051/health
# 应返回: {"status":"ok","mode":"development",...}
注意事项:
- 开发服务器仅提供基础 API 端点,完整功能需要 Tauri 运行时
- 生产构建 (
tauri build) 自动排除开发服务器代码 - 如果端口 50051 被占用,服务器会跳过启动并输出警告
12. SaaS 后端问题
12.1 Admin 登录页无网络请求
症状: 点击 Admin 面板 (localhost:3000/login) 的登录按钮后,页面无任何反应,无网络请求、无控制台错误。
根本原因: 前端 api-client.ts 的 BASE_URL 与后端路由前缀不匹配。
- 前端:
BASE_URL = 'http://localhost:8080',请求路径/auth/login→ 实际请求http://localhost:8080/auth/login - 后端: 路由前缀
/api/v1/auth/login
修复:
- 将
BASE_URL改为'http://localhost:8080/api/v1' - 移除所有 API 路径中多余的
/api/前缀
涉及文件: admin/src/lib/api-client.ts
12.2 SQLite → PostgreSQL 遗留语法导致 500 错误
症状: 登录成功后,仪表盘页面 (/stats/dashboard) 和操作日志 (/logs/operations) 返回 500 DATABASE_ERROR。
根本原因: 后端代码从 SQLite 迁移到 PostgreSQL 时,部分文件遗漏了 SQL 语法转换。
SQLite → PostgreSQL 语法差异:
| SQLite | PostgreSQL | 影响位置 |
|---|---|---|
?1, ?2 占位符 |
$1, $2 |
所有 SQL 查询 |
date('now') |
CURRENT_DATE |
dashboard_stats |
enabled = 1 / = 0 |
enabled = true / = false |
dashboard_stats, totp |
LIMIT ?1 OFFSET ?2 |
LIMIT $1 OFFSET $2 |
list_operation_logs |
INSERT OR IGNORE |
INSERT ... ON CONFLICT DO NOTHING |
schema |
datetime('now') |
NOW() |
schema |
INTEGER PRIMARY KEY AUTOINCREMENT |
BIGSERIAL PRIMARY KEY |
schema |
REAL |
DOUBLE PRECISION |
schema |
遗漏修复的文件:
crates/zclaw-saas/src/account/handlers.rs— dashboard_stats、list_operation_logs、device 相关crates/zclaw-saas/src/relay/handlers.rs— provider api_key 查询、retry_taskcrates/zclaw-saas/src/auth/totp.rs— TOTP 设置/验证/禁用crates/zclaw-saas/src/auth/mod.rs— API Token 验证、last_used_at 更新
排查方法: 全局搜索 ? 数字占位符和 SQLite 特有函数:
grep -rn '?[0-9]\|date(.now.)\|enabled = [01]' crates/zclaw-saas/src/
12.3 Admin 账号角色权限不足 (403)
症状: 登录成功后,/stats/dashboard 和 /logs/operations 返回 403 Forbidden。
根本原因: 通过 /auth/register 注册的账号默认角色为 user,只有 model:read、relay:use、config:read 权限,无法访问 admin 端点。
解决方案: 需要将账号升级为 super_admin 角色。两种方式:
- 设置环境变量自动种子(推荐):
ZCLAW_ADMIN_USERNAME=admin ZCLAW_ADMIN_PASSWORD=your_password ./zclaw-saas
- 直接修改数据库:
# PostgreSQL
psql -U postgres -d zclaw -c "UPDATE accounts SET role = 'super_admin' WHERE username = 'admin'"
权限映射:
| 角色 | 权限 |
|---|---|
user |
model:read, relay:use, config:read |
super_admin |
admin:full, account:admin, provider:manage, model:manage, relay:admin, config:write |
注意: 普通用户通过 API 无法自提升角色 — update_account handler 会剥离非管理员的 role 字段。
12.4 前端 usage 路由与后端不匹配 (404)
症状: 仪表盘请求 /usage/daily?days=30 返回 404。
根本原因: 前端 api-client.ts 使用 /usage/daily 和 /usage/by-model 路径,但后端只有一个统一的 /api/v1/usage 端点,参数为 ?from=...&to=...&provider_id=...&model_id=...。
修复: 将前端 usage.daily() 和 usage.byModel() 合并为 usage.get(params),路径改为 /usage。
涉及文件: admin/src/lib/api-client.ts
13. Admin 前端 ERR_ABORTED / 后端卡死 (2026-03-30)
13.1 症状
- Admin 前端登录后所有 GET 请求返回
ERR_ABORTED(Network 面板显示红色 cancelled) - 后端日志显示请求已接收并处理,但响应无法到达客户端
- 后端 PostgreSQL 连接池(50 max)被耗尽,所有后续请求 hang
GET /api/health也无法响应,整个后端完全卡死
13.2 根因分析(因果链)
Next.js App Router SSR
→ React 组件服务端渲染 + 客户端 hydration 重建
→ SWR 在 hydration mount 时触发 fetch
→ hydration 卸载旧组件 → AbortController abort 请求
→ Vite proxy 已将请求转发到后端
→ 后端不知道请求已被 abort,继续处理(占用 DB 连接)
→ SWR retry 重新发起 → 又被 abort → 死循环
→ PostgreSQL 连接池耗尽 → 后端完全卡死
根本矛盾: Next.js SSR/hydration 机制与 SWR fetch-on-mount 模式存在不可调和的冲突。abort 信号无法从浏览器传播到后端,导致后端持续处理已废弃的请求。
13.3 已尝试的修复(均未解决)
| 尝试 | 方案 | 结果 |
|---|---|---|
| 1 | SWRConfig 全局配置(dedupingInterval, revalidateOnFocus) | 无效,abort 发生在更底层 |
| 2 | AuthGuard 路由守卫重构 | 无效,请求在 guard 之前就发出 |
| 3 | dynamic ssr: false 页面级禁用 SSR |
部分改善,但 hydration 仍触发 abort |
| 4 | 前端直连后端(绕过 Vite proxy) | 无效,问题不在 proxy |
| 5 | health handler 3s 超时 | 只保护了 health 端点,不解决根因 |
| 6 | AbortError 不重试 | 减少了 retry,但首次 abort 仍占用连接 |
13.4 最终解决方案
用纯 SPA(Ant Design Pro)彻底重写 Admin 前端,消除 SSR/hydration 问题。
admin-v2 技术栈:
- Vite 6(纯客户端,无 SSR)
- React 19 + antd v5 + @ant-design/pro-components
- React Router v7 + TanStack Query v5 + Axios + Zustand
关键修复:
- 移除 React
StrictMode(开发模式双重 mount 触发重复请求 + abort) - Axios timeout 10s → 30s(防止慢请求被误杀)
- JWT 拦截器(自动附加 token + 401 刷新)
13.5 验证结果
- 全部 12 页面功能正常,18 个网络请求全部 200
- 零
ERR_ABORTED,后端连接池不再耗尽 - 后端 health 检查持续返回 200
涉及文件: admin-v2/ 目录(全新项目,替换 admin/)
14. 相关文档
- ZCLAW 配置指南 - 配置文件位置、格式和最佳实践
- Agent 和 LLM 提供商配置 - Agent 管理和 Provider 配置
- ZCLAW WebSocket 协议 - WebSocket 通信协议
更新历史
| 日期 | 变更 |
|---|---|
| 2026-03-30 | 添加第 13 节:Admin 前端 ERR_ABORTED / 后端卡死 — Next.js SSR/hydration + SWR 根本冲突导致连接池耗尽,admin-v2 (Ant Design Pro 纯 SPA) 替代方案 |
| 2026-03-28 | 添加 12.1-12.4 节:SaaS 后端问题 — Admin 登录无请求、SQLite→PostgreSQL 遗留语法、角色权限不足、usage 路由不匹配 |
| 2026-03-27 | 添加 9.10/9.11 节:多轮工具调用 tool_call_id + reasoning_content 缺失 — OpenAiMessage 字段补全、[Assistant,ToolUse*] 合并、reasoning 分离追踪 |
| 2026-03-27 | 添加 9.10 节:多轮工具调用 tool_call_id is not found — OpenAiMessage 缺少 tool_call_id 字段 + 连续 ToolUse 未合并 |
| 2026-03-26 | 添加 11.1 节:Web 端无法连接后端进行调试 - 开发模式服务器方案 |
| 2026-03-24 | 添加 9.6 节:日志截断导致 UTF-8 字符边界 Panic - floor_char_boundary 修复方案 |
| 2026-03-24 | 添加 9.5 节:阿里云百炼 Coding Plan 工具调用 400 错误 - 流式+工具不兼容、响应解析优先级、JSON 序列化问题 |
| 2026-03-24 | 添加 10.2 节:skills_dir: None 导致技能系统完全失效 - from_provider() 硬编码问题 |
9.7 Coding Plan API (Kimi/百炼/智谱) User-Agent 403 拒绝
症状: 使用 Kimi Coding Plan (api.kimi.com/coding) 时报 403:
LLM error: API error 403 Forbidden: {"error":{"message":"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.","type":"access_terminated_error"}}
根本原因: Coding Plan 提供商检查 User-Agent 请求头,只允许已知 Coding Agent 客户端访问。ZCLAW 发送的 ZCLAW/0.1.0 不在白名单中。
修复: crates/zclaw-runtime/src/lib.rs — 将 USER_AGENT 改为 claude-code/0.1.0(精确匹配白名单格式,全小写)。
同时修复 desktop/src-tauri/src/llm/mod.rs 中两处 reqwest::Client::new() 也加上 User-Agent。
// 修复前
pub const USER_AGENT: &str = "ZCLAW/0.1.0";
// 修复后
pub const USER_AGENT: &str = "claude-code/0.1.0";
相关文件:
crates/zclaw-runtime/src/lib.rs— USER_AGENT 常量crates/zclaw-runtime/src/driver/openai.rs— 使用 USER_AGENT 构建 HTTP clientdesktop/src-tauri/src/llm/mod.rs— Tauri 侧 LLM 客户端(call_api / call_embedding_api)
9.8 Coding Plan 模型返回空响应(显示 "...")
症状: User-Agent 修复后,模型可以连接,但前端只显示 "..." 无实质性内容。
根本原因: 多层问题叠加:
-
reasoning_content字段未解析: Kimi/Qwen/DeepSeek/GLM 等模型的思考过程通过delta.reasoning_content字段返回(而非delta.content),OpenAI 驱动的OpenAiDelta结构体缺少此字段,所有 thinking 内容被静默丢弃。 -
ThinkingDelta 未累积到
iteration_text:loop_runner.rs中ThinkingDelta只发送给前端(LoopEvent::Delta),不累积到iteration_text,导致最终Complete事件的response为空。 -
每个 reasoning token 重复加
[思考]前缀: 每收到一个ThinkingDelta就加一次[思考],导致显示为[思考] 用户[思考] 只是[思考] 简单地...。
修复:
a) OpenAI 驱动添加 reasoning_content 支持 (openai.rs):
// OpenAiDelta 结构体 — 添加字段
struct OpenAiDelta {
content: Option<String>,
reasoning_content: Option<String>, // ← 新增
tool_calls: Option<Vec<OpenAiToolCallDelta>>,
}
// OpenAiResponseMessage 结构体 — 同样添加
struct OpenAiResponseMessage {
content: Option<String>,
reasoning_content: Option<String>, // ← 新增
tool_calls: Option<Vec<OpenAiToolCallResponse>>,
}
流式路径:reasoning_content → StreamChunk::ThinkingDelta
非流式路径:当 content 为空但 reasoning_content 有值时,用 reasoning 作为文本返回。
b) loop_runner 累积 thinking 内容但不发给用户 (loop_runner.rs):
StreamChunk::ThinkingDelta { delta } => {
// 只累积到 iteration_text(后端 memory),不发给前端
if !in_thinking_phase {
iteration_text.push_str("[思考] ");
in_thinking_phase = true;
}
iteration_text.push_str(delta);
// 不调用 tx.send() — 用户不看到思考过程
}
思考内容仅存入后端 memory 用于上下文,用户界面只显示正式回复。
适用范围: 此修复适用于所有使用 reasoning_content 字段的 Coding Plan 提供商:
- Kimi Coding Plan (
api.kimi.com/coding) - 百炼/DashScope Coding Plan (
coding.dashscope.aliyuncs.com) - 智谱 GLM Coding Plan (
open.bigmodel.cn/api/coding/paas/v4) - DeepSeek R1 等推理模型
无需额外适配:三个提供商都走同一个 OpenAI 兼容驱动,修复一处通用覆盖。
| 2026-03-27 | 添加 9.7/9.8 节:Coding Plan 403 拒绝 + 空响应(User-Agent、reasoning_content、thinking 显示) | | 2026-03-27 | 添加 9.7/9.8/9.9 节:Coding Plan 403 拒绝、空响应、对话上下文丢失 | | 2026-03-24 | 添加 9.4 节:自我进化系统启动错误 - DateTime 类型不匹配和未使用导入警告 | | 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 布局问题 | | 2026-03-17 | 添加首次使用引导流程 | | 2026-03-17 | 添加配置热重载限制问题 | | 2026-03-14 | 初始版本 |