refactor(store): split gatewayStore into specialized domain stores

Major restructuring:
- Split monolithic gatewayStore into 5 focused stores:
  - connectionStore: WebSocket connection and gateway lifecycle
  - configStore: quickConfig, workspaceInfo, MCP services
  - agentStore: clones, usage stats, agent management
  - handStore: hands, approvals, triggers, hand runs
  - workflowStore: workflows, workflow runs, execution

- Update all components to use new stores with selector pattern
- Remove
This commit is contained in:
iven
2026-03-20 22:14:13 +08:00
parent 6f72442531
commit 1cf3f585d3
43 changed files with 2826 additions and 3103 deletions

View File

@@ -14,7 +14,8 @@ import { AgentOnboardingWizard } from './components/AgentOnboardingWizard';
import { HandApprovalModal } from './components/HandApprovalModal';
import { TopBar } from './components/TopBar';
import { DetailDrawer } from './components/DetailDrawer';
import { useGatewayStore, type HandRun } from './store/gatewayStore';
import { useConnectionStore } from './store/connectionStore';
import { useHandStore, type HandRun } from './store/handStore';
import { useTeamStore } from './store/teamStore';
import { useChatStore } from './store/chatStore';
import { getStoredGatewayToken } from './lib/gateway-client';
@@ -53,7 +54,10 @@ function App() {
const [showApprovalModal, setShowApprovalModal] = useState(false);
const [teamViewMode, setTeamViewMode] = useState<'collaboration' | 'orchestrator'>('collaboration');
const { connect, hands, approveHand, loadHands } = useGatewayStore();
const connect = useConnectionStore((s) => s.connect);
const hands = useHandStore((s) => s.hands);
const approveHand = useHandStore((s) => s.approveHand);
const loadHands = useHandStore((s) => s.loadHands);
const { activeTeam, setActiveTeam, teams } = useTeamStore();
const { setCurrentAgent, newConversation } = useChatStore();
const { isNeeded: onboardingNeeded, isLoading: onboardingLoading, markCompleted } = useOnboarding();

View File

@@ -8,11 +8,8 @@
*/
import { useState, useEffect, useCallback } from 'react';
import {
useGatewayStore,
type Approval,
type ApprovalStatus,
} from '../store/gatewayStore';
import { useHandStore } from '../store/handStore';
import type { Approval, ApprovalStatus } from '../store/handStore';
import {
CheckCircle,
XCircle,
@@ -297,8 +294,10 @@ function EmptyState({ filter }: { filter: FilterStatus }) {
// === Main ApprovalsPanel Component ===
export function ApprovalsPanel() {
const { approvals, loadApprovals, respondToApproval, isLoading } =
useGatewayStore();
const approvals = useHandStore((s) => s.approvals);
const loadApprovals = useHandStore((s) => s.loadApprovals);
const respondToApproval = useHandStore((s) => s.respondToApproval);
const isLoading = useHandStore((s) => s.isLoading);
const [filter, setFilter] = useState<FilterStatus>('all');
const [processingId, setProcessingId] = useState<string | null>(null);

View File

@@ -1,12 +1,18 @@
import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { useAgentStore } from '../store/agentStore';
import { useConnectionStore } from '../store/connectionStore';
import { useConfigStore } from '../store/configStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import { Bot, Plus, X, Globe, Cat, Search, BarChart2, Sparkles } from 'lucide-react';
import { AgentOnboardingWizard } from './AgentOnboardingWizard';
import type { Clone } from '../store/agentStore';
export function CloneManager() {
const { clones, loadClones, deleteClone, connectionState, quickConfig } = useGatewayStore();
const clones = useAgentStore((s) => s.clones);
const loadClones = useAgentStore((s) => s.loadClones);
const deleteClone = useAgentStore((s) => s.deleteClone);
const connectionState = useConnectionStore((s) => s.connectionState);
const quickConfig = useConfigStore((s) => s.quickConfig);
const { agents, currentAgent, setCurrentAgent } = useChatStore();
const [showWizard, setShowWizard] = useState(false);

View File

@@ -6,7 +6,7 @@
*/
import { useEffect } from 'react';
import { useGatewayStore, type Hand } from '../store/gatewayStore';
import { useHandStore, type Hand } from '../store/handStore';
import { Zap, Loader2, RefreshCw, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
interface HandListProps {
@@ -42,7 +42,9 @@ const STATUS_LABELS: Record<Hand['status'], string> = {
};
export function HandList({ selectedHandId, onSelectHand }: HandListProps) {
const { hands, loadHands, isLoading } = useGatewayStore();
const hands = useHandStore((s) => s.hands);
const loadHands = useHandStore((s) => s.loadHands);
const isLoading = useHandStore((s) => s.isLoading);
useEffect(() => {
loadHands();

View File

@@ -6,7 +6,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Hand, type HandRun } from '../store/gatewayStore';
import { useHandStore, type Hand, type HandRun } from '../store/handStore';
import {
Zap,
Loader2,
@@ -39,7 +39,12 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; className: string; icon
};
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
const { hands, handRuns, loadHands, loadHandRuns, triggerHand, isLoading } = useGatewayStore();
const hands = useHandStore((s) => s.hands);
const handRuns = useHandStore((s) => s.handRuns);
const loadHands = useHandStore((s) => s.loadHands);
const loadHandRuns = useHandStore((s) => s.loadHandRuns);
const triggerHand = useHandStore((s) => s.triggerHand);
const isLoading = useHandStore((s) => s.isLoading);
const { toast } = useToast();
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
const [isActivating, setIsActivating] = useState(false);
@@ -103,7 +108,7 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
]);
} else {
// Check for specific error in store
const storeError = useGatewayStore.getState().error;
const storeError = useHandStore.getState().error;
if (storeError?.includes('already active')) {
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
} else {

View File

@@ -1,9 +1,10 @@
import { FileText, Globe } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useConfigStore } from '../../store/configStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function MCPServices() {
const { quickConfig, saveQuickConfig } = useGatewayStore();
const quickConfig = useConfigStore((s) => s.quickConfig);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
const services = quickConfig.mcpServices || [];

View File

@@ -1,10 +1,13 @@
import { useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useConfigStore } from '../../store/configStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function Privacy() {
const { quickConfig, workspaceInfo, loadWorkspaceInfo, saveQuickConfig } = useGatewayStore();
const quickConfig = useConfigStore((s) => s.quickConfig);
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
useEffect(() => {
loadWorkspaceInfo().catch(silentErrorHandler('Privacy'));

View File

@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useAgentStore } from '../../store/agentStore';
import { useConnectionStore } from '../../store/connectionStore';
import { BarChart3, TrendingUp, Clock, Zap } from 'lucide-react';
export function UsageStats() {
const { usageStats, loadUsageStats, connectionState } = useGatewayStore();
const usageStats = useAgentStore((s) => s.usageStats);
const loadUsageStats = useAgentStore((s) => s.loadUsageStats);
const connectionState = useConnectionStore((s) => s.connectionState);
const [timeRange, setTimeRange] = useState<'7d' | '30d' | 'all'>('7d');
useEffect(() => {

View File

@@ -1,14 +1,12 @@
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useConfigStore } from '../../store/configStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function Workspace() {
const {
quickConfig,
workspaceInfo,
loadWorkspaceInfo,
saveQuickConfig,
} = useGatewayStore();
const quickConfig = useConfigStore((s) => s.quickConfig);
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
const [projectDir, setProjectDir] = useState('~/.openfang/zclaw-workspace');
useEffect(() => {

View File

@@ -6,7 +6,7 @@ import {
} from 'lucide-react';
import { CloneManager } from './CloneManager';
import { TeamList } from './TeamList';
import { useGatewayStore } from '../store/gatewayStore';
import { useConfigStore } from '../store/configStore';
import { containerVariants, defaultTransition } from '../lib/animations';
export type MainViewType = 'chat' | 'automation' | 'team' | 'swarm' | 'skills';
@@ -44,7 +44,7 @@ export function Sidebar({
}: SidebarProps) {
const [activeTab, setActiveTab] = useState<Tab>('clones');
const [searchQuery, setSearchQuery] = useState('');
const userName = useGatewayStore((state) => state.quickConfig.userName) || '用户7141';
const userName = useConfigStore((state) => state.quickConfig?.userName) || '用户7141';
const handleNavClick = (key: Tab, mainView?: MainViewType) => {
setActiveTab(key);

View File

@@ -5,8 +5,8 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import type { Trigger } from '../store/gatewayStore';
import { useHandStore } from '../store/handStore';
import type { Trigger } from '../store/handStore';
import { CreateTriggerModal } from './CreateTriggerModal';
import {
Zap,
@@ -105,7 +105,11 @@ function TriggerCard({ trigger, onToggle, onDelete, isToggling, isDeleting }: Tr
}
export function TriggersPanel() {
const { triggers, loadTriggers, isLoading, client, deleteTrigger } = useGatewayStore();
const triggers = useHandStore((s) => s.triggers);
const loadTriggers = useHandStore((s) => s.loadTriggers);
const deleteTrigger = useHandStore((s) => s.deleteTrigger);
const isLoading = useHandStore((s) => s.isLoading);
const client = useHandStore((s) => s.client);
const [togglingTrigger, setTogglingTrigger] = useState<string | null>(null);
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);

View File

@@ -8,7 +8,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Workflow, type WorkflowRun } from '../store/gatewayStore';
import { useWorkflowStore, type Workflow, type WorkflowRun } from '../store/workflowStore';
import {
ArrowLeft,
Clock,
@@ -113,7 +113,9 @@ function RunCard({ run, index }: RunCardProps) {
}
export function WorkflowHistory({ workflow, onBack }: WorkflowHistoryProps) {
const { loadWorkflowRuns, cancelWorkflow, isLoading } = useGatewayStore();
const loadWorkflowRuns = useWorkflowStore((s) => s.loadWorkflowRuns);
const cancelWorkflow = useWorkflowStore((s) => s.cancelWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [cancellingRunId, setCancellingRunId] = useState<string | null>(null);

View File

@@ -7,8 +7,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import type { Workflow } from '../store/gatewayStore';
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
import { WorkflowEditor } from './WorkflowEditor';
import { WorkflowHistory } from './WorkflowHistory';
import {
@@ -236,7 +235,13 @@ function WorkflowRow({ workflow, onExecute, onEdit, onDelete, onHistory, isExecu
// === Main WorkflowList Component ===
export function WorkflowList() {
const { workflows, loadWorkflows, executeWorkflow, deleteWorkflow, createWorkflow, updateWorkflow, isLoading } = useGatewayStore();
const workflows = useWorkflowStore((s) => s.workflows);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
const deleteWorkflow = useWorkflowStore((s) => s.deleteWorkflow);
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [deletingWorkflowId, setDeletingWorkflowId] = useState<string | null>(null);
@@ -254,11 +259,11 @@ export function WorkflowList() {
const handleExecute = useCallback(async (id: string, input?: Record<string, unknown>) => {
setExecutingWorkflowId(id);
try {
await executeWorkflow(id, input);
await triggerWorkflow(id, input);
} finally {
setExecutingWorkflowId(null);
}
}, [executeWorkflow]);
}, [triggerWorkflow]);
const handleExecuteClick = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);

View File

@@ -257,7 +257,7 @@ export class AutonomyManager {
identity_update: 'identityAutoUpdate',
identity_rollback: null,
skill_install: 'skillAutoInstall',
skill_uninstall: null,
skill_uninstall: 'skillAutoInstall',
config_change: null,
workflow_trigger: 'autoCompaction',
hand_trigger: null,

View File

@@ -0,0 +1,674 @@
/**
* gateway-api.ts - Gateway REST API Methods
*
* Extracted from gateway-client.ts for modularity.
* Contains all REST API method implementations grouped by domain:
* - Health / Status
* - Agents (Clones)
* - Stats & Workspace
* - Config (Quick Config, Channels, Skills, Scheduler, Models)
* - Hands (OpenFang)
* - Workflows (OpenFang)
* - Sessions (OpenFang)
* - Triggers (OpenFang)
* - Audit (OpenFang)
* - Security (OpenFang)
* - Approvals (OpenFang)
*
* These methods are installed onto GatewayClient.prototype via installApiMethods().
* The GatewayClient core class exposes restGet/restPost/restPut/restDelete/restPatch
* as public methods for this purpose.
*/
import type { GatewayClient } from './gateway-client';
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
import { tomlUtils } from './toml-utils';
import {
getQuickConfigFallback,
getWorkspaceInfoFallback,
getUsageStatsFallback,
getPluginStatusFallback,
getScheduledTasksFallback,
getSecurityStatusFallback,
isNotFoundError,
} from './api-fallbacks';
// === Install all API methods onto GatewayClient prototype ===
export function installApiMethods(ClientClass: { prototype: GatewayClient }): void {
const proto = ClientClass.prototype as any;
// ─── Health / Status ───
proto.health = async function (this: GatewayClient): Promise<any> {
return this.request('health');
};
proto.status = async function (this: GatewayClient): Promise<any> {
return this.request('status');
};
// ─── Agents (Clones) ───
proto.listClones = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/agents');
};
proto.createClone = async function (this: GatewayClient, opts: {
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
emoji?: string;
personality?: string;
communicationStyle?: string;
notes?: string;
}): Promise<any> {
// Build manifest config object, then serialize via tomlUtils
const manifest: Record<string, unknown> = {
name: opts.nickname || opts.name,
model_provider: 'bailian',
model_name: opts.model || 'qwen3.5-plus',
};
// Identity section
const identity: Record<string, string> = {};
if (opts.emoji) identity.emoji = opts.emoji;
if (opts.personality) identity.personality = opts.personality;
if (opts.communicationStyle) identity.communication_style = opts.communicationStyle;
if (Object.keys(identity).length > 0) manifest.identity = identity;
// Scenarios
if (opts.scenarios && opts.scenarios.length > 0) {
manifest.scenarios = opts.scenarios;
}
// User context
const userContext: Record<string, string> = {};
if (opts.userName) userContext.name = opts.userName;
if (opts.userRole) userContext.role = opts.userRole;
if (Object.keys(userContext).length > 0) manifest.user_context = userContext;
const manifestToml = tomlUtils.stringify(manifest);
return this.restPost('/api/agents', {
manifest_toml: manifestToml,
});
};
proto.updateClone = async function (this: GatewayClient, id: string, updates: Record<string, any>): Promise<any> {
return this.restPut(`/api/agents/${id}`, updates);
};
proto.deleteClone = async function (this: GatewayClient, id: string): Promise<any> {
return this.restDelete(`/api/agents/${id}`);
};
// ─── Stats & Workspace ───
proto.getUsageStats = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/stats/usage');
} catch (error) {
if (isNotFoundError(error)) {
return getUsageStatsFallback([]);
}
return {
totalMessages: 0,
totalTokens: 0,
sessionsCount: 0,
agentsCount: 0,
};
}
};
proto.getSessionStats = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/stats/sessions');
} catch {
return { sessions: [] };
}
};
proto.getWorkspaceInfo = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/workspace');
} catch (error) {
if (isNotFoundError(error)) {
return getWorkspaceInfoFallback();
}
return {
rootDir: typeof process !== 'undefined' ? (process.env.HOME || process.env.USERPROFILE || '~') : '~',
skillsDir: null,
handsDir: null,
configDir: null,
};
}
};
proto.getPluginStatus = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/plugins/status');
} catch (error) {
if (isNotFoundError(error)) {
const plugins = getPluginStatusFallback([]);
return { plugins, loaded: plugins.length, total: plugins.length };
}
return { plugins: [], loaded: 0, total: 0 };
}
};
// ─── Quick Config ───
proto.getQuickConfig = async function (this: GatewayClient): Promise<any> {
try {
const config = await this.restGet<{
data_dir?: string;
home_dir?: string;
default_model?: { model?: string; provider?: string };
}>('/api/config');
// 从 localStorage 读取前端特定配置
const storedTheme = localStorage.getItem('zclaw-theme') as 'light' | 'dark' | null;
const storedAutoStart = localStorage.getItem('zclaw-autoStart');
const storedShowToolCalls = localStorage.getItem('zclaw-showToolCalls');
// Map OpenFang config to frontend expected format
return {
quickConfig: {
agentName: 'ZCLAW',
agentRole: 'AI 助手',
userName: '用户',
userRole: '用户',
agentNickname: 'ZCLAW',
scenarios: ['通用对话', '代码助手', '文档编写'],
workspaceDir: config.data_dir || config.home_dir,
gatewayUrl: this.getRestBaseUrl(),
defaultModel: config.default_model?.model,
defaultProvider: config.default_model?.provider,
theme: storedTheme || 'light',
autoStart: storedAutoStart === 'true',
showToolCalls: storedShowToolCalls !== 'false',
autoSaveContext: true,
fileWatching: true,
privacyOptIn: false,
}
};
} catch (error) {
if (isNotFoundError(error)) {
return { quickConfig: getQuickConfigFallback() };
}
return {};
}
};
proto.saveQuickConfig = async function (this: GatewayClient, config: Record<string, any>): Promise<any> {
// 保存前端特定配置到 localStorage
if (config.theme !== undefined) {
localStorage.setItem('zclaw-theme', config.theme);
}
if (config.autoStart !== undefined) {
localStorage.setItem('zclaw-autoStart', String(config.autoStart));
}
if (config.showToolCalls !== undefined) {
localStorage.setItem('zclaw-showToolCalls', String(config.showToolCalls));
}
// Map frontend config back to OpenFang format
const openfangConfig = {
data_dir: config.workspaceDir,
default_model: config.defaultModel ? {
model: config.defaultModel,
provider: config.defaultProvider || 'bailian',
} : undefined,
};
return this.restPut('/api/config', openfangConfig);
};
// ─── Skills ───
proto.listSkills = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/skills');
};
proto.getSkill = async function (this: GatewayClient, id: string): Promise<any> {
return this.restGet(`/api/skills/${id}`);
};
proto.createSkill = async function (this: GatewayClient, skill: {
name: string;
description?: string;
triggers: Array<{ type: string; pattern?: string }>;
actions: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}): Promise<any> {
return this.restPost('/api/skills', skill);
};
proto.updateSkill = async function (this: GatewayClient, id: string, updates: {
name?: string;
description?: string;
triggers?: Array<{ type: string; pattern?: string }>;
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}): Promise<any> {
return this.restPut(`/api/skills/${id}`, updates);
};
proto.deleteSkill = async function (this: GatewayClient, id: string): Promise<any> {
return this.restDelete(`/api/skills/${id}`);
};
// ─── Channels ───
proto.listChannels = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/channels');
};
proto.getChannel = async function (this: GatewayClient, id: string): Promise<any> {
return this.restGet(`/api/channels/${id}`);
};
proto.createChannel = async function (this: GatewayClient, channel: {
type: string;
name: string;
config: Record<string, unknown>;
enabled?: boolean;
}): Promise<any> {
return this.restPost('/api/channels', channel);
};
proto.updateChannel = async function (this: GatewayClient, id: string, updates: {
name?: string;
config?: Record<string, unknown>;
enabled?: boolean;
}): Promise<any> {
return this.restPut(`/api/channels/${id}`, updates);
};
proto.deleteChannel = async function (this: GatewayClient, id: string): Promise<any> {
return this.restDelete(`/api/channels/${id}`);
};
proto.getFeishuStatus = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/channels/feishu/status');
};
// ─── Scheduler ───
proto.listScheduledTasks = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/scheduler/tasks');
} catch (error) {
if (isNotFoundError(error)) {
const tasks = getScheduledTasksFallback([]);
return { tasks, total: tasks.length };
}
return { tasks: [], total: 0 };
}
};
proto.createScheduledTask = async function (this: GatewayClient, task: {
name: string;
schedule: string;
scheduleType: 'cron' | 'interval' | 'once';
target?: { type: 'agent' | 'hand' | 'workflow'; id: string };
description?: string;
enabled?: boolean;
}): Promise<{ id: string; name: string; schedule: string; status: string }> {
return this.restPost('/api/scheduler/tasks', task);
};
proto.deleteScheduledTask = async function (this: GatewayClient, id: string): Promise<void> {
return this.restDelete(`/api/scheduler/tasks/${id}`);
};
proto.toggleScheduledTask = async function (this: GatewayClient, id: string, enabled: boolean): Promise<{ id: string; enabled: boolean }> {
return this.restPatch(`/api/scheduler/tasks/${id}`, { enabled });
};
// ─── OpenFang Hands API ───
proto.listHands = async function (this: GatewayClient): Promise<{
hands: {
id?: string;
name: string;
description?: string;
status?: string;
requirements_met?: boolean;
category?: string;
icon?: string;
tool_count?: number;
tools?: string[];
metric_count?: number;
metrics?: string[];
}[]
}> {
return this.restGet('/api/hands');
};
proto.getHand = async function (this: GatewayClient, name: string): Promise<{
id?: string;
name?: string;
description?: string;
status?: string;
requirements_met?: boolean;
category?: string;
icon?: string;
provider?: string;
model?: string;
requirements?: { description?: string; name?: string; met?: boolean; satisfied?: boolean; details?: string; hint?: string }[];
tools?: string[];
metrics?: string[];
config?: Record<string, unknown>;
tool_count?: number;
metric_count?: number;
}> {
return this.restGet(`/api/hands/${name}`);
};
proto.triggerHand = async function (this: GatewayClient, name: string, params?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
console.log(`[GatewayClient] Triggering hand: ${name}`, params);
try {
const result = await this.restPost<{
instance_id: string;
status: string;
}>(`/api/hands/${name}/activate`, params || {});
console.log(`[GatewayClient] Hand trigger response:`, result);
return { runId: result.instance_id, status: result.status };
} catch (err) {
console.error(`[GatewayClient] Hand trigger failed for ${name}:`, err);
throw err;
}
};
proto.getHandStatus = async function (this: GatewayClient, name: string, runId: string): Promise<{ status: string; result?: unknown }> {
return this.restGet(`/api/hands/${name}/runs/${runId}`);
};
proto.approveHand = async function (this: GatewayClient, name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
return this.restPost(`/api/hands/${name}/runs/${runId}/approve`, { approved, reason });
};
proto.cancelHand = async function (this: GatewayClient, name: string, runId: string): Promise<{ status: string }> {
return this.restPost(`/api/hands/${name}/runs/${runId}/cancel`, {});
};
proto.listHandRuns = async function (this: GatewayClient, name: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: { runId: string; status: string; startedAt: string }[] }> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/hands/${name}/runs?${params}`);
};
// ─── OpenFang Workflows API ───
proto.listWorkflows = async function (this: GatewayClient): Promise<{ workflows: { id: string; name: string; steps: number }[] }> {
return this.restGet('/api/workflows');
};
proto.getWorkflow = async function (this: GatewayClient, id: string): Promise<{ id: string; name: string; steps: unknown[] }> {
return this.restGet(`/api/workflows/${id}`);
};
proto.executeWorkflow = async function (this: GatewayClient, id: string, input?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
return this.restPost(`/api/workflows/${id}/execute`, input);
};
proto.getWorkflowRun = async function (this: GatewayClient, workflowId: string, runId: string): Promise<{ status: string; step: string; result?: unknown }> {
return this.restGet(`/api/workflows/${workflowId}/runs/${runId}`);
};
proto.listWorkflowRuns = async function (this: GatewayClient, workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{
runs: Array<{
runId: string;
status: string;
startedAt: string;
completedAt?: string;
step?: string;
result?: unknown;
error?: string;
}>;
}> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/workflows/${workflowId}/runs?${params}`);
};
proto.cancelWorkflow = async function (this: GatewayClient, workflowId: string, runId: string): Promise<{ status: string }> {
return this.restPost(`/api/workflows/${workflowId}/runs/${runId}/cancel`, {});
};
proto.createWorkflow = async function (this: GatewayClient, workflow: {
name: string;
description?: string;
steps: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}): Promise<{ id: string; name: string }> {
return this.restPost('/api/workflows', workflow);
};
proto.updateWorkflow = async function (this: GatewayClient, id: string, updates: {
name?: string;
description?: string;
steps?: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}): Promise<{ id: string; name: string }> {
return this.restPut(`/api/workflows/${id}`, updates);
};
proto.deleteWorkflow = async function (this: GatewayClient, id: string): Promise<{ status: string }> {
return this.restDelete(`/api/workflows/${id}`);
};
// ─── OpenFang Session API ───
proto.listSessions = async function (this: GatewayClient, opts?: { limit?: number; offset?: number }): Promise<{
sessions: Array<{
id: string;
agent_id: string;
created_at: string;
updated_at?: string;
message_count?: number;
status?: 'active' | 'archived' | 'expired';
}>;
}> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/sessions?${params}`);
};
proto.getSession = async function (this: GatewayClient, sessionId: string): Promise<{
id: string;
agent_id: string;
created_at: string;
updated_at?: string;
message_count?: number;
status?: 'active' | 'archived' | 'expired';
metadata?: Record<string, unknown>;
}> {
return this.restGet(`/api/sessions/${sessionId}`);
};
proto.createSession = async function (this: GatewayClient, opts: {
agent_id: string;
metadata?: Record<string, unknown>;
}): Promise<{
id: string;
agent_id: string;
created_at: string;
}> {
return this.restPost('/api/sessions', opts);
};
proto.deleteSession = async function (this: GatewayClient, sessionId: string): Promise<{ status: string }> {
return this.restDelete(`/api/sessions/${sessionId}`);
};
proto.getSessionMessages = async function (this: GatewayClient, sessionId: string, opts?: {
limit?: number;
offset?: number;
}): Promise<{
messages: Array<{
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
tokens?: { input?: number; output?: number };
}>;
}> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/sessions/${sessionId}/messages?${params}`);
};
// ─── OpenFang Triggers API ───
proto.listTriggers = async function (this: GatewayClient): Promise<{ triggers: { id: string; type: string; enabled: boolean }[] }> {
return this.restGet('/api/triggers');
};
proto.getTrigger = async function (this: GatewayClient, id: string): Promise<{
id: string;
type: string;
name?: string;
enabled: boolean;
config?: Record<string, unknown>;
}> {
return this.restGet(`/api/triggers/${id}`);
};
proto.createTrigger = async function (this: GatewayClient, trigger: {
type: string;
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
handName?: string;
workflowId?: string;
}): Promise<{ id: string }> {
return this.restPost('/api/triggers', trigger);
};
proto.updateTrigger = async function (this: GatewayClient, id: string, updates: {
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
handName?: string;
workflowId?: string;
}): Promise<{ id: string }> {
return this.restPut(`/api/triggers/${id}`, updates);
};
proto.deleteTrigger = async function (this: GatewayClient, id: string): Promise<{ status: string }> {
return this.restDelete(`/api/triggers/${id}`);
};
// ─── OpenFang Audit API ───
proto.getAuditLogs = async function (this: GatewayClient, opts?: { limit?: number; offset?: number }): Promise<{ logs: unknown[] }> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/audit/logs?${params}`);
};
proto.verifyAuditLogChain = async function (this: GatewayClient, logId: string): Promise<{
valid: boolean;
chain_depth?: number;
root_hash?: string;
broken_at_index?: number;
}> {
return this.restGet(`/api/audit/verify/${logId}`);
};
// ─── OpenFang Security API ───
proto.getSecurityStatus = async function (this: GatewayClient): Promise<{ layers: { name: string; enabled: boolean }[] }> {
try {
return await this.restGet('/api/security/status');
} catch (error) {
if (isNotFoundError(error)) {
const status = getSecurityStatusFallback();
return { layers: status.layers };
}
return {
layers: [
{ name: 'device_auth', enabled: true },
{ name: 'rbac', enabled: true },
{ name: 'audit_log', enabled: true },
],
};
}
};
proto.getCapabilities = async function (this: GatewayClient): Promise<{ capabilities: string[] }> {
try {
return await this.restGet('/api/capabilities');
} catch {
return { capabilities: ['chat', 'agents', 'hands', 'workflows'] };
}
};
// ─── OpenFang Approvals API ───
proto.listApprovals = async function (this: GatewayClient, status?: string): Promise<{
approvals: {
id: string;
hand_name: string;
run_id: string;
status: string;
requested_at: string;
requested_by?: string;
reason?: string;
action?: string;
params?: Record<string, unknown>;
responded_at?: string;
responded_by?: string;
response_reason?: string;
}[];
}> {
const params = status ? `?status=${status}` : '';
return this.restGet(`/api/approvals${params}`);
};
proto.respondToApproval = async function (this: GatewayClient, approvalId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
return this.restPost(`/api/approvals/${approvalId}/respond`, { approved, reason });
};
// ─── Models & Config ───
proto.listModels = async function (this: GatewayClient): Promise<{ models: GatewayModelChoice[] }> {
return this.restGet('/api/models');
};
proto.getConfig = async function (this: GatewayClient): Promise<GatewayConfigSnapshot | Record<string, any>> {
return this.restGet('/api/config');
};
proto.applyConfig = async function (this: GatewayClient, raw: string, baseHash?: string, opts?: { sessionKey?: string; note?: string; restartDelayMs?: number }): Promise<any> {
return this.request('config.apply', {
raw,
baseHash,
sessionKey: opts?.sessionKey,
note: opts?.note,
restartDelayMs: opts?.restartDelayMs,
});
};
}

View File

@@ -0,0 +1,175 @@
/**
* gateway-auth.ts - Device Authentication Module
*
* Extracted from gateway-client.ts for modularity.
* Handles Ed25519 device key generation, loading, signing,
* and device identity management using OS keyring or localStorage.
*/
import nacl from 'tweetnacl';
import {
storeDeviceKeys,
getDeviceKeys,
deleteDeviceKeys,
} from './secure-storage';
// === Types ===
export interface DeviceKeys {
deviceId: string;
publicKey: Uint8Array;
secretKey: Uint8Array;
publicKeyBase64: string;
}
export interface LocalDeviceIdentity {
deviceId: string;
publicKeyBase64: string;
}
// === Base64 Encoding ===
export function b64Encode(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// === Key Derivation ===
async function deriveDeviceId(publicKey: Uint8Array): Promise<string> {
const stableBytes = Uint8Array.from(publicKey);
const digest = await crypto.subtle.digest('SHA-256', stableBytes.buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
// === Key Generation ===
async function generateDeviceKeys(): Promise<DeviceKeys> {
const keyPair = nacl.sign.keyPair();
const deviceId = await deriveDeviceId(keyPair.publicKey);
return {
deviceId,
publicKey: keyPair.publicKey,
secretKey: keyPair.secretKey,
publicKeyBase64: b64Encode(keyPair.publicKey),
};
}
// === Key Loading ===
/**
* Load device keys from secure storage.
* Uses OS keyring when available, falls back to localStorage.
*/
export async function loadDeviceKeys(): Promise<DeviceKeys> {
// Try to load from secure storage (keyring or localStorage fallback)
const storedKeys = await getDeviceKeys();
if (storedKeys) {
try {
const deviceId = await deriveDeviceId(storedKeys.publicKey);
return {
deviceId,
publicKey: storedKeys.publicKey,
secretKey: storedKeys.secretKey,
publicKeyBase64: b64Encode(storedKeys.publicKey),
};
} catch (e) {
console.warn('[GatewayClient] Failed to load stored keys:', e);
// Invalid stored keys, clear and regenerate
await deleteDeviceKeys();
}
}
// Generate new keys
const keys = await generateDeviceKeys();
// Store in secure storage (keyring when available, localStorage fallback)
await storeDeviceKeys(keys.publicKey, keys.secretKey);
return keys;
}
// === Public Identity ===
export async function getLocalDeviceIdentity(): Promise<LocalDeviceIdentity> {
const keys = await loadDeviceKeys();
return {
deviceId: keys.deviceId,
publicKeyBase64: keys.publicKeyBase64,
};
}
/**
* Clear cached device keys to force regeneration on next connect.
* Useful when device signature validation fails repeatedly.
*/
export async function clearDeviceKeys(): Promise<void> {
try {
await deleteDeviceKeys();
console.log('[GatewayClient] Device keys cleared');
} catch (e) {
console.warn('[GatewayClient] Failed to clear device keys:', e);
}
}
// === Device Auth Signing ===
export function buildDeviceAuthPayload(params: {
clientId: string;
clientMode: string;
deviceId: string;
nonce: string;
role: string;
scopes: string[];
signedAt: number;
token?: string;
}): string {
return [
'v2',
params.deviceId,
params.clientId,
params.clientMode,
params.role,
params.scopes.join(','),
String(params.signedAt),
params.token || '',
params.nonce,
].join('|');
}
export function signDeviceAuth(params: {
clientId: string;
clientMode: string;
deviceId: string;
nonce: string;
role: string;
scopes: string[];
secretKey: Uint8Array;
token?: string;
}): { signature: string; signedAt: number } {
const signedAt = Date.now();
const message = buildDeviceAuthPayload({
clientId: params.clientId,
clientMode: params.clientMode,
deviceId: params.deviceId,
nonce: params.nonce,
role: params.role,
scopes: params.scopes,
signedAt,
token: params.token,
});
const messageBytes = new TextEncoder().encode(message);
const signature = nacl.sign.detached(messageBytes, params.secretKey);
return {
signature: b64Encode(signature),
signedAt,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
/**
* gateway-storage.ts - Gateway URL/Token Storage & Normalization
*
* Extracted from gateway-client.ts for modularity.
* Manages WSS configuration, URL normalization, and
* localStorage persistence for gateway URL and token.
*/
// === WSS Configuration ===
/**
* Whether to use WSS (WebSocket Secure) instead of WS.
* - Production: defaults to WSS for security
* - Development: defaults to WS for convenience
* - Override: set VITE_USE_WSS=false to force WS in production
*/
const USE_WSS = import.meta.env.VITE_USE_WSS !== 'false' && import.meta.env.PROD;
/**
* Default protocol based on WSS configuration.
*/
const DEFAULT_WS_PROTOCOL = USE_WSS ? 'wss://' : 'ws://';
/**
* Check if a URL points to localhost.
*/
export function isLocalhost(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname === '[::1]';
} catch {
return false;
}
}
// === URL Constants ===
// OpenFang endpoints (port 50051 - actual running port)
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
export const FALLBACK_GATEWAY_URLS = [
DEFAULT_GATEWAY_URL,
`${DEFAULT_WS_PROTOCOL}127.0.0.1:4200/ws`,
];
const GATEWAY_URL_STORAGE_KEY = 'zclaw_gateway_url';
const GATEWAY_TOKEN_STORAGE_KEY = 'zclaw_gateway_token';
// === URL Normalization ===
/**
* Normalize a gateway URL to ensure correct protocol and path.
* - Ensures ws:// or wss:// protocol based on configuration
* - Ensures /ws path suffix
* - Handles both localhost and IP addresses
*/
export function normalizeGatewayUrl(url: string): string {
let normalized = url.trim();
// Remove trailing slashes except for protocol
normalized = normalized.replace(/\/+$/, '');
// Ensure protocol
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
normalized = USE_WSS ? `wss://${normalized}` : `ws://${normalized}`;
}
// Ensure /ws path
if (!normalized.endsWith('/ws')) {
normalized = `${normalized}/ws`;
}
return normalized;
}
// === LocalStorage Helpers ===
export function getStoredGatewayUrl(): string {
try {
const stored = localStorage.getItem(GATEWAY_URL_STORAGE_KEY);
return normalizeGatewayUrl(stored || DEFAULT_GATEWAY_URL);
} catch {
return DEFAULT_GATEWAY_URL;
}
}
export function setStoredGatewayUrl(url: string): string {
const normalized = normalizeGatewayUrl(url || DEFAULT_GATEWAY_URL);
try {
localStorage.setItem(GATEWAY_URL_STORAGE_KEY, normalized);
} catch { /* ignore localStorage failures */ }
return normalized;
}
export function getStoredGatewayToken(): string {
try {
return localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY) || '';
} catch {
return '';
}
}
export function setStoredGatewayToken(token: string): string {
const normalized = token.trim();
try {
if (normalized) {
localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized);
} else {
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
}
} catch {
/* ignore localStorage failures */
}
return normalized;
}

