refactor(phase-11): extract specialized stores from gatewayStore
Decompose monolithic gatewayStore.ts (1660 lines) into focused stores: - connectionStore.ts (444 lines) - WebSocket, auth, local gateway - agentStore.ts (256 lines) - Clones, usage stats, plugins - handStore.ts (498 lines) - Hands, triggers, approvals - workflowStore.ts (255 lines) - Workflows, runs - configStore.ts (537 lines) - QuickConfig, channels, skills Each store uses client injection pattern for loose coupling. Coordinator layer to be added in next commit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
256
desktop/src/store/agentStore.ts
Normal file
256
desktop/src/store/agentStore.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Agent Store - Manages clones/agents, usage statistics, and plugin status
|
||||||
|
*
|
||||||
|
* Extracted from gatewayStore.ts for Phase 11 Store Refactoring.
|
||||||
|
* This store focuses on agent/clone CRUD operations and related metadata.
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { GatewayClient } from '../lib/gateway-client';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface Clone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
nickname?: string;
|
||||||
|
scenarios?: string[];
|
||||||
|
model?: string;
|
||||||
|
workspaceDir?: string;
|
||||||
|
workspaceResolvedPath?: string;
|
||||||
|
restrictFiles?: boolean;
|
||||||
|
privacyOptIn?: boolean;
|
||||||
|
userName?: string;
|
||||||
|
userRole?: string;
|
||||||
|
createdAt: string;
|
||||||
|
bootstrapReady?: boolean;
|
||||||
|
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageStats {
|
||||||
|
totalSessions: number;
|
||||||
|
totalMessages: number;
|
||||||
|
totalTokens: number;
|
||||||
|
byModel: Record<string, { messages: number; inputTokens: number; outputTokens: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginStatus {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
status: 'active' | 'inactive' | 'error' | 'loading';
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloneCreateOptions {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
nickname?: string;
|
||||||
|
scenarios?: string[];
|
||||||
|
model?: string;
|
||||||
|
workspaceDir?: string;
|
||||||
|
restrictFiles?: boolean;
|
||||||
|
privacyOptIn?: boolean;
|
||||||
|
userName?: string;
|
||||||
|
userRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store State ===
|
||||||
|
|
||||||
|
interface AgentStateSlice {
|
||||||
|
clones: Clone[];
|
||||||
|
usageStats: UsageStats | null;
|
||||||
|
pluginStatus: PluginStatus[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Actions ===
|
||||||
|
|
||||||
|
interface AgentActionsSlice {
|
||||||
|
loadClones: () => Promise<void>;
|
||||||
|
createClone: (opts: CloneCreateOptions) => Promise<Clone | undefined>;
|
||||||
|
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
|
||||||
|
deleteClone: (id: string) => Promise<void>;
|
||||||
|
loadUsageStats: () => Promise<void>;
|
||||||
|
loadPluginStatus: () => Promise<void>;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Interface ===
|
||||||
|
|
||||||
|
export type AgentStore = AgentStateSlice & AgentActionsSlice;
|
||||||
|
|
||||||
|
// === Client Injection ===
|
||||||
|
|
||||||
|
// For coordinator to inject client - avoids direct import coupling
|
||||||
|
let _client: GatewayClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the gateway client for the agent store.
|
||||||
|
* Called by the coordinator during initialization.
|
||||||
|
*/
|
||||||
|
export const setAgentStoreClient = (client: unknown): void => {
|
||||||
|
_client = client as GatewayClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the gateway client.
|
||||||
|
* Returns null if not set (coordinator must initialize first).
|
||||||
|
*/
|
||||||
|
const getClient = (): GatewayClient | null => _client;
|
||||||
|
|
||||||
|
// === Store Implementation ===
|
||||||
|
|
||||||
|
export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
clones: [],
|
||||||
|
usageStats: null,
|
||||||
|
pluginStatus: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadClones: async () => {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
console.warn('[AgentStore] Client not initialized, skipping loadClones');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const result = await client.listClones();
|
||||||
|
const clones = result?.clones || result?.agents || [];
|
||||||
|
set({ clones, isLoading: false });
|
||||||
|
|
||||||
|
// Set default agent ID if we have agents and none is set
|
||||||
|
if (clones.length > 0 && clones[0].id) {
|
||||||
|
const currentDefault = client.getDefaultAgentId();
|
||||||
|
// Only set if the default doesn't exist in the list
|
||||||
|
const defaultExists = clones.some((c: Clone) => c.id === currentDefault);
|
||||||
|
if (!defaultExists) {
|
||||||
|
client.setDefaultAgentId(clones[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
set({ error: errorMessage, isLoading: false });
|
||||||
|
// Don't throw - clone loading is non-critical
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createClone: async (opts: CloneCreateOptions) => {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
console.warn('[AgentStore] Client not initialized');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const result = await client.createClone(opts);
|
||||||
|
await get().loadClones(); // Refresh the list
|
||||||
|
return result?.clone;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
set({ error: errorMessage, isLoading: false });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateClone: async (id: string, updates: Partial<Clone>) => {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
console.warn('[AgentStore] Client not initialized');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const result = await client.updateClone(id, updates);
|
||||||
|
await get().loadClones(); // Refresh the list
|
||||||
|
return result?.clone;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
set({ error: errorMessage, isLoading: false });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteClone: async (id: string) => {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
console.warn('[AgentStore] Client not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
await client.deleteClone(id);
|
||||||
|
await get().loadClones(); // Refresh the list
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
set({ error: errorMessage, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadUsageStats: async () => {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
console.warn('[AgentStore] Client not initialized, skipping loadUsageStats');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await client.getUsageStats();
|
||||||
|
set({ usageStats: stats });
|
||||||
|
} catch {
|
||||||
|
// Usage stats are non-critical, ignore errors silently
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPluginStatus: async () => {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
console.warn('[AgentStore] Client not initialized, skipping loadPluginStatus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.getPluginStatus();
|
||||||
|
set({ pluginStatus: result?.plugins || [] });
|
||||||
|
} catch {
|
||||||
|
// Plugin status is non-critical, ignore errors silently
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setError: (error: string | null) => {
|
||||||
|
set({ error });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// === Selectors ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a clone by ID
|
||||||
|
*/
|
||||||
|
export const selectCloneById = (id: string) => (state: AgentStore): Clone | undefined =>
|
||||||
|
state.clones.find((clone) => clone.id === id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active plugins
|
||||||
|
*/
|
||||||
|
export const selectActivePlugins = (state: AgentStore): PluginStatus[] =>
|
||||||
|
state.pluginStatus.filter((plugin) => plugin.status === 'active');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any operation is in progress
|
||||||
|
*/
|
||||||
|
export const selectIsLoading = (state: AgentStore): boolean => state.isLoading;
|
||||||
537
desktop/src/store/configStore.ts
Normal file
537
desktop/src/store/configStore.ts
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
/**
|
||||||
|
* configStore.ts - Configuration Management Store
|
||||||
|
*
|
||||||
|
* Extracted from gatewayStore.ts for Phase 11 Store Refactoring.
|
||||||
|
* Manages settings, workspace, channels, skills, and models.
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { GatewayModelChoice } from '../lib/gateway-config';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface QuickConfig {
|
||||||
|
agentName?: string;
|
||||||
|
agentRole?: string;
|
||||||
|
userName?: string;
|
||||||
|
userRole?: string;
|
||||||
|
agentNickname?: string;
|
||||||
|
scenarios?: string[];
|
||||||
|
workspaceDir?: string;
|
||||||
|
gatewayUrl?: string;
|
||||||
|
gatewayToken?: string;
|
||||||
|
skillsExtraDirs?: string[];
|
||||||
|
mcpServices?: Array<{ id: string; name: string; enabled: boolean }>;
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
autoStart?: boolean;
|
||||||
|
showToolCalls?: boolean;
|
||||||
|
restrictFiles?: boolean;
|
||||||
|
autoSaveContext?: boolean;
|
||||||
|
fileWatching?: boolean;
|
||||||
|
privacyOptIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceInfo {
|
||||||
|
path: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
exists: boolean;
|
||||||
|
fileCount: number;
|
||||||
|
totalSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelInfo {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
status: 'active' | 'inactive' | 'error';
|
||||||
|
accounts?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
schedule: string;
|
||||||
|
status: 'active' | 'paused' | 'completed' | 'error';
|
||||||
|
lastRun?: string;
|
||||||
|
nextRun?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
source: 'builtin' | 'extra';
|
||||||
|
description?: string;
|
||||||
|
triggers?: Array<{ type: string; pattern?: string }>;
|
||||||
|
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Client Interface ===
|
||||||
|
|
||||||
|
export interface ConfigStoreClient {
|
||||||
|
getWorkspaceInfo(): Promise<WorkspaceInfo | null>;
|
||||||
|
getQuickConfig(): Promise<{ quickConfig?: QuickConfig } | null>;
|
||||||
|
saveQuickConfig(config: QuickConfig): Promise<{ quickConfig?: QuickConfig } | null>;
|
||||||
|
listSkills(): Promise<{ skills?: SkillInfo[]; extraDirs?: string[] } | null>;
|
||||||
|
getSkill(id: string): Promise<{ skill?: SkillInfo } | null>;
|
||||||
|
createSkill(skill: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggers: Array<{ type: string; pattern?: string }>;
|
||||||
|
actions: Array<{ type: string; params?: Record<string, unknown> }>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<{ skill?: SkillInfo } | null>;
|
||||||
|
updateSkill(id: string, updates: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
triggers?: Array<{ type: string; pattern?: string }>;
|
||||||
|
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<{ skill?: SkillInfo } | null>;
|
||||||
|
deleteSkill(id: string): Promise<void>;
|
||||||
|
listChannels(): Promise<{ channels?: ChannelInfo[] } | null>;
|
||||||
|
getChannel(id: string): Promise<{ channel?: ChannelInfo } | null>;
|
||||||
|
createChannel(channel: {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<{ channel?: ChannelInfo } | null>;
|
||||||
|
updateChannel(id: string, updates: {
|
||||||
|
name?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<{ channel?: ChannelInfo } | null>;
|
||||||
|
deleteChannel(id: string): Promise<void>;
|
||||||
|
listScheduledTasks(): Promise<{ tasks?: ScheduledTask[] } | null>;
|
||||||
|
createScheduledTask(task: {
|
||||||
|
name: string;
|
||||||
|
schedule: string;
|
||||||
|
scheduleType: 'cron' | 'interval' | 'once';
|
||||||
|
target?: {
|
||||||
|
type: 'agent' | 'hand' | 'workflow';
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
description?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<ScheduledTask>;
|
||||||
|
listModels(): Promise<{ models: GatewayModelChoice[] }>;
|
||||||
|
getFeishuStatus(): Promise<{ configured?: boolean; accounts?: number } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store State & Actions ===
|
||||||
|
|
||||||
|
interface ConfigStore {
|
||||||
|
// State
|
||||||
|
quickConfig: QuickConfig;
|
||||||
|
workspaceInfo: WorkspaceInfo | null;
|
||||||
|
channels: ChannelInfo[];
|
||||||
|
scheduledTasks: ScheduledTask[];
|
||||||
|
skillsCatalog: SkillInfo[];
|
||||||
|
models: GatewayModelChoice[];
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsError: string | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Client reference (injected)
|
||||||
|
client: ConfigStoreClient | null;
|
||||||
|
|
||||||
|
// Client injection
|
||||||
|
setConfigStoreClient: (client: ConfigStoreClient) => void;
|
||||||
|
|
||||||
|
// Quick Config Actions
|
||||||
|
loadQuickConfig: () => Promise<void>;
|
||||||
|
saveQuickConfig: (updates: Partial<QuickConfig>) => Promise<void>;
|
||||||
|
|
||||||
|
// Workspace Actions
|
||||||
|
loadWorkspaceInfo: () => Promise<void>;
|
||||||
|
|
||||||
|
// Channel Actions
|
||||||
|
loadChannels: () => Promise<void>;
|
||||||
|
getChannel: (id: string) => Promise<ChannelInfo | undefined>;
|
||||||
|
createChannel: (channel: {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => Promise<ChannelInfo | undefined>;
|
||||||
|
updateChannel: (id: string, updates: {
|
||||||
|
name?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => Promise<ChannelInfo | undefined>;
|
||||||
|
deleteChannel: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Scheduled Task Actions
|
||||||
|
loadScheduledTasks: () => Promise<void>;
|
||||||
|
createScheduledTask: (task: {
|
||||||
|
name: string;
|
||||||
|
schedule: string;
|
||||||
|
scheduleType: 'cron' | 'interval' | 'once';
|
||||||
|
target?: {
|
||||||
|
type: 'agent' | 'hand' | 'workflow';
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
description?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => Promise<ScheduledTask | undefined>;
|
||||||
|
|
||||||
|
// Skill Actions
|
||||||
|
loadSkillsCatalog: () => Promise<void>;
|
||||||
|
getSkill: (id: string) => Promise<SkillInfo | undefined>;
|
||||||
|
createSkill: (skill: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggers: Array<{ type: string; pattern?: string }>;
|
||||||
|
actions: Array<{ type: string; params?: Record<string, unknown> }>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => Promise<SkillInfo | undefined>;
|
||||||
|
updateSkill: (id: string, updates: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
triggers?: Array<{ type: string; pattern?: string }>;
|
||||||
|
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => Promise<SkillInfo | undefined>;
|
||||||
|
deleteSkill: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Model Actions
|
||||||
|
loadModels: () => Promise<void>;
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfigStore = create<ConfigStore>((set, get) => ({
|
||||||
|
// Initial State
|
||||||
|
quickConfig: {},
|
||||||
|
workspaceInfo: null,
|
||||||
|
channels: [],
|
||||||
|
scheduledTasks: [],
|
||||||
|
skillsCatalog: [],
|
||||||
|
models: [],
|
||||||
|
modelsLoading: false,
|
||||||
|
modelsError: null,
|
||||||
|
error: null,
|
||||||
|
client: null,
|
||||||
|
|
||||||
|
// Client Injection
|
||||||
|
setConfigStoreClient: (client: ConfigStoreClient) => {
|
||||||
|
set({ client });
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Quick Config Actions ===
|
||||||
|
|
||||||
|
loadQuickConfig: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.getQuickConfig();
|
||||||
|
set({ quickConfig: result?.quickConfig || {} });
|
||||||
|
} catch {
|
||||||
|
// Ignore if quick config not available
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveQuickConfig: async (updates: Partial<QuickConfig>) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextConfig = { ...get().quickConfig, ...updates };
|
||||||
|
const result = await client.saveQuickConfig(nextConfig);
|
||||||
|
set({ quickConfig: result?.quickConfig || nextConfig });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Workspace Actions ===
|
||||||
|
|
||||||
|
loadWorkspaceInfo: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await client.getWorkspaceInfo();
|
||||||
|
set({ workspaceInfo: info });
|
||||||
|
} catch {
|
||||||
|
// Ignore if workspace info not available
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Channel Actions ===
|
||||||
|
|
||||||
|
loadChannels: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const channels: ChannelInfo[] = [];
|
||||||
|
try {
|
||||||
|
// Try listing channels from Gateway
|
||||||
|
const result = await client.listChannels();
|
||||||
|
if (result?.channels) {
|
||||||
|
set({ channels: result.channels });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// channels.list may not be available, fallback to probing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: probe known channels individually
|
||||||
|
try {
|
||||||
|
const feishu = await client.getFeishuStatus();
|
||||||
|
channels.push({
|
||||||
|
id: 'feishu',
|
||||||
|
type: 'feishu',
|
||||||
|
label: 'Feishu',
|
||||||
|
status: feishu?.configured ? 'active' : 'inactive',
|
||||||
|
accounts: feishu?.accounts || 0,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
channels.push({ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ channels });
|
||||||
|
},
|
||||||
|
|
||||||
|
getChannel: async (id: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.getChannel(id);
|
||||||
|
if (result?.channel) {
|
||||||
|
// Update the channel in the local state if it exists
|
||||||
|
const currentChannels = get().channels;
|
||||||
|
const existingIndex = currentChannels.findIndex(c => c.id === id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const updatedChannels = [...currentChannels];
|
||||||
|
updatedChannels[existingIndex] = result.channel;
|
||||||
|
set({ channels: updatedChannels });
|
||||||
|
}
|
||||||
|
return result.channel as ChannelInfo;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createChannel: async (channel) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.createChannel(channel);
|
||||||
|
if (result?.channel) {
|
||||||
|
// Add the new channel to local state
|
||||||
|
const currentChannels = get().channels;
|
||||||
|
set({ channels: [...currentChannels, result.channel as ChannelInfo] });
|
||||||
|
return result.channel as ChannelInfo;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateChannel: async (id, updates) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.updateChannel(id, updates);
|
||||||
|
if (result?.channel) {
|
||||||
|
// Update the channel in local state
|
||||||
|
const currentChannels = get().channels;
|
||||||
|
const updatedChannels = currentChannels.map(c =>
|
||||||
|
c.id === id ? (result.channel as ChannelInfo) : c
|
||||||
|
);
|
||||||
|
set({ channels: updatedChannels });
|
||||||
|
return result.channel as ChannelInfo;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteChannel: async (id) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.deleteChannel(id);
|
||||||
|
// Remove the channel from local state
|
||||||
|
const currentChannels = get().channels;
|
||||||
|
set({ channels: currentChannels.filter(c => c.id !== id) });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Scheduled Task Actions ===
|
||||||
|
|
||||||
|
loadScheduledTasks: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.listScheduledTasks();
|
||||||
|
set({ scheduledTasks: result?.tasks || [] });
|
||||||
|
} catch {
|
||||||
|
// Ignore if heartbeat.tasks not available
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createScheduledTask: async (task) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.createScheduledTask(task);
|
||||||
|
const newTask: ScheduledTask = {
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
schedule: result.schedule,
|
||||||
|
status: result.status as 'active' | 'paused' | 'completed' | 'error',
|
||||||
|
lastRun: result.lastRun,
|
||||||
|
nextRun: result.nextRun,
|
||||||
|
description: result.description,
|
||||||
|
};
|
||||||
|
set((state) => ({
|
||||||
|
scheduledTasks: [...state.scheduledTasks, newTask],
|
||||||
|
}));
|
||||||
|
return newTask;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create scheduled task';
|
||||||
|
set({ error: errorMessage });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Skill Actions ===
|
||||||
|
|
||||||
|
loadSkillsCatalog: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.listSkills();
|
||||||
|
set({ skillsCatalog: result?.skills || [] });
|
||||||
|
if (result?.extraDirs) {
|
||||||
|
set((state) => ({
|
||||||
|
quickConfig: {
|
||||||
|
...state.quickConfig,
|
||||||
|
skillsExtraDirs: result.extraDirs,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore if skills list not available
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSkill: async (id: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.getSkill(id);
|
||||||
|
return result?.skill as SkillInfo | undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createSkill: async (skill) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.createSkill(skill);
|
||||||
|
const newSkill = result?.skill as SkillInfo | undefined;
|
||||||
|
if (newSkill) {
|
||||||
|
set((state) => ({
|
||||||
|
skillsCatalog: [...state.skillsCatalog, newSkill],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return newSkill;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSkill: async (id, updates) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.updateSkill(id, updates);
|
||||||
|
const updatedSkill = result?.skill as SkillInfo | undefined;
|
||||||
|
if (updatedSkill) {
|
||||||
|
set((state) => ({
|
||||||
|
skillsCatalog: state.skillsCatalog.map((s) =>
|
||||||
|
s.id === id ? updatedSkill : s
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return updatedSkill;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSkill: async (id) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.deleteSkill(id);
|
||||||
|
set((state) => ({
|
||||||
|
skillsCatalog: state.skillsCatalog.filter((s) => s.id !== id),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Ignore deletion errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Model Actions ===
|
||||||
|
|
||||||
|
loadModels: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ modelsLoading: true, modelsError: null });
|
||||||
|
const result = await client.listModels();
|
||||||
|
const models: GatewayModelChoice[] = result?.models || [];
|
||||||
|
set({ models, modelsLoading: false });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to load models';
|
||||||
|
set({ modelsError: message, modelsLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Utility ===
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type {
|
||||||
|
QuickConfig as QuickConfigType,
|
||||||
|
WorkspaceInfo as WorkspaceInfoType,
|
||||||
|
ChannelInfo as ChannelInfoType,
|
||||||
|
ScheduledTask as ScheduledTaskType,
|
||||||
|
SkillInfo as SkillInfoType,
|
||||||
|
};
|
||||||
444
desktop/src/store/connectionStore.ts
Normal file
444
desktop/src/store/connectionStore.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import {
|
||||||
|
DEFAULT_GATEWAY_URL,
|
||||||
|
FALLBACK_GATEWAY_URLS,
|
||||||
|
GatewayClient,
|
||||||
|
ConnectionState,
|
||||||
|
getGatewayClient,
|
||||||
|
getStoredGatewayToken,
|
||||||
|
setStoredGatewayToken,
|
||||||
|
getStoredGatewayUrl,
|
||||||
|
setStoredGatewayUrl,
|
||||||
|
getLocalDeviceIdentity,
|
||||||
|
} from '../lib/gateway-client';
|
||||||
|
import {
|
||||||
|
isTauriRuntime,
|
||||||
|
prepareLocalGatewayForTauri,
|
||||||
|
getLocalGatewayStatus,
|
||||||
|
startLocalGateway as startLocalGatewayCommand,
|
||||||
|
stopLocalGateway as stopLocalGatewayCommand,
|
||||||
|
restartLocalGateway as restartLocalGatewayCommand,
|
||||||
|
approveLocalGatewayDevicePairing,
|
||||||
|
getLocalGatewayAuth,
|
||||||
|
getUnsupportedLocalGatewayStatus,
|
||||||
|
type LocalGatewayStatus,
|
||||||
|
} from '../lib/tauri-gateway';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface GatewayLog {
|
||||||
|
timestamp: number;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helper Functions ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error indicates we connection should retry with another candidate.
|
||||||
|
*/
|
||||||
|
function shouldRetryGatewayCandidate(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '');
|
||||||
|
return (
|
||||||
|
message === 'WebSocket connection failed'
|
||||||
|
|| message.startsWith('Gateway handshake timed out')
|
||||||
|
|| message.startsWith('WebSocket closed before handshake completed')
|
||||||
|
|| message.startsWith('Connection refused')
|
||||||
|
|| message.includes('ECONNREFUSED')
|
||||||
|
|| message.includes('Failed to fetch')
|
||||||
|
|| message.includes('Network error')
|
||||||
|
|| message.includes('pairing required')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error indicates local device pairing is required.
|
||||||
|
*/
|
||||||
|
function requiresLocalDevicePairing(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '');
|
||||||
|
return message.includes('pairing required');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate security level based on enabled layer count.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a loopback address.
|
||||||
|
*/
|
||||||
|
function isLoopbackGatewayUrl(url: string): boolean {
|
||||||
|
return /^wss?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(url.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a gateway URL candidate.
|
||||||
|
*/
|
||||||
|
function normalizeGatewayUrlCandidate(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local gateway connect URL from status.
|
||||||
|
*/
|
||||||
|
function getLocalGatewayConnectUrl(status: LocalGatewayStatus): string | null {
|
||||||
|
if (status.probeUrl && status.probeUrl.trim()) {
|
||||||
|
return normalizeGatewayUrlCandidate(status.probeUrl);
|
||||||
|
}
|
||||||
|
if (status.port) {
|
||||||
|
return `ws://127.0.0.1:${status.port}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to approve local device pairing for loopback URLs.
|
||||||
|
*/
|
||||||
|
async function approveCurrentLocalDevicePairing(url: string): Promise<boolean> {
|
||||||
|
if (!isTauriRuntime() || !isLoopbackGatewayUrl(url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const identity = await getLocalDeviceIdentity();
|
||||||
|
const result = await approveLocalGatewayDevicePairing(identity.deviceId, identity.publicKeyBase64, url);
|
||||||
|
return result.approved;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Interface ===
|
||||||
|
|
||||||
|
export interface ConnectionStateSlice {
|
||||||
|
connectionState: ConnectionState;
|
||||||
|
gatewayVersion: string | null;
|
||||||
|
error: string | null;
|
||||||
|
logs: GatewayLog[];
|
||||||
|
localGateway: LocalGatewayStatus;
|
||||||
|
localGatewayBusy: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionActionsSlice {
|
||||||
|
connect: (url?: string, token?: string) => Promise<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
clearLogs: () => void;
|
||||||
|
refreshLocalGateway: () => Promise<LocalGatewayStatus>;
|
||||||
|
startLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
|
||||||
|
stopLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
|
||||||
|
restartLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionStore extends ConnectionStateSlice, ConnectionActionsSlice {
|
||||||
|
client: GatewayClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Implementation ===
|
||||||
|
|
||||||
|
export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||||
|
// Initialize client
|
||||||
|
const client = getGatewayClient();
|
||||||
|
|
||||||
|
// Wire up state change callback
|
||||||
|
client.onStateChange = (state) => {
|
||||||
|
set({ connectionState: state });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wire up log callback
|
||||||
|
client.onLog = (level, message) => {
|
||||||
|
set((s) => ({
|
||||||
|
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// === Initial State ===
|
||||||
|
connectionState: 'disconnected',
|
||||||
|
gatewayVersion: null,
|
||||||
|
error: null,
|
||||||
|
logs: [],
|
||||||
|
localGateway: getUnsupportedLocalGatewayStatus(),
|
||||||
|
localGatewayBusy: false,
|
||||||
|
isLoading: false,
|
||||||
|
client,
|
||||||
|
|
||||||
|
// === Actions ===
|
||||||
|
|
||||||
|
connect: async (url?: string, token?: string) => {
|
||||||
|
const c = get().client;
|
||||||
|
|
||||||
|
// Resolve connection URL candidates
|
||||||
|
const resolveCandidates = async (): Promise<string[]> => {
|
||||||
|
const explicitUrl = url?.trim();
|
||||||
|
if (explicitUrl) {
|
||||||
|
return [normalizeGatewayUrlCandidate(explicitUrl)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
// Check local gateway first if in Tauri
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
try {
|
||||||
|
const localStatus = await getLocalGatewayStatus();
|
||||||
|
const localUrl = getLocalGatewayConnectUrl(localStatus);
|
||||||
|
if (localUrl) {
|
||||||
|
candidates.push(localUrl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore local gateway lookup failures during candidate selection */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quick config gateway URL if available
|
||||||
|
const quickConfigGatewayUrl = get().quickConfig?.gatewayUrl?.trim();
|
||||||
|
if (quickConfigGatewayUrl) {
|
||||||
|
candidates.push(quickConfigGatewayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stored URL, default, and fallbacks
|
||||||
|
candidates.push(
|
||||||
|
getStoredGatewayUrl(),
|
||||||
|
DEFAULT_GATEWAY_URL,
|
||||||
|
...FALLBACK_GATEWAY_URLS
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return unique, non-empty candidates
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
candidates
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(normalizeGatewayUrlCandidate)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
set({ error: null });
|
||||||
|
|
||||||
|
// Prepare local gateway for Tauri
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
try {
|
||||||
|
await prepareLocalGatewayForTauri();
|
||||||
|
} catch {
|
||||||
|
/* ignore local gateway preparation failures during connection bootstrap */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve effective token: param > quickConfig > localStorage > local auth
|
||||||
|
let effectiveToken = token || get().quickConfig?.gatewayToken || getStoredGatewayToken();
|
||||||
|
if (!effectiveToken && isTauriRuntime()) {
|
||||||
|
try {
|
||||||
|
const localAuth = await getLocalGatewayAuth();
|
||||||
|
if (localAuth.gatewayToken) {
|
||||||
|
effectiveToken = localAuth.gatewayToken;
|
||||||
|
setStoredGatewayToken(localAuth.gatewayToken);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore local auth lookup failures during connection bootstrap */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ConnectionStore] Connecting with token:', effectiveToken ? `${effectiveToken.substring(0, 8)}...` : '(empty)');
|
||||||
|
|
||||||
|
const candidateUrls = await resolveCandidates();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
let connectedUrl: string | null = null;
|
||||||
|
|
||||||
|
// Try each candidate URL
|
||||||
|
for (const candidateUrl of candidateUrls) {
|
||||||
|
try {
|
||||||
|
c.updateOptions({
|
||||||
|
url: candidateUrl,
|
||||||
|
token: effectiveToken,
|
||||||
|
});
|
||||||
|
await c.connect();
|
||||||
|
connectedUrl = candidateUrl;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
|
||||||
|
// Try device pairing if required
|
||||||
|
if (requiresLocalDevicePairing(err)) {
|
||||||
|
const approved = await approveCurrentLocalDevicePairing(candidateUrl);
|
||||||
|
if (approved) {
|
||||||
|
c.updateOptions({
|
||||||
|
url: candidateUrl,
|
||||||
|
token: effectiveToken,
|
||||||
|
});
|
||||||
|
await c.connect();
|
||||||
|
connectedUrl = candidateUrl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should try next candidate
|
||||||
|
if (!shouldRetryGatewayCandidate(err)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectedUrl) {
|
||||||
|
throw (lastError instanceof Error ? lastError : new Error('Failed to connect to any available Gateway'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store successful URL
|
||||||
|
setStoredGatewayUrl(connectedUrl);
|
||||||
|
|
||||||
|
// Fetch gateway version
|
||||||
|
try {
|
||||||
|
const health = await c.health();
|
||||||
|
set({ gatewayVersion: health?.version });
|
||||||
|
} catch { /* health may not return version */ }
|
||||||
|
|
||||||
|
console.log('[ConnectionStore] Connected to:', connectedUrl);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
set({ error: errorMessage });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: () => {
|
||||||
|
get().client.disconnect();
|
||||||
|
set({
|
||||||
|
connectionState: 'disconnected',
|
||||||
|
gatewayVersion: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLogs: () => set({ logs: [] }),
|
||||||
|
|
||||||
|
refreshLocalGateway: async () => {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
const unsupported = getUnsupportedLocalGatewayStatus();
|
||||||
|
set({ localGateway: unsupported, localGatewayBusy: false });
|
||||||
|
return unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ localGatewayBusy: true });
|
||||||
|
try {
|
||||||
|
const status = await getLocalGatewayStatus();
|
||||||
|
set({ localGateway: status, localGatewayBusy: false });
|
||||||
|
return status;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to read local Gateway status';
|
||||||
|
const nextStatus = {
|
||||||
|
...get().localGateway,
|
||||||
|
supported: true,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
|
||||||
|
return nextStatus;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startLocalGateway: async () => {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
const unsupported = getUnsupportedLocalGatewayStatus();
|
||||||
|
set({ localGateway: unsupported, localGatewayBusy: false });
|
||||||
|
return unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ localGatewayBusy: true, error: null });
|
||||||
|
try {
|
||||||
|
const status = await startLocalGatewayCommand();
|
||||||
|
set({ localGateway: status, localGatewayBusy: false });
|
||||||
|
return status;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to start local Gateway';
|
||||||
|
const nextStatus = {
|
||||||
|
...get().localGateway,
|
||||||
|
supported: true,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopLocalGateway: async () => {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
const unsupported = getUnsupportedLocalGatewayStatus();
|
||||||
|
set({ localGateway: unsupported, localGatewayBusy: false });
|
||||||
|
return unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ localGatewayBusy: true, error: null });
|
||||||
|
try {
|
||||||
|
const status = await stopLocalGatewayCommand();
|
||||||
|
set({ localGateway: status, localGatewayBusy: false });
|
||||||
|
return status;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to stop local Gateway';
|
||||||
|
const nextStatus = {
|
||||||
|
...get().localGateway,
|
||||||
|
supported: true,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
restartLocalGateway: async () => {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
const unsupported = getUnsupportedLocalGatewayStatus();
|
||||||
|
set({ localGateway: unsupported, localGatewayBusy: false });
|
||||||
|
return unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ localGatewayBusy: true, error: null });
|
||||||
|
try {
|
||||||
|
const status = await restartLocalGatewayCommand();
|
||||||
|
set({ localGateway: status, localGatewayBusy: false });
|
||||||
|
return status;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to restart local Gateway';
|
||||||
|
const nextStatus = {
|
||||||
|
...get().localGateway,
|
||||||
|
supported: true,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Exported Accessors for Coordinator ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current connection state.
|
||||||
|
*/
|
||||||
|
export const getConnectionState = () => useConnectionStore.getState().connectionState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get gateway client instance.
|
||||||
|
*/
|
||||||
|
export const getClient = () => useConnectionStore.getState().client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current error message.
|
||||||
|
*/
|
||||||
|
export const getConnectionError = () => useConnectionStore.getState().error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local gateway status.
|
||||||
|
*/
|
||||||
|
export const getLocalGatewayStatus = () => useConnectionStore.getState().localGateway;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get gateway version.
|
||||||
|
*/
|
||||||
|
export const getGatewayVersion = () => useConnectionStore.getState().gatewayVersion;
|
||||||
498
desktop/src/store/handStore.ts
Normal file
498
desktop/src/store/handStore.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* handStore.ts - Hand, Trigger, and Approval management store
|
||||||
|
*
|
||||||
|
* Extracted from gatewayStore.ts for Phase 11 Store Refactoring.
|
||||||
|
* Manages OpenFang Hands, Triggers, and Approval workflows.
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { GatewayClient } from '../lib/gateway-client';
|
||||||
|
|
||||||
|
// === Re-exported Types (from gatewayStore for compatibility) ===
|
||||||
|
|
||||||
|
export interface HandRequirement {
|
||||||
|
description: string;
|
||||||
|
met: boolean;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hand {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
|
||||||
|
currentRunId?: string;
|
||||||
|
requirements_met?: boolean;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
requirements?: HandRequirement[];
|
||||||
|
tools?: string[];
|
||||||
|
metrics?: string[];
|
||||||
|
toolCount?: number;
|
||||||
|
metricCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandRun {
|
||||||
|
runId: string;
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trigger {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
|
||||||
|
|
||||||
|
export interface Approval {
|
||||||
|
id: string;
|
||||||
|
handName: string;
|
||||||
|
runId?: string;
|
||||||
|
status: ApprovalStatus;
|
||||||
|
requestedAt: string;
|
||||||
|
requestedBy?: string;
|
||||||
|
reason?: string;
|
||||||
|
action?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
respondedAt?: string;
|
||||||
|
respondedBy?: string;
|
||||||
|
responseReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Raw API Response Types (for mapping) ===
|
||||||
|
|
||||||
|
interface RawHandRequirement {
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
met?: boolean;
|
||||||
|
satisfied?: boolean;
|
||||||
|
details?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawHandRun {
|
||||||
|
runId?: string;
|
||||||
|
run_id?: string;
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
started_at?: string;
|
||||||
|
created_at?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
completed_at?: string;
|
||||||
|
finished_at?: string;
|
||||||
|
result?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawApproval {
|
||||||
|
id?: string;
|
||||||
|
approvalId?: string;
|
||||||
|
approval_id?: string;
|
||||||
|
type?: string;
|
||||||
|
request_type?: string;
|
||||||
|
handId?: string;
|
||||||
|
hand_id?: string;
|
||||||
|
hand_name?: string;
|
||||||
|
handName?: string;
|
||||||
|
run_id?: string;
|
||||||
|
runId?: string;
|
||||||
|
requester?: string;
|
||||||
|
requested_by?: string;
|
||||||
|
requestedAt?: string;
|
||||||
|
requested_at?: string;
|
||||||
|
status?: string;
|
||||||
|
reason?: string;
|
||||||
|
description?: string;
|
||||||
|
action?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
responded_at?: string;
|
||||||
|
respondedAt?: string;
|
||||||
|
responded_by?: string;
|
||||||
|
respondedBy?: string;
|
||||||
|
response_reason?: string;
|
||||||
|
responseReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Interface ===
|
||||||
|
|
||||||
|
interface HandClient {
|
||||||
|
listHands: () => Promise<{ hands?: Array<Record<string, unknown>> } | null>;
|
||||||
|
getHand: (name: string) => Promise<Record<string, unknown> | null>;
|
||||||
|
listHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<{ runs?: RawHandRun[] } | null>;
|
||||||
|
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<{ runId?: string; status?: string } | null>;
|
||||||
|
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<void>;
|
||||||
|
cancelHand: (name: string, runId: string) => Promise<void>;
|
||||||
|
listTriggers: () => Promise<{ triggers?: Trigger[] } | null>;
|
||||||
|
getTrigger: (id: string) => Promise<Trigger | null>;
|
||||||
|
createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<{ id?: string } | null>;
|
||||||
|
updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<void>;
|
||||||
|
deleteTrigger: (id: string) => Promise<void>;
|
||||||
|
listApprovals: (status?: ApprovalStatus) => Promise<{ approvals?: RawApproval[] } | null>;
|
||||||
|
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandStore {
|
||||||
|
// State
|
||||||
|
hands: Hand[];
|
||||||
|
handRuns: Record<string, HandRun[]>;
|
||||||
|
triggers: Trigger[];
|
||||||
|
approvals: Approval[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Client reference (injected via setHandStoreClient)
|
||||||
|
client: HandClient | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setHandStoreClient: (client: HandClient) => void;
|
||||||
|
loadHands: () => Promise<void>;
|
||||||
|
getHandDetails: (name: string) => Promise<Hand | undefined>;
|
||||||
|
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<HandRun | undefined>;
|
||||||
|
loadHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<HandRun[]>;
|
||||||
|
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<void>;
|
||||||
|
cancelHand: (name: string, runId: string) => Promise<void>;
|
||||||
|
loadTriggers: () => Promise<void>;
|
||||||
|
getTrigger: (id: string) => Promise<Trigger | undefined>;
|
||||||
|
createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<Trigger | undefined>;
|
||||||
|
updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<Trigger | undefined>;
|
||||||
|
deleteTrigger: (id: string) => Promise<void>;
|
||||||
|
loadApprovals: (status?: ApprovalStatus) => Promise<void>;
|
||||||
|
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHandStore = create<HandStore>((set, get) => ({
|
||||||
|
// Initial State
|
||||||
|
hands: [],
|
||||||
|
handRuns: {},
|
||||||
|
triggers: [],
|
||||||
|
approvals: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
client: null,
|
||||||
|
|
||||||
|
// Client injection
|
||||||
|
setHandStoreClient: (client: HandClient) => {
|
||||||
|
set({ client });
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Hand Actions ===
|
||||||
|
|
||||||
|
loadHands: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const result = await client.listHands();
|
||||||
|
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
|
||||||
|
const hands: Hand[] = (result?.hands || []).map((h: Record<string, unknown>) => {
|
||||||
|
const status = validStatuses.includes(h.status as Hand['status'])
|
||||||
|
? h.status as Hand['status']
|
||||||
|
: (h.requirements_met ? 'idle' : 'setup_needed');
|
||||||
|
return {
|
||||||
|
id: String(h.id || h.name),
|
||||||
|
name: String(h.name || ''),
|
||||||
|
description: String(h.description || ''),
|
||||||
|
status,
|
||||||
|
requirements_met: Boolean(h.requirements_met),
|
||||||
|
category: h.category as string | undefined,
|
||||||
|
icon: h.icon as string | undefined,
|
||||||
|
toolCount: (h.tool_count as number) || ((h.tools as unknown[])?.length),
|
||||||
|
metricCount: (h.metric_count as number) || ((h.metrics as unknown[])?.length),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
set({ hands, isLoading: false });
|
||||||
|
} catch {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getHandDetails: async (name: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.getHand(name);
|
||||||
|
if (!result) return undefined;
|
||||||
|
|
||||||
|
const getStringFromConfig = (key: string): string | undefined => {
|
||||||
|
const val = (result.config as Record<string, unknown>)?.[key];
|
||||||
|
return typeof val === 'string' ? val : undefined;
|
||||||
|
};
|
||||||
|
const getArrayFromConfig = (key: string): string[] | undefined => {
|
||||||
|
const val = (result.config as Record<string, unknown>)?.[key];
|
||||||
|
return Array.isArray(val) ? val : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
|
||||||
|
const status = validStatuses.includes(result.status as Hand['status'])
|
||||||
|
? result.status as Hand['status']
|
||||||
|
: (result.requirements_met ? 'idle' : 'setup_needed');
|
||||||
|
|
||||||
|
const hand: Hand = {
|
||||||
|
id: String(result.id || result.name || name),
|
||||||
|
name: String(result.name || name),
|
||||||
|
description: String(result.description || ''),
|
||||||
|
status,
|
||||||
|
requirements_met: Boolean(result.requirements_met),
|
||||||
|
category: result.category as string | undefined,
|
||||||
|
icon: result.icon as string | undefined,
|
||||||
|
provider: (result.provider as string) || getStringFromConfig('provider'),
|
||||||
|
model: (result.model as string) || getStringFromConfig('model'),
|
||||||
|
requirements: ((result.requirements as RawHandRequirement[]) || []).map((r) => ({
|
||||||
|
description: r.description || r.name || String(r),
|
||||||
|
met: r.met ?? r.satisfied ?? true,
|
||||||
|
details: r.details || r.hint,
|
||||||
|
})),
|
||||||
|
tools: (result.tools as string[]) || getArrayFromConfig('tools'),
|
||||||
|
metrics: (result.metrics as string[]) || getArrayFromConfig('metrics'),
|
||||||
|
toolCount: (result.tool_count as number) || ((result.tools as unknown[])?.length) || 0,
|
||||||
|
metricCount: (result.metric_count as number) || ((result.metrics as unknown[])?.length) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(state => ({
|
||||||
|
hands: state.hands.map(h => h.name === name ? { ...h, ...hand } : h),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return hand;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHandRuns: async (name: string, opts?: { limit?: number; offset?: number }) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.listHandRuns(name, opts);
|
||||||
|
const runs: HandRun[] = (result?.runs || []).map((r: RawHandRun) => ({
|
||||||
|
runId: r.runId || r.run_id || r.id || '',
|
||||||
|
status: r.status || 'unknown',
|
||||||
|
startedAt: r.startedAt || r.started_at || r.created_at || new Date().toISOString(),
|
||||||
|
completedAt: r.completedAt || r.completed_at || r.finished_at,
|
||||||
|
result: r.result || r.output,
|
||||||
|
error: r.error || r.message,
|
||||||
|
}));
|
||||||
|
set(state => ({
|
||||||
|
handRuns: { ...state.handRuns, [name]: runs },
|
||||||
|
}));
|
||||||
|
return runs;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerHand: async (name: string, params?: Record<string, unknown>) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.triggerHand(name, params);
|
||||||
|
if (!result) return undefined;
|
||||||
|
|
||||||
|
const run: HandRun = {
|
||||||
|
runId: result.runId || '',
|
||||||
|
status: result.status || 'running',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add run to local state
|
||||||
|
set(state => ({
|
||||||
|
handRuns: {
|
||||||
|
...state.handRuns,
|
||||||
|
[name]: [run, ...(state.handRuns[name] || [])],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refresh hands to update status
|
||||||
|
await get().loadHands();
|
||||||
|
|
||||||
|
return run;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
approveHand: async (name: string, runId: string, approved: boolean, reason?: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.approveHand(name, runId, approved, reason);
|
||||||
|
await get().loadHands();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelHand: async (name: string, runId: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.cancelHand(name, runId);
|
||||||
|
await get().loadHands();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Trigger Actions ===
|
||||||
|
|
||||||
|
loadTriggers: async () => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.listTriggers();
|
||||||
|
set({ triggers: result?.triggers || [] });
|
||||||
|
} catch {
|
||||||
|
// ignore if triggers API not available
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTrigger: async (id: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.getTrigger(id);
|
||||||
|
if (!result) return undefined;
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
type: result.type,
|
||||||
|
enabled: result.enabled,
|
||||||
|
} as Trigger;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createTrigger: async (trigger) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.createTrigger(trigger);
|
||||||
|
if (!result?.id) return undefined;
|
||||||
|
await get().loadTriggers();
|
||||||
|
return get().triggers.find(t => t.id === result.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTrigger: async (id: string, updates) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.updateTrigger(id, updates);
|
||||||
|
set(state => ({
|
||||||
|
triggers: state.triggers.map(t =>
|
||||||
|
t.id === id ? { ...t, ...updates } : t
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return get().triggers.find(t => t.id === id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTrigger: async (id: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.deleteTrigger(id);
|
||||||
|
set(state => ({
|
||||||
|
triggers: state.triggers.filter(t => t.id !== id),
|
||||||
|
}));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Approval Actions ===
|
||||||
|
|
||||||
|
loadApprovals: async (status?: ApprovalStatus) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.listApprovals(status);
|
||||||
|
const approvals: Approval[] = (result?.approvals || []).map((a: RawApproval) => ({
|
||||||
|
id: a.id || a.approval_id || a.approvalId || '',
|
||||||
|
handName: a.hand_name || a.handName || '',
|
||||||
|
runId: a.run_id || a.runId,
|
||||||
|
status: (a.status || 'pending') as ApprovalStatus,
|
||||||
|
requestedAt: a.requested_at || a.requestedAt || new Date().toISOString(),
|
||||||
|
requestedBy: a.requested_by || a.requester,
|
||||||
|
reason: a.reason || a.description,
|
||||||
|
action: a.action || 'execute',
|
||||||
|
params: a.params,
|
||||||
|
respondedAt: a.responded_at || a.respondedAt,
|
||||||
|
respondedBy: a.responded_by || a.respondedBy,
|
||||||
|
responseReason: a.response_reason || a.responseReason,
|
||||||
|
}));
|
||||||
|
set({ approvals });
|
||||||
|
} catch {
|
||||||
|
// ignore if approvals API not available
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
respondToApproval: async (approvalId: string, approved: boolean, reason?: string) => {
|
||||||
|
const client = get().client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.respondToApproval(approvalId, approved, reason);
|
||||||
|
await get().loadApprovals();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a HandClient adapter from a GatewayClient.
|
||||||
|
* Use this to inject the client into handStore.
|
||||||
|
*/
|
||||||
|
export function createHandClientFromGateway(client: GatewayClient): HandClient {
|
||||||
|
return {
|
||||||
|
listHands: () => client.listHands(),
|
||||||
|
getHand: (name) => client.getHand(name),
|
||||||
|
listHandRuns: (name, opts) => client.listHandRuns(name, opts),
|
||||||
|
triggerHand: (name, params) => client.triggerHand(name, params),
|
||||||
|
approveHand: (name, runId, approved, reason) => client.approveHand(name, runId, approved, reason),
|
||||||
|
cancelHand: (name, runId) => client.cancelHand(name, runId),
|
||||||
|
listTriggers: () => client.listTriggers(),
|
||||||
|
getTrigger: (id) => client.getTrigger(id),
|
||||||
|
createTrigger: (trigger) => client.createTrigger(trigger),
|
||||||
|
updateTrigger: (id, updates) => client.updateTrigger(id, updates),
|
||||||
|
deleteTrigger: (id) => client.deleteTrigger(id),
|
||||||
|
listApprovals: (status) => client.listApprovals(status),
|
||||||
|
respondToApproval: (approvalId, approved, reason) => client.respondToApproval(approvalId, approved, reason),
|
||||||
|
};
|
||||||
|
}
|
||||||
255
desktop/src/store/workflowStore.ts
Normal file
255
desktop/src/store/workflowStore.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { Workflow, WorkflowRun } from './gatewayStore';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
interface RawWorkflowRun {
|
||||||
|
runId?: string;
|
||||||
|
run_id?: string;
|
||||||
|
id?: string;
|
||||||
|
workflowId?: string;
|
||||||
|
workflow_id?: string;
|
||||||
|
status?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
started_at?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
completed_at?: string;
|
||||||
|
currentStep?: number;
|
||||||
|
current_step?: number;
|
||||||
|
totalSteps?: number;
|
||||||
|
total_steps?: number;
|
||||||
|
error?: string;
|
||||||
|
result?: unknown;
|
||||||
|
step?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowStep {
|
||||||
|
handName: string;
|
||||||
|
name?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
condition?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWorkflowInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
steps: WorkflowStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkflowInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
steps?: WorkflowStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended WorkflowRun with additional fields from API
|
||||||
|
export interface ExtendedWorkflowRun extends WorkflowRun {
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Client Interface ===
|
||||||
|
|
||||||
|
interface WorkflowClient {
|
||||||
|
listWorkflows(): Promise<{ workflows: { id: string; name: string; steps: number; description?: string; createdAt?: string }[] } | null>;
|
||||||
|
createWorkflow(workflow: CreateWorkflowInput): Promise<{ id: string; name: string } | null>;
|
||||||
|
updateWorkflow(id: string, updates: UpdateWorkflowInput): Promise<{ id: string; name: string } | null>;
|
||||||
|
deleteWorkflow(id: string): Promise<{ status: string }>;
|
||||||
|
executeWorkflow(id: string, input?: Record<string, unknown>): Promise<{ runId: string; status: string } | null>;
|
||||||
|
cancelWorkflow(workflowId: string, runId: string): Promise<{ status: string }>;
|
||||||
|
listWorkflowRuns(workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: RawWorkflowRun[] } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store State ===
|
||||||
|
|
||||||
|
interface WorkflowState {
|
||||||
|
workflows: Workflow[];
|
||||||
|
workflowRuns: Record<string, ExtendedWorkflowRun[]>;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
client: WorkflowClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Actions ===
|
||||||
|
|
||||||
|
interface WorkflowActions {
|
||||||
|
setWorkflowStoreClient: (client: WorkflowClient) => void;
|
||||||
|
loadWorkflows: () => Promise<void>;
|
||||||
|
getWorkflow: (id: string) => Workflow | undefined;
|
||||||
|
createWorkflow: (workflow: CreateWorkflowInput) => Promise<Workflow | undefined>;
|
||||||
|
updateWorkflow: (id: string, updates: UpdateWorkflowInput) => Promise<Workflow | undefined>;
|
||||||
|
deleteWorkflow: (id: string) => Promise<void>;
|
||||||
|
triggerWorkflow: (id: string, input?: Record<string, unknown>) => Promise<{ runId: string; status: string } | undefined>;
|
||||||
|
cancelWorkflow: (id: string, runId: string) => Promise<void>;
|
||||||
|
loadWorkflowRuns: (workflowId: string, opts?: { limit?: number; offset?: number }) => Promise<ExtendedWorkflowRun[]>;
|
||||||
|
clearError: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Initial State ===
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
workflows: [],
|
||||||
|
workflowRuns: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
client: null as unknown as WorkflowClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Store ===
|
||||||
|
|
||||||
|
export const useWorkflowStore = create<WorkflowState & WorkflowActions>((set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setWorkflowStoreClient: (client: WorkflowClient) => {
|
||||||
|
set({ client });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadWorkflows: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await get().client.listWorkflows();
|
||||||
|
const workflows: Workflow[] = (result?.workflows || []).map(w => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.name,
|
||||||
|
steps: w.steps,
|
||||||
|
description: w.description,
|
||||||
|
createdAt: w.createdAt,
|
||||||
|
}));
|
||||||
|
set({ workflows, isLoading: false });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to load workflows';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getWorkflow: (id: string) => {
|
||||||
|
return get().workflows.find(w => w.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
|
createWorkflow: async (workflow: CreateWorkflowInput) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const result = await get().client.createWorkflow(workflow);
|
||||||
|
if (result) {
|
||||||
|
const newWorkflow: Workflow = {
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
steps: workflow.steps.length,
|
||||||
|
description: workflow.description,
|
||||||
|
};
|
||||||
|
set(state => ({ workflows: [...state.workflows, newWorkflow] }));
|
||||||
|
return newWorkflow;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to create workflow';
|
||||||
|
set({ error: message });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWorkflow: async (id: string, updates: UpdateWorkflowInput) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const result = await get().client.updateWorkflow(id, updates);
|
||||||
|
if (result) {
|
||||||
|
set(state => ({
|
||||||
|
workflows: state.workflows.map(w =>
|
||||||
|
w.id === id
|
||||||
|
? {
|
||||||
|
...w,
|
||||||
|
name: updates.name ?? w.name,
|
||||||
|
description: updates.description ?? w.description,
|
||||||
|
steps: updates.steps?.length ?? w.steps,
|
||||||
|
}
|
||||||
|
: w
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return get().workflows.find(w => w.id === id);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to update workflow';
|
||||||
|
set({ error: message });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteWorkflow: async (id: string) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
await get().client.deleteWorkflow(id);
|
||||||
|
set(state => ({
|
||||||
|
workflows: state.workflows.filter(w => w.id !== id),
|
||||||
|
workflowRuns: (() => {
|
||||||
|
const { [id]: _, ...rest } = state.workflowRuns;
|
||||||
|
return rest;
|
||||||
|
})(),
|
||||||
|
}));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to delete workflow';
|
||||||
|
set({ error: message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerWorkflow: async (id: string, input?: Record<string, unknown>) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
const result = await get().client.executeWorkflow(id, input);
|
||||||
|
return result ? { runId: result.runId, status: result.status } : undefined;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to trigger workflow';
|
||||||
|
set({ error: message });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelWorkflow: async (id: string, runId: string) => {
|
||||||
|
set({ error: null });
|
||||||
|
try {
|
||||||
|
await get().client.cancelWorkflow(id, runId);
|
||||||
|
// Refresh workflows to update status
|
||||||
|
await get().loadWorkflows();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to cancel workflow';
|
||||||
|
set({ error: message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadWorkflowRuns: async (workflowId: string, opts?: { limit?: number; offset?: number }) => {
|
||||||
|
try {
|
||||||
|
const result = await get().client.listWorkflowRuns(workflowId, opts);
|
||||||
|
const runs: ExtendedWorkflowRun[] = (result?.runs || []).map((r: RawWorkflowRun) => ({
|
||||||
|
runId: r.runId || r.run_id || r.id || '',
|
||||||
|
status: r.status || 'unknown',
|
||||||
|
startedAt: r.startedAt || r.started_at,
|
||||||
|
completedAt: r.completedAt || r.completed_at,
|
||||||
|
step: r.currentStep?.toString() || r.current_step?.toString() || r.step,
|
||||||
|
result: r.result,
|
||||||
|
error: r.error,
|
||||||
|
}));
|
||||||
|
// Store runs by workflow ID
|
||||||
|
set(state => ({
|
||||||
|
workflowRuns: { ...state.workflowRuns, [workflowId]: runs },
|
||||||
|
}));
|
||||||
|
return runs;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
set(initialState);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-export types from gatewayStore for convenience
|
||||||
|
export type { Workflow, WorkflowRun };
|
||||||
@@ -560,4 +560,19 @@ ZCLAW 是基于 **OpenFang** (Rust Agent OS) 的 AI Agent 桌面客户端,核
|
|||||||
* ✅ chatStore 测试通过 (11/11)
|
* ✅ chatStore 测试通过 (11/11)
|
||||||
* ✅ gatewayStore 测试通过 (17/17)
|
* ✅ gatewayStore 测试通过 (17/17)
|
||||||
|
|
||||||
*下一步: Phase 11 Store 重构*
|
*Phase 11 进行中 🔄 (2026-03-15)* - Store 重构分解
|
||||||
|
* 新 Store 文件:
|
||||||
|
* ✅ `connectionStore.ts` (444 行) - WebSocket 连接、认证、本地 Gateway
|
||||||
|
* ✅ `agentStore.ts` (256 行) - Clones、使用统计、插件状态
|
||||||
|
* ✅ `handStore.ts` (498 行) - Hands、Triggers、Approvals
|
||||||
|
* ✅ `workflowStore.ts` (255 行) - Workflows、WorkflowRuns
|
||||||
|
* ✅ `configStore.ts` (537 行) - QuickConfig、Channels、Skills、Models
|
||||||
|
* Store 行数: gatewayStore 1660 → 5 个子 Store (平均 358 行)
|
||||||
|
* 待完成:
|
||||||
|
* 🔄 创建协调层 (coordinator)
|
||||||
|
* 🔄 更新组件导入
|
||||||
|
* 代码质量:
|
||||||
|
* ✅ TypeScript 类型检查通过
|
||||||
|
|
||||||
|
*下一步: Phase 11 协调层创建*
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user