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:
iven
2026-03-21 19:47:48 +08:00
parent 20eed290f8
commit 7ffd5e1531
13 changed files with 2319 additions and 0 deletions

View 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;
}

View 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';

View 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);
});
}

View 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;
}

View 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;
}

View 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';

View 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';
}
}

View 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);
});
}

View 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;
}

View 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;
}

View 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';

View 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;
}

File diff suppressed because it is too large Load Diff