View File

@@ -0,0 +1,96 @@
/**
* gateway-types.ts - Gateway Protocol Types
*
* Extracted from gateway-client.ts for modularity.
* Contains all WebSocket protocol types, stream event types,
* and connection state definitions.
*/
// === Protocol Types ===
export interface GatewayRequest {
type: 'req';
id: string;
method: string;
params?: Record<string, unknown>;
}
export interface GatewayError {
code?: string;
message?: string;
details?: unknown;
}
export interface GatewayResponse {
type: 'res';
id: string;
ok: boolean;
payload?: unknown;
error?: GatewayError;
}
export interface GatewayEvent {
type: 'event';
event: string;
payload?: unknown;
seq?: number;
}
export interface GatewayPong {
type: 'pong';
timestamp?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent | GatewayPong;
// === Stream Types ===
export interface AgentStreamDelta {
stream: 'assistant' | 'tool' | 'lifecycle' | 'hand' | 'workflow';
delta?: string;
content?: string;
tool?: string;
toolInput?: string;
toolOutput?: string;
phase?: 'start' | 'end' | 'error';
runId?: string;
error?: string;
// Hand event fields
handName?: string;
handStatus?: string;
handResult?: unknown;
// Workflow event fields
workflowId?: string;
workflowStep?: string;
workflowStatus?: string;
workflowResult?: unknown;
}
/** OpenFang WebSocket stream event types */
export interface OpenFangStreamEvent {
type: 'text_delta' | 'phase' | 'response' | 'typing' | 'tool_call' | 'tool_result' | 'hand' | 'workflow' | 'error' | 'connected' | 'agents_updated';
content?: string;
phase?: 'streaming' | 'done';
state?: 'start' | 'stop';
tool?: string;
input?: unknown;
output?: string;
result?: unknown;
hand_name?: string;
hand_status?: string;
hand_result?: unknown;
workflow_id?: string;
workflow_step?: string;
workflow_status?: string;
workflow_result?: unknown;
message?: string;
code?: string;
agent_id?: string;
agents?: Array<{ id: string; name: string; status: string }>;
}
// === Connection State ===
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
export type EventCallback = (payload: unknown) => void;

View File

@@ -6,6 +6,7 @@
*/
import { create } from 'zustand';
import type { GatewayModelChoice } from '../lib/gateway-config';
import { setStoredGatewayUrl, setStoredGatewayToken } from '../lib/gateway-client';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
@@ -233,6 +234,13 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
try {
const nextConfig = { ...get().quickConfig, ...updates };
// Persist gateway URL/token to localStorage for reconnection
if (nextConfig.gatewayUrl) {
setStoredGatewayUrl(nextConfig.gatewayUrl);
}
if (Object.prototype.hasOwnProperty.call(updates, 'gatewayToken')) {
setStoredGatewayToken(nextConfig.gatewayToken || '');
}
const result = await client.saveQuickConfig(nextConfig);
set({ quickConfig: result?.quickConfig || nextConfig });
} catch (err: unknown) {
@@ -278,12 +286,12 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
channels.push({
id: 'feishu',
type: 'feishu',
label: 'Feishu',
label: '飞书 (Feishu)',
status: feishu?.configured ? 'active' : 'inactive',
accounts: feishu?.accounts || 0,
});
} catch {
channels.push({ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'inactive' });
channels.push({ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'inactive' });
}
set({ channels });

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@
*
* The coordinator:
* 1. Injects the shared client into all stores
* 2. Provides a composite hook that combines all store slices
* 3. Re-exports all individual stores for direct access
* 2. Re-exports all individual stores for direct access
*/
// === Re-export Individual Stores ===
@@ -26,6 +25,12 @@ export type { WorkflowStore, WorkflowStateSlice, WorkflowActionsSlice, Workflow,
export { useConfigStore, setConfigStoreClient } from './configStore';
export type { ConfigStore, ConfigStateSlice, ConfigActionsSlice, QuickConfig, WorkspaceInfo, ChannelInfo, ScheduledTask, SkillInfo } from './configStore';
export { useSecurityStore, setSecurityStoreClient } from './securityStore';
export type { SecurityStore, SecurityStateSlice, SecurityActionsSlice, SecurityLayer, SecurityStatus, AuditLogEntry } from './securityStore';
export { useSessionStore, setSessionStoreClient } from './sessionStore';
export type { SessionStore, SessionStateSlice, SessionActionsSlice, Session, SessionMessage } from './sessionStore';
// === New Stores ===
export { useMemoryGraphStore } from './memoryGraphStore';
export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout } from './memoryGraphStore';
@@ -49,14 +54,15 @@ export type {
SessionOptions,
} from '../components/BrowserHand/templates/types';
// === Composite Store Hook ===
// === Store Initialization ===
import { useMemo } from 'react';
import { useConnectionStore, getClient } from './connectionStore';
import { useAgentStore, setAgentStoreClient } from './agentStore';
import { useHandStore, setHandStoreClient } from './handStore';
import { useWorkflowStore, setWorkflowStoreClient } from './workflowStore';
import { useConfigStore, setConfigStoreClient } from './configStore';
import { getClient } from './connectionStore';
import { setAgentStoreClient } from './agentStore';
import { setHandStoreClient } from './handStore';
import { setWorkflowStoreClient } from './workflowStore';
import { setConfigStoreClient } from './configStore';
import { setSecurityStoreClient } from './securityStore';
import { setSessionStoreClient } from './sessionStore';
/**
* Initialize all stores with the shared client.
@@ -70,207 +76,8 @@ export function initializeStores(): void {
setHandStoreClient(client);
setWorkflowStoreClient(client);
setConfigStoreClient(client);
}
/**
* Hook that provides a composite view of all stores.
* Use this for components that need access to multiple store slices.
*
* For components that only need specific slices, import the individual
* store hooks directly (e.g., useConnectionStore, useAgentStore).
*/
export function useCompositeStore() {
// Subscribe to all stores
const connectionState = useConnectionStore((s) => s.connectionState);
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
const connectionError = useConnectionStore((s) => s.error);
const logs = useConnectionStore((s) => s.logs);
const localGateway = useConnectionStore((s) => s.localGateway);
const localGatewayBusy = useConnectionStore((s) => s.localGatewayBusy);
const isLoading = useConnectionStore((s) => s.isLoading);
const client = useConnectionStore((s) => s.client);
const clones = useAgentStore((s) => s.clones);
const usageStats = useAgentStore((s) => s.usageStats);
const pluginStatus = useAgentStore((s) => s.pluginStatus);
const hands = useHandStore((s) => s.hands);
const handRuns = useHandStore((s) => s.handRuns);
const triggers = useHandStore((s) => s.triggers);
const approvals = useHandStore((s) => s.approvals);
const workflows = useWorkflowStore((s) => s.workflows);
const workflowRuns = useWorkflowStore((s) => s.workflowRuns);
const quickConfig = useConfigStore((s) => s.quickConfig);
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
const channels = useConfigStore((s) => s.channels);
const scheduledTasks = useConfigStore((s) => s.scheduledTasks);
const skillsCatalog = useConfigStore((s) => s.skillsCatalog);
const models = useConfigStore((s) => s.models);
const modelsLoading = useConfigStore((s) => s.modelsLoading);
const modelsError = useConfigStore((s) => s.modelsError);
// Get all actions
const connect = useConnectionStore((s) => s.connect);
const disconnect = useConnectionStore((s) => s.disconnect);
const clearLogs = useConnectionStore((s) => s.clearLogs);
const refreshLocalGateway = useConnectionStore((s) => s.refreshLocalGateway);
const startLocalGateway = useConnectionStore((s) => s.startLocalGateway);
const stopLocalGateway = useConnectionStore((s) => s.stopLocalGateway);
const restartLocalGateway = useConnectionStore((s) => s.restartLocalGateway);
const loadClones = useAgentStore((s) => s.loadClones);
const createClone = useAgentStore((s) => s.createClone);
const updateClone = useAgentStore((s) => s.updateClone);
const deleteClone = useAgentStore((s) => s.deleteClone);
const loadUsageStats = useAgentStore((s) => s.loadUsageStats);
const loadPluginStatus = useAgentStore((s) => s.loadPluginStatus);
const loadHands = useHandStore((s) => s.loadHands);
const getHandDetails = useHandStore((s) => s.getHandDetails);
const triggerHand = useHandStore((s) => s.triggerHand);
const loadHandRuns = useHandStore((s) => s.loadHandRuns);
const loadTriggers = useHandStore((s) => s.loadTriggers);
const createTrigger = useHandStore((s) => s.createTrigger);
const deleteTrigger = useHandStore((s) => s.deleteTrigger);
const loadApprovals = useHandStore((s) => s.loadApprovals);
const respondToApproval = useHandStore((s) => s.respondToApproval);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const getWorkflow = useWorkflowStore((s) => s.getWorkflow);
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
const deleteWorkflow = useWorkflowStore((s) => s.deleteWorkflow);
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
const loadWorkflowRuns = useWorkflowStore((s) => s.loadWorkflowRuns);
const loadQuickConfig = useConfigStore((s) => s.loadQuickConfig);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
const loadChannels = useConfigStore((s) => s.loadChannels);
const getChannel = useConfigStore((s) => s.getChannel);
const createChannel = useConfigStore((s) => s.createChannel);
const updateChannel = useConfigStore((s) => s.updateChannel);
const deleteChannel = useConfigStore((s) => s.deleteChannel);
const loadScheduledTasks = useConfigStore((s) => s.loadScheduledTasks);
const createScheduledTask = useConfigStore((s) => s.createScheduledTask);
const loadSkillsCatalog = useConfigStore((s) => s.loadSkillsCatalog);
const getSkill = useConfigStore((s) => s.getSkill);
const createSkill = useConfigStore((s) => s.createSkill);
const updateSkill = useConfigStore((s) => s.updateSkill);
const deleteSkill = useConfigStore((s) => s.deleteSkill);
const loadModels = useConfigStore((s) => s.loadModels);
// Memoize the composite store to prevent unnecessary re-renders
return useMemo(() => ({
// Connection state
connectionState,
gatewayVersion,
error: connectionError,
logs,
localGateway,
localGatewayBusy,
isLoading,
client,
// Agent state
clones,
usageStats,
pluginStatus,
// Hand state
hands,
handRuns,
triggers,
approvals,
// Workflow state
workflows,
workflowRuns,
// Config state
quickConfig,
workspaceInfo,
channels,
scheduledTasks,
skillsCatalog,
models,
modelsLoading,
modelsError,
// Connection actions
connect,
disconnect,
clearLogs,
refreshLocalGateway,
startLocalGateway,
stopLocalGateway,
restartLocalGateway,
// Agent actions
loadClones,
createClone,
updateClone,
deleteClone,
loadUsageStats,
loadPluginStatus,
// Hand actions
loadHands,
getHandDetails,
triggerHand,
loadHandRuns,
loadTriggers,
createTrigger,
deleteTrigger,
loadApprovals,
respondToApproval,
// Workflow actions
loadWorkflows,
getWorkflow,
createWorkflow,
updateWorkflow,
deleteWorkflow,
triggerWorkflow,
loadWorkflowRuns,
// Config actions
loadQuickConfig,
saveQuickConfig,
loadWorkspaceInfo,
loadChannels,
getChannel,
createChannel,
updateChannel,
deleteChannel,
loadScheduledTasks,
createScheduledTask,
loadSkillsCatalog,
getSkill,
createSkill,
updateSkill,
deleteSkill,
loadModels,
// Legacy sendMessage (delegates to client)
sendMessage: async (message: string, sessionKey?: string) => {
return client.chat(message, { sessionKey });
},
}), [
connectionState, gatewayVersion, connectionError, logs, localGateway, localGatewayBusy, isLoading, client,
clones, usageStats, pluginStatus,
hands, handRuns, triggers, approvals,
workflows, workflowRuns,
quickConfig, workspaceInfo, channels, scheduledTasks, skillsCatalog, models, modelsLoading, modelsError,
connect, disconnect, clearLogs, refreshLocalGateway, startLocalGateway, stopLocalGateway, restartLocalGateway,
loadClones, createClone, updateClone, deleteClone, loadUsageStats, loadPluginStatus,
loadHands, getHandDetails, triggerHand, loadHandRuns, loadTriggers, createTrigger, deleteTrigger, loadApprovals, respondToApproval,
loadWorkflows, getWorkflow, createWorkflow, updateWorkflow, deleteWorkflow, triggerWorkflow, loadWorkflowRuns,
loadQuickConfig, saveQuickConfig, loadWorkspaceInfo, loadChannels, getChannel, createChannel, updateChannel, deleteChannel,
loadScheduledTasks, createScheduledTask, loadSkillsCatalog, getSkill, createSkill, updateSkill, deleteSkill, loadModels,
]);
setSecurityStoreClient(client);
setSessionStoreClient(client);
}
/**

View File

@@ -0,0 +1,141 @@
/**
* securityStore.ts - Security Status and Audit Log Management
*
* Extracted from gatewayStore.ts for Store Refactoring.
* Manages OpenFang security layers, security status, and audit logs.
*/
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
export interface SecurityLayer {
name: string;
enabled: boolean;
description?: string;
}
export interface SecurityStatus {
layers: SecurityLayer[];
enabledCount: number;
totalCount: number;
securityLevel: 'critical' | 'high' | 'medium' | 'low';
}
export interface AuditLogEntry {
id: string;
timestamp: string;
action: string;
actor?: string;
result?: 'success' | 'failure';
details?: Record<string, unknown>;
// Merkle hash chain fields (OpenFang)
hash?: string;
previousHash?: string;
}
// === Helpers ===
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
}
// === Client Interface ===
interface SecurityClient {
getSecurityStatus(): Promise<{ layers?: SecurityLayer[] } | null>;
getAuditLogs(opts?: { limit?: number; offset?: number }): Promise<{ logs?: AuditLogEntry[] } | null>;
}
// === Store Interface ===
export interface SecurityStateSlice {
securityStatus: SecurityStatus | null;
securityStatusLoading: boolean;
securityStatusError: string | null;
auditLogs: AuditLogEntry[];
auditLogsLoading: boolean;
}
export interface SecurityActionsSlice {
loadSecurityStatus: () => Promise<void>;
loadAuditLogs: (opts?: { limit?: number; offset?: number }) => Promise<void>;
}
export type SecurityStore = SecurityStateSlice & SecurityActionsSlice & { client: SecurityClient | null };
// === Store Implementation ===
export const useSecurityStore = create<SecurityStore>((set, get) => ({
// Initial state
securityStatus: null,
securityStatusLoading: false,
securityStatusError: null,
auditLogs: [],
auditLogsLoading: false,
client: null,
loadSecurityStatus: async () => {
const client = get().client;
if (!client) return;
set({ securityStatusLoading: true, securityStatusError: null });
try {
const result = await client.getSecurityStatus();
if (result?.layers) {
const layers = result.layers as SecurityLayer[];
const enabledCount = layers.filter(l => l.enabled).length;
const totalCount = layers.length;
const securityLevel = calculateSecurityLevel(enabledCount, totalCount);
set({
securityStatus: { layers, enabledCount, totalCount, securityLevel },
securityStatusLoading: false,
securityStatusError: null,
});
} else {
set({
securityStatusLoading: false,
securityStatusError: 'API returned no data',
});
}
} catch (err: unknown) {
set({
securityStatusLoading: false,
securityStatusError: (err instanceof Error ? err.message : String(err)) || 'Security API not available',
});
}
},
loadAuditLogs: async (opts?: { limit?: number; offset?: number }) => {
const client = get().client;
if (!client) return;
set({ auditLogsLoading: true });
try {
const result = await client.getAuditLogs(opts);
set({ auditLogs: (result?.logs || []) as AuditLogEntry[], auditLogsLoading: false });
} catch {
set({ auditLogsLoading: false });
/* ignore if audit API not available */
}
},
}));
// === Client Injection ===
function createSecurityClientFromGateway(client: GatewayClient): SecurityClient {
return {
getSecurityStatus: () => client.getSecurityStatus() as Promise<{ layers?: SecurityLayer[] } | null>,
getAuditLogs: (opts) => client.getAuditLogs(opts) as Promise<{ logs?: AuditLogEntry[] } | null>,
};
}
export function setSecurityStoreClient(client: unknown): void {
const securityClient = createSecurityClientFromGateway(client as GatewayClient);
useSecurityStore.setState({ client: securityClient });
}

