feat(hands): restructure Hands UI with Chinese localization
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>
This commit is contained in:
932
tests/fixtures/openfang-mock-server.ts
vendored
Normal file
932
tests/fixtures/openfang-mock-server.ts
vendored
Normal file
@@ -0,0 +1,932 @@
|
||||
/**
|
||||
* OpenFang Mock Server for Testing
|
||||
*
|
||||
* Simulates OpenFang Kernel API endpoints and WebSocket events.
|
||||
* Provides a complete test double for the ZCLAW desktop client.
|
||||
*
|
||||
* Usage:
|
||||
* const server = createOpenFangMockServer({ port: 4200 });
|
||||
* await server.start();
|
||||
* // ... run tests ...
|
||||
* await server.stop();
|
||||
*/
|
||||
|
||||
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import { WebSocketServer, WebSocket, RawData } from 'ws';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface MockServerConfig {
|
||||
port?: number;
|
||||
host?: string;
|
||||
version?: string;
|
||||
connectionDelay?: number;
|
||||
challengeDelay?: number;
|
||||
}
|
||||
|
||||
export interface MockServerInstance {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
getPort: () => number;
|
||||
getWsUrl: () => string;
|
||||
getHttpUrl: () => string;
|
||||
reset: () => void;
|
||||
setHands: (hands: MockHand[]) => void;
|
||||
setWorkflows: (workflows: MockWorkflow[]) => void;
|
||||
setTriggers: (triggers: MockTrigger[]) => void;
|
||||
setAgents: (agents: MockAgent[]) => void;
|
||||
setSecurityLayers: (layers: MockSecurityLayer[]) => void;
|
||||
addAuditLog: (entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>) => void;
|
||||
simulateStreamEvent: (event: string, payload: unknown) => void;
|
||||
getConnectedClients: () => number;
|
||||
}
|
||||
|
||||
export interface MockHand {
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'idle' | 'running' | 'needs_approval' | 'completed' | 'error';
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MockWorkflow {
|
||||
id: string;
|
||||
name: string;
|
||||
steps: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MockTrigger {
|
||||
id: string;
|
||||
type: 'webhook' | 'schedule' | 'event';
|
||||
enabled: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MockAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
nickname?: string;
|
||||
scenarios?: string[];
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
workspaceResolvedPath?: string;
|
||||
restrictFiles?: boolean;
|
||||
privacyOptIn?: boolean;
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
bootstrapReady?: boolean;
|
||||
}
|
||||
|
||||
export interface MockSecurityLayer {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MockAuditLogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
actor?: string;
|
||||
result?: 'success' | 'failure';
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// === Sample Data ===
|
||||
|
||||
const DEFAULT_HANDS: MockHand[] = [
|
||||
{ name: 'clip', description: 'Video processing and vertical screen generation', status: 'idle' },
|
||||
{ name: 'lead', description: 'Sales lead discovery and qualification', status: 'idle' },
|
||||
{ name: 'collector', description: 'Data collection and aggregation', status: 'idle' },
|
||||
{ name: 'predictor', description: 'Predictive analytics', status: 'idle' },
|
||||
{ name: 'researcher', description: 'Deep research and analysis', status: 'idle' },
|
||||
{ name: 'twitter', description: 'Twitter automation', status: 'idle' },
|
||||
{ name: 'browser', description: 'Browser automation', status: 'idle' },
|
||||
];
|
||||
|
||||
const DEFAULT_WORKFLOWS: MockWorkflow[] = [
|
||||
{ id: 'wf-001', name: 'Daily Report', steps: 3, description: 'Generate daily summary report' },
|
||||
{ id: 'wf-002', name: 'Data Pipeline', steps: 5, description: 'ETL data processing pipeline' },
|
||||
{ id: 'wf-003', name: 'Research Task', steps: 4, description: 'Multi-step research workflow' },
|
||||
];
|
||||
|
||||
const DEFAULT_TRIGGERS: MockTrigger[] = [
|
||||
{ id: 'tr-001', type: 'schedule', enabled: true, config: { cron: '0 9 * * *' } },
|
||||
{ id: 'tr-002', type: 'webhook', enabled: true, config: { path: '/hooks/data' } },
|
||||
{ id: 'tr-003', type: 'event', enabled: false, config: { eventType: 'data.received' } },
|
||||
];
|
||||
|
||||
const DEFAULT_AGENTS: MockAgent[] = [
|
||||
{
|
||||
id: 'agent-001',
|
||||
name: 'Default Agent',
|
||||
role: 'assistant',
|
||||
nickname: 'ZCLAW',
|
||||
scenarios: ['general', 'coding'],
|
||||
model: 'gpt-4',
|
||||
workspaceDir: '~/.openfang/workspaces/default',
|
||||
workspaceResolvedPath: '/home/user/.openfang/workspaces/default',
|
||||
restrictFiles: false,
|
||||
privacyOptIn: false,
|
||||
userName: 'User',
|
||||
userRole: 'developer',
|
||||
createdAt: new Date().toISOString(),
|
||||
bootstrapReady: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SECURITY_LAYERS: MockSecurityLayer[] = [
|
||||
{ name: 'input_validation', enabled: true, description: 'Input sanitization and validation' },
|
||||
{ name: 'rate_limiting', enabled: true, description: 'Request rate limiting' },
|
||||
{ name: 'authentication', enabled: true, description: 'Ed25519 + JWT authentication' },
|
||||
{ name: 'authorization', enabled: true, description: 'RBAC capability gates' },
|
||||
{ name: 'encryption_at_rest', enabled: true, description: 'Data encryption at rest' },
|
||||
{ name: 'encryption_in_transit', enabled: true, description: 'TLS encryption' },
|
||||
{ name: 'audit_logging', enabled: true, description: 'Merkle hash chain audit' },
|
||||
{ name: 'session_management', enabled: true, description: 'Secure session handling' },
|
||||
{ name: 'device_attestation', enabled: true, description: 'Device identity verification' },
|
||||
{ name: 'content_security', enabled: true, description: 'Content Security Policy' },
|
||||
{ name: 'csp_headers', enabled: true, description: 'HTTP security headers' },
|
||||
{ name: 'cors_policy', enabled: true, description: 'Cross-origin resource policy' },
|
||||
{ name: 'sandbox_isolation', enabled: true, description: 'Process sandboxing' },
|
||||
{ name: 'secret_management', enabled: true, description: 'Secure secret storage' },
|
||||
{ name: 'intrusion_detection', enabled: false, description: 'IDS monitoring' },
|
||||
{ name: 'backup_recovery', enabled: true, description: 'Backup and disaster recovery' },
|
||||
];
|
||||
|
||||
// === Server Implementation ===
|
||||
|
||||
export function createOpenFangMockServer(config: MockServerConfig = {}): MockServerInstance {
|
||||
const {
|
||||
port = 4200,
|
||||
host = '127.0.0.1',
|
||||
version = '2026.3.13',
|
||||
connectionDelay = 0,
|
||||
challengeDelay = 0,
|
||||
} = config;
|
||||
|
||||
let httpServer: Server | null = null;
|
||||
let wsServer: WebSocketServer | null = null;
|
||||
let actualPort = port;
|
||||
|
||||
// Mutable state
|
||||
let hands = [...DEFAULT_HANDS];
|
||||
let workflows = [...DEFAULT_WORKFLOWS];
|
||||
let triggers = [...DEFAULT_TRIGGERS];
|
||||
let agents = [...DEFAULT_AGENTS];
|
||||
let securityLayers = [...DEFAULT_SECURITY_LAYERS];
|
||||
let auditLogs: MockAuditLogEntry[] = [];
|
||||
let handRuns = new Map<string, { status: string; result?: unknown; startedAt: string }>();
|
||||
let workflowRuns = new Map<string, { status: string; step?: string; result?: unknown }>();
|
||||
const connectedClients = new Set<WebSocket>();
|
||||
|
||||
// === REST API Handlers ===
|
||||
|
||||
function handleHealth(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { version, status: 'ok' });
|
||||
}
|
||||
|
||||
function handleGetAgents(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { clones: agents });
|
||||
}
|
||||
|
||||
function handleCreateAgent(req: IncomingMessage, res: ServerResponse): void {
|
||||
parseBody(req, (body) => {
|
||||
const newAgent: MockAgent = {
|
||||
id: `agent-${Date.now()}`,
|
||||
name: body.name || 'New Agent',
|
||||
role: body.role,
|
||||
nickname: body.nickname,
|
||||
scenarios: body.scenarios,
|
||||
model: body.model,
|
||||
workspaceDir: body.workspaceDir,
|
||||
restrictFiles: body.restrictFiles,
|
||||
privacyOptIn: body.privacyOptIn,
|
||||
userName: body.userName,
|
||||
userRole: body.userRole,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
agents.push(newAgent);
|
||||
addAuditLogEntry({
|
||||
action: 'agent.created',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { agentId: newAgent.id, name: newAgent.name },
|
||||
});
|
||||
sendJson(res, { clone: newAgent }, 201);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdateAgent(req: IncomingMessage, res: ServerResponse, agentId: string): void {
|
||||
parseBody(req, (body) => {
|
||||
const index = agents.findIndex((a) => a.id === agentId);
|
||||
if (index === -1) {
|
||||
sendError(res, 'Agent not found', 404);
|
||||
return;
|
||||
}
|
||||
agents[index] = { ...agents[index], ...body, updatedAt: new Date().toISOString() };
|
||||
addAuditLogEntry({
|
||||
action: 'agent.updated',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { agentId, updates: body },
|
||||
});
|
||||
sendJson(res, { clone: agents[index] });
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteAgent(_req: IncomingMessage, res: ServerResponse, agentId: string): void {
|
||||
const index = agents.findIndex((a) => a.id === agentId);
|
||||
if (index === -1) {
|
||||
sendError(res, 'Agent not found', 404);
|
||||
return;
|
||||
}
|
||||
agents.splice(index, 1);
|
||||
addAuditLogEntry({
|
||||
action: 'agent.deleted',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { agentId },
|
||||
});
|
||||
sendJson(res, { ok: true });
|
||||
}
|
||||
|
||||
function handleGetHands(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { hands });
|
||||
}
|
||||
|
||||
function handleGetHand(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
||||
const hand = hands.find((h) => h.name === name);
|
||||
if (!hand) {
|
||||
sendError(res, 'Hand not found', 404);
|
||||
return;
|
||||
}
|
||||
sendJson(res, { name: hand.name, description: hand.description, config: hand.config || {} });
|
||||
}
|
||||
|
||||
function handleTriggerHand(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
||||
const hand = hands.find((h) => h.name === name);
|
||||
if (!hand) {
|
||||
sendError(res, 'Hand not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = `run-${name}-${Date.now()}`;
|
||||
const needsApproval = name === 'lead' || name === 'twitter'; // Some hands need approval
|
||||
|
||||
const status = needsApproval ? 'needs_approval' : 'running';
|
||||
handRuns.set(runId, { status, startedAt: new Date().toISOString() });
|
||||
|
||||
// Update hand status
|
||||
const handIndex = hands.findIndex((h) => h.name === name);
|
||||
hands[handIndex] = { ...hand, status: needsApproval ? 'needs_approval' : 'running' };
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'hand.triggered',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { handName: name, runId, needsApproval },
|
||||
});
|
||||
|
||||
sendJson(res, { runId, status });
|
||||
|
||||
// Simulate async events for running hands
|
||||
if (!needsApproval) {
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('hand', {
|
||||
handName: name,
|
||||
runId,
|
||||
status: 'running',
|
||||
phase: 'start',
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApproveHand(_req: IncomingMessage, res: ServerResponse, name: string, runId: string): void {
|
||||
parseBody(_req, (body) => {
|
||||
const run = handRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const approved = body.approved === true;
|
||||
run.status = approved ? 'running' : 'cancelled';
|
||||
handRuns.set(runId, run);
|
||||
|
||||
addAuditLogEntry({
|
||||
action: approved ? 'hand.approved' : 'hand.rejected',
|
||||
actor: 'operator',
|
||||
result: 'success',
|
||||
details: { handName: name, runId, reason: body.reason },
|
||||
});
|
||||
|
||||
sendJson(res, { status: run.status });
|
||||
|
||||
if (approved) {
|
||||
simulateStreamEvent('hand', {
|
||||
handName: name,
|
||||
runId,
|
||||
status: 'running',
|
||||
phase: 'start',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancelHand(_req: IncomingMessage, res: ServerResponse, name: string, runId: string): void {
|
||||
const run = handRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
run.status = 'cancelled';
|
||||
handRuns.set(runId, run);
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'hand.cancelled',
|
||||
actor: 'operator',
|
||||
result: 'success',
|
||||
details: { handName: name, runId },
|
||||
});
|
||||
|
||||
sendJson(res, { status: 'cancelled' });
|
||||
}
|
||||
|
||||
function handleGetHandRuns(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
||||
const runs = Array.from(handRuns.entries())
|
||||
.filter(([runId]) => runId.startsWith(`run-${name}-`))
|
||||
.map(([runId, data]) => ({
|
||||
runId,
|
||||
status: data.status,
|
||||
startedAt: data.startedAt,
|
||||
}));
|
||||
sendJson(res, { runs });
|
||||
}
|
||||
|
||||
function handleGetWorkflows(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { workflows });
|
||||
}
|
||||
|
||||
function handleGetWorkflow(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
||||
const workflow = workflows.find((w) => w.id === id);
|
||||
if (!workflow) {
|
||||
sendError(res, 'Workflow not found', 404);
|
||||
return;
|
||||
}
|
||||
sendJson(res, {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
steps: Array.from({ length: workflow.steps }, (_, i) => ({
|
||||
id: `step-${i + 1}`,
|
||||
name: `Step ${i + 1}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function handleExecuteWorkflow(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
||||
const workflow = workflows.find((w) => w.id === id);
|
||||
if (!workflow) {
|
||||
sendError(res, 'Workflow not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
parseBody(_req, (body) => {
|
||||
const runId = `wf-run-${id}-${Date.now()}`;
|
||||
workflowRuns.set(runId, { status: 'running', step: 'step-1' });
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'workflow.started',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { workflowId: id, runId, input: body },
|
||||
});
|
||||
|
||||
sendJson(res, { runId, status: 'running' });
|
||||
|
||||
// Simulate workflow progress
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('workflow', {
|
||||
workflowId: id,
|
||||
runId,
|
||||
status: 'running',
|
||||
step: 'step-1',
|
||||
workflowStatus: 'executing',
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetWorkflowRun(_req: IncomingMessage, res: ServerResponse, workflowId: string, runId: string): void {
|
||||
const run = workflowRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
sendJson(res, { status: run.status, step: run.step, result: run.result });
|
||||
}
|
||||
|
||||
function handleCancelWorkflow(_req: IncomingMessage, res: ServerResponse, workflowId: string, runId: string): void {
|
||||
const run = workflowRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
run.status = 'cancelled';
|
||||
workflowRuns.set(runId, run);
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'workflow.cancelled',
|
||||
actor: 'operator',
|
||||
result: 'success',
|
||||
details: { workflowId, runId },
|
||||
});
|
||||
|
||||
sendJson(res, { status: 'cancelled' });
|
||||
}
|
||||
|
||||
function handleGetTriggers(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { triggers });
|
||||
}
|
||||
|
||||
function handleGetAuditLogs(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url || '/', `http://${host}`);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
const paginatedLogs = auditLogs.slice(offset, offset + limit);
|
||||
sendJson(res, { logs: paginatedLogs, total: auditLogs.length });
|
||||
}
|
||||
|
||||
function handleGetSecurityStatus(_req: IncomingMessage, res: ServerResponse): void {
|
||||
const enabledCount = securityLayers.filter((l) => l.enabled).length;
|
||||
sendJson(res, {
|
||||
layers: securityLayers,
|
||||
enabledCount,
|
||||
totalCount: securityLayers.length,
|
||||
securityLevel: calculateSecurityLevel(enabledCount, securityLayers.length),
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetCapabilities(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
capabilities: [
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.admin',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleChat(req: IncomingMessage, res: ServerResponse): void {
|
||||
parseBody(req, (body) => {
|
||||
const runId = `chat-${Date.now()}`;
|
||||
const sessionId = body.session_id || `session-${Date.now()}`;
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'chat.message',
|
||||
actor: body.agent_id || 'default',
|
||||
result: 'success',
|
||||
details: { runId, sessionId, messageLength: body.message?.length || 0 },
|
||||
});
|
||||
|
||||
sendJson(res, { runId, sessionId });
|
||||
|
||||
// Simulate streaming events
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('agent', {
|
||||
stream: 'assistant',
|
||||
delta: 'Hello',
|
||||
runId,
|
||||
});
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('agent', {
|
||||
stream: 'assistant',
|
||||
delta: '! How can I help you today?',
|
||||
runId,
|
||||
});
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('agent', {
|
||||
stream: 'lifecycle',
|
||||
phase: 'end',
|
||||
runId,
|
||||
});
|
||||
}, 100);
|
||||
}, 100);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetModels(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
models: [
|
||||
{ id: 'gpt-4', name: 'GPT-4', provider: 'openai' },
|
||||
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' },
|
||||
{ id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', provider: 'deepseek' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetConfig(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
server: { host: '127.0.0.1', port: actualPort },
|
||||
agent: { default_model: 'gpt-4' },
|
||||
llm: {
|
||||
providers: [
|
||||
{ name: 'openai', api_key: '${OPENAI_API_KEY}' },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetQuickConfig(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
quickConfig: {
|
||||
agentName: 'ZCLAW',
|
||||
agentRole: 'assistant',
|
||||
userName: 'User',
|
||||
userRole: 'developer',
|
||||
theme: 'dark',
|
||||
showToolCalls: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetWorkspace(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
path: '~/.openfang/workspaces/default',
|
||||
resolvedPath: '/home/user/.openfang/workspaces/default',
|
||||
exists: true,
|
||||
fileCount: 42,
|
||||
totalSize: 1024 * 1024 * 5, // 5MB
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetSkills(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
skills: [
|
||||
{ id: 'skill-001', name: 'Code Review', path: '/skills/code-review', source: 'builtin' },
|
||||
{ id: 'skill-002', name: 'Translation', path: '/skills/translation', source: 'builtin' },
|
||||
{ id: 'skill-003', name: 'Research', path: '/skills/research', source: 'extra' },
|
||||
],
|
||||
extraDirs: ['/extra/skills'],
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetChannels(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
channels: [
|
||||
{ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'active', accounts: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetStatsUsage(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
totalSessions: 10,
|
||||
totalMessages: 150,
|
||||
totalTokens: 50000,
|
||||
byModel: {
|
||||
'gpt-4': { messages: 100, inputTokens: 30000, outputTokens: 15000 },
|
||||
'claude-3-sonnet': { messages: 50, inputTokens: 4000, outputTokens: 1000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetPluginsStatus(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
plugins: [
|
||||
{ id: 'feishu', name: 'Feishu Integration', status: 'active' },
|
||||
{ id: 'chinese-models', name: 'Chinese Models', status: 'active' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// === HTTP Request Handler ===
|
||||
|
||||
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
// Add CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Route matching
|
||||
const routes: Array<{
|
||||
method: string | string[];
|
||||
pattern: RegExp;
|
||||
handler: (req: IncomingMessage, res: ServerResponse, ...matches: string[]) => void;
|
||||
}> = [
|
||||
{ method: 'GET', pattern: /^\/api\/health$/, handler: handleHealth },
|
||||
{ method: 'GET', pattern: /^\/api\/agents$/, handler: handleGetAgents },
|
||||
{ method: 'POST', pattern: /^\/api\/agents$/, handler: handleCreateAgent },
|
||||
{ method: 'PUT', pattern: /^\/api\/agents\/([^/]+)$/, handler: handleUpdateAgent },
|
||||
{ method: 'DELETE', pattern: /^\/api\/agents\/([^/]+)$/, handler: handleDeleteAgent },
|
||||
{ method: 'GET', pattern: /^\/api\/hands$/, handler: handleGetHands },
|
||||
{ method: 'GET', pattern: /^\/api\/hands\/([^/]+)$/, handler: handleGetHand },
|
||||
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/trigger$/, handler: handleTriggerHand },
|
||||
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/runs\/([^/]+)\/approve$/, handler: handleApproveHand },
|
||||
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/runs\/([^/]+)\/cancel$/, handler: handleCancelHand },
|
||||
{ method: 'GET', pattern: /^\/api\/hands\/([^/]+)\/runs$/, handler: handleGetHandRuns },
|
||||
{ method: 'GET', pattern: /^\/api\/workflows$/, handler: handleGetWorkflows },
|
||||
{ method: 'GET', pattern: /^\/api\/workflows\/([^/]+)$/, handler: handleGetWorkflow },
|
||||
{ method: 'POST', pattern: /^\/api\/workflows\/([^/]+)\/execute$/, handler: handleExecuteWorkflow },
|
||||
{ method: 'GET', pattern: /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)$/, handler: handleGetWorkflowRun },
|
||||
{ method: 'POST', pattern: /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)\/cancel$/, handler: handleCancelWorkflow },
|
||||
{ method: 'GET', pattern: /^\/api\/triggers$/, handler: handleGetTriggers },
|
||||
{ method: 'GET', pattern: /^\/api\/audit\/logs$/, handler: handleGetAuditLogs },
|
||||
{ method: 'GET', pattern: /^\/api\/security\/status$/, handler: handleGetSecurityStatus },
|
||||
{ method: 'GET', pattern: /^\/api\/capabilities$/, handler: handleGetCapabilities },
|
||||
{ method: 'POST', pattern: /^\/api\/chat$/, handler: handleChat },
|
||||
{ method: 'GET', pattern: /^\/api\/models$/, handler: handleGetModels },
|
||||
{ method: 'GET', pattern: /^\/api\/config$/, handler: handleGetConfig },
|
||||
{ method: 'GET', pattern: /^\/api\/config\/quick$/, handler: handleGetQuickConfig },
|
||||
{ method: 'GET', pattern: /^\/api\/workspace$/, handler: handleGetWorkspace },
|
||||
{ method: 'GET', pattern: /^\/api\/skills$/, handler: handleGetSkills },
|
||||
{ method: 'GET', pattern: /^\/api\/channels$/, handler: handleGetChannels },
|
||||
{ method: 'GET', pattern: /^\/api\/stats\/usage$/, handler: handleGetStatsUsage },
|
||||
{ method: 'GET', pattern: /^\/api\/plugins\/status$/, handler: handleGetPluginsStatus },
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
const methods = Array.isArray(route.method) ? route.method : [route.method];
|
||||
if (!methods.includes(req.method || 'GET')) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (match) {
|
||||
try {
|
||||
route.handler(req, res, ...match.slice(1));
|
||||
} catch (error) {
|
||||
console.error('[MockServer] Handler error:', error);
|
||||
sendError(res, 'Internal server error', 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
sendError(res, 'Not found', 404);
|
||||
}
|
||||
|
||||
// === WebSocket Handler ===
|
||||
|
||||
function handleWebSocket(ws: WebSocket): void {
|
||||
connectedClients.add(ws);
|
||||
|
||||
ws.on('message', (data: RawData) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'req') {
|
||||
handleWsRequest(ws, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MockServer] WebSocket message parse error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
connectedClients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[MockServer] WebSocket error:', error);
|
||||
connectedClients.delete(ws);
|
||||
});
|
||||
|
||||
// Send challenge after a short delay
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const nonce = `nonce-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'event',
|
||||
event: 'connect.challenge',
|
||||
payload: { nonce },
|
||||
}));
|
||||
}
|
||||
}, challengeDelay);
|
||||
}
|
||||
|
||||
function handleWsRequest(ws: WebSocket, message: { id: string; method: string; params?: unknown }): void {
|
||||
if (message.method === 'connect') {
|
||||
// Handle connect handshake
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
sessionId: `session-${Date.now()}`,
|
||||
protocolVersion: 3,
|
||||
serverVersion: version,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, connectionDelay);
|
||||
} else if (message.method === 'health') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: true,
|
||||
payload: { version, status: 'ok' },
|
||||
}));
|
||||
} else if (message.method === 'status') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
uptime: Math.floor(process.uptime()),
|
||||
connections: connectedClients.size,
|
||||
version,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// Unknown method
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: { code: 'METHOD_NOT_FOUND', message: `Unknown method: ${message.method}` },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function sendJson(res: ServerResponse, data: unknown, statusCode = 200): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, message: string, statusCode = 500): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message, code: statusCode } }));
|
||||
}
|
||||
|
||||
function parseBody<T>(req: IncomingMessage, callback: (body: T) => void): void {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const parsed = body ? JSON.parse(body) : {};
|
||||
callback(parsed);
|
||||
} catch {
|
||||
// Send error response if we have access to res
|
||||
// This is a simplified version; in real use, pass res to this function
|
||||
console.error('[MockServer] Failed to parse request body');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addAuditLogEntry(entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>): void {
|
||||
const logEntry: MockAuditLogEntry = {
|
||||
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
...entry,
|
||||
};
|
||||
auditLogs.unshift(logEntry); // Add to beginning for reverse chronological order
|
||||
// Keep only last 1000 logs
|
||||
if (auditLogs.length > 1000) {
|
||||
auditLogs = auditLogs.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
|
||||
if (totalCount === 0) return 'low';
|
||||
const ratio = enabledCount / totalCount;
|
||||
if (ratio >= 0.875) return 'critical'; // 14-16 layers
|
||||
if (ratio >= 0.625) return 'high'; // 10-13 layers
|
||||
if (ratio >= 0.375) return 'medium'; // 6-9 layers
|
||||
return 'low'; // 0-5 layers
|
||||
}
|
||||
|
||||
// === Public API ===
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpServer = createServer(handleRequest);
|
||||
|
||||
httpServer.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(port, host, () => {
|
||||
const address = httpServer?.address();
|
||||
actualPort = typeof address === 'object' && address ? address.port : port;
|
||||
|
||||
// Set up WebSocket server
|
||||
wsServer = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||
wsServer.on('connection', handleWebSocket);
|
||||
|
||||
console.log(`[MockServer] Started on http://${host}:${actualPort}`);
|
||||
console.log(`[MockServer] WebSocket available at ws://${host}:${actualPort}/ws`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
stop: () => {
|
||||
return new Promise((resolve) => {
|
||||
// Close all WebSocket connections
|
||||
if (wsServer) {
|
||||
wsServer.clients.forEach((ws) => {
|
||||
ws.close(1000, 'Server shutting down');
|
||||
});
|
||||
wsServer.close(() => {
|
||||
wsServer = null;
|
||||
});
|
||||
}
|
||||
|
||||
if (httpServer) {
|
||||
httpServer.close(() => {
|
||||
console.log('[MockServer] Stopped');
|
||||
httpServer = null;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getPort: () => actualPort,
|
||||
getWsUrl: () => `ws://${host}:${actualPort}/ws`,
|
||||
getHttpUrl: () => `http://${host}:${actualPort}`,
|
||||
|
||||
reset: () => {
|
||||
hands = [...DEFAULT_HANDS];
|
||||
workflows = [...DEFAULT_WORKFLOWS];
|
||||
triggers = [...DEFAULT_TRIGGERS];
|
||||
agents = [...DEFAULT_AGENTS];
|
||||
securityLayers = [...DEFAULT_SECURITY_LAYERS];
|
||||
auditLogs = [];
|
||||
handRuns.clear();
|
||||
workflowRuns.clear();
|
||||
},
|
||||
|
||||
setHands: (newHands: MockHand[]) => {
|
||||
hands = newHands;
|
||||
},
|
||||
|
||||
setWorkflows: (newWorkflows: MockWorkflow[]) => {
|
||||
workflows = newWorkflows;
|
||||
},
|
||||
|
||||
setTriggers: (newTriggers: MockTrigger[]) => {
|
||||
triggers = newTriggers;
|
||||
},
|
||||
|
||||
setAgents: (newAgents: MockAgent[]) => {
|
||||
agents = newAgents;
|
||||
},
|
||||
|
||||
setSecurityLayers: (newLayers: MockSecurityLayer[]) => {
|
||||
securityLayers = newLayers;
|
||||
},
|
||||
|
||||
addAuditLog: (entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>) => {
|
||||
addAuditLogEntry(entry);
|
||||
},
|
||||
|
||||
simulateStreamEvent: (event: string, payload: unknown) => {
|
||||
const message = JSON.stringify({ type: 'event', event, payload });
|
||||
wsServer?.clients.forEach((ws) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getConnectedClients: () => connectedClients.size,
|
||||
};
|
||||
}
|
||||
|
||||
// === Export for CommonJS compatibility ===
|
||||
|
||||
export default createOpenFangMockServer;
|
||||
Reference in New Issue
Block a user