docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective

Major changes:
- Shift from "OpenFang desktop client" to "independent AI Agent desktop app"
- Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?"
- Simplify project structure and tech stack sections
- Replace OpenClaw vs OpenFang comparison with unified backend approach
- Consolidate troubleshooting from scattered sections into organized FAQ
- Update Hands system documentation with 8 capabilities and status
- Stream
This commit is contained in:
iven
2026-03-20 19:30:09 +08:00
parent 3518fc8ece
commit 6f72442531
63 changed files with 8920 additions and 857 deletions

View File

@@ -1,36 +1,86 @@
import { useEffect, useState } from 'react';
import { useState, useEffect } from 'react';
import { getStoredGatewayToken, getStoredGatewayUrl } from '../../lib/gateway-client';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
import { silentErrorHandler } from '../../lib/error-utils';
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X } from 'lucide-react';
// Helper function to format context window size
function formatContextWindow(tokens?: number): string {
if (!tokens) return '';
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
// 自定义模型数据结构
interface CustomModel {
id: string;
name: string;
provider: string;
apiKey?: string;
apiProtocol: 'openai' | 'anthropic' | 'custom';
baseUrl?: string;
isDefault?: boolean;
createdAt: string;
}
// 可用的 Provider 列表
const AVAILABLE_PROVIDERS = [
{ id: 'zhipu', name: '智谱 (ZhipuAI)', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' },
{ id: 'qwen', name: '百炼/通义千问 (Qwen)', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
{ id: 'kimi', name: 'Kimi (Moonshot)', baseUrl: 'https://api.moonshot.cn/v1' },
{ id: 'minimax', name: 'MiniMax', baseUrl: 'https://api.minimax.chat/v1' },
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1' },
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
{ id: 'custom', name: '自定义', baseUrl: '' },
];
const STORAGE_KEY = 'zclaw-custom-models';
// 从 localStorage 加载自定义模型
function loadCustomModels(): CustomModel[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// ignore
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`;
return [];
}
// 保存自定义模型到 localStorage
function saveCustomModels(models: CustomModel[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(models));
} catch {
// ignore
}
return `${tokens}`;
}
export function ModelsAPI() {
const { connectionState, connect, disconnect, quickConfig, saveQuickConfig, models, modelsLoading, modelsError, loadModels } = useGatewayStore();
const { connectionState, connect, disconnect, quickConfig, loadModels } = useGatewayStore();
const { currentModel, setCurrentModel } = useChatStore();
const [gatewayUrl, setGatewayUrl] = useState(getStoredGatewayUrl());
const [gatewayToken, setGatewayToken] = useState(quickConfig.gatewayToken || getStoredGatewayToken());
// 自定义模型状态
const [customModels, setCustomModels] = useState<CustomModel[]>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [editingModel, setEditingModel] = useState<CustomModel | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
// 表单状态
const [formData, setFormData] = useState({
provider: 'zhipu',
modelId: '',
displayName: '',
apiKey: '',
apiProtocol: 'openai' as 'openai' | 'anthropic' | 'custom',
baseUrl: '',
});
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
// Load models when connected
// 加载自定义模型
useEffect(() => {
if (connected && models.length === 0 && !modelsLoading) {
loadModels();
}
}, [connected, models.length, modelsLoading, loadModels]);
setCustomModels(loadCustomModels());
}, []);
useEffect(() => {
setGatewayUrl(quickConfig.gatewayUrl || getStoredGatewayUrl());
@@ -45,196 +95,335 @@ export function ModelsAPI() {
).catch(silentErrorHandler('ModelsAPI')), 500);
};
const handleSaveGatewaySettings = () => {
saveQuickConfig({
gatewayUrl,
gatewayToken,
}).catch(silentErrorHandler('ModelsAPI'));
// 打开添加模型弹窗
const handleOpenAddModal = () => {
setFormData({
provider: 'zhipu',
modelId: '',
displayName: '',
apiKey: '',
apiProtocol: 'openai',
baseUrl: AVAILABLE_PROVIDERS[0].baseUrl,
});
setEditingModel(null);
setShowAddModal(true);
};
const handleRefreshModels = () => {
// 打开编辑模型弹窗
const handleOpenEditModal = (model: CustomModel) => {
setFormData({
provider: model.provider,
modelId: model.id,
displayName: model.name,
apiKey: model.apiKey || '',
apiProtocol: model.apiProtocol,
baseUrl: model.baseUrl || '',
});
setEditingModel(model);
setShowAddModal(true);
};
// 保存模型
const handleSaveModel = () => {
if (!formData.modelId.trim()) return;
const newModel: CustomModel = {
id: formData.modelId.trim(),
name: formData.displayName.trim() || formData.modelId.trim(),
provider: formData.provider,
apiKey: formData.apiKey.trim(),
apiProtocol: formData.apiProtocol,
baseUrl: formData.baseUrl.trim() || AVAILABLE_PROVIDERS.find(p => p.id === formData.provider)?.baseUrl,
createdAt: editingModel?.createdAt || new Date().toISOString(),
};
let updatedModels: CustomModel[];
if (editingModel) {
// 编辑模式
updatedModels = customModels.map(m => m.id === editingModel.id ? newModel : m);
} else {
// 添加模式
updatedModels = [...customModels, newModel];
}
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
setShowAddModal(false);
setEditingModel(null);
// 刷新模型列表
loadModels();
};
// 删除模型
const handleDeleteModel = (modelId: string) => {
const updatedModels = customModels.filter(m => m.id !== modelId);
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
};
// 设为默认模型
const handleSetDefault = (modelId: string) => {
setCurrentModel(modelId);
// 更新自定义模型的默认状态
const updatedModels = customModels.map(m => ({
...m,
isDefault: m.id === modelId,
}));
setCustomModels(updatedModels);
saveCustomModels(updatedModels);
};
// Provider 变更时更新 baseUrl
const handleProviderChange = (providerId: string) => {
const provider = AVAILABLE_PROVIDERS.find(p => p.id === providerId);
setFormData({
...formData,
provider: providerId,
baseUrl: provider?.baseUrl || '',
});
};
return (
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold text-gray-900"> API</h1>
<h1 className="text-xl font-bold text-gray-900 dark:text-white"> API</h1>
<button
onClick={handleReconnect}
disabled={connecting}
className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 px-3 py-1.5 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
{connecting ? '连接中...' : '重新连接'}
</button>
</div>
{/* Gateway 连接状态 */}
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wider"></h3>
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-2">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">Gateway </h3>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium text-orange-600">{currentModel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Gateway </span>
<span className="text-sm text-gray-500 dark:text-gray-400"></span>
<span className={`text-sm ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400"></span>
<span className="text-sm font-medium text-orange-600">{currentModel || '未选择'}</span>
</div>
</div>
</div>
{/* 内置模型 */}
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider"></h3>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">ZCLAW </span>
<span className="text-xs text-gray-400"> Gateway </span>
</div>
</div>
</div>
{/* 自定义模型 */}
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider"></h3>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400"></span>
{connected && (
<button
onClick={handleRefreshModels}
disabled={modelsLoading}
className="text-xs text-orange-600 hover:text-orange-700 disabled:opacity-50"
>
{modelsLoading ? '加载中...' : '刷新'}
</button>
)}
</div>
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider"></h3>
<button
onClick={handleOpenAddModal}
className="text-xs text-orange-600 hover:text-orange-700 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
</button>
</div>
{/* Loading state */}
{modelsLoading && (
<div className="bg-white rounded-xl border border-gray-200 p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-500"></div>
<span className="ml-3 text-sm text-gray-500">...</span>
</div>
{customModels.length === 0 ? (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm text-center">
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"></p>
</div>
)}
{/* Error state */}
{modelsError && !modelsLoading && (
<div className="bg-white rounded-xl border border-red-200 p-4 shadow-sm">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm font-medium text-red-800"></p>
<p className="text-xs text-red-600 mt-1">{modelsError}</p>
<button
onClick={handleRefreshModels}
className="mt-2 text-xs text-red-600 hover:text-red-700 underline"
>
</button>
</div>
</div>
</div>
)}
{/* Not connected state */}
{!connected && !modelsLoading && !modelsError && (
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="text-center">
<svg className="w-8 h-8 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p className="text-sm text-gray-500"> Gateway </p>
</div>
</div>
)}
{/* Model list */}
{connected && !modelsLoading && !modelsError && models.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
{models.map((model) => {
const isActive = model.id === currentModel;
return (
<div key={model.id} className={`flex justify-between items-center p-4 ${isActive ? 'bg-orange-50/50' : ''}`}>
<div>
<div className="text-sm text-gray-900">{model.name}</div>
<div className="flex items-center gap-2 mt-1">
{model.provider && (
<span className="text-xs text-gray-400">{model.provider}</span>
)}
{model.contextWindow && (
<span className="text-xs text-gray-400">
{model.provider && '|'}
{formatContextWindow(model.contextWindow)}
</span>
)}
{model.maxOutput && (
<span className="text-xs text-gray-400">
{formatContextWindow(model.maxOutput)}
</span>
)}
</div>
</div>
<div className="flex gap-2 text-xs items-center">
{isActive ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs"></span>
) : (
<button onClick={() => setCurrentModel(model.id)} className="text-orange-600 hover:underline"></button>
) : (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl divide-y divide-gray-100 dark:divide-gray-700 shadow-sm">
{customModels.map((model) => (
<div
key={model.id}
className={`flex justify-between items-center p-4 ${currentModel === model.id ? 'bg-orange-50/50 dark:bg-orange-900/10' : ''}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{model.name}</span>
{currentModel === model.id && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded"></span>
)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{AVAILABLE_PROVIDERS.find(p => p.id === model.provider)?.name || model.provider}
{model.apiKey ? ' · 已配置 API Key' : ' · 未配置 API Key'}
</div>
</div>
);
})}
<div className="flex items-center gap-2 text-xs">
{currentModel !== model.id && (
<button
onClick={() => handleSetDefault(model.id)}
className="text-orange-600 hover:underline flex items-center gap-1"
>
<Star className="w-3 h-3" />
</button>
)}
<button
onClick={() => handleOpenEditModal(model)}
className="text-gray-500 dark:text-gray-400 hover:underline flex items-center gap-1"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={() => handleDeleteModel(model.id)}
className="text-red-500 hover:underline flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Empty state */}
{connected && !modelsLoading && !modelsError && models.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="text-center">
<svg className="w-8 h-8 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 20a8 8 0 100-16 8 8 0 000 16z" />
</svg>
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"> Gateway Provider </p>
{/* 添加/编辑模型弹窗 */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowAddModal(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
{/* 弹窗头部 */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700 p-6 flex justify-between items-center z-10">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{editingModel ? '编辑模型' : '添加模型'}
</h3>
<button
onClick={() => setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 弹窗内容 */}
<div className="p-6 space-y-4">
{/* 警告提示 */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-100 dark:border-yellow-800 rounded-lg p-3 text-xs text-yellow-800 dark:text-yellow-200 flex items-start gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>使</span>
</div>
{/* 服务商 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* </label>
<select
value={formData.provider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
{AVAILABLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* 模型 ID */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* ID</label>
<input
type="text"
value={formData.modelId}
onChange={(e) => setFormData({ ...formData, modelId: e.target.value })}
placeholder="如glm-4-plus"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{/* 显示名称 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
placeholder="如GLM-4-Plus"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{/* API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Key</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="请填写 API Key"
className="w-full px-3 py-2 pr-10 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* API 协议 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API </label>
<select
value={formData.apiProtocol}
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as 'openai' | 'anthropic' | 'custom' })}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="custom"></option>
</select>
</div>
{/* Base URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Base URL</label>
<input
type="text"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.example.com/v1"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
</div>
{/* 弹窗底部 */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 p-6 flex justify-end gap-3">
<button
onClick={() => setShowAddModal(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm"
>
</button>
<button
onClick={handleSaveModel}
disabled={!formData.modelId.trim()}
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingModel ? '保存' : '添加'}
</button>
</div>
</div>
)}
<div className="mt-3 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
Gateway Provider Key
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">Gateway URL</span>
<span className={`px-2 py-0.5 rounded text-xs border ${connected ? 'bg-green-50 text-green-600 border-green-100' : 'bg-red-50 text-red-600 border-red-100'}`}>
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
</span>
</div>
<div className="flex gap-2">
<button onClick={handleReconnect} className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
</button>
<button onClick={handleSaveGatewaySettings} className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg transition-colors">
</button>
</div>
</div>
<div className="space-y-3 bg-gray-50 border border-gray-200 rounded-xl p-3 text-xs text-gray-600 font-mono shadow-sm">
<input
type="text"
value={gatewayUrl}
onChange={(e) => setGatewayUrl(e.target.value)}
onBlur={() => { saveQuickConfig({ gatewayUrl }).catch(silentErrorHandler('ModelsAPI')); }}
className="w-full bg-transparent border-none outline-none"
/>
<input
type="password"
value={gatewayToken}
onChange={(e) => setGatewayToken(e.target.value)}
onBlur={() => { saveQuickConfig({ gatewayToken }).catch(silentErrorHandler('ModelsAPI')); }}
placeholder="Gateway auth token"
className="w-full bg-transparent border-none outline-none"
/>
</div>
)}
</div>
);
}