View File

@@ -0,0 +1,228 @@
/**
* sessionStore.ts - Session Management Store
*
* Extracted from gatewayStore.ts for Store Refactoring.
* Manages Gateway sessions and session messages.
*/
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
export interface Session {
id: string;
agentId: string;
createdAt: string;
updatedAt?: string;
messageCount?: number;
status?: 'active' | 'archived' | 'expired';
metadata?: Record<string, unknown>;
}
export interface SessionMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: string;
tokens?: { input?: number; output?: number };
}
// === Raw API Response Types ===
interface RawSession {
id?: string;
sessionId?: string;
session_id?: string;
agentId?: string;
agent_id?: string;
model?: string;
status?: string;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
messageCount?: number;
message_count?: number;
metadata?: Record<string, unknown>;
}
interface RawSessionMessage {
id?: string;
messageId?: string;
message_id?: string;
role?: string;
content?: string;
createdAt?: string;
created_at?: string;
metadata?: Record<string, unknown>;
tokens?: { input?: number; output?: number };
}
// === Client Interface ===
interface SessionClient {
listSessions(opts?: { limit?: number; offset?: number }): Promise<{ sessions?: RawSession[] } | null>;
getSession(sessionId: string): Promise<Record<string, unknown> | null>;
createSession(params: { agent_id: string; metadata?: Record<string, unknown> }): Promise<Record<string, unknown> | null>;
deleteSession(sessionId: string): Promise<void>;
getSessionMessages(sessionId: string, opts?: { limit?: number; offset?: number }): Promise<{ messages?: RawSessionMessage[] } | null>;
}
// === Store Interface ===
export interface SessionStateSlice {
sessions: Session[];
sessionMessages: Record<string, SessionMessage[]>;
isLoading: boolean;
error: string | null;
}
export interface SessionActionsSlice {
loadSessions: (opts?: { limit?: number; offset?: number }) => Promise<void>;
getSession: (sessionId: string) => Promise<Session | undefined>;
createSession: (agentId: string, metadata?: Record<string, unknown>) => Promise<Session | undefined>;
deleteSession: (sessionId: string) => Promise<void>;
loadSessionMessages: (sessionId: string, opts?: { limit?: number; offset?: number }) => Promise<SessionMessage[]>;
}
export type SessionStore = SessionStateSlice & SessionActionsSlice & { client: SessionClient | null };
// === Store Implementation ===
export const useSessionStore = create<SessionStore>((set, get) => ({
// Initial state
sessions: [],
sessionMessages: {},
isLoading: false,
error: null,
client: null,
loadSessions: async (opts?: { limit?: number; offset?: number }) => {
const client = get().client;
if (!client) return;
try {
const result = await client.listSessions(opts);
const sessions: Session[] = (result?.sessions || [])
.filter((s: RawSession) => s.id || s.session_id)
.map((s: RawSession) => ({
id: s.id || s.session_id || '',
agentId: s.agent_id || s.agentId || '',
createdAt: s.created_at || s.createdAt || new Date().toISOString(),
updatedAt: s.updated_at || s.updatedAt,
messageCount: s.message_count || s.messageCount,
status: s.status as Session['status'],
metadata: s.metadata,
}));
set({ sessions });
} catch {
/* ignore if sessions API not available */
}
},
getSession: async (sessionId: string) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.getSession(sessionId);
if (!result) return undefined;
const session: Session = {
id: result.id as string,
agentId: result.agent_id as string,
createdAt: result.created_at as string,
updatedAt: result.updated_at as string | undefined,
messageCount: result.message_count as number | undefined,
status: result.status as Session['status'],
metadata: result.metadata as Record<string, unknown> | undefined,
};
set(state => ({
sessions: state.sessions.some(s => s.id === sessionId)
? state.sessions.map(s => s.id === sessionId ? session : s)
: [...state.sessions, session],
}));
return session;
} catch {
return undefined;
}
},
createSession: async (agentId: string, metadata?: Record<string, unknown>) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.createSession({ agent_id: agentId, metadata });
if (!result) return undefined;
const session: Session = {
id: result.id as string,
agentId: result.agent_id as string,
createdAt: result.created_at as string,
status: 'active',
metadata,
};
set(state => ({ sessions: [...state.sessions, session] }));
return session;
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
return undefined;
}
},
deleteSession: async (sessionId: string) => {
const client = get().client;
if (!client) return;
try {
await client.deleteSession(sessionId);
set(state => ({
sessions: state.sessions.filter(s => s.id !== sessionId),
sessionMessages: Object.fromEntries(
Object.entries(state.sessionMessages).filter(([id]) => id !== sessionId)
),
}));
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
throw err;
}
},
loadSessionMessages: async (sessionId: string, opts?: { limit?: number; offset?: number }) => {
const client = get().client;
if (!client) return [];
try {
const result = await client.getSessionMessages(sessionId, opts);
const messages: SessionMessage[] = (result?.messages || []).map((m: RawSessionMessage) => ({
id: m.id || m.message_id || '',
role: (m.role || 'user') as 'user' | 'assistant' | 'system',
content: m.content || '',
createdAt: m.created_at || m.createdAt || new Date().toISOString(),
tokens: m.tokens,
}));
set(state => ({
sessionMessages: { ...state.sessionMessages, [sessionId]: messages },
}));
return messages;
} catch {
return [];
}
},
}));
// === Client Injection ===
function createSessionClientFromGateway(client: GatewayClient): SessionClient {
return {
listSessions: (opts) => client.listSessions(opts),
getSession: (sessionId) => client.getSession(sessionId),
createSession: (params) => client.createSession(params),
deleteSession: async (sessionId) => { await client.deleteSession(sessionId); },
getSessionMessages: (sessionId, opts) => client.getSessionMessages(sessionId, opts),
};
}
export function setSessionStoreClient(client: unknown): void {
const sessionClient = createSessionClientFromGateway(client as GatewayClient);
useSessionStore.setState({ client: sessionClient });
}

