Files
zclaw_openfang/docs/knowledge-base/troubleshooting.md
iven a7d33d0207
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
feat(admin): Admin V2 — Ant Design Pro 纯 SPA 重写
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 平台文档。
2026-03-30 09:35:59 +08:00

68 KiB
Raw Blame History

故障排查指南

记录开发过程中遇到的问题、根因分析和解决方案。


1. 连接问题

1.1 WebSocket 连接失败

症状: WebSocket connection failedUnexpected 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

解决方案:

  1. 检查 Agent 使用的提供商:
curl -s http://127.0.0.1:50051/api/status | jq '.agents[] | {name, model_provider}'
  1. 配置对应的 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
  1. 重启 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",...}}  # 不一致!

解决方案:

  1. 必须完全重启 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
  1. 验证配置已生效:
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 流式响应不显示

症状: 消息发送后无响应或响应不完整

排查步骤:

  1. 确认 WebSocket 连接状态:
console.log(client.getState()); // 应为 'connected'
  1. 检查事件处理:
// 确保处理了 text_delta 事件
ws.on('message', (data) => {
  const event = JSON.parse(data.toString());
  if (event.type === 'text_delta') {
    console.log('Delta:', event.content);
  }
});
  1. 验证消息格式:
// ✅ 正确
{ 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 不反映状态变化

检查:

  1. 确保使用 selector
// ✅ 正确 - 使用 selector
const messages = useChatStore((state) => state.messages);

// ❌ 错误 - 可能导致不必要的重渲染
const store = useChatStore();
const messages = store.messages;
  1. 检查 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;
    }
  }
},

验证修复:

  1. 与 Agent A 对话
  2. 切换到 Agent B
  3. 切换回 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 开发服务器未启动或连接失败

解决方案:

  1. 确保 pnpm dev 在运行
  2. 检查 tauri.conf.json 中的 beforeDevCommand
  3. 检查浏览器控制台错误

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 等)。

实现方案:

  1. 检测首次使用 - 使用 useOnboarding hook 检查 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 }
    }
    
  2. 引导向导 - 使用 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);
          }}
        />
      );
    }
    
  3. 数据持久化 - 用户信息存储在 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 引导流程集成

引导步骤:

  1. 认识用户 - 收集用户名称和角色
  2. Agent 身份 - 设置助手名称、昵称、emoji
  3. 人格风格 - 选择沟通风格
  4. 使用场景 - 选择应用场景
  5. 工作环境 - 配置工作目录

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

验证修复:

  1. 清除 localStorage 中的 onboarding 标记
  2. 重新启动应用
  3. 完成引导流程 → 应该成功更新默认 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

验证修复:

  1. 发送消息并获得 AI 回复
  2. 按 F5 刷新页面
  3. 对话内容应该保留

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 内容为空

根本原因: 多个配置问题导致记忆未被提取

问题分析:

  1. LLM 提取默认禁用: useLLM: false 导致只使用规则提取
  2. 提取阈值过高: minMessagesForExtraction: 4 短对话不会触发
  3. agentId 不一致: MemoryPanel'zclaw-main'MemoryGraph'default'
  4. Gateway 端点不存在: GatewayLLMAdapter 调用 /api/llm/completeZCLAW 无此端点

修复方案:

// 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.ts
  • desktop/src/lib/llm-service.ts
  • desktop/src/components/MemoryGraph.tsx

验证修复:

  1. 打开浏览器控制台
  2. 进行至少 2 轮对话
  3. 查看日志: [MemoryExtractor] Using LLM-powered semantic extraction
  4. 检查 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

解决方案:

  1. 更新 vite.config.ts:
proxy: {
  '/api': {
    target: 'http://127.0.0.1:50051',  // 使用实际运行端口
    // ...
  },
}
  1. 更新 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 客户端默认 URL
  • desktop/src/components/Settings/General.tsx - 设置页面显示地址
  • desktop/src/components/Settings/ModelsAPI.tsx - 模型 API 重连逻辑

