feat(domains): create domain-driven architecture for Phase 2
Chat Domain: - Add types.ts with Message, Conversation, Agent types - Add store.ts with Valtio-based state management - Add hooks.ts with useChatState, useMessages, etc. - Add index.ts for public API export Hands Domain: - Add types.ts with Hand, Trigger, Approval types - Add machine.ts with XState state machine - Add store.ts with Valtio-based state management - Add hooks.ts with useHands, useApprovalQueue, etc. Shared Module: - Add types.ts with Result, AsyncResult, PaginatedResponse - Add error-handling.ts with AppError, NetworkError, etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
76
desktop/src/domains/chat/hooks.ts
Normal file
76
desktop/src/domains/chat/hooks.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Chat Domain Hooks
|
||||
*
|
||||
* React hooks for accessing chat state with Valtio.
|
||||
* Only re-renders when accessed properties change.
|
||||
*/
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { chatStore } from './store';
|
||||
import type { Message, Agent, Conversation } from './types';
|
||||
|
||||
/**
|
||||
* Hook to access the full chat state snapshot.
|
||||
* Only re-renders when accessed properties change.
|
||||
*/
|
||||
export function useChatState() {
|
||||
return useSnapshot(chatStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access messages only.
|
||||
* Only re-renders when messages change.
|
||||
*/
|
||||
export function useMessages(): readonly Message[] {
|
||||
const { messages } = useSnapshot(chatStore);
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access streaming state.
|
||||
* Only re-renders when isStreaming changes.
|
||||
*/
|
||||
export function useIsStreaming(): boolean {
|
||||
const { isStreaming } = useSnapshot(chatStore);
|
||||
return isStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access current agent.
|
||||
*/
|
||||
export function useCurrentAgent(): Agent | null {
|
||||
const { currentAgent } = useSnapshot(chatStore);
|
||||
return currentAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access all agents.
|
||||
*/
|
||||
export function useAgents(): readonly Agent[] {
|
||||
const { agents } = useSnapshot(chatStore);
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access conversations.
|
||||
*/
|
||||
export function useConversations(): readonly Conversation[] {
|
||||
const { conversations } = useSnapshot(chatStore);
|
||||
return conversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access current model.
|
||||
*/
|
||||
export function useCurrentModel(): string {
|
||||
const { currentModel } = useSnapshot(chatStore);
|
||||
return currentModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access chat actions.
|
||||
* Returns the store directly for calling actions.
|
||||
* Does not cause re-renders.
|
||||
*/
|
||||
export function useChatActions() {
|
||||
return chatStore;
|
||||
}
|
||||
48
desktop/src/domains/chat/index.ts
Normal file
48
desktop/src/domains/chat/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Chat Domain
|
||||
*
|
||||
* Core chat functionality including messaging, conversations, and agents.
|
||||
*
|
||||
* @example
|
||||
* // Using hooks (recommended)
|
||||
* import { useMessages, useChatActions } from '@/domains/chat';
|
||||
*
|
||||
* function ChatComponent() {
|
||||
* const messages = useMessages();
|
||||
* const { addMessage } = useChatActions();
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Using store directly (for actions)
|
||||
* import { chatStore } from '@/domains/chat';
|
||||
*
|
||||
* chatStore.addMessage({ id: '1', role: 'user', content: 'Hello', timestamp: new Date() });
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Message,
|
||||
MessageFile,
|
||||
CodeBlock,
|
||||
Conversation,
|
||||
Agent,
|
||||
AgentProfileLike,
|
||||
ChatState,
|
||||
} from './types';
|
||||
|
||||
// Store
|
||||
export { chatStore, toChatAgent } from './store';
|
||||
export type { ChatStore } from './store';
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useChatState,
|
||||
useMessages,
|
||||
useIsStreaming,
|
||||
useCurrentAgent,
|
||||
useAgents,
|
||||
useConversations,
|
||||
useCurrentModel,
|
||||
useChatActions,
|
||||
} from './hooks';
|
||||
222
desktop/src/domains/chat/store.ts
Normal file
222
desktop/src/domains/chat/store.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Chat Domain Store
|
||||
*
|
||||
* Valtio-based state management for chat.
|
||||
* Replaces Zustand for better performance with fine-grained reactivity.
|
||||
*/
|
||||
import { proxy, subscribe } from 'valtio';
|
||||
import type { Message, Conversation, Agent, AgentProfileLike } from './types';
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const DEFAULT_AGENT: Agent = {
|
||||
id: '1',
|
||||
name: 'ZCLAW',
|
||||
icon: '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: '发送消息开始对话',
|
||||
time: '',
|
||||
};
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function generateConvId(): string {
|
||||
return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function deriveTitle(messages: Message[]): string {
|
||||
const firstUser = messages.find(m => m.role === 'user');
|
||||
if (firstUser) {
|
||||
const text = firstUser.content.trim();
|
||||
return text.length > 30 ? text.slice(0, 30) + '...' : text;
|
||||
}
|
||||
return '新对话';
|
||||
}
|
||||
|
||||
export function toChatAgent(profile: AgentProfileLike): Agent {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
icon: profile.nickname?.slice(0, 1) || profile.name.slice(0, 1) || '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: profile.role || '新分身',
|
||||
time: '',
|
||||
};
|
||||
}
|
||||
|
||||
// === Store Interface ===
|
||||
|
||||
export interface ChatStore {
|
||||
// State
|
||||
messages: Message[];
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
agents: Agent[];
|
||||
currentAgent: Agent | null;
|
||||
isStreaming: boolean;
|
||||
currentModel: string;
|
||||
sessionKey: string | null;
|
||||
|
||||
// Actions
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
setCurrentAgent: (agent: Agent) => void;
|
||||
syncAgents: (profiles: AgentProfileLike[]) => void;
|
||||
setCurrentModel: (model: string) => void;
|
||||
setStreaming: (streaming: boolean) => void;
|
||||
setSessionKey: (key: string | null) => void;
|
||||
newConversation: () => void;
|
||||
switchConversation: (id: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
// === Create Proxy State ===
|
||||
|
||||
export const chatStore = proxy<ChatStore>({
|
||||
// Initial state
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [DEFAULT_AGENT],
|
||||
currentAgent: DEFAULT_AGENT,
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
|
||||
// === Actions ===
|
||||
|
||||
addMessage: (message: Message) => {
|
||||
chatStore.messages.push(message);
|
||||
},
|
||||
|
||||
updateMessage: (id: string, updates: Partial<Message>) => {
|
||||
const msg = chatStore.messages.find(m => m.id === id);
|
||||
if (msg) {
|
||||
Object.assign(msg, updates);
|
||||
}
|
||||
},
|
||||
|
||||
deleteMessage: (id: string) => {
|
||||
const index = chatStore.messages.findIndex(m => m.id === id);
|
||||
if (index >= 0) {
|
||||
chatStore.messages.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentAgent: (agent: Agent) => {
|
||||
chatStore.currentAgent = agent;
|
||||
},
|
||||
|
||||
syncAgents: (profiles: AgentProfileLike[]) => {
|
||||
if (profiles.length === 0) {
|
||||
chatStore.agents = [DEFAULT_AGENT];
|
||||
} else {
|
||||
chatStore.agents = profiles.map(toChatAgent);
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentModel: (model: string) => {
|
||||
chatStore.currentModel = model;
|
||||
},
|
||||
|
||||
setStreaming: (streaming: boolean) => {
|
||||
chatStore.isStreaming = streaming;
|
||||
},
|
||||
|
||||
setSessionKey: (key: string | null) => {
|
||||
chatStore.sessionKey = key;
|
||||
},
|
||||
|
||||
newConversation: () => {
|
||||
// Save current conversation if has messages
|
||||
if (chatStore.messages.length > 0) {
|
||||
const conversation: Conversation = {
|
||||
id: chatStore.currentConversationId || generateConvId(),
|
||||
title: deriveTitle(chatStore.messages),
|
||||
messages: [...chatStore.messages],
|
||||
sessionKey: chatStore.sessionKey,
|
||||
agentId: chatStore.currentAgent?.id || null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Check if conversation already exists
|
||||
const existingIndex = chatStore.conversations.findIndex(
|
||||
c => c.id === chatStore.currentConversationId
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
chatStore.conversations[existingIndex] = conversation;
|
||||
} else {
|
||||
chatStore.conversations.unshift(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset for new conversation
|
||||
chatStore.messages = [];
|
||||
chatStore.sessionKey = null;
|
||||
chatStore.isStreaming = false;
|
||||
chatStore.currentConversationId = null;
|
||||
},
|
||||
|
||||
switchConversation: (id: string) => {
|
||||
const conv = chatStore.conversations.find(c => c.id === id);
|
||||
if (conv) {
|
||||
// Save current first
|
||||
if (chatStore.messages.length > 0) {
|
||||
const currentConv: Conversation = {
|
||||
id: chatStore.currentConversationId || generateConvId(),
|
||||
title: deriveTitle(chatStore.messages),
|
||||
messages: [...chatStore.messages],
|
||||
sessionKey: chatStore.sessionKey,
|
||||
agentId: chatStore.currentAgent?.id || null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const existingIndex = chatStore.conversations.findIndex(
|
||||
c => c.id === chatStore.currentConversationId
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
chatStore.conversations[existingIndex] = currentConv;
|
||||
} else {
|
||||
chatStore.conversations.unshift(currentConv);
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to new
|
||||
chatStore.messages = [...conv.messages];
|
||||
chatStore.sessionKey = conv.sessionKey;
|
||||
chatStore.currentConversationId = conv.id;
|
||||
}
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
const index = chatStore.conversations.findIndex(c => c.id === id);
|
||||
if (index >= 0) {
|
||||
chatStore.conversations.splice(index, 1);
|
||||
|
||||
// If deleting current, clear messages
|
||||
if (chatStore.currentConversationId === id) {
|
||||
chatStore.messages = [];
|
||||
chatStore.sessionKey = null;
|
||||
chatStore.currentConversationId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
chatStore.messages = [];
|
||||
},
|
||||
});
|
||||
|
||||
// === Dev Mode Logging ===
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
subscribe(chatStore, (ops) => {
|
||||
console.log('[ChatStore] Changes:', ops);
|
||||
});
|
||||
}
|
||||
81
desktop/src/domains/chat/types.ts
Normal file
81
desktop/src/domains/chat/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Chat Domain Types
|
||||
*
|
||||
* Core types for the chat system.
|
||||
* Extracted from chatStore.ts for domain-driven organization.
|
||||
*/
|
||||
|
||||
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' | '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 {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
agentId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
lastMessage: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface AgentProfileLike {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
messages: Message[];
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
agents: Agent[];
|
||||
currentAgent: Agent | null;
|
||||
isStreaming: boolean;
|
||||
currentModel: string;
|
||||
sessionKey: string | null;
|
||||
}
|
||||
79
desktop/src/domains/hands/hooks.ts
Normal file
79
desktop/src/domains/hands/hooks.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Hands Domain Hooks
|
||||
*
|
||||
* React hooks for accessing hands state with Valtio.
|
||||
*/
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { handsStore } from './store';
|
||||
import type { Hand, ApprovalRequest, Trigger, HandRun } from './types';
|
||||
|
||||
/**
|
||||
* Hook to access the full hands state snapshot.
|
||||
*/
|
||||
export function useHandsState() {
|
||||
return useSnapshot(handsStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access hands list.
|
||||
*/
|
||||
export function useHands(): readonly Hand[] {
|
||||
const { hands } = useSnapshot(handsStore);
|
||||
return hands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access a specific hand by ID.
|
||||
*/
|
||||
export function useHand(id: string): Hand | undefined {
|
||||
const { hands } = useSnapshot(handsStore);
|
||||
return hands.find(h => h.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access approval queue.
|
||||
*/
|
||||
export function useApprovalQueue(): readonly ApprovalRequest[] {
|
||||
const { approvalQueue } = useSnapshot(handsStore);
|
||||
return approvalQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access triggers.
|
||||
*/
|
||||
export function useTriggers(): readonly Trigger[] {
|
||||
const { triggers } = useSnapshot(handsStore);
|
||||
return triggers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access a specific run.
|
||||
*/
|
||||
export function useRun(runId: string): HandRun | undefined {
|
||||
const { runs } = useSnapshot(handsStore);
|
||||
return runs[runId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if any hand is loading.
|
||||
*/
|
||||
export function useHandsLoading(): boolean {
|
||||
const { isLoading } = useSnapshot(handsStore);
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access hands error.
|
||||
*/
|
||||
export function useHandsError(): string | null {
|
||||
const { error } = useSnapshot(handsStore);
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access hands actions.
|
||||
* Returns the store directly for calling actions.
|
||||
*/
|
||||
export function useHandsActions() {
|
||||
return handsStore;
|
||||
}
|
||||
51
desktop/src/domains/hands/index.ts
Normal file
51
desktop/src/domains/hands/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Hands Domain
|
||||
*
|
||||
* Automation and hands management functionality.
|
||||
*
|
||||
* @example
|
||||
* // Using hooks
|
||||
* import { useHands, useHandsActions } from '@/domains/hands';
|
||||
*
|
||||
* function HandsComponent() {
|
||||
* const hands = useHands();
|
||||
* const { setHands, updateHand } = useHandsActions();
|
||||
* // ...
|
||||
* }
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Hand,
|
||||
HandStatus,
|
||||
HandRequirement,
|
||||
HandRun,
|
||||
HandLog,
|
||||
Trigger,
|
||||
TriggerType,
|
||||
TriggerConfig,
|
||||
ApprovalRequest,
|
||||
HandsState,
|
||||
HandsEvent,
|
||||
HandContext,
|
||||
} from './types';
|
||||
|
||||
// Machine
|
||||
export { handMachine, getHandStatusFromState } from './machine';
|
||||
|
||||
// Store
|
||||
export { handsStore } from './store';
|
||||
export type { HandsStore } from './store';
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useHandsState,
|
||||
useHands,
|
||||
useHand,
|
||||
useApprovalQueue,
|
||||
useTriggers,
|
||||
useRun,
|
||||
useHandsLoading,
|
||||
useHandsError,
|
||||
useHandsActions,
|
||||
} from './hooks';
|
||||
166
desktop/src/domains/hands/machine.ts
Normal file
166
desktop/src/domains/hands/machine.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Hands State Machine
|
||||
*
|
||||
* XState machine for managing hand execution lifecycle.
|
||||
* Provides predictable state transitions for automation tasks.
|
||||
*/
|
||||
import { setup, assign, fromPromise } from 'xstate';
|
||||
import type { HandContext, HandsEvent } from './types';
|
||||
|
||||
// === Machine Setup ===
|
||||
|
||||
export const handMachine = setup({
|
||||
types: {
|
||||
context: {} as HandContext,
|
||||
events: {} as HandsEvent,
|
||||
},
|
||||
actions: {
|
||||
setRunId: assign({
|
||||
runId: (_, params: { runId: string }) => params.runId,
|
||||
}),
|
||||
setError: assign({
|
||||
error: (_, params: { error: string }) => params.error,
|
||||
}),
|
||||
setResult: assign({
|
||||
result: (_, params: { result: unknown }) => params.result,
|
||||
}),
|
||||
setProgress: assign({
|
||||
progress: (_, params: { progress: number }) => params.progress,
|
||||
}),
|
||||
clearError: assign({
|
||||
error: null,
|
||||
}),
|
||||
resetContext: assign({
|
||||
runId: null,
|
||||
error: null,
|
||||
result: null,
|
||||
progress: 0,
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
hasError: ({ context }) => context.error !== null,
|
||||
isApproved: ({ event }) => event.type === 'APPROVE',
|
||||
},
|
||||
}).createMachine({
|
||||
id: 'hand',
|
||||
initial: 'idle',
|
||||
context: {
|
||||
handId: '',
|
||||
handName: '',
|
||||
runId: null,
|
||||
error: null,
|
||||
result: null,
|
||||
progress: 0,
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
START: {
|
||||
target: 'running',
|
||||
actions: {
|
||||
type: 'setRunId',
|
||||
params: () => ({ runId: `run_${Date.now()}` }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
running: {
|
||||
entry: assign({ progress: 0 }),
|
||||
on: {
|
||||
APPROVE: {
|
||||
target: 'needs_approval',
|
||||
},
|
||||
COMPLETE: {
|
||||
target: 'success',
|
||||
actions: {
|
||||
type: 'setResult',
|
||||
params: ({ event }) => ({ result: (event as { result: unknown }).result }),
|
||||
},
|
||||
},
|
||||
ERROR: {
|
||||
target: 'error',
|
||||
actions: {
|
||||
type: 'setError',
|
||||
params: ({ event }) => ({ error: (event as { error: string }).error }),
|
||||
},
|
||||
},
|
||||
CANCEL: {
|
||||
target: 'cancelled',
|
||||
},
|
||||
},
|
||||
},
|
||||
needs_approval: {
|
||||
on: {
|
||||
APPROVE: 'running',
|
||||
REJECT: 'idle',
|
||||
CANCEL: 'idle',
|
||||
},
|
||||
},
|
||||
success: {
|
||||
on: {
|
||||
RESET: {
|
||||
target: 'idle',
|
||||
actions: 'resetContext',
|
||||
},
|
||||
START: {
|
||||
target: 'running',
|
||||
actions: {
|
||||
type: 'setRunId',
|
||||
params: () => ({ runId: `run_${Date.now()}` }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
RESET: {
|
||||
target: 'idle',
|
||||
actions: 'resetContext',
|
||||
},
|
||||
START: {
|
||||
target: 'running',
|
||||
actions: {
|
||||
type: 'setRunId',
|
||||
params: () => ({ runId: `run_${Date.now()}` }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cancelled: {
|
||||
on: {
|
||||
RESET: {
|
||||
target: 'idle',
|
||||
actions: 'resetContext',
|
||||
},
|
||||
START: {
|
||||
target: 'running',
|
||||
actions: {
|
||||
type: 'setRunId',
|
||||
params: () => ({ runId: `run_${Date.now()}` }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// === Helper to get status from machine state ===
|
||||
|
||||
export function getHandStatusFromState(stateValue: string): import('./types').HandStatus {
|
||||
switch (stateValue) {
|
||||
case 'idle':
|
||||
return 'idle';
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'needs_approval':
|
||||
return 'needs_approval';
|
||||
case 'success':
|
||||
return 'idle'; // Success maps back to idle
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'cancelled':
|
||||
return 'idle';
|
||||
default:
|
||||
return 'idle';
|
||||
}
|
||||
}
|
||||
105
desktop/src/domains/hands/store.ts
Normal file
105
desktop/src/domains/hands/store.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Hands Domain Store
|
||||
*
|
||||
* Valtio-based state management for hands/automation.
|
||||
*/
|
||||
import { proxy, subscribe } from 'valtio';
|
||||
import type { Hand, HandRun, Trigger, ApprovalRequest, HandsState } from './types';
|
||||
|
||||
// === Store Interface ===
|
||||
|
||||
export interface HandsStore extends HandsState {
|
||||
// Actions
|
||||
setHands: (hands: Hand[]) => void;
|
||||
updateHand: (id: string, updates: Partial<Hand>) => void;
|
||||
addRun: (run: HandRun) => void;
|
||||
updateRun: (runId: string, updates: Partial<HandRun>) => void;
|
||||
setTriggers: (triggers: Trigger[]) => void;
|
||||
updateTrigger: (id: string, updates: Partial<Trigger>) => void;
|
||||
addApproval: (request: ApprovalRequest) => void;
|
||||
removeApproval: (id: string) => void;
|
||||
clearApprovals: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
// === Create Proxy State ===
|
||||
|
||||
export const handsStore = proxy<HandsStore>({
|
||||
// Initial state
|
||||
hands: [],
|
||||
runs: {},
|
||||
triggers: [],
|
||||
approvalQueue: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// === Actions ===
|
||||
|
||||
setHands: (hands: Hand[]) => {
|
||||
handsStore.hands = hands;
|
||||
},
|
||||
|
||||
updateHand: (id: string, updates: Partial<Hand>) => {
|
||||
const hand = handsStore.hands.find(h => h.id === id);
|
||||
if (hand) {
|
||||
Object.assign(hand, updates);
|
||||
}
|
||||
},
|
||||
|
||||
addRun: (run: HandRun) => {
|
||||
handsStore.runs[run.runId] = run;
|
||||
},
|
||||
|
||||
updateRun: (runId: string, updates: Partial<HandRun>) => {
|
||||
if (handsStore.runs[runId]) {
|
||||
Object.assign(handsStore.runs[runId], updates);
|
||||
}
|
||||
},
|
||||
|
||||
setTriggers: (triggers: Trigger[]) => {
|
||||
handsStore.triggers = triggers;
|
||||
},
|
||||
|
||||
updateTrigger: (id: string, updates: Partial<Trigger>) => {
|
||||
const trigger = handsStore.triggers.find(t => t.id === id);
|
||||
if (trigger) {
|
||||
Object.assign(trigger, updates);
|
||||
}
|
||||
},
|
||||
|
||||
addApproval: (request: ApprovalRequest) => {
|
||||
// Check if already exists
|
||||
const exists = handsStore.approvalQueue.some(a => a.id === request.id);
|
||||
if (!exists) {
|
||||
handsStore.approvalQueue.push(request);
|
||||
}
|
||||
},
|
||||
|
||||
removeApproval: (id: string) => {
|
||||
const index = handsStore.approvalQueue.findIndex(a => a.id === id);
|
||||
if (index >= 0) {
|
||||
handsStore.approvalQueue.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
clearApprovals: () => {
|
||||
handsStore.approvalQueue = [];
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => {
|
||||
handsStore.isLoading = loading;
|
||||
},
|
||||
|
||||
setError: (error: string | null) => {
|
||||
handsStore.error = error;
|
||||
},
|
||||
});
|
||||
|
||||
// === Dev Mode Logging ===
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
subscribe(handsStore, (ops) => {
|
||||
console.log('[HandsStore] Changes:', ops);
|
||||
});
|
||||
}
|
||||
123
desktop/src/domains/hands/types.ts
Normal file
123
desktop/src/domains/hands/types.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Hands Domain Types
|
||||
*
|
||||
* Core types for the automation/hands system.
|
||||
*/
|
||||
|
||||
export interface HandRequirement {
|
||||
description: string;
|
||||
met: boolean;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface Hand {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: HandStatus;
|
||||
currentRunId?: string;
|
||||
requirements_met?: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
requirements?: HandRequirement[];
|
||||
tools?: string[];
|
||||
metrics?: string[];
|
||||
toolCount?: number;
|
||||
metricCount?: number;
|
||||
}
|
||||
|
||||
export type HandStatus =
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'needs_approval'
|
||||
| 'error'
|
||||
| 'unavailable'
|
||||
| 'setup_needed';
|
||||
|
||||
export interface HandRun {
|
||||
runId: string;
|
||||
handId: string;
|
||||
handName: string;
|
||||
status: 'running' | 'completed' | 'error' | 'cancelled';
|
||||
startedAt: Date;
|
||||
completedAt?: Date;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
progress?: number;
|
||||
logs?: HandLog[];
|
||||
}
|
||||
|
||||
export interface HandLog {
|
||||
timestamp: Date;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Trigger {
|
||||
id: string;
|
||||
handId: string;
|
||||
type: TriggerType;
|
||||
enabled: boolean;
|
||||
config: TriggerConfig;
|
||||
}
|
||||
|
||||
export type TriggerType = 'manual' | 'schedule' | 'event' | 'webhook';
|
||||
|
||||
export interface TriggerConfig {
|
||||
schedule?: string; // Cron expression
|
||||
event?: string; // Event name
|
||||
webhook?: {
|
||||
path: string;
|
||||
method: 'GET' | 'POST';
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: string;
|
||||
handName: string;
|
||||
runId: string;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface HandsState {
|
||||
hands: Hand[];
|
||||
runs: Record<string, HandRun>;
|
||||
triggers: Trigger[];
|
||||
approvalQueue: ApprovalRequest[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// === XState Types ===
|
||||
|
||||
export type HandsEventType =
|
||||
| 'START'
|
||||
| 'APPROVE'
|
||||
| 'REJECT'
|
||||
| 'COMPLETE'
|
||||
| 'ERROR'
|
||||
| 'RESET'
|
||||
| 'CANCEL';
|
||||
|
||||
export interface HandsEvent {
|
||||
type: HandsEventType;
|
||||
handId?: string;
|
||||
runId?: string;
|
||||
requestId?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HandContext {
|
||||
handId: string;
|
||||
handName: string;
|
||||
runId: string | null;
|
||||
error: string | null;
|
||||
result: unknown;
|
||||
progress: number;
|
||||
}
|
||||
105
desktop/src/shared/error-handling.ts
Normal file
105
desktop/src/shared/error-handling.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Shared Error Handling
|
||||
*
|
||||
* Unified error handling utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Application error class with error code.
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a AppError from an unknown error.
|
||||
*/
|
||||
static fromUnknown(error: unknown, code: string): AppError {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
return new AppError(getErrorMessage(error), code, isError(error) ? error : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network error class.
|
||||
*/
|
||||
export class NetworkError extends AppError {
|
||||
constructor(message: string, public readonly statusCode?: number, cause?: Error) {
|
||||
super(message, 'NETWORK_ERROR', cause);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error class.
|
||||
*/
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, public readonly field?: string, cause?: Error) {
|
||||
super(message, 'VALIDATION_ERROR', cause);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error class.
|
||||
*/
|
||||
export class AuthError extends AppError {
|
||||
constructor(message: string = 'Authentication required', cause?: Error) {
|
||||
super(message, 'AUTH_ERROR', cause);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for Error.
|
||||
*/
|
||||
export function isError(error: unknown): error is Error {
|
||||
return error instanceof Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message from unknown error.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (isError(error)) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap error with code.
|
||||
*/
|
||||
export function wrapError(error: unknown, code: string): AppError {
|
||||
return AppError.fromUnknown(error, code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a specific error class.
|
||||
*/
|
||||
export function isAppError(error: unknown): error is AppError {
|
||||
return error instanceof AppError;
|
||||
}
|
||||
|
||||
export function isNetworkError(error: unknown): error is NetworkError {
|
||||
return error instanceof NetworkError;
|
||||
}
|
||||
|
||||
export function isValidationError(error: unknown): error is ValidationError {
|
||||
return error instanceof ValidationError;
|
||||
}
|
||||
|
||||
export function isAuthError(error: unknown): error is AuthError {
|
||||
return error instanceof AuthError;
|
||||
}
|
||||
31
desktop/src/shared/index.ts
Normal file
31
desktop/src/shared/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared Module
|
||||
*
|
||||
* Common utilities, types, and error handling.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Result,
|
||||
AsyncResult,
|
||||
PaginatedResponse,
|
||||
AsyncStatus,
|
||||
AsyncState,
|
||||
Entity,
|
||||
NamedEntity,
|
||||
} from './types';
|
||||
|
||||
// Errors
|
||||
export {
|
||||
AppError,
|
||||
NetworkError,
|
||||
ValidationError,
|
||||
AuthError,
|
||||
isError,
|
||||
getErrorMessage,
|
||||
wrapError,
|
||||
isAppError,
|
||||
isNetworkError,
|
||||
isValidationError,
|
||||
isAuthError,
|
||||
} from './error-handling';
|
||||
58
desktop/src/shared/types.ts
Normal file
58
desktop/src/shared/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Shared Types
|
||||
*
|
||||
* Common types used across domains.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Result type for functional error handling.
|
||||
*/
|
||||
export type Result<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
/**
|
||||
* Async result for promises.
|
||||
*/
|
||||
export type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
|
||||
|
||||
/**
|
||||
* Paginated response for list endpoints.
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common status for async operations.
|
||||
*/
|
||||
export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
/**
|
||||
* Generic async state wrapper.
|
||||
*/
|
||||
export interface AsyncState<T, E = Error> {
|
||||
status: AsyncStatus;
|
||||
data: T | null;
|
||||
error: E | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity with common fields.
|
||||
*/
|
||||
export interface Entity {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named entity with name field.
|
||||
*/
|
||||
export interface NamedEntity extends Entity {
|
||||
name: string;
|
||||
}
|
||||
1174
docs/superpowers/plans/2026-03-21-phase2-domain-reorganization.md
Normal file
1174
docs/superpowers/plans/2026-03-21-phase2-domain-reorganization.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user