View File

@@ -24,8 +24,6 @@ import type {
ReviewFeedback,
TaskDeliverable,
} from '../types/team';
import { parseJsonOrDefault } from '../lib/json-utils';
// === Store State ===
interface TeamStoreState {

View File

@@ -1,7 +1,23 @@
import { create } from 'zustand';
import { Workflow, WorkflowRun } from './gatewayStore';
import type { GatewayClient } from '../lib/gateway-client';
// === Core Types (previously imported from gatewayStore) ===
export interface Workflow {
id: string;
name: string;
steps: number;
description?: string;
createdAt?: string;
}
export interface WorkflowRun {
runId: string;
status: string;
step?: string;
result?: unknown;
}
// === Types ===
interface RawWorkflowRun {
@@ -256,8 +272,7 @@ export const useWorkflowStore = create<WorkflowStateSlice & WorkflowActionsSlice
},
}));
// Re-export types from gatewayStore for convenience
export type { Workflow, WorkflowRun };
// Types are now defined locally in this file (no longer imported from gatewayStore)
// === Client Injection ===

View File

@@ -0,0 +1,277 @@
# ZCLAW 代码层面未完成工作分析
> 分析日期2026-03-20
> 基于 git diff 和代码审查
## 一、当前重构状态
### 1.1 已完成的重构
| 模块 | 原始状态 | 当前状态 | 说明 |
|------|----------|----------|------|
| gatewayStore.ts | 1800+ 行巨型文件 | ~100 行 facade | 已拆分为 7 个 domain stores |
| gateway-client.ts | 65KB 单文件 | 模块化 | 拆分为 5 个文件 |
| viking-*.ts | 5 个文件 | 已删除 | 移至 docs/archive/ |
| vector-memory.ts | 385 行 | 已删除 | 功能移至 Rust 后端 |
| context-builder.ts | 409 行 | 已删除 | 功能移至 Rust 后端 |
| session-persistence.ts | 655 行 | 已删除 | 功能移至 Rust 后端 |
### 1.2 新增文件(未提交)
```
desktop/src/lib/gateway-api.ts - REST API 方法实现
desktop/src/lib/gateway-auth.ts - Ed25519 设备认证
desktop/src/lib/gateway-storage.ts - URL/token 持久化
desktop/src/lib/gateway-types.ts - 协议类型定义
desktop/src/store/securityStore.ts - 安全状态管理
desktop/src/store/sessionStore.ts - 会话状态管理
```
---
## 二、代码层面待完成工作
### 2.1 🔴 高优先级Store 迁移
**问题**App.tsx 和 34+ 个组件仍使用 `useGatewayStore` (兼容层),而非新的 domain-specific stores。
**待迁移组件清单**
```bash
# 查找所有使用 useGatewayStore 的文件
desktop/src/App.tsx # 核心入口
desktop/src/components/ChatArea.tsx
desktop/src/components/Sidebar.tsx
desktop/src/components/RightPanel.tsx
desktop/src/components/HandsPanel.tsx
desktop/src/components/HandApprovalModal.tsx
# ... 更多组件
```
**迁移策略**
```typescript
// 旧方式(兼容层,不推荐)
const { hands, triggers, approvals } = useGatewayStore();
// 新方式(推荐,按需导入)
import { useHandStore } from './store/handStore';
const { hands, triggers, approvals } = useHandStore();
```
**收益**
- 减少 re-render当前 useCompositeStore 订阅 40+ 状态)
- 更清晰的依赖关系
- 更好的代码分割
---
### 2.2 🔴 高优先级useCompositeStore 性能优化
**问题**`store/index.ts` 中的 `useCompositeStore` 订阅了所有 store 的几乎所有状态。
```typescript
// 当前实现(有问题)
export function useCompositeStore() {
// 订阅了 40+ 个状态
const connectionState = useConnectionStore((s) => s.connectionState);
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
// ... 40+ 个订阅
}
```
**建议**
1. 废弃 `useCompositeStore`
2. 组件直接使用 domain-specific stores
3. 仅在确实需要跨域状态的场景使用 selector 模式
---
### 2.3 🟡 中优先级:测试文件更新
**已删除测试文件**
```
tests/desktop/session-persistence.test.ts (424 行)
tests/desktop/vector-memory.test.ts (299 行)
tests/desktop/viking-adapter.test.ts (446 行)
```
**需更新测试文件**
```
tests/desktop/gatewayStore.test.ts (190 行需更新)
tests/desktop/swarm-skills.test.ts (6 行需更新)
```
**缺失测试**
- `securityStore.test.ts` - 新 store 无测试
- `sessionStore.test.ts` - 新 store 无测试
- `gateway-api.test.ts` - 新模块无测试
- `gateway-auth.test.ts` - 新模块无测试
---
### 2.4 🟡 中优先级:类型定义清理
**问题**`gatewayStore.ts` 仍定义了一些类型,这些应该移到各自的 store 或 types 文件。
```typescript
// gatewayStore.ts 中定义的类型(应迁移)
export interface HandRunStore { ... }
export interface ScheduledJob { ... }
export interface EventTrigger { ... }
export interface RunHistoryEntry { ... }
```
**建议**
1. `HandRunStore``handStore.ts`
2. `ScheduledJob`, `EventTrigger`, `RunHistoryEntry` → 新建 `types/automation.ts`
---
### 2.5 🟢 低优先级:组件集成度提升
**存在但集成度低的组件**
| 组件 | 文件 | 问题 |
|------|------|------|
| HeartbeatConfig | `components/Settings/HeartbeatConfig.tsx` | 未在 Settings 页面使用 |
| CreateTriggerModal | `components/Automation/CreateTriggerModal.tsx` | 未在 Automation 面板集成 |
| PersonalitySelector | `components/Agent/PersonalitySelector.tsx` | 未在 Agent 创建流程使用 |
| ScenarioTags | `components/Agent/ScenarioTags.tsx` | 未在 Agent 编辑使用 |
| DevQALoop | `components/Dev/DevQALoop.tsx` | 开发调试组件,未集成 |
---
### 2.6 🟢 低优先级:文档与代码同步
**文档声称完成但代码未验证**
| 功能 | 文档状态 | 代码状态 |
|------|----------|----------|
| 身份演化 | ✅ 完成 | ❓ 未验证与后端集成 |
| 上下文压缩 | ✅ 完成 | ❓ 未验证触发条件 |
| 心跳巡检 | ✅ 完成 | ❓ 未验证实际执行 |
| 记忆持久化 | ✅ 完成 | ❓ 依赖 localStorage |
---
## 三、Tauri Rust 后端状态
### 3.1 已实现的 Rust 模块
| 模块 | 文件 | 功能 | 状态 |
|------|------|------|------|
| OpenFang 集成 | `lib.rs` | Gateway 生命周期管理 | ✅ 完整 |
| Viking Server | `viking_server.rs` | 本地向量数据库 | ✅ 完整 |
| Viking Commands | `viking_commands.rs` | Viking CLI 封装 | ✅ 完整 |
| Browser Automation | `browser/*.rs` | Fantoccini 浏览器控制 | ✅ 完整 |
| Memory Extraction | `memory/*.rs` | 记忆提取、上下文构建 | ✅ 完整 |
| LLM Integration | `llm/mod.rs` | LLM 调用封装 | ✅ 完整 |
| Secure Storage | `secure_storage.rs` | OS keyring/keychain | ✅ 完整 |
### 3.2 Rust 后端与前端对齐问题
**问题**:前端 `lib/` 下有大量智能逻辑(记忆、反思、心跳),与 Rust 后端功能重叠。
| 前端文件 | Rust 对应 | 建议 |
|----------|-----------|------|
| `agent-memory.ts` | `memory/extractor.rs` | 统一到 Rust 端 |
| `context-compactor.ts` | `memory/context_builder.rs` | 统一到 Rust 端 |
| `heartbeat-engine.ts` | 无 | 迁移到 Rust 端 |
| `reflection-engine.ts` | 无 | 迁移到 Rust 端 |
| `agent-identity.ts` | 无 | 迁移到 Rust 端 |
**收益**
- 后端持久运行(关闭浏览器不中断)
- 多端共享 Agent 状态
- 更可靠的数据持久化
---
## 四、技术债务清单
### 4.1 代码质量
| 问题 | 位置 | 严重度 |
|------|------|--------|
| 使用 `any` 类型 | 多处 | 中 |
| 空 catch 块 | `sessionStore.ts:119` | 低 |
| 硬编码字符串 | 多处 | 低 |
| 重复的类型定义 | `gatewayStore.ts` vs 各 store | 中 |
### 4.2 架构问题
| 问题 | 说明 | 建议 |
|------|------|------|
| 前端承担后端职责 | 记忆/反思/心跳在前端 | 迁移到 Rust |
| Store 过度订阅 | useCompositeStore 订阅 40+ 状态 | 按需订阅 |
| 兼容层膨胀 | gatewayStore.ts 作为 facade | 逐步移除 |
---
## 五、行动建议
### 本周必做
1. **提交当前重构** - gateway-client 模块化、store 拆分已完成
2. **更新测试** - 为新 store 和 gateway 模块添加测试
3. **迁移 App.tsx** - 从 useGatewayStore 迁移到 domain stores
### 两周内
1. **移除 useCompositeStore** - 组件直接使用 domain stores
2. **清理类型定义** - 统一到各自的 store 或 types 文件
3. **集成低使用率组件** - HeartbeatConfig, CreateTriggerModal 等
### 一个月内
1. **前端智能层迁移** - 将记忆/反思/心跳迁移到 Rust 后端
2. **端到端测试** - Playwright + Tauri driver 验证核心流程
3. **性能优化** - 减少不必要的 re-render
---
## 六、代码变更统计
```
当前未提交变更:
21 files changed, 578 insertions(+), 7324 deletions(-)
删除的文件(已归档):
- desktop/src/lib/context-builder.ts (409 行)
- desktop/src/lib/session-persistence.ts (655 行)
- desktop/src/lib/vector-memory.ts (385 行)
- desktop/src/lib/viking-adapter.ts (734 行)
- desktop/src/lib/viking-client.ts (353 行)
- desktop/src/lib/viking-local.ts (144 行)
- desktop/src/lib/viking-memory-adapter.ts (408 行)
- desktop/src/lib/viking-server-manager.ts (231 行)
新增的文件:
+ desktop/src/lib/gateway-api.ts (新建)
+ desktop/src/lib/gateway-auth.ts (新建)
+ desktop/src/lib/gateway-storage.ts (新建)
+ desktop/src/lib/gateway-types.ts (新建)
+ desktop/src/store/securityStore.ts (新建)
+ desktop/src/store/sessionStore.ts (新建)
```
---
## 七、总结
**重构进度**:约 70% 完成
- ✅ Store 拆分完成
- ✅ Gateway Client 模块化完成
- ✅ Viking 相关代码清理完成
- ⏳ 组件迁移进行中(仍使用兼容层)
- ⏳ 测试更新待完成
- ❌ 前端智能层迁移未开始
**最大风险**
1. useCompositeStore 性能问题40+ 状态订阅)
2. 前端智能逻辑(记忆/反思)依赖 localStorage不可靠
3. 缺少端到端测试验证
**建议策略**
先完成当前重构(提交、测试、组件迁移),再启动前端智能层向 Rust 迁移。