排查流程:

  1. 先用 netstat 确认实际监听端口
  2. 对比 runtime-manifest.json 声明端口与实际端口
  3. 确保所有前端配置使用实际监听端口
  4. 重启 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 中的代码存在两个严重问题:

  1. 模型 ID 硬编码: 使用固定的 "claude-sonnet-4-20250514" 而非用户配置的模型
  2. 响应被丢弃: 返回硬编码的 "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(), // 丢弃真实响应!
    // ...
})

修复方案:

  1. 添加配置字段到 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
}
  1. 使用配置的模型:
let request = CompletionRequest {
    model: self.model.clone(), // 使用配置的模型
    // ...
};
  1. 提取实际响应内容:
// 从 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, // 返回真实响应
    // ...
})
  1. 在 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 - 模型配置传递

验证修复:

  1. 配置 Coding Plan APIhttps://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):

// ❌ 错误 - 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"设置生效:

// ✅ 正确 - 始终使用 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_messagesend_message_stream 方法

设计决策:

ZCLAW 的设计是让用户在"模型与 API"页面设置全局模型,而不是为每个 Agent 单独设置。因此:

  • Kernel 配置应该优先于 Agent 配置
  • Agent 配置主要用于存储 personality、system_prompt 等
  • model 配置应该由全局设置控制

