Major changes: - Add HandList.tsx component for left sidebar - Add HandTaskPanel.tsx for middle content area - Restructure Sidebar tabs: 分身/HANDS/Workflow - Remove Hands tab from RightPanel - Localize all UI text to Chinese - Archive legacy OpenClaw documentation - Add Hands integration lessons document - Update feature checklist with new components UI improvements: - Left sidebar now shows Hands list with status icons - Middle area shows selected Hand's tasks and results - Consistent styling with Tailwind CSS - Chinese status labels and buttons Documentation: - Create docs/archive/openclaw-legacy/ for old docs - Add docs/knowledge-base/hands-integration-lessons.md - Update docs/knowledge-base/feature-checklist.md - Update docs/knowledge-base/README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
/**
|
|
* ZCLAW UI Extensions Plugin
|
|
*
|
|
* Registers custom Gateway RPC methods that the ZCLAW Tauri desktop UI
|
|
* calls for features beyond standard OpenClaw protocol.
|
|
*
|
|
* Custom methods:
|
|
* - zclaw.clones.list → list all agent clones (分身)
|
|
* - zclaw.clones.create → create a new clone
|
|
* - zclaw.clones.update → update clone config
|
|
* - zclaw.clones.delete → delete a clone
|
|
* - zclaw.stats.usage → token usage statistics
|
|
* - zclaw.stats.sessions → session statistics
|
|
* - zclaw.config.quick → quick configuration (name/role/scenarios)
|
|
* - zclaw.workspace.info → workspace information
|
|
* - zclaw.plugins.status → all ZCLAW plugin statuses
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
interface PluginAPI {
|
|
config: Record<string, any>;
|
|
registerGatewayMethod(method: string, handler: (ctx: RpcContext) => void): void;
|
|
registerHook(event: string, handler: (...args: any[]) => any, meta?: Record<string, any>): void;
|
|
}
|
|
|
|
interface RpcContext {
|
|
params: Record<string, any>;
|
|
respond(ok: boolean, payload: any): void;
|
|
}
|
|
|
|
interface SkillInfo {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
source: 'builtin' | 'extra';
|
|
}
|
|
|
|
// Clone (分身) management - stored in ZCLAW config
|
|
interface CloneConfig {
|
|
id: string;
|
|
name: string;
|
|
role?: string;
|
|
nickname?: string;
|
|
scenarios?: string[];
|
|
model?: string;
|
|
workspaceDir?: string;
|
|
workspaceResolvedPath?: string;
|
|
restrictFiles?: boolean;
|
|
privacyOptIn?: boolean;
|
|
userName?: string;
|
|
userRole?: string;
|
|
bootstrapReady?: boolean;
|
|
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export default function register(api: PluginAPI) {
|
|
const configDir = process.env.OPENCLAW_HOME || path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw');
|
|
const zclawDataPath = path.join(configDir, 'zclaw-data.json');
|
|
const templateRoot = path.resolve(__dirname, '../../config');
|
|
const bootstrapFiles = ['SOUL.md', 'AGENTS.md', 'IDENTITY.md', 'USER.md'] as const;
|
|
|
|
// Helper: read/write ZCLAW data
|
|
function readZclawData(): { clones: CloneConfig[]; quickConfig?: Record<string, any> } {
|
|
try {
|
|
if (fs.existsSync(zclawDataPath)) {
|
|
return JSON.parse(fs.readFileSync(zclawDataPath, 'utf-8'));
|
|
}
|
|
} catch { /* ignore */ }
|
|
return { clones: [] };
|
|
}
|
|
|
|
function writeZclawData(data: any) {
|
|
fs.mkdirSync(path.dirname(zclawDataPath), { recursive: true });
|
|
fs.writeFileSync(zclawDataPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
}
|
|
|
|
function uniquePaths(paths: string[]) {
|
|
return Array.from(new Set(paths.filter(Boolean).map((item) => path.resolve(item))));
|
|
}
|
|
|
|
function sanitizePathSegment(value: string) {
|
|
return value
|
|
.trim()
|
|
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function resolveWorkspacePath(workspaceDir: string | undefined, clone: Pick<CloneConfig, 'id' | 'name'>) {
|
|
const baseWorkspace =
|
|
workspaceDir ||
|
|
api.config?.agents?.defaults?.workspace ||
|
|
'~/.openclaw/zclaw-workspace';
|
|
const expandedBase = baseWorkspace.replace('~', process.env.HOME || process.env.USERPROFILE || '');
|
|
const resolvedBase = path.resolve(expandedBase);
|
|
const cloneDirName = sanitizePathSegment(clone.name) || clone.id;
|
|
return path.join(resolvedBase, 'agents', cloneDirName);
|
|
}
|
|
|
|
function readTemplate(fileName: typeof bootstrapFiles[number], fallback: string) {
|
|
const filePath = path.join(templateRoot, fileName);
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
return fs.readFileSync(filePath, 'utf-8');
|
|
}
|
|
} catch { /* ignore */ }
|
|
return fallback;
|
|
}
|
|
|
|
function renderIdentity(clone: CloneConfig) {
|
|
const nickname = clone.nickname || clone.name;
|
|
return `# ${clone.name} 身份
|
|
|
|
- **名字**: ${clone.name}
|
|
- **昵称**: ${nickname}
|
|
- **Emoji**: ${nickname.slice(0, 1) || '🦞'}
|
|
- **描述**: ${clone.role || 'ZCLAW Agent'}
|
|
- **模型**: ${clone.model || '继承当前默认模型'}
|
|
- **创建时间**: ${clone.createdAt}
|
|
`;
|
|
}
|
|
|
|
function renderSoul(clone: CloneConfig) {
|
|
const scenarios = clone.scenarios?.length ? clone.scenarios.join('、') : '通用协作';
|
|
return `# ${clone.name} 人格
|
|
|
|
你是 ${clone.name},一个运行在 ZCLAW / OpenClaw 体系中的 Agent。
|
|
|
|
## 角色定位
|
|
|
|
- **角色**: ${clone.role || '未设置'}
|
|
- **擅长场景**: ${scenarios}
|
|
- **工作方式**: 高执行力、中文优先、面向交付
|
|
|
|
## 边界
|
|
|
|
- 文件访问限制: ${clone.restrictFiles ? '已开启' : '未开启'}
|
|
- 优化计划: ${clone.privacyOptIn ? '已加入' : '未加入'}
|
|
- 工作目录: ${clone.workspaceResolvedPath || clone.workspaceDir || '继承系统默认'}
|
|
`;
|
|
}
|
|
|
|
function renderUser(clone: CloneConfig) {
|
|
return `# 用户配置
|
|
|
|
- **名字**: ${clone.userName || '未设置'}
|
|
- **角色**: ${clone.userRole || '未设置'}
|
|
- **语言**: 中文 (zh-CN)
|
|
- **时区**: Asia/Shanghai (UTC+8)
|
|
|
|
## 协作说明
|
|
|
|
- 默认按照用户当前任务优先级推进工作
|
|
- 在风险操作前先确认
|
|
- 输出以可执行结果为导向
|
|
`;
|
|
}
|
|
|
|
function renderAgents(clone: CloneConfig) {
|
|
const scenarios = clone.scenarios?.length ? clone.scenarios.map((item) => `- ${item}`).join('\n') : '- 通用协作';
|
|
return `# ${clone.name} Agent 指令
|
|
|
|
## 当前 Agent
|
|
|
|
- **名称**: ${clone.name}
|
|
- **角色**: ${clone.role || '未设置'}
|
|
- **昵称**: ${clone.nickname || clone.name}
|
|
- **工作目录**: ${clone.workspaceResolvedPath || clone.workspaceDir || '继承系统默认'}
|
|
|
|
## 专注场景
|
|
|
|
${scenarios}
|
|
|
|
## 运行要求
|
|
|
|
1. 优先完成当前明确目标
|
|
2. 长任务分阶段汇报
|
|
3. 风险操作先确认
|
|
4. 保持产出可复现、可审计
|
|
`;
|
|
}
|
|
|
|
function ensureCloneWorkspace(clone: CloneConfig) {
|
|
const workspaceResolvedPath = resolveWorkspacePath(clone.workspaceDir, clone);
|
|
fs.mkdirSync(workspaceResolvedPath, { recursive: true });
|
|
|
|
const contents: Record<typeof bootstrapFiles[number], string> = {
|
|
'IDENTITY.md': renderIdentity(clone),
|
|
'SOUL.md': renderSoul(clone),
|
|
'USER.md': renderUser(clone),
|
|
'AGENTS.md': renderAgents(clone),
|
|
};
|
|
|
|
const files = bootstrapFiles.map((fileName) => {
|
|
const filePath = path.join(workspaceResolvedPath, fileName);
|
|
const content = contents[fileName] || readTemplate(fileName, `# ${fileName}`);
|
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|
return {
|
|
name: fileName,
|
|
path: filePath,
|
|
exists: fs.existsSync(filePath),
|
|
};
|
|
});
|
|
|
|
return {
|
|
...clone,
|
|
workspaceResolvedPath,
|
|
bootstrapReady: files.every((file) => file.exists),
|
|
bootstrapFiles: files,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// === Clone Management ===
|
|
|
|
api.registerGatewayMethod('zclaw.clones.list', ({ respond }: RpcContext) => {
|
|
const data = readZclawData();
|
|
respond(true, { clones: data.clones });
|
|
});
|
|
|
|
api.registerGatewayMethod('zclaw.clones.create', ({ params, respond }: RpcContext) => {
|
|
const data = readZclawData();
|
|
const rawClone: CloneConfig = {
|
|
id: `clone_${Date.now().toString(36)}`,
|
|
name: params.name || 'New Clone',
|
|
role: params.role,
|
|
nickname: params.nickname,
|
|
scenarios: params.scenarios || [],
|
|
model: params.model,
|
|
workspaceDir: params.workspaceDir,
|
|
restrictFiles: params.restrictFiles,
|
|
privacyOptIn: params.privacyOptIn,
|
|
userName: params.userName,
|
|
userRole: params.userRole,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
const clone = ensureCloneWorkspace(rawClone);
|
|
data.clones.push(clone);
|
|
writeZclawData(data);
|
|
respond(true, { clone });
|
|
});
|
|
|
|
api.registerGatewayMethod('zclaw.clones.update', ({ params, respond }: RpcContext) => {
|
|
const data = readZclawData();
|
|
const index = data.clones.findIndex((c: CloneConfig) => c.id === params.id);
|
|
if (index === -1) {
|
|
respond(false, { error: 'Clone not found' });
|
|
return;
|
|
}
|
|
const updatedClone = ensureCloneWorkspace({
|
|
...data.clones[index],
|
|
...params.updates,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
data.clones[index] = updatedClone;
|
|
writeZclawData(data);
|
|
respond(true, { clone: updatedClone });
|
|
});
|
|
|
|
api.registerGatewayMethod('zclaw.clones.delete', ({ params, respond }: RpcContext) => {
|
|
const data = readZclawData();
|
|
data.clones = data.clones.filter((c: CloneConfig) => c.id !== params.id);
|
|
writeZclawData(data);
|
|
respond(true, { ok: true });
|
|
});
|
|
|
|
// === Statistics ===
|
|
|
|
api.registerGatewayMethod('zclaw.stats.usage', ({ respond }: RpcContext) => {
|
|
// Read session files to compute token usage
|
|
const sessionsDir = path.join(configDir, 'agents');
|
|
const stats = {
|
|
totalSessions: 0,
|
|
totalMessages: 0,
|
|
totalTokens: 0,
|
|
byModel: {} as Record<string, { messages: number; inputTokens: number; outputTokens: number }>,
|
|
};
|
|
|
|
try {
|
|
if (fs.existsSync(sessionsDir)) {
|
|
const agentDirs = fs.readdirSync(sessionsDir);
|
|
for (const agentDir of agentDirs) {
|
|
const sessDir = path.join(sessionsDir, agentDir, 'sessions');
|
|
if (!fs.existsSync(sessDir)) continue;
|
|
const sessionFiles = fs.readdirSync(sessDir).filter((f: string) => f.endsWith('.jsonl'));
|
|
stats.totalSessions += sessionFiles.length;
|
|
|
|
for (const file of sessionFiles) {
|
|
try {
|
|
const content = fs.readFileSync(path.join(sessDir, file), 'utf-8');
|
|
const lines = content.split('\n').filter(Boolean);
|
|
for (const line of lines) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if (entry.role === 'assistant' || entry.role === 'user') {
|
|
stats.totalMessages++;
|
|
}
|
|
if (entry.usage) {
|
|
const model = entry.model || 'unknown';
|
|
if (!stats.byModel[model]) {
|
|
stats.byModel[model] = { messages: 0, inputTokens: 0, outputTokens: 0 };
|
|
}
|
|
stats.byModel[model].messages++;
|
|
stats.byModel[model].inputTokens += entry.usage.input_tokens || 0;
|
|
stats.byModel[model].outputTokens += entry.usage.output_tokens || 0;
|
|
stats.totalTokens += (entry.usage.input_tokens || 0) + (entry.usage.output_tokens || 0);
|
|
}
|
|
} catch { /* skip malformed lines */ }
|
|
}
|
|
} catch { /* skip unreadable files */ }
|
|
}
|
|
}
|
|
}
|
|
} catch { /* sessions dir may not exist yet */ }
|
|
|
|
respond(true, stats);
|
|
});
|
|
|
|
api.registerGatewayMethod('zclaw.stats.sessions', ({ respond }: RpcContext) => {
|
|
const sessionsDir = path.join(configDir, 'agents');
|
|
const sessions: any[] = [];
|
|
|
|
try {
|
|
if (fs.existsSync(sessionsDir)) {
|
|
const agentDirs = fs.readdirSync(sessionsDir);
|
|
for (const agentDir of agentDirs) {
|
|
const sessDir = path.join(sessionsDir, agentDir, 'sessions');
|
|
if (!fs.existsSync(sessDir)) continue;
|
|
const sessionFiles = fs.readdirSync(sessDir).filter((f: string) => f.endsWith('.jsonl'));
|
|
for (const file of sessionFiles) {
|
|
const stat = fs.statSync(path.join(sessDir, file));
|
|
sessions.push({
|
|
id: file.replace('.jsonl', ''),
|
|
agentId: agentDir,
|
|
createdAt: stat.birthtime.toISOString(),
|
|
updatedAt: stat.mtime.toISOString(),
|
|
size: stat.size,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
respond(true, { sessions });
|
|
});
|
|
|
|
// === Quick Configuration ===
|
|
|
|
api.registerGatewayMethod('zclaw.config.quick', ({ params, respond }: RpcContext) => {
|
|
const data = readZclawData();
|
|
if (params.get) {
|
|
respond(true, { quickConfig: data.quickConfig || {} });
|
|
return;
|
|
}
|
|
const { get, ...rest } = params;
|
|
data.quickConfig = {
|
|
...(data.quickConfig || {}),
|
|
...rest,
|
|
scenarios: Array.isArray(rest.scenarios) ? rest.scenarios : (data.quickConfig?.scenarios || []),
|
|
};
|
|
writeZclawData(data);
|
|
respond(true, { ok: true, quickConfig: data.quickConfig });
|
|
});
|
|
|
|
api.registerGatewayMethod('zclaw.skills.list', ({ respond }: RpcContext) => {
|
|
const data = readZclawData();
|
|
const builtinRoot = path.resolve(__dirname, '../../skills');
|
|
const extraRoots = [
|
|
...(api.config?.skills?.load?.extraDirs || []),
|
|
...(data.quickConfig?.skillsExtraDirs || []),
|
|
].map((item: string) => item.replace('~', process.env.HOME || process.env.USERPROFILE || ''));
|
|
const searchRoots = [
|
|
{ root: builtinRoot, source: 'builtin' as const },
|
|
...uniquePaths(extraRoots).map((root) => ({ root, source: 'extra' as const })),
|
|
];
|
|
|
|
const skills: SkillInfo[] = [];
|
|
|
|
for (const target of searchRoots) {
|
|
try {
|
|
if (!fs.existsSync(target.root)) continue;
|
|
const entries = fs.readdirSync(target.root, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const skillPath = path.join(target.root, entry.name, 'SKILL.md');
|
|
if (!fs.existsSync(skillPath)) continue;
|
|
skills.push({
|
|
id: `${target.source}:${entry.name}`,
|
|
name: entry.name,
|
|
path: skillPath,
|
|
source: target.source,
|
|
});
|
|
}
|
|
} catch { /* ignore skill scan failures */ }
|
|
}
|
|
|
|
respond(true, {
|
|
skills,
|
|
extraDirs: data.quickConfig?.skillsExtraDirs || api.config?.skills?.load?.extraDirs || [],
|
|
});
|
|
});
|
|
|
|
// === Workspace Info ===
|
|
|
|
api.registerGatewayMethod('zclaw.workspace.info', ({ respond }: RpcContext) => {
|
|
const data = readZclawData();
|
|
const workspace =
|
|
data.quickConfig?.workspaceDir ||
|
|
api.config?.agents?.defaults?.workspace ||
|
|
'~/.openclaw/zclaw-workspace';
|
|
const resolvedPath = workspace.replace('~', process.env.HOME || process.env.USERPROFILE || '');
|
|
|
|
let exists = false;
|
|
let fileCount = 0;
|
|
let totalSize = 0;
|
|
|
|
try {
|
|
exists = fs.existsSync(resolvedPath);
|
|
if (exists) {
|
|
const files = fs.readdirSync(resolvedPath, { recursive: true }) as string[];
|
|
for (const file of files) {
|
|
const fullPath = path.join(resolvedPath, file);
|
|
try {
|
|
const stat = fs.statSync(fullPath);
|
|
if (stat.isFile()) {
|
|
fileCount++;
|
|
totalSize += stat.size;
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
respond(true, {
|
|
path: workspace,
|
|
resolvedPath,
|
|
exists,
|
|
fileCount,
|
|
totalSize,
|
|
});
|
|
});
|
|
|
|
// === Plugin Status ===
|
|
|
|
api.registerGatewayMethod('zclaw.plugins.status', ({ respond }: RpcContext) => {
|
|
respond(true, {
|
|
plugins: [
|
|
{ id: 'zclaw-chinese-models', status: 'active', version: '0.1.0' },
|
|
{ id: 'zclaw-feishu', status: api.config?.channels?.feishu?.enabled ? 'active' : 'inactive', version: '0.1.0' },
|
|
{ id: 'zclaw-ui', status: 'active', version: '0.1.0' },
|
|
],
|
|
});
|
|
});
|
|
|
|
// Startup log
|
|
api.registerHook('gateway:startup', async () => {
|
|
console.log('[ZCLAW] UI extension RPC methods registered');
|
|
}, { name: 'zclaw-ui.startup', description: 'Log ZCLAW UI extensions registration' });
|
|
}
|