- Add types/api-responses.ts with ApiResponse<T>, PaginatedResponse<T> - Add types/errors.ts with comprehensive error type hierarchy - Replace all any usage (53 → 0, 100% reduction) - Add RawAPI response interfaces for type-safe mapping - Update catch blocks to use unknown with type narrowing - Add getState mock to chatStore tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1661 lines
51 KiB
TypeScript
1661 lines
51 KiB
TypeScript
import { create } from 'zustand';
|
|
import { DEFAULT_GATEWAY_URL, FALLBACK_GATEWAY_URLS, GatewayClient, ConnectionState, getGatewayClient, getLocalDeviceIdentity, getStoredGatewayToken, getStoredGatewayUrl, setStoredGatewayToken, setStoredGatewayUrl } from '../lib/gateway-client';
|
|
import type { GatewayModelChoice } from '../lib/gateway-config';
|
|
import { approveLocalGatewayDevicePairing, getLocalGatewayAuth, getLocalGatewayStatus, getUnsupportedLocalGatewayStatus, isTauriRuntime, prepareLocalGatewayForTauri, restartLocalGateway as restartLocalGatewayCommand, startLocalGateway as startLocalGatewayCommand, stopLocalGateway as stopLocalGatewayCommand, type LocalGatewayStatus } from '../lib/tauri-gateway';
|
|
import { useChatStore } from './chatStore';
|
|
|
|
interface GatewayLog {
|
|
timestamp: number;
|
|
level: string;
|
|
message: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface UsageStats {
|
|
totalSessions: number;
|
|
totalMessages: number;
|
|
totalTokens: number;
|
|
byModel: Record<string, { messages: number; inputTokens: number; outputTokens: number }>;
|
|
}
|
|
|
|
interface ChannelInfo {
|
|
id: string;
|
|
type: string;
|
|
label: string;
|
|
status: 'active' | 'inactive' | 'error';
|
|
accounts?: number;
|
|
error?: string;
|
|
}
|
|
|
|
export interface PluginStatus {
|
|
id: string;
|
|
name?: string;
|
|
status: 'active' | 'inactive' | 'error' | 'loading';
|
|
version?: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface ScheduledTask {
|
|
id: string;
|
|
name: string;
|
|
schedule: string;
|
|
status: 'active' | 'paused' | 'completed' | 'error';
|
|
lastRun?: string;
|
|
nextRun?: string;
|
|
description?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface WorkspaceInfo {
|
|
path: string;
|
|
resolvedPath: string;
|
|
exists: boolean;
|
|
fileCount: number;
|
|
totalSize: number;
|
|
}
|
|
|
|
// === 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;
|
|
requester?: string;
|
|
requested_by?: string;
|
|
status?: string;
|
|
createdAt?: string;
|
|
created_at?: string;
|
|
details?: Record<string, unknown>;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface RawSessionMessage {
|
|
id?: string;
|
|
messageId?: string;
|
|
message_id?: string;
|
|
role?: string;
|
|
content?: string;
|
|
createdAt?: string;
|
|
created_at?: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// === OpenFang Types ===
|
|
|
|
export interface HandRequirement {
|
|
description: string;
|
|
met: boolean;
|
|
details?: string;
|
|
}
|
|
|
|
export interface Hand {
|
|
id: string; // Hand ID used for API calls
|
|
name: string; // Display name
|
|
description: string;
|
|
status: 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
|
|
currentRunId?: string;
|
|
requirements_met?: boolean;
|
|
category?: string; // productivity, data, content, communication
|
|
icon?: string;
|
|
// Extended fields from details API
|
|
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 HandRunStore {
|
|
runs: HandRun[];
|
|
isLoading: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export interface Workflow {
|
|
id: string;
|
|
name: string;
|
|
steps: number;
|
|
description?: string;
|
|
createdAt?: string;
|
|
}
|
|
|
|
export interface WorkflowRun {
|
|
runId: string;
|
|
status: string;
|
|
step?: string;
|
|
result?: unknown;
|
|
}
|
|
|
|
// === Session Types ===
|
|
|
|
export interface SessionMessage {
|
|
id: string;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
createdAt: string;
|
|
tokens?: { input?: number; output?: number };
|
|
}
|
|
|
|
export interface Session {
|
|
id: string;
|
|
agentId: string;
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
messageCount?: number;
|
|
status?: 'active' | 'archived' | 'expired';
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface Trigger {
|
|
id: string;
|
|
type: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
// === Scheduler Types ===
|
|
|
|
export interface ScheduledJob {
|
|
id: string;
|
|
name: string;
|
|
cron: string;
|
|
enabled: boolean;
|
|
handName?: string;
|
|
workflowId?: string;
|
|
lastRun?: string;
|
|
nextRun?: string;
|
|
}
|
|
|
|
export interface EventTrigger {
|
|
id: string;
|
|
name: string;
|
|
eventType: string;
|
|
enabled: boolean;
|
|
handName?: string;
|
|
workflowId?: string;
|
|
}
|
|
|
|
export interface RunHistoryEntry {
|
|
id: string;
|
|
type: 'scheduled_job' | 'event_trigger';
|
|
sourceId: string;
|
|
sourceName: string;
|
|
status: 'success' | 'failure' | 'running';
|
|
startedAt: string;
|
|
completedAt?: string;
|
|
duration?: number;
|
|
error?: string;
|
|
}
|
|
|
|
// === Approval Types ===
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// === Security Types ===
|
|
|
|
export interface SecurityLayer {
|
|
name: string;
|
|
enabled: boolean;
|
|
description?: string;
|
|
}
|
|
|
|
export interface SecurityStatus {
|
|
layers: SecurityLayer[];
|
|
enabledCount: number;
|
|
totalCount: number;
|
|
securityLevel: 'critical' | 'high' | 'medium' | 'low';
|
|
}
|
|
|
|
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')
|
|
);
|
|
}
|
|
|
|
function requiresLocalDevicePairing(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error || '');
|
|
return message.includes('pairing required');
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function isLoopbackGatewayUrl(url: string): boolean {
|
|
return /^wss?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(url.trim());
|
|
}
|
|
|
|
async function approveCurrentLocalDevicePairing(url: string): Promise<boolean> {
|
|
if (!isTauriRuntime() || !isLoopbackGatewayUrl(url)) {
|
|
return false;
|
|
}
|
|
|
|
const identity = await getLocalDeviceIdentity();
|
|
const result = await approveLocalGatewayDevicePairing(identity.deviceId, identity.publicKeyBase64, url);
|
|
return result.approved;
|
|
}
|
|
|
|
interface GatewayStore {
|
|
// Connection state
|
|
connectionState: ConnectionState;
|
|
gatewayVersion: string | null;
|
|
error: string | null;
|
|
logs: GatewayLog[];
|
|
localGateway: LocalGatewayStatus;
|
|
localGatewayBusy: boolean;
|
|
isLoading: boolean;
|
|
|
|
// Data
|
|
clones: Clone[];
|
|
usageStats: UsageStats | null;
|
|
pluginStatus: PluginStatus[];
|
|
channels: ChannelInfo[];
|
|
scheduledTasks: ScheduledTask[];
|
|
skillsCatalog: SkillInfo[];
|
|
quickConfig: QuickConfig;
|
|
workspaceInfo: WorkspaceInfo | null;
|
|
|
|
// Models Data
|
|
models: GatewayModelChoice[];
|
|
modelsLoading: boolean;
|
|
modelsError: string | null;
|
|
|
|
// OpenFang Data
|
|
hands: Hand[];
|
|
handRuns: Record<string, HandRun[]>; // handName -> runs
|
|
workflows: Workflow[];
|
|
triggers: Trigger[];
|
|
auditLogs: AuditLogEntry[];
|
|
securityStatus: SecurityStatus | null;
|
|
securityStatusLoading: boolean;
|
|
securityStatusError: string | null;
|
|
approvals: Approval[];
|
|
// Session Data
|
|
sessions: Session[];
|
|
sessionMessages: Record<string, SessionMessage[]>; // sessionId -> messages
|
|
// Workflow Runs Data
|
|
workflowRuns: Record<string, WorkflowRun[]>; // workflowId -> runs
|
|
|
|
// Client reference
|
|
client: GatewayClient;
|
|
|
|
// Actions
|
|
connect: (url?: string, token?: string) => Promise<void>;
|
|
disconnect: () => void;
|
|
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
|
|
loadClones: () => Promise<void>;
|
|
createClone: (opts: {
|
|
name: string;
|
|
role?: string;
|
|
nickname?: string;
|
|
scenarios?: string[];
|
|
model?: string;
|
|
workspaceDir?: string;
|
|
restrictFiles?: boolean;
|
|
privacyOptIn?: boolean;
|
|
userName?: string;
|
|
userRole?: string;
|
|
}) => Promise<Clone | undefined>;
|
|
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
|
|
deleteClone: (id: string) => Promise<void>;
|
|
loadUsageStats: () => Promise<void>;
|
|
loadPluginStatus: () => Promise<void>;
|
|
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>;
|
|
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>;
|
|
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>;
|
|
loadQuickConfig: () => Promise<void>;
|
|
saveQuickConfig: (updates: Partial<QuickConfig>) => Promise<void>;
|
|
loadWorkspaceInfo: () => Promise<void>;
|
|
refreshLocalGateway: () => Promise<LocalGatewayStatus>;
|
|
startLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
|
|
stopLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
|
|
restartLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
|
|
clearLogs: () => void;
|
|
|
|
// Models Actions
|
|
loadModels: () => Promise<void>;
|
|
|
|
// OpenFang Actions
|
|
loadHands: () => Promise<void>;
|
|
getHandDetails: (name: string) => Promise<Hand | undefined>;
|
|
loadHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<HandRun[]>;
|
|
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<HandRun | undefined>;
|
|
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<void>;
|
|
cancelHand: (name: string, runId: string) => Promise<void>;
|
|
loadWorkflows: () => Promise<void>;
|
|
createWorkflow: (workflow: {
|
|
name: string;
|
|
description?: string;
|
|
steps: Array<{
|
|
handName: string;
|
|
name?: string;
|
|
params?: Record<string, unknown>;
|
|
condition?: string;
|
|
}>;
|
|
}) => Promise<Workflow | undefined>;
|
|
updateWorkflow: (id: string, updates: {
|
|
name?: string;
|
|
description?: string;
|
|
steps?: Array<{
|
|
handName: string;
|
|
name?: string;
|
|
params?: Record<string, unknown>;
|
|
condition?: string;
|
|
}>;
|
|
}) => Promise<Workflow | undefined>;
|
|
deleteWorkflow: (id: string) => Promise<void>;
|
|
executeWorkflow: (id: string, input?: Record<string, unknown>) => Promise<WorkflowRun | undefined>;
|
|
cancelWorkflow: (id: string, runId: string) => Promise<void>;
|
|
loadTriggers: () => Promise<void>;
|
|
// Workflow Run Actions
|
|
loadWorkflowRuns: (workflowId: string, opts?: { limit?: number; offset?: number }) => Promise<WorkflowRun[]>;
|
|
// Trigger Actions
|
|
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>;
|
|
loadAuditLogs: (opts?: { limit?: number; offset?: number }) => Promise<void>;
|
|
loadSecurityStatus: () => Promise<void>;
|
|
loadApprovals: (status?: ApprovalStatus) => Promise<void>;
|
|
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
|
|
// Session Actions
|
|
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[]>;
|
|
}
|
|
|
|
function normalizeGatewayUrlCandidate(url: string): string {
|
|
return url.trim().replace(/\/+$/, '');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export const useGatewayStore = create<GatewayStore>((set, get) => {
|
|
const client = getGatewayClient();
|
|
|
|
// Wire up state change callback
|
|
client.onStateChange = (state) => {
|
|
set({ connectionState: state });
|
|
};
|
|
|
|
client.onLog = (level, message) => {
|
|
set((s) => ({
|
|
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
|
|
}));
|
|
};
|
|
|
|
return {
|
|
connectionState: 'disconnected',
|
|
gatewayVersion: null,
|
|
error: null,
|
|
logs: [],
|
|
localGateway: getUnsupportedLocalGatewayStatus(),
|
|
localGatewayBusy: false,
|
|
isLoading: false,
|
|
clones: [],
|
|
usageStats: null,
|
|
pluginStatus: [],
|
|
channels: [],
|
|
scheduledTasks: [],
|
|
skillsCatalog: [],
|
|
quickConfig: {},
|
|
workspaceInfo: null,
|
|
// Models state
|
|
models: [],
|
|
modelsLoading: false,
|
|
modelsError: null,
|
|
// OpenFang state
|
|
hands: [],
|
|
handRuns: {}, // handName -> runs
|
|
workflows: [],
|
|
triggers: [],
|
|
auditLogs: [],
|
|
securityStatus: null,
|
|
securityStatusLoading: false,
|
|
securityStatusError: null,
|
|
approvals: [],
|
|
// Session state
|
|
sessions: [],
|
|
sessionMessages: {},
|
|
// Workflow Runs state
|
|
workflowRuns: {},
|
|
client,
|
|
|
|
connect: async (url?: string, token?: string) => {
|
|
const c = get().client;
|
|
const resolveCandidates = async (): Promise<string[]> => {
|
|
const explicitUrl = url?.trim();
|
|
if (explicitUrl) {
|
|
return [normalizeGatewayUrlCandidate(explicitUrl)];
|
|
}
|
|
|
|
const candidates: string[] = [];
|
|
|
|
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 */
|
|
}
|
|
}
|
|
|
|
const quickConfigGatewayUrl = get().quickConfig.gatewayUrl?.trim();
|
|
if (quickConfigGatewayUrl) {
|
|
candidates.push(quickConfigGatewayUrl);
|
|
}
|
|
|
|
candidates.push(getStoredGatewayUrl(), DEFAULT_GATEWAY_URL, ...FALLBACK_GATEWAY_URLS);
|
|
|
|
return Array.from(
|
|
new Set(
|
|
candidates
|
|
.filter(Boolean)
|
|
.map(normalizeGatewayUrlCandidate)
|
|
)
|
|
);
|
|
};
|
|
|
|
try {
|
|
set({ error: null });
|
|
if (isTauriRuntime()) {
|
|
try {
|
|
await prepareLocalGatewayForTauri();
|
|
} catch {
|
|
/* ignore local gateway preparation failures during connection bootstrap */
|
|
}
|
|
}
|
|
// Use the first non-empty token from: param > quickConfig > localStorage
|
|
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('[GatewayStore] Connecting with token:', effectiveToken ? `${effectiveToken.substring(0, 8)}...` : '(empty)');
|
|
const candidateUrls = await resolveCandidates();
|
|
let lastError: unknown = null;
|
|
let connectedUrl: string | null = null;
|
|
|
|
for (const candidateUrl of candidateUrls) {
|
|
try {
|
|
c.updateOptions({
|
|
url: candidateUrl,
|
|
token: effectiveToken,
|
|
});
|
|
await c.connect();
|
|
connectedUrl = candidateUrl;
|
|
break;
|
|
} catch (err) {
|
|
lastError = err;
|
|
if (requiresLocalDevicePairing(err)) {
|
|
const approved = await approveCurrentLocalDevicePairing(candidateUrl);
|
|
if (approved) {
|
|
c.updateOptions({
|
|
url: candidateUrl,
|
|
token: effectiveToken,
|
|
});
|
|
await c.connect();
|
|
connectedUrl = candidateUrl;
|
|
break;
|
|
}
|
|
}
|
|
if (!shouldRetryGatewayCandidate(err)) {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!connectedUrl) {
|
|
throw (lastError instanceof Error ? lastError : new Error('无法连接到任何可用 Gateway'));
|
|
}
|
|
|
|
setStoredGatewayUrl(connectedUrl);
|
|
|
|
// Fetch initial data after connection
|
|
try {
|
|
const health = await c.health();
|
|
set({ gatewayVersion: health?.version });
|
|
} catch { /* health may not return version */ }
|
|
await Promise.allSettled([
|
|
get().loadQuickConfig(),
|
|
get().loadWorkspaceInfo(),
|
|
get().loadClones(),
|
|
get().loadUsageStats(),
|
|
get().loadPluginStatus(),
|
|
get().loadScheduledTasks(),
|
|
get().loadSkillsCatalog(),
|
|
// OpenFang data loading
|
|
get().loadHands(),
|
|
get().loadWorkflows(),
|
|
get().loadTriggers(),
|
|
get().loadSecurityStatus(),
|
|
]);
|
|
await get().loadChannels();
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
disconnect: () => {
|
|
get().client.disconnect();
|
|
},
|
|
|
|
sendMessage: async (message: string, sessionKey?: string) => {
|
|
const c = get().client;
|
|
return c.chat(message, { sessionKey });
|
|
},
|
|
|
|
loadClones: async () => {
|
|
try {
|
|
const result = await get().client.listClones();
|
|
const clones = result?.clones || result?.agents || [];
|
|
set({ clones });
|
|
useChatStore.getState().syncAgents(clones);
|
|
|
|
// Set default agent ID if we have agents and none is set
|
|
if (clones.length > 0 && clones[0].id) {
|
|
const client = get().client;
|
|
const currentDefault = client.getDefaultAgentId();
|
|
// Only set if the default doesn't exist in the list
|
|
const defaultExists = clones.some((c) => c.id === currentDefault);
|
|
if (!defaultExists) {
|
|
client.setDefaultAgentId(clones[0].id);
|
|
}
|
|
}
|
|
} catch { /* ignore if method not available */ }
|
|
},
|
|
|
|
createClone: async (opts) => {
|
|
try {
|
|
const result = await get().client.createClone(opts);
|
|
await get().loadClones();
|
|
return result?.clone;
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
updateClone: async (id, updates) => {
|
|
try {
|
|
const result = await get().client.updateClone(id, updates);
|
|
await get().loadClones();
|
|
return result?.clone;
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
deleteClone: async (id: string) => {
|
|
try {
|
|
await get().client.deleteClone(id);
|
|
await get().loadClones();
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
},
|
|
|
|
loadUsageStats: async () => {
|
|
try {
|
|
const stats = await get().client.getUsageStats();
|
|
set({ usageStats: stats });
|
|
} catch { /* ignore */ }
|
|
},
|
|
|
|
loadPluginStatus: async () => {
|
|
try {
|
|
const result = await get().client.getPluginStatus();
|
|
set({ pluginStatus: result?.plugins || [] });
|
|
} catch { /* ignore */ }
|
|
},
|
|
|
|
loadChannels: async () => {
|
|
const channels: { id: string; type: string; label: string; status: 'active' | 'inactive' | 'error'; accounts?: number; error?: string }[] = [];
|
|
try {
|
|
// Try listing channels from Gateway
|
|
const result = await get().client.listChannels();
|
|
if (result?.channels) {
|
|
set({ channels: result.channels });
|
|
return;
|
|
}
|
|
} catch { /* channels.list may not be available */ }
|
|
|
|
// Fallback: probe known channels individually
|
|
try {
|
|
const feishu = await get().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' });
|
|
}
|
|
|
|
// QQ channel (check if qqbot plugin is loaded)
|
|
const plugins = get().pluginStatus;
|
|
const qqPlugin = plugins.find((p) => (p.name || p.id || '').toLowerCase().includes('qqbot'));
|
|
if (qqPlugin) {
|
|
channels.push({
|
|
id: 'qqbot',
|
|
type: 'qqbot',
|
|
label: 'QQ 机器人',
|
|
status: qqPlugin.status === 'active' ? 'active' : 'inactive',
|
|
});
|
|
}
|
|
|
|
set({ channels });
|
|
},
|
|
|
|
getChannel: async (id: string) => {
|
|
try {
|
|
const result = await get().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) => {
|
|
try {
|
|
const result = await get().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) => {
|
|
try {
|
|
const result = await get().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) => {
|
|
try {
|
|
await get().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) });
|
|
}
|
|
},
|
|
|
|
loadScheduledTasks: async () => {
|
|
try {
|
|
const result = await get().client.listScheduledTasks();
|
|
set({ scheduledTasks: result?.tasks || [] });
|
|
} catch { /* ignore if heartbeat.tasks not available */ }
|
|
},
|
|
|
|
createScheduledTask: async (task) => {
|
|
try {
|
|
const result = await get().client.createScheduledTask(task);
|
|
const newTask = {
|
|
id: result.id,
|
|
name: result.name,
|
|
schedule: result.schedule,
|
|
status: result.status as 'active' | 'paused' | 'completed' | 'error',
|
|
};
|
|
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 });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
loadSkillsCatalog: async () => {
|
|
try {
|
|
const result = await get().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) => {
|
|
try {
|
|
const result = await get().client.getSkill(id);
|
|
return result?.skill as SkillInfo | undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
createSkill: async (skill) => {
|
|
try {
|
|
const result = await get().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) => {
|
|
try {
|
|
const result = await get().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) => {
|
|
try {
|
|
await get().client.deleteSkill(id);
|
|
set((state) => ({
|
|
skillsCatalog: state.skillsCatalog.filter((s) => s.id !== id),
|
|
}));
|
|
} catch { /* ignore deletion errors */ }
|
|
},
|
|
|
|
loadQuickConfig: async () => {
|
|
try {
|
|
const result = await get().client.getQuickConfig();
|
|
set({ quickConfig: result?.quickConfig || {} });
|
|
} catch { /* ignore if quick config not available */ }
|
|
},
|
|
|
|
saveQuickConfig: async (updates) => {
|
|
try {
|
|
const nextConfig = { ...get().quickConfig, ...updates };
|
|
if (nextConfig.gatewayUrl) {
|
|
setStoredGatewayUrl(nextConfig.gatewayUrl);
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(updates, 'gatewayToken')) {
|
|
setStoredGatewayToken(nextConfig.gatewayToken || '');
|
|
}
|
|
const result = await get().client.saveQuickConfig(nextConfig);
|
|
set({ quickConfig: result?.quickConfig || nextConfig });
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
},
|
|
|
|
loadWorkspaceInfo: async () => {
|
|
try {
|
|
const info = await get().client.getWorkspaceInfo();
|
|
set({ workspaceInfo: info });
|
|
} catch { /* ignore if workspace info not available */ }
|
|
},
|
|
|
|
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?.message || '读取本地 Gateway 状态失败';
|
|
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?.message || '启动本地 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?.message || '停止本地 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?.message || '重启本地 Gateway 失败';
|
|
const nextStatus = {
|
|
...get().localGateway,
|
|
supported: true,
|
|
error: message,
|
|
};
|
|
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
// === OpenFang Actions ===
|
|
|
|
loadHands: async () => {
|
|
set({ isLoading: true });
|
|
try {
|
|
const result = await get().client.listHands();
|
|
// Map API response to Hand interface
|
|
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'] as const;
|
|
const hands: Hand[] = (result?.hands || []).map(h => {
|
|
const status = validStatuses.includes(h.status as any)
|
|
? h.status as Hand['status']
|
|
: (h.requirements_met ? 'idle' : 'setup_needed');
|
|
return {
|
|
id: h.id || h.name,
|
|
name: h.name,
|
|
description: h.description || '',
|
|
status,
|
|
requirements_met: h.requirements_met,
|
|
category: h.category,
|
|
icon: h.icon,
|
|
toolCount: h.tool_count || h.tools?.length,
|
|
metricCount: h.metric_count || h.metrics?.length,
|
|
};
|
|
});
|
|
set({ hands, isLoading: false });
|
|
} catch {
|
|
set({ isLoading: false });
|
|
/* ignore if hands API not available */
|
|
}
|
|
},
|
|
|
|
getHandDetails: async (name: string) => {
|
|
try {
|
|
const result = await get().client.getHand(name);
|
|
if (!result) return undefined;
|
|
|
|
// Helper to extract string from unknown config
|
|
const getStringFromConfig = (key: string): string | undefined => {
|
|
const val = result.config?.[key];
|
|
return typeof val === 'string' ? val : undefined;
|
|
};
|
|
const getArrayFromConfig = (key: string): string[] | undefined => {
|
|
const val = result.config?.[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 any)
|
|
? result.status as Hand['status']
|
|
: (result.requirements_met ? 'idle' : 'setup_needed');
|
|
|
|
// Map API response to extended Hand interface
|
|
const hand: Hand = {
|
|
id: result.id || result.name || name,
|
|
name: result.name || name,
|
|
description: result.description || '',
|
|
status,
|
|
requirements_met: result.requirements_met,
|
|
category: result.category,
|
|
icon: result.icon,
|
|
provider: result.provider || getStringFromConfig('provider'),
|
|
model: result.model || getStringFromConfig('model'),
|
|
requirements: result.requirements?.map((r: RawHandRequirement) => ({
|
|
description: r.description || r.name || String(r),
|
|
met: r.met ?? r.satisfied ?? true,
|
|
details: r.details || r.hint,
|
|
})),
|
|
tools: result.tools || getArrayFromConfig('tools'),
|
|
metrics: result.metrics || getArrayFromConfig('metrics'),
|
|
toolCount: result.tool_count || result.tools?.length || 0,
|
|
metricCount: result.metric_count || result.metrics?.length || 0,
|
|
};
|
|
|
|
// Update hands list with detailed info
|
|
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 }) => {
|
|
try {
|
|
const result = await get().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,
|
|
}));
|
|
// Store runs by hand name
|
|
set(state => ({
|
|
handRuns: { ...state.handRuns, [name]: runs },
|
|
}));
|
|
return runs;
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
triggerHand: async (name: string, params?: Record<string, unknown>) => {
|
|
try {
|
|
const result = await get().client.triggerHand(name, params);
|
|
return result ? { runId: result.runId, status: result.status, startedAt: new Date().toISOString() } : undefined;
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
approveHand: async (name: string, runId: string, approved: boolean, reason?: string) => {
|
|
try {
|
|
await get().client.approveHand(name, runId, approved, reason);
|
|
// Refresh hands to update status
|
|
await get().loadHands();
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
cancelHand: async (name: string, runId: string) => {
|
|
try {
|
|
await get().client.cancelHand(name, runId);
|
|
// Refresh hands to update status
|
|
await get().loadHands();
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
loadWorkflows: async () => {
|
|
set({ isLoading: true });
|
|
try {
|
|
const result = await get().client.listWorkflows();
|
|
set({ workflows: result?.workflows || [], isLoading: false });
|
|
} catch {
|
|
set({ isLoading: false });
|
|
/* ignore if workflows API not available */
|
|
}
|
|
},
|
|
|
|
createWorkflow: async (workflow: {
|
|
name: string;
|
|
description?: string;
|
|
steps: Array<{
|
|
handName: string;
|
|
name?: string;
|
|
params?: Record<string, unknown>;
|
|
condition?: string;
|
|
}>;
|
|
}) => {
|
|
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) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
updateWorkflow: async (id: string, updates: {
|
|
name?: string;
|
|
description?: string;
|
|
steps?: Array<{
|
|
handName: string;
|
|
name?: string;
|
|
params?: Record<string, unknown>;
|
|
condition?: string;
|
|
}>;
|
|
}) => {
|
|
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) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
deleteWorkflow: async (id: string) => {
|
|
try {
|
|
await get().client.deleteWorkflow(id);
|
|
set(state => ({
|
|
workflows: state.workflows.filter(w => w.id !== id),
|
|
}));
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
executeWorkflow: async (id: string, input?: Record<string, unknown>) => {
|
|
try {
|
|
const result = await get().client.executeWorkflow(id, input);
|
|
return result ? { runId: result.runId, status: result.status } : undefined;
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
cancelWorkflow: async (id: string, runId: string) => {
|
|
try {
|
|
await get().client.cancelWorkflow(id, runId);
|
|
// Refresh workflows to update status
|
|
await get().loadWorkflows();
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
loadTriggers: async () => {
|
|
try {
|
|
const result = await get().client.listTriggers();
|
|
set({ triggers: result?.triggers || [] });
|
|
} catch { /* ignore if triggers API not available */ }
|
|
},
|
|
|
|
getTrigger: async (id: string) => {
|
|
try {
|
|
const result = await get().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) => {
|
|
try {
|
|
const result = await get().client.createTrigger(trigger);
|
|
if (!result?.id) return undefined;
|
|
// Refresh triggers list after creation
|
|
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) => {
|
|
try {
|
|
await get().client.updateTrigger(id, updates);
|
|
// Update local state
|
|
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) => {
|
|
try {
|
|
await get().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;
|
|
}
|
|
},
|
|
|
|
loadAuditLogs: async (opts?: { limit?: number; offset?: number }) => {
|
|
try {
|
|
const result = await get().client.getAuditLogs(opts);
|
|
set({ auditLogs: (result?.logs || []) as AuditLogEntry[] });
|
|
} catch { /* ignore if audit API not available */ }
|
|
},
|
|
|
|
loadSecurityStatus: async () => {
|
|
set({ securityStatusLoading: true, securityStatusError: null });
|
|
try {
|
|
const result = await get().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',
|
|
});
|
|
}
|
|
},
|
|
|
|
loadApprovals: async (status?: ApprovalStatus) => {
|
|
try {
|
|
const result = await get().client.listApprovals(status);
|
|
const approvals: Approval[] = (result?.approvals || []).map((a: RawApproval) => ({
|
|
id: a.id || a.approval_id,
|
|
handName: a.hand_name || a.handName,
|
|
runId: a.run_id || a.runId,
|
|
status: a.status || 'pending',
|
|
requestedAt: a.requested_at || a.requestedAt || new Date().toISOString(),
|
|
requestedBy: a.requested_by || a.requestedBy,
|
|
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) => {
|
|
try {
|
|
await get().client.respondToApproval(approvalId, approved, reason);
|
|
// Refresh approvals after response
|
|
await get().loadApprovals();
|
|
} catch (err: unknown) {
|
|
set({ error: err instanceof Error ? err.message : String(err) });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
// === Session Actions ===
|
|
|
|
loadSessions: async (opts?: { limit?: number; offset?: number }) => {
|
|
try {
|
|
const result = await get().client.listSessions(opts);
|
|
const sessions: Session[] = (result?.sessions || []).map((s: RawSession) => ({
|
|
id: s.id,
|
|
agentId: s.agent_id,
|
|
createdAt: s.created_at,
|
|
updatedAt: s.updated_at,
|
|
messageCount: s.message_count,
|
|
status: s.status,
|
|
metadata: s.metadata,
|
|
}));
|
|
set({ sessions });
|
|
} catch {
|
|
/* ignore if sessions API not available */
|
|
}
|
|
},
|
|
|
|
getSession: async (sessionId: string) => {
|
|
try {
|
|
const result = await get().client.getSession(sessionId);
|
|
if (!result) return undefined;
|
|
const session: Session = {
|
|
id: result.id,
|
|
agentId: result.agent_id,
|
|
createdAt: result.created_at,
|
|
updatedAt: result.updated_at,
|
|
messageCount: result.message_count,
|
|
status: result.status,
|
|
metadata: result.metadata,
|
|
};
|
|
// Update in list if exists
|
|
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>) => {
|
|
try {
|
|
const result = await get().client.createSession({ agent_id: agentId, metadata });
|
|
if (!result) return undefined;
|
|
const session: Session = {
|
|
id: result.id,
|
|
agentId: result.agent_id,
|
|
createdAt: result.created_at,
|
|
status: 'active',
|
|
metadata: 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) => {
|
|
try {
|
|
await get().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 }) => {
|
|
try {
|
|
const result = await get().client.getSessionMessages(sessionId, opts);
|
|
const messages: SessionMessage[] = (result?.messages || []).map((m: RawSessionMessage) => ({
|
|
id: m.id,
|
|
role: m.role,
|
|
content: m.content,
|
|
createdAt: m.created_at,
|
|
tokens: m.tokens,
|
|
}));
|
|
set(state => ({
|
|
sessionMessages: { ...state.sessionMessages, [sessionId]: messages },
|
|
}));
|
|
return messages;
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
clearLogs: () => set({ logs: [] }),
|
|
|
|
// === Models Actions ===
|
|
|
|
loadModels: async () => {
|
|
try {
|
|
set({ modelsLoading: true, modelsError: null });
|
|
const result = await get().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 });
|
|
}
|
|
},
|
|
|
|
// === Workflow Run Actions ===
|
|
|
|
loadWorkflowRuns: async (workflowId: string, opts?: { limit?: number; offset?: number }) => {
|
|
try {
|
|
const result = await get().client.listWorkflowRuns(workflowId, opts);
|
|
const runs: WorkflowRun[] = (result?.runs || []).map((r: RawWorkflowRun) => ({
|
|
runId: r.runId || r.run_id,
|
|
status: r.status,
|
|
startedAt: r.startedAt || r.started_at,
|
|
completedAt: r.completedAt || r.completed_at,
|
|
step: r.step,
|
|
result: r.result,
|
|
error: r.error,
|
|
}));
|
|
// Store runs by workflow ID
|
|
set(state => ({
|
|
workflowRuns: { ...state.workflowRuns, [workflowId]: runs },
|
|
}));
|
|
return runs;
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
|
|
|
|
|
|
|