View File

@@ -0,0 +1,304 @@
# ZCLAW 项目深度梳理分析与头脑风暴
> 分析日期2026-03-20
## 一、项目全景概览
ZCLAW 是一个基于 OpenFang (类 OpenClaw) 定制化的中文优先 AI Agent 桌面客户端,采用 Tauri 2.0 (Rust + React 19) 架构,目标对标智谱 AutoClaw 和腾讯 QClaw。
### 1.1 技术栈全景
| 层级 | 技术选型 | 成熟度 |
|------|----------|--------|
| 桌面框架 | Tauri 2.0 (Rust + React 19) | ✅ 合理 |
| 前端 | React 19 + TailwindCSS + Zustand + Framer Motion + Lucide | ✅ 现代 |
| 后端通信 | WebSocket (Gateway Protocol v3) + Tauri Commands | ✅ 完整 |
| 状态管理 | Zustand (13 个 Store 文件) + Composite Store | ⚠️ 过度拆分 |
| 配置格式 | TOML (替代 JSON) | ✅ 用户友好 |
| 测试 | Vitest + jsdom (317 tests) | ✅ 覆盖良好 |
| 依赖 | 极精简 (ws + zod) | ✅ 轻量 |
### 1.2 规模数据
| 维度 | 数量 |
|------|------|
| 前端组件 | 50+ .tsx 文件 (88 个 components 目录项) |
| Lib 工具 | 42 个 lib 文件 (~65KB gateway-client 最大) |
| Store 文件 | 13 个 (gatewayStore 59KB 为最大单文件) |
| 类型定义 | 13 个类型文件 |
| Skills | 68 个 SKILL.md 技能定义 |
| Hands | 7 个 HAND.toml 能力包 |
| Plugins | 3 个 (chinese-models, feishu, ui) |
| 测试 | 15 个测试文件, 317 tests |
| 文档 | 84 个 docs 目录项 |
---
## 二、架构深度分析
### 2.1 数据流架构
```
用户操作 → React UI → Zustand Store → GatewayClient (WS) → OpenFang Kernel
↘ TauriGateway (IPC) → Rust Backend
↘ VikingClient → OpenViking (向量DB)
```
**优点:**
- 清晰的分层设计UI/Store/Client 职责明确
- 统一的 Gateway Client 抽象层,禁止组件内直接创建 WS
**问题:**
- gatewayStore.ts 59KB是一个巨型 God Store虽然已拆分出 connectionStore/agentStore/handStore 等,但旧的 gatewayStore 仍保留且被 App.tsx 直接引用
- Store Coordinator (store/index.ts) 的 useCompositeStore 订阅了所有 store 的几乎全部状态,会导致任何状态变化触发全量 re-render
### 2.2 通信层分析
**Node.js 端 (src/gateway/):**
- manager.ts — 子进程管理,有自动重启、健康检查,设计完整
- ws-client.ts — 完整的 Protocol v3 握手、请求/响应、事件订阅、自动重连
**浏览器端 (desktop/src/lib/gateway-client.ts):**
- 65KB 的单文件职责过重包含了连接管理、RPC 调用、事件监听、所有业务方法
### 2.3 智能层分析
这是 ZCLAW 最有价值的差异化层:
| 模块 | 文件 | 测试 | 集成 |
|------|------|------|------|
| Agent 记忆 | agent-memory.ts (14KB) | 42 tests | ✅ MemoryPanel |
| 身份演化 | agent-identity.ts (10KB) | ✅ | ❓ 后端 |
| 上下文压缩 | context-compactor.ts (14KB) | 23 tests | ✅ chatStore |
| 自我反思 | reflection-engine.ts (21KB) | 28 tests | ✅ ReflectionLog |
| 心跳引擎 | heartbeat-engine.ts (10KB) | ✅ | ❓ 未验证 |
| 自主授权 | autonomy-manager.ts (15KB) | ✅ | ✅ AutonomyConfig |
| 主动学习 | active-learning.ts (10KB) | ✅ | ✅ ActiveLearningPanel |
| Agent 蜂群 | agent-swarm.ts (16KB) | 43 tests | ✅ SwarmDashboard |
| 向量记忆 | vector-memory.ts (11KB) | 10 tests | ❌ 未集成到 UI |
### 2.4 前端组件分析
**已集成且工作正常:**
ChatArea, RightPanel (多 tab), Sidebar, Settings (10 页), HandsPanel, HandApprovalModal, SwarmDashboard, TeamCollaborationView, SkillMarket, AgentOnboardingWizard, AutomationPanel
**存在但集成度低:**
HeartbeatConfig, CreateTriggerModal, PersonalitySelector, ScenarioTags, DevQALoop
---
## 三、SWOT 分析
### 💪 优势 (Strengths)
1. **技术栈先进** — Tauri 2.0 比 Electron 体积小 10x+,性能好
2. **智能层设计深刻** — 记忆系统、身份演化、自我反思、上下文压缩是真正的差异化能力
3. **Skills 生态丰富** — 68 个 Skill 覆盖写作、数据分析、社媒运营、前端开发等
4. **Hands 系统完整** — 7 个能力包 + 审批/触发/审计全链路
5. **中文优先** — 中文模型 Provider (GLM/Qwen/Kimi/MiniMax) + 飞书集成
6. **测试覆盖好** — 317 tests, 涵盖核心 lib 和 store
7. **文档极其详尽** — 84 个文档文件,有架构图、偏离分析、审计报告、知识库
### 🔴 劣势 (Weaknesses)
1. **代码膨胀严重**
- gatewayStore.ts 59KB, gateway-client.ts 65KB — 单文件过大
- 42 个 lib 文件,部分职责重叠 (viking-*.ts 有 5 个文件)
- 88 个 components复杂度管理困难
2. **v1→v2 架构迁移未彻底**
- src/core/ 归档代码仍保留v1 的 multi-agent/memory/proactive 与 v2 的 desktop/src/lib 存在概念重叠
- 新旧 store 并存 (gatewayStore vs connectionStore/agentStore/...)
3. **前后端耦合不清晰**
- 大量智能逻辑 (记忆、反思、压缩) 在前端 lib 中实现
- 这些应该是后端/Gateway 的职责,放在前端会导致:数据不持久、多端不同步、逻辑重复
4. **真实集成测试缺失**
- PROGRESS.md 中 Phase 4 "真实集成测试"全部未完成
- 没有端到端测试验证 Gateway 连接→消息收发→模型调用
5. **Tauri Rust 后端基本空白**
- desktop/src-tauri/ 标记为 TODO
- 安全存储、子进程管理等应由 Rust 端承担
6. **配置系统双重标准**
- config.toml + chinese-providers.toml 是 TOML 格式
- 但 README 提到 openclaw.default.jsonplugins 使用 plugin.json
- 配置格式不统一
### 🟡 机会 (Opportunities)
1. **中国 AI Agent 市场爆发** — 智谱/通义/月之暗面/DeepSeek 的中文模型生态成熟
2. **本地优先隐私诉求增长** — 企业和个人对数据隐私要求越来越高
3. **OpenFang 生态缺口** — 市场上没有优质的中文定制化 OpenFang 桌面客户端
4. **飞书+企业微信整合** — 企业 IM 集成是刚需,特别是在中国市场
5. **Skill 市场变现** — 74 个 Skills 可以发展成社区市场
### 🔵 威胁 (Threats)
1. **竞品迭代极快** — Cursor/Windsurf/AutoClaw/QClaw 都在快速迭代
2. **OpenFang 上游变化** — Gateway Protocol 版本升级可能导致兼容性问题
3. **LLM API 不稳定** — 中国模型厂商的 API 变更频繁
4. **单人/小团队维护压力** — 50+ 组件、42 个 lib、13 个 store 的维护成本极高
---
## 四、关键问题深度诊断
### 4.1 🔴 最大风险:前端承担了后端职责
目前 desktop/src/lib/ 下有大量本应属于后端的逻辑:
```
agent-memory.ts → 应在 Gateway/Rust 端
agent-identity.ts → 应在 Gateway/Rust 端
reflection-engine.ts → 应在 Gateway/Rust 端
heartbeat-engine.ts → 应在 Gateway/Rust 端
context-compactor.ts → 应在 Gateway/Rust 端
agent-swarm.ts → 应在 Gateway/Rust 端
vector-memory.ts → 应在 Gateway/Rust 端
```
**后果:**
- 关闭浏览器/桌面端后,心跳、反思、主动学习全部停止
- 数据持久化依赖 localStorage不可靠
- 无法多端共享 Agent 状态
### 4.2 🔴 Store 架构需要统一
当前存在两套 store 体系:
- 旧 gatewayStore.ts (59KB) — 被 App.tsx 直接使用
- 新 拆分的 connectionStore/agentStore/handStore/workflowStore/configStore
store/index.ts 试图用 useCompositeStore 桥接,但依赖列表长达 40+ 项,任何状态变化都会触发 re-render。
### 4.3 🟡 文档 vs 现实的差距
虽然 FRONTEND_INTEGRATION_AUDIT.md 声称"所有组件已集成",但:
- HeartbeatConfig, CreateTriggerModal, PersonalitySelector 仍未集成
- 身份演化、上下文压缩、心跳巡检的 UI 集成标记为 "❓ 未验证"
- Phase 4 真实集成测试 0% 完成
---
## 五、头脑风暴:未来方向
### 💡 方向一:架构收敛 — "做减法"(推荐优先级 P0
**核心思想:** 项目已经膨胀过快,在增加新功能前应先收敛。
| 行动 | 效果 | 工作量 |
|------|------|--------|
| 将智能层 lib 迁移到 Tauri Rust 端或 Gateway 插件 | 后端持久运行,多端共享 | 大 |
| 彻底删除旧 gatewayStore.ts统一用拆分后的 stores | 消除重复、降低 re-render | 中 |
| 合并 viking-*.ts (5 文件 → 1-2 文件) | 降低复杂度 | 小 |
| 拆分 gateway-client.ts (65KB → 模块化) | 可维护性提升 | 中 |
| 统一配置格式 (TOML 或 JSON不混用) | 用户体验统一 | 小 |
### 💡 方向二:端到端可用性 — "跑通闭环"(推荐优先级 P0
**核心思想:** 317 个单元测试通过不代表产品可用,需要真实跑通。
| 行动 | 验证点 |
|------|--------|
| 安装 OpenFang验证 Gateway 连接 | 子进程启动 → WS 握手 → 心跳 |
| 配置中文模型 API Key测试对话 | 流式响应 → 模型切换 → 上下文管理 |
| 测试飞书 Channel 收发消息 | OAuth → 消息接收 → Agent 处理 → 回复 |
| 测试 Hands 触发完整流程 | 意图识别 → 参数收集 → 审批 → 执行 → 结果 |
| 验证记忆持久化 | 重启后记忆保留 → 跨会话记忆命中 |
### 💡 方向三Tauri Rust 后端落地 — "真正的桌面应用"
**现状:** desktop/src-tauri/ 基本空白,大量能力应由 Rust 端承担。
**设想:**
```rust
// Tauri Commands 愿景
#[tauri::command]
async fn start_gateway(config: GatewayConfig) -> Result<GatewayStatus>
#[tauri::command]
async fn memory_search(query: String) -> Result<Vec<MemoryEntry>>
#[tauri::command]
async fn heartbeat_tick() -> Result<HeartbeatResult>
#[tauri::command]
async fn secure_store_get(key: String) -> Result<String>
```
**好处:**
- Gateway 生命周期由 Rust 管理,稳定性↑
- 记忆/反思/心跳在 Rust 后台持续运行
- 安全存储用系统 Keychain不再依赖 localStorage
- 离线能力Rust 端可以在无网络时缓存操作
### 💡 方向四:差异化功能深化 — "不做小 ChatGPT"
ZCLAW 不应与 ChatGPT/Claude Desktop 竞争"对话体验",而应聚焦:
| 差异化方向 | 竞品不具备 | 实现路径 |
|------------|------------|----------|
| "AI 分身"日常代理 | AutoClaw 有但不开放 | Clone 系统 + 飞书/微信 Channel → 让 AI 分身帮你回消息、整理日程 |
| "本地知识库" Agent | ChatGPT/Claude 是云端 | 向量记忆 + 本地文件索引 → 跨项目知识积累 |
| "自主工作流"引擎 | Cursor 只做代码辅助 | Hands + Scheduler + Workflow → 定时任务自动执行(如每日新闻摘要、竞品监控) |
| "团队蜂群"协作 | 市场上极少 | SwarmDashboard 已有基础 → 多 Agent 分工合作解决复杂问题 |
| "中文场景" Skills | 国际产品不覆盖 | 小红书运营、知乎策略、微信公众号、飞书文档操作 → 已有 Skill 定义 |
### 💡 方向五:开发者体验 (DX) 优化
| 改进 | 现状 | 目标 |
|------|------|------|
| 启动脚本 | 需要 start-all.ps1 + 多步操作 | pnpm dev 一键启动全栈 |
| 热重载 | Vite HMR 可用 | 加上 Gateway 插件热重载 |
| 类型安全 | 部分 any | 全量 strict TypeScript |
| E2E 测试 | 无 | Playwright + Tauri driver |
| CI/CD | 无 | GitHub Actions 自动测试+构建 |
### 💡 方向六:商业化路径探索
基于现有能力的最短变现路径:
```
阶段 1 (Q2): "个人 AI 助手" — 免费开源
→ 建立 GitHub 社区 → 收集种子用户反馈
→ 核心卖点: 本地优先 + 中文模型 + 飞书集成
阶段 2 (Q3): "Pro 版" — 订阅制 ¥49/月
→ 云端记忆同步
→ 高级 Skills (如量化交易分析、SEO 自动优化)
→ 优先技术支持
阶段 3 (Q4): "团队版" — ¥199/人/月
→ 多 Agent 协作编排
→ 企业级审计日志
→ 私有部署选项
```
---
## 六、行动建议总结
### 🔥 立即要做 (本周)
1. **跑通 Gateway 连接 + 真实模型对话** — 验证产品核心价值
2. **清理 gatewayStore.ts** — 统一到拆分后的 stores消除 59KB 巨型文件
3. **拆分 gateway-client.ts** — 65KB 按职责模块化
### 📌 短期 (2 周)
1. **将心跳/记忆/反思引擎迁到 Tauri Rust 端** — 解决前端承担后端职责的根本问题
2. **添加 E2E 测试** — Playwright 验证核心流程
3. **清理 v1 归档代码** — 移除 src/core/ 的旧系统,减少混淆
### 🎯 中期 (1-2 月)
1. **落地"AI 分身日常代理"场景** — Clone + 飞书 = 用户最容易感知的价值
2. **技能市场 MVP** — 68 个 Skill 已就绪,缺的是发现/安装/评价 UI
3. **本地知识库 + 向量搜索** — Viking 集成代码已有,需要打通到 UI
---
## 核心判断
ZCLAW 的设计远大于实现。智能层的 lib 代码、68 个 Skills、7 个 Hands 的架构设计都非常出色,但最大的短板是**端到端可用性未经验证**。
**建议的策略是:先收敛、跑通闭环、再扩展。**

