feat(hands): restructure Hands UI with Chinese localization
Major changes: - Add HandList.tsx component for left sidebar - Add HandTaskPanel.tsx for middle content area - Restructure Sidebar tabs: 分身/HANDS/Workflow - Remove Hands tab from RightPanel - Localize all UI text to Chinese - Archive legacy OpenClaw documentation - Add Hands integration lessons document - Update feature checklist with new components UI improvements: - Left sidebar now shows Hands list with status icons - Middle area shows selected Hand's tasks and results - Consistent styling with Tailwind CSS - Chinese status labels and buttons Documentation: - Create docs/archive/openclaw-legacy/ for old docs - Add docs/knowledge-base/hands-integration-lessons.md - Update docs/knowledge-base/feature-checklist.md - Update docs/knowledge-base/README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,16 +2,42 @@
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
|
||||
|
||||
export interface MessageFile {
|
||||
name: string;
|
||||
path?: string;
|
||||
size?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface CodeBlock {
|
||||
language?: string;
|
||||
filename?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
runId?: string;
|
||||
streaming?: boolean;
|
||||
toolName?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
error?: string;
|
||||
// Hand event fields
|
||||
handName?: string;
|
||||
handStatus?: string;
|
||||
handResult?: unknown;
|
||||
// Workflow event fields
|
||||
workflowId?: string;
|
||||
workflowStep?: string;
|
||||
workflowStatus?: string;
|
||||
workflowResult?: unknown;
|
||||
// Output files and code blocks
|
||||
files?: MessageFile[];
|
||||
codeBlocks?: CodeBlock[];
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
@@ -19,6 +45,7 @@ export interface Conversation {
|
||||
title: string;
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
agentId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -32,6 +59,13 @@ export interface Agent {
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface AgentProfileLike {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: Message[];
|
||||
conversations: Conversation[];
|
||||
@@ -45,6 +79,7 @@ interface ChatState {
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||
setCurrentAgent: (agent: Agent) => void;
|
||||
syncAgents: (profiles: AgentProfileLike[]) => void;
|
||||
setCurrentModel: (model: string) => void;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
initStreamListener: () => () => void;
|
||||
@@ -66,23 +101,83 @@ function deriveTitle(messages: Message[]): string {
|
||||
return '新对话';
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT: Agent = {
|
||||
id: '1',
|
||||
name: 'ZCLAW',
|
||||
icon: '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: '发送消息开始对话',
|
||||
time: '',
|
||||
};
|
||||
|
||||
export function toChatAgent(profile: AgentProfileLike): Agent {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
icon: profile.nickname?.slice(0, 1) || '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: profile.role || '新分身',
|
||||
time: '',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConversationAgentId(agent: Agent | null): string | null {
|
||||
if (!agent || agent.id === DEFAULT_AGENT.id) {
|
||||
return null;
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
function resolveGatewayAgentId(agent: Agent | null): string | undefined {
|
||||
if (!agent || agent.id === DEFAULT_AGENT.id || agent.id.startsWith('clone_')) {
|
||||
return undefined;
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
function resolveAgentForConversation(agentId: string | null, agents: Agent[]): Agent {
|
||||
if (!agentId) {
|
||||
return DEFAULT_AGENT;
|
||||
}
|
||||
return agents.find((agent) => agent.id === agentId) || DEFAULT_AGENT;
|
||||
}
|
||||
|
||||
function upsertActiveConversation(
|
||||
conversations: Conversation[],
|
||||
state: Pick<ChatState, 'messages' | 'sessionKey' | 'currentConversationId' | 'currentAgent'>
|
||||
): Conversation[] {
|
||||
if (state.messages.length === 0) {
|
||||
return conversations;
|
||||
}
|
||||
|
||||
const currentId = state.currentConversationId || generateConvId();
|
||||
const existingIdx = conversations.findIndex((conversation) => conversation.id === currentId);
|
||||
const nextConversation: Conversation = {
|
||||
id: currentId,
|
||||
title: deriveTitle(state.messages),
|
||||
messages: [...state.messages],
|
||||
sessionKey: state.sessionKey,
|
||||
agentId: resolveConversationAgentId(state.currentAgent),
|
||||
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
conversations[existingIdx] = nextConversation;
|
||||
return conversations;
|
||||
}
|
||||
|
||||
return [nextConversation, ...conversations];
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'ZCLAW',
|
||||
icon: '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: '发送消息开始对话',
|
||||
time: '',
|
||||
},
|
||||
],
|
||||
currentAgent: null,
|
||||
agents: [DEFAULT_AGENT],
|
||||
currentAgent: DEFAULT_AGENT,
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
@@ -97,32 +192,42 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
})),
|
||||
|
||||
setCurrentAgent: (agent) => set({ currentAgent: agent }),
|
||||
setCurrentAgent: (agent) =>
|
||||
set((state) => {
|
||||
if (state.currentAgent?.id === agent.id) {
|
||||
return { currentAgent: agent };
|
||||
}
|
||||
|
||||
const conversations = upsertActiveConversation([...state.conversations], state);
|
||||
return {
|
||||
conversations,
|
||||
currentAgent: agent,
|
||||
messages: [],
|
||||
sessionKey: null,
|
||||
isStreaming: false,
|
||||
currentConversationId: null,
|
||||
};
|
||||
}),
|
||||
|
||||
syncAgents: (profiles) =>
|
||||
set((state) => {
|
||||
const agents = profiles.length > 0 ? profiles.map(toChatAgent) : [DEFAULT_AGENT];
|
||||
const currentAgent = state.currentConversationId
|
||||
? resolveAgentForConversation(
|
||||
state.conversations.find((conversation) => conversation.id === state.currentConversationId)?.agentId || null,
|
||||
agents
|
||||
)
|
||||
: state.currentAgent
|
||||
? agents.find((agent) => agent.id === state.currentAgent?.id) || agents[0]
|
||||
: agents[0];
|
||||
return { agents, currentAgent };
|
||||
}),
|
||||
|
||||
setCurrentModel: (model) => set({ currentModel: model }),
|
||||
|
||||
newConversation: () => {
|
||||
const state = get();
|
||||
let conversations = [...state.conversations];
|
||||
|
||||
// Save current conversation if it has messages
|
||||
if (state.messages.length > 0) {
|
||||
const currentId = state.currentConversationId || generateConvId();
|
||||
const existingIdx = conversations.findIndex(c => c.id === currentId);
|
||||
const conv: Conversation = {
|
||||
id: currentId,
|
||||
title: deriveTitle(state.messages),
|
||||
messages: [...state.messages],
|
||||
sessionKey: state.sessionKey,
|
||||
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (existingIdx >= 0) {
|
||||
conversations[existingIdx] = conv;
|
||||
} else {
|
||||
conversations = [conv, ...conversations];
|
||||
}
|
||||
}
|
||||
const conversations = upsertActiveConversation([...state.conversations], state);
|
||||
|
||||
set({
|
||||
conversations,
|
||||
@@ -135,21 +240,7 @@ export const useChatStore = create<ChatState>()(
|
||||
|
||||
switchConversation: (id: string) => {
|
||||
const state = get();
|
||||
let conversations = [...state.conversations];
|
||||
|
||||
// Save current conversation first
|
||||
if (state.messages.length > 0 && state.currentConversationId) {
|
||||
const existingIdx = conversations.findIndex(c => c.id === state.currentConversationId);
|
||||
if (existingIdx >= 0) {
|
||||
conversations[existingIdx] = {
|
||||
...conversations[existingIdx],
|
||||
messages: [...state.messages],
|
||||
sessionKey: state.sessionKey,
|
||||
updatedAt: new Date(),
|
||||
title: deriveTitle(state.messages),
|
||||
};
|
||||
}
|
||||
}
|
||||
const conversations = upsertActiveConversation([...state.conversations], state);
|
||||
|
||||
const target = conversations.find(c => c.id === id);
|
||||
if (target) {
|
||||
@@ -157,6 +248,7 @@ export const useChatStore = create<ChatState>()(
|
||||
conversations,
|
||||
messages: [...target.messages],
|
||||
sessionKey: target.sessionKey,
|
||||
currentAgent: resolveAgentForConversation(target.agentId, state.agents),
|
||||
currentConversationId: target.id,
|
||||
isStreaming: false,
|
||||
});
|
||||
@@ -174,7 +266,9 @@ export const useChatStore = create<ChatState>()(
|
||||
},
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
const { addMessage, currentModel, sessionKey } = get();
|
||||
const { addMessage, currentAgent, sessionKey } = get();
|
||||
const effectiveSessionKey = sessionKey || `session_${Date.now()}`;
|
||||
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
|
||||
|
||||
// Add user message
|
||||
const userMsg: Message = {
|
||||
@@ -199,22 +293,115 @@ export const useChatStore = create<ChatState>()(
|
||||
|
||||
try {
|
||||
const client = getGatewayClient();
|
||||
|
||||
// Try streaming first (OpenFang WebSocket)
|
||||
if (client.getState() === 'connected') {
|
||||
const { runId } = await client.chatStream(
|
||||
content,
|
||||
{
|
||||
onDelta: (delta: string) => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: m.content + delta }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
},
|
||||
onTool: (tool: string, input: string, output: string) => {
|
||||
const toolMsg: Message = {
|
||||
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'tool',
|
||||
content: output || input,
|
||||
timestamp: new Date(),
|
||||
runId,
|
||||
toolName: tool,
|
||||
toolInput: input,
|
||||
toolOutput: output,
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, toolMsg] }));
|
||||
},
|
||||
onHand: (name: string, status: string, result?: unknown) => {
|
||||
const handMsg: Message = {
|
||||
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'hand',
|
||||
content: result
|
||||
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
|
||||
: `Hand: ${name} - ${status}`,
|
||||
timestamp: new Date(),
|
||||
runId,
|
||||
handName: name,
|
||||
handStatus: status,
|
||||
handResult: result,
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, handMsg] }));
|
||||
},
|
||||
onComplete: () => {
|
||||
set((state) => ({
|
||||
isStreaming: false,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, streaming: false } : m
|
||||
),
|
||||
}));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
set((state) => ({
|
||||
isStreaming: false,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: `⚠️ ${error}`, streaming: false, error }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionKey: effectiveSessionKey,
|
||||
agentId: effectiveAgentId,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sessionKey) {
|
||||
set({ sessionKey: effectiveSessionKey });
|
||||
}
|
||||
|
||||
// Store runId on the message for correlation
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, runId } : m
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to REST API (non-streaming)
|
||||
const result = await client.chat(content, {
|
||||
sessionKey: sessionKey || undefined,
|
||||
model: currentModel,
|
||||
sessionKey: effectiveSessionKey,
|
||||
agentId: effectiveAgentId,
|
||||
});
|
||||
|
||||
// Store session key for continuity
|
||||
if (!sessionKey) {
|
||||
set({ sessionKey: `session_${Date.now()}` });
|
||||
set({ sessionKey: effectiveSessionKey });
|
||||
}
|
||||
|
||||
// OpenFang returns response directly (no WebSocket streaming)
|
||||
if (result.response) {
|
||||
set((state) => ({
|
||||
isStreaming: false,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: result.response || '', streaming: false }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// The actual streaming content comes via the 'agent' event listener
|
||||
// set in initStreamListener(). The runId links events to this message.
|
||||
// Store runId on the message for correlation
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, toolInput: result.runId } : m
|
||||
m.id === assistantId ? { ...m, runId: result.runId } : m
|
||||
),
|
||||
}));
|
||||
} catch (err: any) {
|
||||
@@ -241,29 +428,37 @@ export const useChatStore = create<ChatState>()(
|
||||
const unsubscribe = client.onAgentStream((delta: AgentStreamDelta) => {
|
||||
const state = get();
|
||||
|
||||
// Find the currently streaming assistant message
|
||||
const streamingMsg = [...state.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === 'assistant' && m.streaming);
|
||||
.find((m) => (
|
||||
m.role === 'assistant'
|
||||
&& m.streaming
|
||||
&& (
|
||||
(delta.runId && m.runId === delta.runId)
|
||||
|| (!delta.runId && m.runId == null)
|
||||
)
|
||||
))
|
||||
|| [...state.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === 'assistant' && m.streaming);
|
||||
|
||||
if (!streamingMsg) return;
|
||||
|
||||
if (delta.stream === 'assistant' && delta.delta) {
|
||||
// Append text delta to the streaming message
|
||||
if (delta.stream === 'assistant' && (delta.delta || delta.content)) {
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.id === streamingMsg.id
|
||||
? { ...m, content: m.content + delta.delta }
|
||||
? { ...m, content: m.content + (delta.delta || delta.content || '') }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
} else if (delta.stream === 'tool') {
|
||||
// Add a tool message
|
||||
const toolMsg: Message = {
|
||||
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'tool',
|
||||
content: delta.toolOutput || '',
|
||||
timestamp: new Date(),
|
||||
runId: delta.runId,
|
||||
toolName: delta.tool,
|
||||
toolInput: delta.toolInput,
|
||||
toolOutput: delta.toolOutput,
|
||||
@@ -271,7 +466,6 @@ export const useChatStore = create<ChatState>()(
|
||||
set((s) => ({ messages: [...s.messages, toolMsg] }));
|
||||
} else if (delta.stream === 'lifecycle') {
|
||||
if (delta.phase === 'end' || delta.phase === 'error') {
|
||||
// Mark streaming complete
|
||||
set((s) => ({
|
||||
isStreaming: false,
|
||||
messages: s.messages.map((m) =>
|
||||
@@ -285,6 +479,37 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
}));
|
||||
}
|
||||
} else if (delta.stream === 'hand') {
|
||||
// Handle Hand trigger events from OpenFang
|
||||
const handMsg: Message = {
|
||||
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'hand',
|
||||
content: delta.handResult
|
||||
? (typeof delta.handResult === 'string' ? delta.handResult : JSON.stringify(delta.handResult, null, 2))
|
||||
: `Hand: ${delta.handName || 'unknown'} - ${delta.handStatus || 'triggered'}`,
|
||||
timestamp: new Date(),
|
||||
runId: delta.runId,
|
||||
handName: delta.handName,
|
||||
handStatus: delta.handStatus,
|
||||
handResult: delta.handResult,
|
||||
};
|
||||
set((s) => ({ messages: [...s.messages, handMsg] }));
|
||||
} else if (delta.stream === 'workflow') {
|
||||
// Handle Workflow execution events from OpenFang
|
||||
const workflowMsg: Message = {
|
||||
id: `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'workflow',
|
||||
content: delta.workflowResult
|
||||
? (typeof delta.workflowResult === 'string' ? delta.workflowResult : JSON.stringify(delta.workflowResult, null, 2))
|
||||
: `Workflow: ${delta.workflowId || 'unknown'} step ${delta.workflowStep || '?'} - ${delta.workflowStatus || 'running'}`,
|
||||
timestamp: new Date(),
|
||||
runId: delta.runId,
|
||||
workflowId: delta.workflowId,
|
||||
workflowStep: delta.workflowStep,
|
||||
workflowStatus: delta.workflowStatus,
|
||||
workflowResult: delta.workflowResult,
|
||||
};
|
||||
set((s) => ({ messages: [...s.messages, workflowMsg] }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { GatewayClient, ConnectionState, getGatewayClient } from '../lib/gateway-client';
|
||||
import { create } from 'zustand';
|
||||
import { DEFAULT_GATEWAY_URL, FALLBACK_GATEWAY_URLS, GatewayClient, ConnectionState, getGatewayClient, getLocalDeviceIdentity, getStoredGatewayToken, getStoredGatewayUrl, setStoredGatewayToken, setStoredGatewayUrl } from '../lib/gateway-client';
|
||||
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;
|
||||
@@ -14,7 +16,16 @@ interface Clone {
|
||||
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 {
|
||||
@@ -43,12 +54,218 @@ interface ScheduledTask {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
source: 'builtin' | 'extra';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// === 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;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
steps: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
runId: string;
|
||||
status: string;
|
||||
step?: string;
|
||||
result?: 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>;
|
||||
}
|
||||
|
||||
// === 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[];
|
||||
@@ -56,6 +273,17 @@ interface GatewayStore {
|
||||
pluginStatus: any[];
|
||||
channels: ChannelInfo[];
|
||||
scheduledTasks: ScheduledTask[];
|
||||
skillsCatalog: SkillInfo[];
|
||||
quickConfig: QuickConfig;
|
||||
workspaceInfo: WorkspaceInfo | null;
|
||||
|
||||
// OpenFang Data
|
||||
hands: Hand[];
|
||||
workflows: Workflow[];
|
||||
triggers: Trigger[];
|
||||
auditLogs: AuditLogEntry[];
|
||||
securityStatus: SecurityStatus | null;
|
||||
approvals: Approval[];
|
||||
|
||||
// Client reference
|
||||
client: GatewayClient;
|
||||
@@ -65,13 +293,62 @@ interface GatewayStore {
|
||||
disconnect: () => void;
|
||||
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
|
||||
loadClones: () => Promise<void>;
|
||||
createClone: (opts: { name: string; role?: string; scenarios?: string[] }) => 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>;
|
||||
loadScheduledTasks: () => Promise<void>;
|
||||
loadSkillsCatalog: () => 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;
|
||||
|
||||
// OpenFang Actions
|
||||
loadHands: () => Promise<void>;
|
||||
getHandDetails: (name: string) => Promise<Hand | undefined>;
|
||||
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>;
|
||||
executeWorkflow: (id: string, input?: Record<string, unknown>) => Promise<WorkflowRun | undefined>;
|
||||
cancelWorkflow: (id: string, runId: string) => Promise<void>;
|
||||
loadTriggers: () => 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>;
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -93,24 +370,146 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
gatewayVersion: null,
|
||||
error: null,
|
||||
logs: [],
|
||||
localGateway: getUnsupportedLocalGatewayStatus(),
|
||||
localGatewayBusy: false,
|
||||
isLoading: false,
|
||||
clones: [],
|
||||
usageStats: null,
|
||||
pluginStatus: [],
|
||||
channels: [],
|
||||
scheduledTasks: [],
|
||||
skillsCatalog: [],
|
||||
quickConfig: {},
|
||||
workspaceInfo: null,
|
||||
// OpenFang state
|
||||
hands: [],
|
||||
workflows: [],
|
||||
triggers: [],
|
||||
auditLogs: [],
|
||||
securityStatus: null,
|
||||
approvals: [],
|
||||
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 });
|
||||
const c = url ? getGatewayClient({ url, token }) : get().client;
|
||||
await c.connect();
|
||||
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: any) {
|
||||
set({ error: err.message });
|
||||
throw err;
|
||||
@@ -129,16 +528,42 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
loadClones: async () => {
|
||||
try {
|
||||
const result = await get().client.listClones();
|
||||
set({ clones: result?.clones || [] });
|
||||
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: any) => c.id === currentDefault);
|
||||
if (!defaultExists) {
|
||||
client.setDefaultAgentId(clones[0].id);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore if method not available */ }
|
||||
},
|
||||
|
||||
createClone: async (opts) => {
|
||||
try {
|
||||
await get().client.createClone(opts);
|
||||
const result = await get().client.createClone(opts);
|
||||
await get().loadClones();
|
||||
return result?.clone;
|
||||
} catch (err: any) {
|
||||
set({ error: err.message });
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
updateClone: async (id, updates) => {
|
||||
try {
|
||||
const result = await get().client.updateClone(id, updates);
|
||||
await get().loadClones();
|
||||
return result?.clone;
|
||||
} catch (err: any) {
|
||||
set({ error: err.message });
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -212,6 +637,345 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
} catch { /* ignore if heartbeat.tasks not available */ }
|
||||
},
|
||||
|
||||
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 */ }
|
||||
},
|
||||
|
||||
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: any) {
|
||||
set({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
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: any) {
|
||||
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: any) {
|
||||
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: any) {
|
||||
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: any) {
|
||||
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 hands: Hand[] = (result?.hands || []).map(h => ({
|
||||
id: h.id || h.name,
|
||||
name: h.name,
|
||||
description: h.description || '',
|
||||
status: h.status || (h.requirements_met ? 'idle' : 'setup_needed'),
|
||||
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;
|
||||
|
||||
// Map API response to extended Hand interface
|
||||
const hand: Hand = {
|
||||
id: result.id || result.name || name,
|
||||
name: result.name || name,
|
||||
description: result.description || '',
|
||||
status: result.status || (result.requirements_met ? 'idle' : 'setup_needed'),
|
||||
requirements_met: result.requirements_met,
|
||||
category: result.category,
|
||||
icon: result.icon,
|
||||
provider: result.provider || result.config?.provider,
|
||||
model: result.model || result.config?.model,
|
||||
requirements: result.requirements?.map((r: any) => ({
|
||||
description: r.description || r.name || String(r),
|
||||
met: r.met ?? r.satisfied ?? true,
|
||||
details: r.details || r.hint,
|
||||
})),
|
||||
tools: result.tools || result.config?.tools,
|
||||
metrics: result.metrics || result.config?.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;
|
||||
}
|
||||
},
|
||||
|
||||
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 } : undefined;
|
||||
} catch (err: any) {
|
||||
set({ error: err.message });
|
||||
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: any) {
|
||||
set({ error: err.message });
|
||||
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: any) {
|
||||
set({ error: err.message });
|
||||
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 */
|
||||
}
|
||||
},
|
||||
|
||||
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: any) {
|
||||
set({ error: err.message });
|
||||
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: any) {
|
||||
set({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
loadTriggers: async () => {
|
||||
try {
|
||||
const result = await get().client.listTriggers();
|
||||
set({ triggers: result?.triggers || [] });
|
||||
} catch { /* ignore if triggers API not available */ }
|
||||
},
|
||||
|
||||
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 () => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { /* ignore if security API not available */ }
|
||||
},
|
||||
|
||||
loadApprovals: async (status?: ApprovalStatus) => {
|
||||
try {
|
||||
const result = await get().client.listApprovals(status);
|
||||
const approvals: Approval[] = (result?.approvals || []).map((a: any) => ({
|
||||
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: any) {
|
||||
set({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
clearLogs: () => set({ logs: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user