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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
674
desktop/src/lib/gateway-api.ts
Normal file
674
desktop/src/lib/gateway-api.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
175
desktop/src/lib/gateway-auth.ts
Normal file
175
desktop/src/lib/gateway-auth.ts
Normal 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
118
desktop/src/lib/gateway-storage.ts
Normal file
118
desktop/src/lib/gateway-storage.ts
Normal 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;
|
||||
}
|
||||
96
desktop/src/lib/gateway-types.ts
Normal file
96
desktop/src/lib/gateway-types.ts
Normal 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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
141
desktop/src/store/securityStore.ts
Normal file
141
desktop/src/store/securityStore.ts
Normal 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 });
|
||||
}
|
||||
228
desktop/src/store/sessionStore.ts
Normal file
228
desktop/src/store/sessionStore.ts
Normal 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 });
|
||||
}
|
||||
@@ -24,8 +24,6 @@ import type {
|
||||
ReviewFeedback,
|
||||
TaskDeliverable,
|
||||
} from '../types/team';
|
||||
import { parseJsonOrDefault } from '../lib/json-utils';
|
||||
|
||||
// === Store State ===
|
||||
|
||||
interface TeamStoreState {
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
277
docs/analysis/CODE-LEVEL-TODO.md
Normal file
277
docs/analysis/CODE-LEVEL-TODO.md
Normal 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 迁移。
|
||||
304
docs/analysis/ZCLAW-DEEP-ANALYSIS.md
Normal file
304
docs/analysis/ZCLAW-DEEP-ANALYSIS.md
Normal 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.json,plugins 使用 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 的架构设计都非常出色,但最大的短板是**端到端可用性未经验证**。
|
||||
|
||||
**建议的策略是:先收敛、跑通闭环、再扩展。**
|
||||
27
docs/archive/v1-viking-dead-code/README.md
Normal file
27
docs/archive/v1-viking-dead-code/README.md
Normal 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
|
||||
153
plans/mossy-dreaming-umbrella.md
Normal file
153
plans/mossy-dreaming-umbrella.md
Normal 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** |
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user