验证修复:

  1. 在"模型与 API"页面配置新模型
  2. 发送消息
  3. 检查终端日志,应显示 using model=新模型 from kernel config
  4. 检查 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`

解决方案:

  1. 手动移除未使用的导入
  2. 或使用 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

根本原因: 多层问题叠加

  1. 流式模式不支持工具调用: 阿里云百炼 (DashScope) Coding Plan API 的限制:

    "tools暂时无法与stream=True同时使用"

    • 当同时启用 stream: truetoolsAPI 行为异常
    • 工具调用参数无法正确传输
  2. 响应解析优先级错误: convert_response 方法优先处理 content 字段,即使它是空字符串

    • 当 API 返回 content: Some("")tool_calls: [...]
    • 代码错误地选择了空的 content导致响应为空
  3. ToolUse 消息 JSON 序列化错误: 当 inputNull

    • serde_json::to_string(input) 产生 "null" 字符串
    • API 要求 "{}" (空对象) 格式

问题分析:

工具调用的完整流程:

用户消息 → LLM 决定调用工具 → 返回 tool_calls → 执行工具 → 返回结果 → LLM 生成最终响应

在百炼 API 中,由于流式 + 工具不兼容:

stream=true + tools → API 行为异常 → tool_calls 参数丢失 → 空工具名/重复调用

修复方案:

  1. 检测不兼容的 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);  // 使用非流式模式
    }
    // ... 正常流式逻辑
}
  1. 实现 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 { ... });
    })
}
  1. 修复响应解析优先级 (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() }]
        };
        // ...
    }
};
  1. 修复 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 可能存在同样问题

验证修复:

  1. 配置百炼 Coding Plan API (https://coding.dashscope.aliyuncs.com/v1)
  2. 发送需要调用 skill 的消息(如"查询腾讯财报"
  3. 应看到日志:[OpenAiDriver:stream] Provider detected that may not support streaming with tools
  4. 工具应正确执行,参数完整

调试日志示例:

[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

症状:

  • 会话一直卡在"思考中..."状态
  • 终端显示 panicbyte 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 - 日志截断逻辑

验证修复:

  1. 启动应用
  2. 发送包含中文的消息
  3. 查看终端日志,应正常显示截断的内容
  4. 会话不应卡住

最佳实践:

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.rssend_message_stream_with_prompt() 每次调用都执行 self.memory.create_session(agent_id) 创建全新 session。前端虽然传了 session_id 用于事件路由,但 kernel 完全忽略这个 ID导致 loop_runnerget_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_idSessionId,传入 kernel

相关流程:

  1. 前端 chatStore 发送 sessionId: "session_xxx" 到 Tauri 命令
  2. kernel_commands.rs 解析为 SessionId 传给 kernel
  3. kernel 复用已有 session → loop_runner.get_messages() 返回历史
  4. 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.rsbuild_api_request() 有两个关键缺陷:

  1. OpenAiMessage 缺少 tool_call_id 字段: OpenAI 协议要求 role: "tool" 的消息必须携带 tool_call_id 来匹配对应的工具调用。当前代码用 tool_call_id: _ 丢弃了这个值,且结构体中没有该字段。

  2. 连续的 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):

  1. OpenAiMessage 添加 tool_call_id 字段:
struct OpenAiMessage {
    role: String,
    content: Option<String>,
    tool_calls: Option<Vec<OpenAiToolCall>>,
    tool_call_id: Option<String>,  // ← 新增
}
  1. 重写消息转换逻辑 — 合并连续 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.rsOpenAiMessage 结构体 + 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 字段。当前有两层缺陷:

  1. loop_runner 未分离 reasoning 和 text: ThinkingDeltaTextDelta 混在 iteration_text 中(带 [思考] 前缀),且工具调用时不保存 Assistant 消息,导致 reasoning_content 完全丢失。

  2. 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 消息,同时携带 contentreasoning_contenttool_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.rsOpenAiMessage + build_api_request() 合并逻辑

适用范围: 所有启用 thinking/reasoning 的 OpenAI 兼容提供商Kimi、百炼、DeepSeek 等)。


10. 技能系统问题

10.1 Agent 无法调用合适的技能

症状: 用户发送消息(如"查询某公司财报"Agent 没有调用相关技能,只是直接回复文本

根本原因:

  1. 系统提示词缺少技能列表: LLM 不知道有哪些技能可用
  2. SkillManifest 缺少 triggers 字段: 触发词无法传递给 LLM
  3. 技能触发词覆盖不足: "财报" 无法匹配 "财务报告"

问题分析:

Agent 调用技能的完整链路:

用户消息 → LLM → 选择 execute_skill 工具 → 传入 skill_id → 执行技能

如果 LLM 不知道有哪些 skill_id 可用,就无法主动调用。

修复方案:

  1. 在系统提示词中注入技能列表 (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
}
  1. 添加 triggers 字段到 SkillManifest (skill.rs):
pub struct SkillManifest {
    // ... existing fields
    /// Trigger words for skill activation
    #[serde(default)]
    pub triggers: Vec<String>,
}
  1. 解析 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;
}
  1. 添加常见触发词 (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 - 技能定义文件

验证修复:

  1. 重启应用
  2. 发送"查询腾讯财报"
  3. 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 - 技能目录扫描逻辑

验证修复:

  1. 启动应用,查看终端日志
  2. 应看到 [Kernel] Scanning skills directory: ./skills
  3. 发送 "查询腾讯财报"
  4. 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 文件

根本原因: 多层问题叠加

  1. 技能目录路径解析失败: Tauri dev 模式下 current_exe()current_dir() 返回意外路径

    • current_dir() 可能返回 desktop/src-tauri 而非项目根目录
    • current_exe() 可能返回 Tauri CLI 或 node.exe 而非编译后的 exe
  2. SkillRegistry.async 上下文使用 blocking_write(): 在 tokio 异步运行时中调用 blocking_write() 导致 panic

    thread '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!
}

修复方案:

  1. 使用编译时路径作为技能目录备选 (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. 当前工作目录及向上搜索
    // ... 其他备选方案
}
  1. 将 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: 更新 extractTriggersextractCapabilities

验证修复:

  1. 启动应用,查看终端日志
  2. 应看到 [kernel_init] Skills directory: ... (exists: true)
  3. 技能市场应显示 77 个技能
  4. 点击技能可展开查看详情

技能目录发现优先级:

  1. ZCLAW_SKILLS_DIR 环境变量
  2. CARGO_MANIFEST_DIR/../skills (编译时路径)
  3. current_dir()/skills 及向上搜索
  4. current_exe()/skills 及向上搜索
  5. 回退到 current_dir()/skills

10.4 Pipeline YAML 解析失败 - 类型不匹配

症状:

  • Pipeline 列表显示为空Found 0 pipelines
  • 后端调试日志显示扫描目录成功但没有找到任何 Pipeline
  • 没有明显的错误消息

根本原因: YAML 文件中的字段类型与 Rust 类型定义不匹配

问题分析:

  1. FileExport action formats 字段类型不匹配:

    • Rust 定义:formats: Vec<ExportFormat>(枚举数组)
    • YAML 写法:formats: ${inputs.export_formats}(表达式字符串)
    • serde_yaml 无法将字符串解析为枚举数组,静默失败
  2. InputType serde rename_all 配置错误:

    • YAML 使用 multi-selectkebab-case
    • Rust serde 配置 rename_all = "snake_case"
    • 期望 multi_select 但收到 multi-select,解析失败

修复方案:

  1. 将 formats 字段改为 String 类型 (types.rs):
FileExport {
    formats: String,  // 从 Vec<ExportFormat> 改为 String
    input: String,
    output_dir: Option<String>,
}
  1. 在运行时解析 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();
  1. 修正 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 formats
  • crates/zclaw-pipeline/src/executor.rs - 运行时解析 formats
  • pipelines/**/*.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

安全设计:

  1. Feature Flag 控制: 只有显式启用 dev-server feature 才会编译服务器代码
  2. 仅 localhost 绑定: 不暴露到外部网络
  3. CORS 白名单: 只允许 Vite 开发服务器端口 (1420, 5173)
  4. 生产构建排除: 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.tsBASE_URL 与后端路由前缀不匹配。

  • 前端: BASE_URL = 'http://localhost:8080',请求路径 /auth/login → 实际请求 http://localhost:8080/auth/login
  • 后端: 路由前缀 /api/v1/auth/login

修复:

  1. BASE_URL 改为 'http://localhost:8080/api/v1'
  2. 移除所有 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_task
  • crates/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:readrelay:useconfig:read 权限,无法访问 admin 端点。

解决方案: 需要将账号升级为 super_admin 角色。两种方式:

  1. 设置环境变量自动种子(推荐):
ZCLAW_ADMIN_USERNAME=admin ZCLAW_ADMIN_PASSWORD=your_password ./zclaw-saas
  1. 直接修改数据库:
# 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_ABORTEDNetwork 面板显示红色 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 最终解决方案

用纯 SPAAnt 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

关键修复:

  1. 移除 React StrictMode(开发模式双重 mount 触发重复请求 + abort
  2. Axios timeout 10s → 30s防止慢请求被误杀
  3. JWT 拦截器(自动附加 token + 401 刷新)

13.5 验证结果

  • 全部 12 页面功能正常18 个网络请求全部 200
  • ERR_ABORTED,后端连接池不再耗尽
  • 后端 health 检查持续返回 200

涉及文件: admin-v2/ 目录(全新项目,替换 admin/


14. 相关文档


更新历史

日期 变更
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 client
  • desktop/src-tauri/src/llm/mod.rs — Tauri 侧 LLM 客户端call_api / call_embedding_api

9.8 Coding Plan 模型返回空响应(显示 "..."

症状: User-Agent 修复后,模型可以连接,但前端只显示 "..." 无实质性内容。

根本原因: 多层问题叠加:

  1. reasoning_content 字段未解析: Kimi/Qwen/DeepSeek/GLM 等模型的思考过程通过 delta.reasoning_content 字段返回(而非 delta.contentOpenAI 驱动的 OpenAiDelta 结构体缺少此字段,所有 thinking 内容被静默丢弃。

  2. ThinkingDelta 未累积到 iteration_text: loop_runner.rsThinkingDelta 只发送给前端(LoopEvent::Delta),不累积到 iteration_text,导致最终 Complete 事件的 response 为空。

  3. 每个 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_contentStreamChunk::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 | 初始版本 |