View File

@@ -0,0 +1,27 @@
# V1 Viking Dead Code Archive
Archived on 2026-03-20 during gateway-client refactoring.
These files formed an isolated dependency island with **zero external consumers** in the active codebase. They implemented a Viking vector database integration that was never wired into the application's import graph.
## Archived Files
### lib/ (8 files)
- `viking-local.ts` — Local Viking server wrapper
- `viking-client.ts` — Viking HTTP client
- `viking-adapter.ts` — Viking adapter (bridge to memory system)
- `viking-server-manager.ts` — Viking server lifecycle management
- `viking-memory-adapter.ts` — Viking ↔ memory adapter
- `context-builder.ts` — Context builder using Viking
- `vector-memory.ts` — Vector memory using Viking
- `session-persistence.ts` — Session persistence using Viking
### tests/ (3 files)
- `viking-adapter.test.ts`
- `vector-memory.test.ts`
- `session-persistence.test.ts`
## Reason for Archival
- No file in `desktop/src/` imports any of these modules
- The entire chain is self-referential (only imports each other)
- Functionality has been superseded by OpenFang's native memory/session APIs

View File

@@ -0,0 +1,153 @@
# ZCLAW Store 优化实施计划
## Context
ZCLAW 项目正在从 monolithic `gatewayStore.ts` 迁移到 domain-specific stores。当前存在以下问题
1. `useCompositeStore` 是死代码0 处使用),订阅 59 个状态
2. 34 处组件仍使用 `useGatewayStore` 兼容层
3. 部分组件已迁移,部分仍需迁移
4. 存在未使用的类型定义
**目标**: 清理死代码,逐步迁移组件到 domain stores减少不必要的 re-render。
---
## Phase 1: 死代码清理 (5 min)
### 1.1 删除 useCompositeStore
**文件**: `desktop/src/store/index.ts`
- 删除第 92-284 行的 `useCompositeStore` 函数
- 保留 `initializeStores` 和 re-exports
### 1.2 删除未使用类型
**文件**: `desktop/src/store/gatewayStore.ts`
- 删除 `HandRunStore`, `ScheduledJob`, `EventTrigger`, `RunHistoryEntry`
**验证**:
```bash
pnpm tsc --noEmit && pnpm vitest run
```
---
## Phase 2: 简单组件迁移 (30 min)
### 2.1 只读状态组件
| 组件 | 迁移到 |
|------|--------|
| `components/Sidebar.tsx` | `useConfigStore` |
| `components/Settings/SecurityStatus.tsx` | `useSecurityStore` |
| `components/Settings/AuditLogsPanel.tsx` | `useSecurityStore` |
| `components/Settings/SecurityLayersPanel.tsx` | `useSecurityStore` |
| `components/Settings/UsageStats.tsx` | `useAgentStore` |
### 2.2 迁移模式
**Before**:
```typescript
const userName = useGatewayStore((state) => state.quickConfig.userName);
```
**After**:
```typescript
import { useConfigStore } from '../store/configStore';
const userName = useConfigStore((s) => s.quickConfig?.userName) || '用户';
```
---
## Phase 3: 单一领域组件迁移 (45 min)
| 组件 | 迁移到 |
|------|--------|
| `components/HandList.tsx` | `useHandStore` |
| `components/ApprovalsPanel.tsx` | `useHandStore` |
| `components/TriggersPanel.tsx` | `useHandStore` |
| `components/WorkflowList.tsx` | `useWorkflowStore` |
| `components/WorkflowHistory.tsx` | `useWorkflowStore` |
---
## Phase 4: 复杂组件迁移 (40 min)
### 4.1 App.tsx
**当前**:
```typescript
const { connect, hands, approveHand, loadHands } = useGatewayStore();
```
**迁移到**:
```typescript
import { useConnectionStore } from '../store/connectionStore';
import { useHandStore } from '../store/handStore';
const connect = useConnectionStore((s) => s.connect);
const hands = useHandStore((s) => s.hands);
const approveHand = useHandStore((s) => s.approveHand);
const loadHands = useHandStore((s) => s.loadHands);
```
### 4.2 CloneManager.tsx → `useAgentStore`
### 4.3 HandTaskPanel.tsx → 统一使用 `useHandStore`
---
## Phase 5: 测试与验证 (30 min)
### 5.1 运行现有测试
```bash
pnpm vitest run
```
### 5.2 手动验证
```bash
pnpm start:dev
```
验证点:
- [ ] App 启动正常,连接 Gateway
- [ ] 聊天功能正常
- [ ] Hands 触发和审批正常
- [ ] Workflows 执行正常
- [ ] 设置页面正常
### 5.3 类型检查
```bash
pnpm tsc --noEmit
```
---
## 关键文件
| 文件 | 操作 |
|------|------|
| `desktop/src/store/index.ts` | 删除 useCompositeStore |
| `desktop/src/store/gatewayStore.ts` | 删除未使用类型,标记 @deprecated |
| `desktop/src/App.tsx` | 迁移到 domain stores |
| `desktop/src/components/Sidebar.tsx` | 迁移到 useConfigStore |
| `desktop/src/components/HandList.tsx` | 迁移到 useHandStore |
| `desktop/src/components/WorkflowList.tsx` | 迁移到 useWorkflowStore |
---
## 风险与缓解
| 风险 | 缓解措施 |
|------|----------|
| 迁移后功能异常 | 每个组件迁移后立即手动测试 |
| 类型错误 | 严格 TypeScript 检查 |
| Post-connect 逻辑丢失 | connectionStore 已有协调逻辑 |
---
## 预计时间
| 阶段 | 时间 |
|------|------|
| Phase 1: 死代码清理 | 5 min |
| Phase 2: 简单组件 | 30 min |
| Phase 3: 单一领域 | 45 min |
| Phase 4: 复杂组件 | 40 min |
| Phase 5: 测试验证 | 30 min |
| **总计** | **~2.5 h** |

