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
Update audit tracker, roadmap, architecture docs, add admin-v2 Roles page + Billing tests, sync CLAUDE.md, Cargo.toml, docker-compose.yml, add deep-research / frontend-design / chart-visualization skills Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2314 lines
76 KiB
Markdown
2314 lines
76 KiB
Markdown
# 故障排查指南
|
||
|
||
> 记录开发过程中遇到的问题、根因分析和解决方案。
|
||
|
||
---
|
||
|
||
## 1. 连接问题
|
||
|
||
### 1.1 WebSocket 连接失败
|
||
|
||
**症状**: `WebSocket connection failed` 或 `Unexpected server response: 400`
|
||
|
||
**排查步骤**:
|
||
|
||
```bash
|
||
# 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`
|
||
|
||
**解决方案**:
|
||
|
||
```bash
|
||
# 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 使用的提供商:
|
||
```bash
|
||
curl -s http://127.0.0.1:50051/api/status | jq '.agents[] | {name, model_provider}'
|
||
```
|
||
|
||
2. 配置对应的 API Key:
|
||
```bash
|
||
# 编辑 ~/.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
|
||
```
|
||
|
||
3. 重启 ZCLAW:
|
||
```bash
|
||
./zclaw.exe restart
|
||
```
|
||
|
||
**快速解决**: 使用已配置的 Agent
|
||
|
||
| Agent | 提供商 | 状态 |
|
||
|-------|--------|------|
|
||
| General Assistant | zhipu | 通常已配置 |
|
||
|
||
### 2.1.1 配置热重载限制(重要)
|
||
|
||
**症状**: 修改 `config.toml` 后,`/api/config` 和 `/api/status` 仍然返回旧配置
|
||
|
||
**根本原因**: ZCLAW 将配置持久化在 SQLite 数据库中,`config.toml` 只在启动时读取
|
||
|
||
**验证问题**:
|
||
```bash
|
||
# 检查 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` 不会更新持久化配置)
|
||
```bash
|
||
# 方法 1: 通过 API 关闭(然后手动重启)
|
||
curl -X POST http://127.0.0.1:50051/api/shutdown
|
||
|
||
# 方法 2: 使用 CLI
|
||
./zclaw.exe stop
|
||
./zclaw.exe start
|
||
```
|
||
|
||
2. **验证配置已生效**:
|
||
```bash
|
||
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 端点
|
||
|
||
**错误代码**:
|
||
```typescript
|
||
// ❌ 错误 - /api/status 不返回 agents 字段
|
||
const status = await this.restGet('/api/status');
|
||
if (status?.agents && status.agents.length > 0) { ... }
|
||
```
|
||
|
||
**修复代码**:
|
||
```typescript
|
||
// ✅ 正确 - 使用 /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
|
||
|
||
**验证修复**:
|
||
```bash
|
||
# 确认 /api/agents 返回数据
|
||
curl http://127.0.0.1:50051/api/agents
|
||
# 应返回: [{ "id": "uuid", "name": "...", "state": "Running" }]
|
||
```
|
||
|
||
### 2.3 流式响应不显示
|
||
|
||
**症状**: 消息发送后无响应或响应不完整
|
||
|
||
**排查步骤**:
|
||
|
||
1. 确认 WebSocket 连接状态:
|
||
```typescript
|
||
console.log(client.getState()); // 应为 'connected'
|
||
```
|
||
|
||
2. 检查事件处理:
|
||
```typescript
|
||
// 确保处理了 text_delta 事件
|
||
ws.on('message', (data) => {
|
||
const event = JSON.parse(data.toString());
|
||
if (event.type === 'text_delta') {
|
||
console.log('Delta:', event.content);
|
||
}
|
||
});
|
||
```
|
||
|
||
3. 验证消息格式:
|
||
```javascript
|
||
// ✅ 正确
|
||
{ type: 'message', content: 'Hello', session_id: 'xxx' }
|
||
|
||
// ❌ 错误
|
||
{ type: 'chat', message: { role: 'user', content: 'Hello' } }
|
||
```
|
||
|
||
### 2.3 消息格式错误
|
||
|
||
**症状**: WebSocket 连接成功,但发送消息后收到错误
|
||
|
||
**根本原因**: 使用了文档中的格式,而非实际格式
|
||
|
||
**正确的消息格式**:
|
||
```json
|
||
{
|
||
"type": "message",
|
||
"content": "你的消息内容",
|
||
"session_id": "唯一会话ID"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 前端问题
|
||
|
||
### 3.1 Zustand 状态不更新
|
||
|
||
**症状**: UI 不反映状态变化
|
||
|
||
**检查**:
|
||
|
||
1. 确保使用 selector:
|
||
```typescript
|
||
// ✅ 正确 - 使用 selector
|
||
const messages = useChatStore((state) => state.messages);
|
||
|
||
// ❌ 错误 - 可能导致不必要的重渲染
|
||
const store = useChatStore();
|
||
const messages = store.messages;
|
||
```
|
||
|
||
2. 检查 immer/persist 配置
|
||
|
||
### 3.2 切换 Agent 后对话消失
|
||
|
||
**症状**: 点击其他 Agent 后,之前的对话内容丢失
|
||
|
||
**根本原因**: `setCurrentAgent` 切换 Agent 时清空了 `messages`,但没有恢复该 Agent 之前的对话
|
||
|
||
**解决方案**:
|
||
|
||
修改 `chatStore.ts` 中的 `setCurrentAgent` 函数:
|
||
```typescript
|
||
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`:
|
||
```typescript
|
||
partialize: (state) => ({
|
||
conversations: state.conversations,
|
||
currentModel: state.currentModel,
|
||
currentAgentId: state.currentAgent?.id, // 添加此行
|
||
currentConversationId: state.currentConversationId,
|
||
}),
|
||
```
|
||
|
||
添加 `onRehydrateStorage` 钩子恢复消息:
|
||
```typescript
|
||
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 流式消息累积错误
|
||
|
||
**症状**: 流式内容显示不正确或重复
|
||
|
||
**解决方案**:
|
||
```typescript
|
||
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 配置错误
|
||
|
||
**解决方案**:
|
||
```bash
|
||
# 更新 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 启用详细日志
|
||
|
||
```typescript
|
||
// gateway-client.ts
|
||
private log(level: string, message: string, data?: unknown) {
|
||
if (this.debug) {
|
||
console.log(`[GatewayClient:${level}]`, message, data || '');
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.2 WebSocket 抓包
|
||
|
||
```bash
|
||
# 使用 wscat 测试
|
||
npm install -g wscat
|
||
wscat -c ws://127.0.0.1:50051/api/agents/{agentId}/ws
|
||
```
|
||
|
||
### 5.3 检查 ZCLAW 状态
|
||
|
||
```bash
|
||
# 完整状态
|
||
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:
|
||
```typescript
|
||
// 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` 组件:
|
||
```typescript
|
||
// 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:
|
||
```typescript
|
||
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
|
||
|
||
**错误代码**:
|
||
```typescript
|
||
// ❌ 错误 - 总是尝试创建新 Agent
|
||
const clone = await createClone(createOptions);
|
||
```
|
||
|
||
**修复代码**:
|
||
```typescript
|
||
// ✅ 正确 - 如果存在现有 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` 中指定的字段:
|
||
```typescript
|
||
partialize: (state) => ({
|
||
conversations: state.conversations, // ← 从这里恢复
|
||
currentModel: state.currentModel,
|
||
currentAgentId: state.currentAgent?.id,
|
||
currentConversationId: state.currentConversationId,
|
||
}),
|
||
```
|
||
|
||
但 `messages` 数组只在内存中,刷新后丢失。恢复逻辑依赖 `conversations` 数组:
|
||
```typescript
|
||
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`
|
||
|
||
**修复代码**:
|
||
```typescript
|
||
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 容器中
|
||
|
||
**问题代码**:
|
||
```typescript
|
||
// ❌ 错误 - Fragment 没有 flex 布局
|
||
return (
|
||
<>
|
||
<div className="h-14 ...">Header</div>
|
||
<div className="flex-1 ...">Messages</div>
|
||
<div className="border-t ...">Input</div>
|
||
</>
|
||
);
|
||
```
|
||
|
||
**修复代码**:
|
||
```typescript
|
||
// ✅ 正确 - 使用 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) │ ← 固定在底部
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.6 Agent 对话窗口无法滚动 — Flexbox 高度链断裂
|
||
|
||
**症状**: 对话消息较多时,消息区域无法滚动,输入框被推到视口外不可见。
|
||
|
||
**根本原因**: CSS flexbox 高度传递链中两层断裂,导致 `overflow-y-auto` 从未生效:
|
||
|
||
1. **`ResizableChatLayout` 使用 `flex-1` 但父级不是 flex 容器** — ChatArea 外层是 `<div className="relative h-full">`(非 flex),`flex-1` 无效,容器高度塌缩为内容高度。
|
||
|
||
2. **`Conversation` 组件缺少 `min-h-0`** — flex 子元素默认 `min-height: auto`(等于内容高度),阻止容器收缩,内容撑开后 `overflow-y-auto` 无效。
|
||
|
||
**高度传递链(修复前)**:
|
||
|
||
```
|
||
ChatArea: relative h-full ← 继承父级 ✓
|
||
└─ ResizableChatLayout: flex-1 ← 父级非 flex,flex-1 无效 ✗
|
||
└─ chatPanel: h-full ← 继承塌缩高度 ✗
|
||
└─ Conversation: flex-1 ← min-height:auto 阻止收缩 ✗
|
||
└─ overflow-y-auto ← 永远不触发滚动 ✗
|
||
```
|
||
|
||
**修复**:
|
||
|
||
| 文件 | 修改 | 原因 |
|
||
|------|------|------|
|
||
| `desktop/src/components/ai/ResizableChatLayout.tsx` | `flex-1` → `h-full`(两处) | 父级非 flex 容器,`flex-1` 无效 |
|
||
| `desktop/src/components/ai/Conversation.tsx` | 添加 `min-h-0` | flex 子元素需要 `min-h-0` 才能收缩 |
|
||
|
||
**高度传递链(修复后)**:
|
||
|
||
```
|
||
ChatArea: relative h-full ← 继承父级 ✓
|
||
└─ ResizableChatLayout: h-full ← 直接继承高度 ✓
|
||
└─ chatPanel: h-full ← 继承正确高度 ✓
|
||
├─ Header: h-14 shrink-0 ← 固定高度 ✓
|
||
├─ Conversation: flex-1 min-h-0 overflow-y-auto ← 占剩余空间,可收缩 ✓
|
||
└─ Input: shrink-0 ← 固定底部 ✓
|
||
```
|
||
|
||
**CSS 原理**:
|
||
|
||
- `flex-1` = `flex: 1 1 0%`,仅在父级是 flex 容器时生效。非 flex 父级中等于未设置。
|
||
- `min-height: auto`(默认值)= 内容最小高度,阻止 flex 子元素收缩到比内容更小。
|
||
- `min-h-0`(`min-height: 0`)允许 flex 子元素收缩,`overflow` 才能生效。
|
||
|
||
**验证修复**:
|
||
1. 进行多轮对话使消息超出视口高度
|
||
2. 消息区域应出现滚动条,输入框始终固定在底部可见
|
||
|
||
**相关文件**:
|
||
- `desktop/src/components/ChatArea.tsx` — 布局结构
|
||
- `desktop/src/components/ai/ResizableChatLayout.tsx` — 双面板布局
|
||
- `desktop/src/components/ai/Conversation.tsx` — 消息滚动容器
|
||
|
||
---
|
||
|
||
## 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/complete`,ZCLAW 无此端点
|
||
|
||
**修复方案**:
|
||
|
||
```typescript
|
||
// 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`
|
||
|
||
**修复方案**:
|
||
|
||
```typescript
|
||
// 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`:
|
||
```typescript
|
||
proxy: {
|
||
'/api': {
|
||
target: 'http://127.0.0.1:50051', // 使用实际运行端口
|
||
// ...
|
||
},
|
||
}
|
||
```
|
||
|
||
2. 更新 `gateway-client.ts`:
|
||
```typescript
|
||
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`, // 保留作为备选
|
||
];
|
||
```
|
||
|
||
**验证端口**:
|
||
```bash
|
||
# 检查实际运行的端口
|
||
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 开发服务器
|
||
|
||
**验证修复**:
|
||
```bash
|
||
# 检查端口监听
|
||
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`):
|
||
```rust
|
||
// ❌ 错误 - 硬编码模型和响应
|
||
let request = CompletionRequest {
|
||
model: "claude-sonnet-4-20250514".to_string(), // 硬编码!
|
||
// ...
|
||
};
|
||
|
||
// ...
|
||
|
||
Ok(AgentLoopResult {
|
||
response: "Response placeholder".to_string(), // 丢弃真实响应!
|
||
// ...
|
||
})
|
||
```
|
||
|
||
**修复方案**:
|
||
|
||
1. **添加配置字段到 AgentLoop**:
|
||
```rust
|
||
pub struct AgentLoop {
|
||
// ... existing fields
|
||
model: String,
|
||
system_prompt: Option<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
|
||
}
|
||
```
|
||
|
||
2. **使用配置的模型**:
|
||
```rust
|
||
let request = CompletionRequest {
|
||
model: self.model.clone(), // 使用配置的模型
|
||
// ...
|
||
};
|
||
```
|
||
|
||
3. **提取实际响应内容**:
|
||
```rust
|
||
// 从 CompletionResponse.content 提取文本
|
||
let response_text = response.content
|
||
.iter()
|
||
.filter_map(|block| match block {
|
||
ContentBlock::Text { text } => Some(text.clone()),
|
||
ContentBlock::Thinking { thinking } => Some(format!("[思考] {}", thinking)),
|
||
ContentBlock::ToolUse { name, input, .. } => {
|
||
Some(format!("[工具调用] {}({})", name, serde_json::to_string(input).unwrap_or_default()))
|
||
}
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
Ok(AgentLoopResult {
|
||
response: response_text, // 返回真实响应
|
||
// ...
|
||
})
|
||
```
|
||
|
||
4. **在 kernel.rs 中传递模型配置**:
|
||
```rust
|
||
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 API(如 `https://coding.dashscope.aliyuncs.com/v1`)
|
||
2. 发送消息
|
||
3. 应该收到实际的 LLM 响应而非占位符
|
||
|
||
**特别说明**: 此问题影响所有 LLM 提供商,不仅限于 Coding Plan API。任何自定义模型配置都会被忽略。
|
||
|
||
### 9.2 Coding Plan API 配置流程
|
||
|
||
**支持的 Coding Plan 端点**:
|
||
|
||
| 提供商 | Provider ID | Base URL |
|
||
|--------|-------------|----------|
|
||
| Kimi Coding Plan | `kimi-coding` | `https://api.kimi.com/coding/v1` |
|
||
| 百炼 Coding Plan | `qwen-coding` | `https://coding.dashscope.aliyuncs.com/v1` |
|
||
| 智谱 GLM Coding Plan | `zhipu-coding` | `https://open.bigmodel.cn/api/coding/paas/v4` |
|
||
|
||
**配置流程**:
|
||
|
||
1. **前端** (`ModelsAPI.tsx`): 用户选择 Provider,输入 API Key 和 Model ID
|
||
2. **存储** (`localStorage`): 保存为 `CustomModel` 对象
|
||
3. **连接时** (`connectionStore.ts`): 从 localStorage 读取配置
|
||
4. **传递给内核** (`kernel-client.ts`): 通过 `kernel_init` 命令传递
|
||
5. **内核处理** (`kernel_commands.rs`): 根据 Provider 和 Base URL 创建驱动
|
||
|
||
**关键代码路径**:
|
||
```
|
||
UI 配置 → localStorage → connectionStore.getDefaultModelConfig()
|
||
→ kernelClient.setConfig() → invoke('kernel_init', { configRequest })
|
||
→ KernelConfig → create_driver() → OpenAiDriver::with_base_url()
|
||
```
|
||
|
||
**注意事项**:
|
||
- Coding Plan 使用 OpenAI 兼容协议 (`api_protocol: "openai"`)
|
||
- Base URL 必须包含完整路径(如 `/v1`)
|
||
- 未知 Provider 会走 fallback 逻辑,使用 `local_base_url` 作为自定义端点
|
||
|
||
### 9.3 更换模型配置后仍使用旧模型
|
||
|
||
**症状**: 在"模型与 API"页面切换模型后,对话仍然使用旧模型,API 请求中的 model 字段是旧的值
|
||
|
||
**示例日志**:
|
||
```
|
||
[kernel_init] Final config: model=qwen3.5-plus, base_url=https://coding.dashscope.aliyuncs.com/v1
|
||
[OpenAiDriver] Request body: {"model":"kimi-for-coding",...} # 旧模型!
|
||
```
|
||
|
||
**根本原因**: Agent 配置持久化在数据库中,其 `model` 字段优先于 Kernel 的配置
|
||
|
||
**问题代码** (`crates/zclaw-kernel/src/kernel.rs`):
|
||
```rust
|
||
// ❌ 错误 - Agent 的 model 优先于 Kernel 的 model
|
||
let model = if !agent_config.model.model.is_empty() {
|
||
agent_config.model.model.clone() // 持久化的旧值
|
||
} else {
|
||
self.config.model().to_string()
|
||
};
|
||
```
|
||
|
||
**问题分析**:
|
||
|
||
1. Agent 配置在创建时保存到 SQLite 数据库
|
||
2. Kernel 启动时从数据库恢复 Agent 配置
|
||
3. `send_message` 中 Agent 的 model 配置优先于 Kernel 的当前配置
|
||
4. 用户在"模型与 API"页面更改的是 Kernel 配置,不影响已持久化的 Agent 配置
|
||
|
||
**修复方案**:
|
||
|
||
让 Kernel 的当前配置优先,确保用户的"模型与 API"设置生效:
|
||
|
||
```rust
|
||
// ✅ 正确 - 始终使用 Kernel 的当前 model 配置
|
||
let model = self.config.model().to_string();
|
||
|
||
eprintln!("[Kernel] send_message: using model={} from kernel config", model);
|
||
```
|
||
|
||
**影响范围**:
|
||
- `crates/zclaw-kernel/src/kernel.rs` - `send_message` 和 `send_message_stream` 方法
|
||
|
||
**设计决策**:
|
||
|
||
ZCLAW 的设计是让用户在"模型与 API"页面设置全局模型,而不是为每个 Agent 单独设置。因此:
|
||
- Kernel 配置应该优先于 Agent 配置
|
||
- Agent 配置主要用于存储 personality、system_prompt 等
|
||
- model 配置应该由全局设置控制
|
||
|
||
**验证修复**:
|
||
1. 在"模型与 API"页面配置新模型
|
||
2. 发送消息
|
||
3. 检查终端日志,应显示 `using model=新模型 from kernel config`
|
||
4. 检查 API 请求体,`model` 字段应为新模型
|
||
|
||
---
|
||
|
||
## 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>` 后再计算:
|
||
|
||
```rust
|
||
// 错误写法
|
||
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` 自动修复
|
||
|
||
**自动修复命令**:
|
||
```bash
|
||
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: true` 和 `tools` 时,API 行为异常
|
||
- 工具调用参数无法正确传输
|
||
|
||
2. **响应解析优先级错误**: `convert_response` 方法优先处理 `content` 字段,即使它是空字符串
|
||
- 当 API 返回 `content: Some("")` 和 `tool_calls: [...]` 时
|
||
- 代码错误地选择了空的 content,导致响应为空
|
||
|
||
3. **ToolUse 消息 JSON 序列化错误**: 当 `input` 为 `Null` 时
|
||
- `serde_json::to_string(input)` 产生 `"null"` 字符串
|
||
- API 要求 `"{}"` (空对象) 格式
|
||
|
||
**问题分析**:
|
||
|
||
工具调用的完整流程:
|
||
```
|
||
用户消息 → LLM 决定调用工具 → 返回 tool_calls → 执行工具 → 返回结果 → LLM 生成最终响应
|
||
```
|
||
|
||
在百炼 API 中,由于流式 + 工具不兼容:
|
||
```
|
||
stream=true + tools → API 行为异常 → tool_calls 参数丢失 → 空工具名/重复调用
|
||
```
|
||
|
||
**修复方案**:
|
||
|
||
1. **检测不兼容的 Provider 并使用非流式模式** (`openai.rs:stream`):
|
||
|
||
```rust
|
||
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); // 使用非流式模式
|
||
}
|
||
// ... 正常流式逻辑
|
||
}
|
||
```
|
||
|
||
2. **实现 `stream_from_complete` 方法**: 调用非流式 API,然后模拟流式输出
|
||
|
||
```rust
|
||
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 { ... });
|
||
})
|
||
}
|
||
```
|
||
|
||
3. **修复响应解析优先级** (`convert_response`):
|
||
|
||
```rust
|
||
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() }]
|
||
};
|
||
// ...
|
||
}
|
||
};
|
||
```
|
||
|
||
4. **修复 ToolUse 消息的 JSON 序列化**:
|
||
|
||
```rust
|
||
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
|
||
|
||
**症状**:
|
||
- 会话一直卡在"思考中..."状态
|
||
- 终端显示 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`):
|
||
```rust
|
||
// ❌ 错误 - 按字节截断,可能切断多字节字符
|
||
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()` 找到最近的合法字符边界:
|
||
|
||
```rust
|
||
// ✅ 正确 - 使用 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.rs` 的 `send_message_stream_with_prompt()` 每次调用都执行 `self.memory.create_session(agent_id)` 创建全新 session。前端虽然传了 `session_id` 用于事件路由,但 kernel 完全忽略这个 ID,导致 `loop_runner` 的 `get_messages()` 永远返回空数组,LLM 从未收到历史消息。
|
||
|
||
```rust
|
||
// 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 |
|
||
|
||
**相关流程**:
|
||
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.rs` 的 `build_api_request()` 有两个关键缺陷:
|
||
|
||
1. **`OpenAiMessage` 缺少 `tool_call_id` 字段**: OpenAI 协议要求 `role: "tool"` 的消息必须携带 `tool_call_id` 来匹配对应的工具调用。当前代码用 `tool_call_id: _` 丢弃了这个值,且结构体中没有该字段。
|
||
|
||
2. **连续的 `ToolUse` 消息没有合并**: 同一轮 LLM 响应的多个工具调用应该在同一个 assistant 消息中(`tool_calls` 数组),而不是每个工具调用生成一个独立的 assistant 消息。
|
||
|
||
修复前的 API 请求格式(错误):
|
||
```json
|
||
[
|
||
{ "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
|
||
]
|
||
```
|
||
|
||
修复后(正确):
|
||
```json
|
||
[
|
||
{ "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` 字段**:
|
||
|
||
```rust
|
||
struct OpenAiMessage {
|
||
role: String,
|
||
content: Option<String>,
|
||
tool_calls: Option<Vec<OpenAiToolCall>>,
|
||
tool_call_id: Option<String>, // ← 新增
|
||
}
|
||
```
|
||
|
||
2. **重写消息转换逻辑** — 合并连续 `ToolUse` 消息 + 传递 `tool_call_id`:
|
||
|
||
```rust
|
||
// 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` 字段。当前有两层缺陷:
|
||
|
||
1. **loop_runner 未分离 reasoning 和 text**: `ThinkingDelta` 和 `TextDelta` 混在 `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`):
|
||
|
||
```rust
|
||
// 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 请求格式:
|
||
```json
|
||
{
|
||
"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 没有调用相关技能,只是直接回复文本
|
||
|
||
**根本原因**:
|
||
|
||
1. **系统提示词缺少技能列表**: LLM 不知道有哪些技能可用
|
||
2. **SkillManifest 缺少 triggers 字段**: 触发词无法传递给 LLM
|
||
3. **技能触发词覆盖不足**: "财报" 无法匹配 "财务报告"
|
||
|
||
**问题分析**:
|
||
|
||
Agent 调用技能的完整链路:
|
||
```
|
||
用户消息 → LLM → 选择 execute_skill 工具 → 传入 skill_id → 执行技能
|
||
```
|
||
|
||
如果 LLM 不知道有哪些 skill_id 可用,就无法主动调用。
|
||
|
||
**修复方案**:
|
||
|
||
1. **在系统提示词中注入技能列表** (`kernel.rs`):
|
||
|
||
```rust
|
||
/// 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
|
||
}
|
||
```
|
||
|
||
2. **添加 triggers 字段到 SkillManifest** (`skill.rs`):
|
||
|
||
```rust
|
||
pub struct SkillManifest {
|
||
// ... existing fields
|
||
/// Trigger words for skill activation
|
||
#[serde(default)]
|
||
pub triggers: Vec<String>,
|
||
}
|
||
```
|
||
|
||
3. **解析 SKILL.md 中的 triggers** (`loader.rs`):
|
||
|
||
```rust
|
||
// Parse triggers list in frontmatter
|
||
if in_triggers_list && line.starts_with("- ") {
|
||
triggers.push(line[2..].trim().trim_matches('"').to_string());
|
||
continue;
|
||
}
|
||
```
|
||
|
||
4. **添加常见触发词** (`skills/finance-tracker/SKILL.md`):
|
||
|
||
```yaml
|
||
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`):
|
||
```rust
|
||
// ❌ 错误 - 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 工具可用
|
||
```
|
||
|
||
**修复方案**:
|
||
```rust
|
||
// ✅ 正确 - 使用默认技能目录
|
||
Self {
|
||
database_url: default_database_url(),
|
||
llm,
|
||
skills_dir: default_skills_dir(), // 使用 ./skills 目录
|
||
}
|
||
```
|
||
|
||
**修复代码** (`config.rs:161-165`):
|
||
```rust
|
||
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()`,如果工作目录不同可能失效。更可靠的方案是使用可执行文件目录:
|
||
|
||
```rust
|
||
// 建议改进
|
||
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`):
|
||
```rust
|
||
// ❌ 错误 - 在 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`):
|
||
```rust
|
||
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. 当前工作目录及向上搜索
|
||
// ... 其他备选方案
|
||
}
|
||
```
|
||
|
||
2. **将 load_skill_from_dir 改为 async** (`registry.rs`):
|
||
```rust
|
||
// ✅ 正确 - 使用 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`
|
||
|
||
**验证修复**:
|
||
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-select`(kebab-case)
|
||
- Rust serde 配置 `rename_all = "snake_case"`
|
||
- 期望 `multi_select` 但收到 `multi-select`,解析失败
|
||
|
||
**修复方案**:
|
||
|
||
1. **将 formats 字段改为 String 类型** (`types.rs`):
|
||
```rust
|
||
FileExport {
|
||
formats: String, // 从 Vec<ExportFormat> 改为 String
|
||
input: String,
|
||
output_dir: Option<String>,
|
||
}
|
||
```
|
||
|
||
2. **在运行时解析 formats 表达式** (`executor.rs`):
|
||
```rust
|
||
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();
|
||
```
|
||
|
||
3. **修正 InputType serde 配置** (`types.rs`):
|
||
```rust
|
||
#[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 控制:
|
||
|
||
**启动方式**:
|
||
```bash
|
||
# 启动带开发服务器的 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 调用 |
|
||
|
||
**验证服务器运行**:
|
||
```bash
|
||
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`
|
||
|
||
**修复**:
|
||
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 特有函数:
|
||
```bash
|
||
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` 角色。两种方式:
|
||
|
||
1. **设置环境变量自动种子**(推荐):
|
||
```bash
|
||
ZCLAW_ADMIN_USERNAME=admin ZCLAW_ADMIN_PASSWORD=your_password ./zclaw-saas
|
||
```
|
||
|
||
2. **直接修改数据库**:
|
||
```bash
|
||
# 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
|
||
|
||
**关键修复**:
|
||
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. SaaS Relay 模式未生效 — llm_routing 读取路径 Bug (2026-03-31)
|
||
|
||
**症状**: Admin 后台配置 `llm_routing=relay`,但桌面端仍走本地 Kernel 模式(需要本地 API Key),不经过 SaaS Key Pool 中转。
|
||
|
||
**根本原因**: `desktop/src/store/connectionStore.ts` 第 362 行的 `llm_routing` 读取路径多了一层嵌套:
|
||
|
||
```typescript
|
||
// ❌ 错误:storedAccount 是 SaaSAccountInfo 对象本身,不是 { account: SaaSAccountInfo }
|
||
const adminRouting = storedAccount?.account?.llm_routing;
|
||
|
||
// ✅ 正确:直接读取 SaaSAccountInfo.llm_routing
|
||
const adminRouting = storedAccount?.llm_routing;
|
||
```
|
||
|
||
`saveSaaSSession` 将 `session.account`(即 `SaaSAccountInfo`)直接 `JSON.stringify` 存入 localStorage 的 `zclaw-saas-account` 键。但 connectionStore 误以为存储结构是 `{ account: { llm_routing: ... } }`,实际是 `{ llm_routing: ... }`。
|
||
|
||
**修复**: 修改 `desktop/src/store/connectionStore.ts` 第 362 行。
|
||
|
||
**影响**: 此 bug 导致 `llm_routing` 管理端配置从未生效,所有桌面端都忽略 admin 路由优先级,走默认连接模式。
|
||
|
||
**验证**: 登录后检查 SaaS 后端 relay tasks 表是否有新记录。如果没有新 relay task 创建,说明走了本地模式。
|
||
|
||
---
|
||
|
||
## 15. SaaS Relay 403 — Coding Plan 不识别 User-Agent (2026-03-31)
|
||
|
||
**症状**: SaaS relay 模式调用 Kimi Coding Plan 时返回 403:
|
||
|
||
```json
|
||
{"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"}}
|
||
```
|
||
|
||
**根本原因**: SaaS relay 的 `execute_relay()` 使用默认 reqwest User-Agent(`reqwest/0.12.x`),Kimi Coding Plan 检查 User-Agent 白名单。
|
||
|
||
**修复**: `crates/zclaw-saas/src/relay/service.rs` 的 HTTP 请求构建中添加:
|
||
|
||
```rust
|
||
.header("User-Agent", "claude-code/1.0")
|
||
```
|
||
|
||
**注意**: 此修复与 9.7 节类似,但 9.7 修复的是桌面端直连时的 User-Agent,此处修复的是 SaaS 后端作为代理转发时的 User-Agent。两者都需要。
|
||
|
||
---
|
||
|
||
## 16. 桌面端会话持久化三连 Bug (2026-04-01)
|
||
|
||
### 16.1 页面刷新跳回登录页
|
||
|
||
**症状**: F5 刷新页面后被踢回登录界面,必须重新输入密码。
|
||
|
||
**根本原因**: `saasStore.restoreSession()` 方法已实现(从 OS keyring 读取 token + 验证),但从未被调用。Zustand store 初始化 `isLoggedIn: false`,`App.tsx` 同步检查后立即渲染 `<LoginPage />`。
|
||
|
||
**修复**: `desktop/src/App.tsx` — 添加 `isRestoring` 状态,启动时调用 `restoreSession()`,恢复完成前显示加载屏。
|
||
|
||
### 16.2 登录后空白对话页
|
||
|
||
**症状**: 登录后看到空白对话页面,必须手动点击 agent 才能看到之前的对话。
|
||
|
||
**根本原因**: `CloneManager` 在 `loadClones()` 后没有调用 `chatStore.syncAgents()`。`syncAgents` 包含恢复 `currentAgent` 和从 conversations 数组恢复 messages 的安全网逻辑。
|
||
|
||
**修复**: `desktop/src/components/CloneManager.tsx` — `loadClones().then()` 中调用 `syncAgents(clones)`。
|
||
|
||
### 16.3 Agent 无上下文记忆 — session ID 映射断裂
|
||
|
||
**症状**: 每条消息 Agent 都说"这是第一条消息",多轮对话上下文完全丢失。
|
||
|
||
**根本原因**: 前端生成的 `sessionKey` UUID 传到 kernel 后,`get_messages()` 查不到(因为该 ID 从未存入 sessions 表),于是 `create_session()` 生成全新的 UUID。下一轮前端再传原 UUID,又创建新 session。前端 session ID 和数据库 session ID 永远无法匹配。
|
||
|
||
```
|
||
Turn 1: 前端 "abc-123" → DB 查不到 → 创建 "xyz-789" → 消息存到 "xyz-789"
|
||
Turn 2: 前端 "abc-123" → DB 查不到 → 创建 "def-456" → 消息存到 "def-456"
|
||
结果: 永远找不到历史消息
|
||
```
|
||
|
||
**修复**:
|
||
1. `crates/zclaw-memory/src/store.rs` — 添加 `get_or_create_session()` 方法,直接用前端提供的 ID 创建 session
|
||
2. `crates/zclaw-kernel/src/kernel.rs` — 使用 `get_or_create_session` 替代 lookup-then-create
|
||
|
||
### 16.4 `messages[N] must not be empty` — assistant 消息空 content
|
||
|
||
**症状**: Bug 16.3 修复后暴露:`API error 400: messages[4] with role 'assistant' must not be empty`
|
||
|
||
**根本原因**: Bug 16.3 修复后对话历史终于被正确发送给 LLM。但历史中的 `ToolUse` 消息对应的 assistant 消息 content 为空(只有 tool_calls 没有 text)。Kimi/Qwen API 要求 assistant 消息的 `content` 不能为空字符串。
|
||
|
||
**修复**: `crates/zclaw-runtime/src/driver/openai.rs` — `flush_pending` 中 assistant 消息的 `content` 字段:空字符串 → 有意义的占位符(`"正在思考..."` / `"正在调用工具..."`)。
|
||
|
||
**涉及文件**:
|
||
- `desktop/src/App.tsx`
|
||
- `desktop/src/components/CloneManager.tsx`
|
||
- `crates/zclaw-memory/src/store.rs`
|
||
- `crates/zclaw-kernel/src/kernel.rs`
|
||
- `crates/zclaw-runtime/src/driver/openai.rs`
|
||
|
||
---
|
||
|
||
## 17. 相关文档
|
||
|
||
- [ZCLAW 配置指南](./zclaw-configuration.md) - 配置文件位置、格式和最佳实践
|
||
- [Agent 和 LLM 提供商配置](./agent-provider-config.md) - Agent 管理和 Provider 配置
|
||
- [ZCLAW WebSocket 协议](./zclaw-websocket-protocol.md) - WebSocket 通信协议
|
||
|
||
---
|
||
|
||
## 更新历史
|
||
|
||
| 日期 | 变更 |
|
||
|------|------|
|
||
| 2026-04-02 | 添加 3.6 节:Agent 对话窗口无法滚动 — Flexbox 高度链断裂(ResizableChatLayout flex-1 在非 flex 父级无效 + Conversation 缺少 min-h-0) |
|
||
| 2026-04-01 | 添加第 16 节:桌面端会话持久化四连 Bug — 页面刷新跳登录 + 空白对话页 + Agent 无记忆(session ID 映射断裂) + assistant 空 content 400 |
|
||
| 2026-03-31 | 添加第 14/15 节:llm_routing 读取路径 Bug + SaaS Relay 403 User-Agent 缺失 — relay 模式从未生效的根因分析 |
|
||
| 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。
|
||
|
||
```rust
|
||
// 修复前
|
||
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.content`),OpenAI 驱动的 `OpenAiDelta` 结构体缺少此字段,所有 thinking 内容被静默丢弃。
|
||
|
||
2. **ThinkingDelta 未累积到 `iteration_text`**: `loop_runner.rs` 中 `ThinkingDelta` 只发送给前端(`LoopEvent::Delta`),不累积到 `iteration_text`,导致最终 `Complete` 事件的 `response` 为空。
|
||
|
||
3. **每个 reasoning token 重复加 `[思考]` 前缀**: 每收到一个 `ThinkingDelta` 就加一次 `[思考] `,导致显示为 `[思考] 用户[思考] 只是[思考] 简单地...`。
|
||
|
||
**修复**:
|
||
|
||
**a) OpenAI 驱动添加 `reasoning_content` 支持** (`openai.rs`):
|
||
|
||
```rust
|
||
// 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`):
|
||
|
||
```rust
|
||
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 | 初始版本 |
|