View File

@@ -342,6 +342,22 @@ function resetClientMocks() {
});
}
// Helper to inject mockClient into all domain stores
async function injectMockClient() {
const { setAgentStoreClient } = await import('../../desktop/src/store/agentStore');
const { setHandStoreClient } = await import('../../desktop/src/store/handStore');
const { setWorkflowStoreClient } = await import('../../desktop/src/store/workflowStore');
const { setConfigStoreClient } = await import('../../desktop/src/store/configStore');
const { setSecurityStoreClient } = await import('../../desktop/src/store/securityStore');
const { setSessionStoreClient } = await import('../../desktop/src/store/sessionStore');
setAgentStoreClient(mockClient);
setHandStoreClient(mockClient);
setWorkflowStoreClient(mockClient);
setConfigStoreClient(mockClient);
setSecurityStoreClient(mockClient);
setSessionStoreClient(mockClient);
}
describe('gatewayStore desktop flows', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -350,51 +366,59 @@ describe('gatewayStore desktop flows', () => {
});
it('loads post-connect data and syncs agents after a successful connection', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useConnectionStore } = await import('../../desktop/src/store/connectionStore');
const { useAgentStore } = await import('../../desktop/src/store/agentStore');
const { useConfigStore } = await import('../../desktop/src/store/configStore');
await useGatewayStore.getState().connect('ws://127.0.0.1:18789', 'token-123');
await useConnectionStore.getState().connect('ws://127.0.0.1:18789', 'token-123');
// Post-connect: load data from domain stores (mimics facade connect)
await Promise.allSettled([
useConfigStore.getState().loadQuickConfig(),
useConfigStore.getState().loadWorkspaceInfo(),
useAgentStore.getState().loadClones(),
useAgentStore.getState().loadUsageStats(),
useAgentStore.getState().loadPluginStatus(),
useConfigStore.getState().loadScheduledTasks(),
useConfigStore.getState().loadSkillsCatalog(),
useConfigStore.getState().loadChannels(),
]);
const state = useGatewayStore.getState();
expect(mockClient.updateOptions).toHaveBeenCalledWith({
url: 'ws://127.0.0.1:18789',
token: 'token-123',
});
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(state.connectionState).toBe('connected');
expect(state.gatewayVersion).toBe('2026.3.11');
expect(state.quickConfig.gatewayUrl).toBe('ws://127.0.0.1:18789');
expect(state.workspaceInfo?.resolvedPath).toBe('C:/Users/test/.openclaw/zclaw-workspace');
expect(state.pluginStatus).toHaveLength(1);
expect(state.skillsCatalog).toHaveLength(1);
expect(state.channels).toEqual([
expect(useConnectionStore.getState().connectionState).toBe('connected');
expect(useConnectionStore.getState().gatewayVersion).toBe('2026.3.11');
expect(useConfigStore.getState().quickConfig.gatewayUrl).toBe('ws://127.0.0.1:18789');
expect(useConfigStore.getState().workspaceInfo?.resolvedPath).toBe('C:/Users/test/.openclaw/zclaw-workspace');
expect(useAgentStore.getState().pluginStatus).toHaveLength(1);
expect(useConfigStore.getState().skillsCatalog).toHaveLength(1);
expect(useConfigStore.getState().channels).toEqual([
{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 },
]);
expect(syncAgentsMock).toHaveBeenCalledWith([
{
id: 'clone_alpha',
name: 'Alpha',
role: '代码助手',
createdAt: '2026-03-13T00:00:00.000Z',
},
]);
expect(setStoredGatewayUrlMock).toHaveBeenCalledWith('ws://127.0.0.1:18789');
});
it('falls back to feishu probing with the correct chinese label when channels.list is unavailable', async () => {
mockClient.listChannels.mockRejectedValueOnce(new Error('channels.list unavailable'));
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useConfigStore } = await import('../../desktop/src/store/configStore');
await useGatewayStore.getState().loadChannels();
await useConfigStore.getState().loadChannels();
expect(useGatewayStore.getState().channels).toEqual([
expect(useConfigStore.getState().channels).toEqual([
{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 },
]);
});
it('merges and persists quick config updates through the gateway store', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
it('merges and persists quick config updates through the config store', async () => {
await injectMockClient();
const { useConfigStore } = await import('../../desktop/src/store/configStore');
useGatewayStore.setState({
useConfigStore.setState({
quickConfig: {
agentName: 'Alpha',
theme: 'light',
@@ -403,7 +427,7 @@ describe('gatewayStore desktop flows', () => {
},
});
await useGatewayStore.getState().saveQuickConfig({
await useConfigStore.getState().saveQuickConfig({
gatewayToken: 'new-token',
workspaceDir: 'C:/workspace-next',
});
@@ -416,7 +440,7 @@ describe('gatewayStore desktop flows', () => {
workspaceDir: 'C:/workspace-next',
});
expect(setStoredGatewayTokenMock).toHaveBeenCalledWith('new-token');
expect(useGatewayStore.getState().quickConfig.workspaceDir).toBe('C:/workspace-next');
expect(useConfigStore.getState().quickConfig.workspaceDir).toBe('C:/workspace-next');
});
it('returns the updated clone and refreshes the clone list after update', async () => {
@@ -446,10 +470,11 @@ describe('gatewayStore desktop flows', () => {
clones: refreshedClones,
});
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useAgentStore } = await import('../../desktop/src/store/agentStore');
await useGatewayStore.getState().loadClones();
const updated = await useGatewayStore.getState().updateClone('clone_alpha', {
await useAgentStore.getState().loadClones();
const updated = await useAgentStore.getState().updateClone('clone_alpha', {
name: 'Alpha Prime',
role: '架构助手',
});
@@ -459,7 +484,7 @@ describe('gatewayStore desktop flows', () => {
name: 'Alpha Prime',
role: '架构助手',
});
expect(useGatewayStore.getState().clones).toEqual(refreshedClones);
expect(useAgentStore.getState().clones).toEqual(refreshedClones);
});
});
@@ -471,12 +496,13 @@ describe('OpenFang actions', () => {
});
it('loads hands from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
await useGatewayStore.getState().loadHands();
await useHandStore.getState().loadHands();
expect(mockClient.listHands).toHaveBeenCalledTimes(1);
expect(useGatewayStore.getState().hands).toEqual([
expect(useHandStore.getState().hands).toEqual([
{
id: 'echo',
name: 'echo',
@@ -503,9 +529,10 @@ describe('OpenFang actions', () => {
});
it('triggers a hand and returns the run result', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
const result = await useGatewayStore.getState().triggerHand('echo', { message: 'hello' });
const result = await useHandStore.getState().triggerHand('echo', { message: 'hello' });
expect(mockClient.triggerHand).toHaveBeenCalledWith('echo', { message: 'hello' });
expect(result).toMatchObject({
@@ -516,29 +543,32 @@ describe('OpenFang actions', () => {
it('sets error when triggerHand fails', async () => {
mockClient.triggerHand.mockRejectedValueOnce(new Error('Hand not found'));
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
const result = await useGatewayStore.getState().triggerHand('nonexistent');
const result = await useHandStore.getState().triggerHand('nonexistent');
expect(result).toBeUndefined();
expect(useGatewayStore.getState().error).toBe('Hand not found');
expect(useHandStore.getState().error).toBe('Hand not found');
});
it('loads workflows from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
await useGatewayStore.getState().loadWorkflows();
await useWorkflowStore.getState().loadWorkflows();
expect(mockClient.listWorkflows).toHaveBeenCalledTimes(1);
expect(useGatewayStore.getState().workflows).toEqual([
expect(useWorkflowStore.getState().workflows).toEqual([
{ id: 'wf_1', name: 'Data Pipeline', steps: 3 },
]);
});
it('executes a workflow and returns the run result', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
const result = await useGatewayStore.getState().executeWorkflow('wf_1', { input: 'data' });
const result = await useWorkflowStore.getState().triggerWorkflow('wf_1', { input: 'data' });
expect(mockClient.executeWorkflow).toHaveBeenCalledWith('wf_1', { input: 'data' });
expect(result).toMatchObject({
@@ -548,46 +578,50 @@ describe('OpenFang actions', () => {
});
it('loads triggers from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
await useGatewayStore.getState().loadTriggers();
await useHandStore.getState().loadTriggers();
expect(mockClient.listTriggers).toHaveBeenCalledTimes(1);
expect(useGatewayStore.getState().triggers).toEqual([
expect(useHandStore.getState().triggers).toEqual([
{ id: 'trig_1', type: 'webhook', enabled: true },
]);
});
it('loads audit logs from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadAuditLogs({ limit: 50, offset: 0 });
await useSecurityStore.getState().loadAuditLogs({ limit: 50, offset: 0 });
expect(mockClient.getAuditLogs).toHaveBeenCalledWith({ limit: 50, offset: 0 });
expect(useGatewayStore.getState().auditLogs).toEqual([
expect(useSecurityStore.getState().auditLogs).toEqual([
{ id: 'log_1', timestamp: '2026-03-13T10:00:00Z', action: 'hand.trigger', actor: 'user1' },
]);
});
it('initializes OpenFang state with empty arrays', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
const { useHandStore } = await import('../../desktop/src/store/handStore');
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
const state = useGatewayStore.getState();
expect(state.hands).toEqual([]);
expect(state.workflows).toEqual([]);
expect(state.triggers).toEqual([]);
expect(state.auditLogs).toEqual([]);
expect(useHandStore.getState().hands).toEqual([]);
expect(useWorkflowStore.getState().workflows).toEqual([]);
expect(useHandStore.getState().triggers).toEqual([]);
expect(useSecurityStore.getState().auditLogs).toEqual([]);
});
// === Security Tests ===
it('loads security status from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadSecurityStatus();
await useSecurityStore.getState().loadSecurityStatus();
expect(mockClient.getSecurityStatus).toHaveBeenCalledTimes(1);
const { securityStatus } = useGatewayStore.getState();
const { securityStatus } = useSecurityStore.getState();
expect(securityStatus).not.toBeNull();
expect(securityStatus?.totalCount).toBe(16);
expect(securityStatus?.enabledCount).toBe(11);
@@ -595,21 +629,23 @@ describe('OpenFang actions', () => {
});
it('calculates security level correctly (critical for 14+ layers)', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadSecurityStatus();
await useSecurityStore.getState().loadSecurityStatus();
const { securityStatus } = useGatewayStore.getState();
const { securityStatus } = useSecurityStore.getState();
// 11/16 enabled = 68.75% = 'high' level
expect(securityStatus?.securityLevel).toBe('high');
});
it('identifies disabled security layers', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadSecurityStatus();
await useSecurityStore.getState().loadSecurityStatus();
const { securityStatus } = useGatewayStore.getState();
const { securityStatus } = useSecurityStore.getState();
const disabledLayers = securityStatus?.layers.filter(l => !l.enabled) || [];
expect(disabledLayers.length).toBe(5);
expect(disabledLayers.map(l => l.name)).toContain('Content Filtering');
@@ -617,31 +653,33 @@ describe('OpenFang actions', () => {
});
it('sets isLoading during loadHands', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
// Reset store state
useGatewayStore.setState({ hands: [], isLoading: false });
useHandStore.setState({ hands: [], isLoading: false });
const loadPromise = useGatewayStore.getState().loadHands();
const loadPromise = useHandStore.getState().loadHands();
// Check isLoading was set to true at start
// (this might be false again by the time we check due to async)
await loadPromise;
// After completion, isLoading should be false
expect(useGatewayStore.getState().isLoading).toBe(false);
expect(useGatewayStore.getState().hands.length).toBeGreaterThan(0);
expect(useHandStore.getState().isLoading).toBe(false);
expect(useHandStore.getState().hands.length).toBeGreaterThan(0);
});
it('sets isLoading during loadWorkflows', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
// Reset store state
useGatewayStore.setState({ workflows: [], isLoading: false });
useWorkflowStore.setState({ workflows: [], isLoading: false });
await useGatewayStore.getState().loadWorkflows();
await useWorkflowStore.getState().loadWorkflows();
expect(useGatewayStore.getState().isLoading).toBe(false);
expect(useGatewayStore.getState().workflows.length).toBeGreaterThan(0);
expect(useWorkflowStore.getState().isLoading).toBe(false);
expect(useWorkflowStore.getState().workflows.length).toBeGreaterThan(0);
});
});

View File

@@ -428,11 +428,13 @@ describe('SkillDiscoveryEngine', () => {
});
it('toggles install status', () => {
engine.setSkillInstalled('code-review', false);
const r1 = engine.setSkillInstalled('code-review', false, { skipAutonomyCheck: true });
expect(r1.success).toBe(true);
const skill = engine.getAllSkills().find(s => s.id === 'code-review');
expect(skill!.installed).toBe(false);
engine.setSkillInstalled('code-review', true);
const r2 = engine.setSkillInstalled('code-review', true, { skipAutonomyCheck: true });
expect(r2.success).toBe(true);
const skill2 = engine.getAllSkills().find(s => s.id === 'code-review');
expect(skill2!.installed).toBe(true);
});