Enterprise AI Workspace prototype: LobeChat (de-branded) + Enterprise Gateway + Postgres/Redis stack
Revalidate Docs / Revalidate Docs (push) Failing after 2s
E2E CI / Check Duplicate Run (push) Failing after 5s
Test CI / Check Duplicate Run (push) Failing after 6s
E2E CI / Test Web App (push) Has been skipped
Test CI / Test Packages (push) Has been skipped
Test CI / Test App (shard 1/3) (push) Has been skipped
Test CI / Test App (shard 2/3) (push) Has been skipped
Test CI / Test App (shard 3/3) (push) Has been skipped
Test CI / Test Desktop App (push) Has been skipped
🔄 Branch Synchronization / sync-branches (push) Failing after 11s
Test CI / Test Database (push) Has been skipped
Test CI / Merge and Upload App Coverage (push) Has been skipped
Database Schema Visualization CI / build (push) Failing after 4m14s
Revalidate Docs / Revalidate Docs (push) Failing after 2s
E2E CI / Check Duplicate Run (push) Failing after 5s
Test CI / Check Duplicate Run (push) Failing after 6s
E2E CI / Test Web App (push) Has been skipped
Test CI / Test Packages (push) Has been skipped
Test CI / Test App (shard 1/3) (push) Has been skipped
Test CI / Test App (shard 2/3) (push) Has been skipped
Test CI / Test App (shard 3/3) (push) Has been skipped
Test CI / Test Desktop App (push) Has been skipped
🔄 Branch Synchronization / sync-branches (push) Failing after 11s
Test CI / Test Database (push) Has been skipped
Test CI / Merge and Upload App Coverage (push) Has been skipped
Database Schema Visualization CI / build (push) Failing after 4m14s
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: zustand
|
||||
description: Zustand state management guide. Use when working with store code (src/store/**), implementing actions, managing state, or creating slices. Triggers on Zustand store development, state management questions, or action implementation.
|
||||
---
|
||||
|
||||
# LobeHub Zustand State Management
|
||||
|
||||
## Action Type Hierarchy
|
||||
|
||||
### 1. Public Actions
|
||||
|
||||
Main interfaces for UI components:
|
||||
|
||||
- Naming: Verb form (`createTopic`, `sendMessage`)
|
||||
- Responsibilities: Parameter validation, flow orchestration
|
||||
|
||||
### 2. Internal Actions (`internal_*`)
|
||||
|
||||
Core business logic implementation:
|
||||
|
||||
- Naming: `internal_` prefix (`internal_createTopic`)
|
||||
- Responsibilities: Optimistic updates, service calls, error handling
|
||||
- Should not be called directly by UI
|
||||
|
||||
### 3. Dispatch Methods (`internal_dispatch*`)
|
||||
|
||||
State update handlers:
|
||||
|
||||
- Naming: `internal_dispatch` + entity (`internal_dispatchTopic`)
|
||||
- Responsibilities: Calling reducers, updating store
|
||||
|
||||
## When to Use Reducer vs Simple `set`
|
||||
|
||||
**Use Reducer Pattern:**
|
||||
|
||||
- Managing object lists/maps (`messagesMap`, `topicMaps`)
|
||||
- Optimistic updates
|
||||
- Complex state transitions
|
||||
|
||||
**Use Simple `set`:**
|
||||
|
||||
- Toggling booleans
|
||||
- Updating simple values
|
||||
- Setting single state fields
|
||||
|
||||
## Optimistic Update Pattern
|
||||
|
||||
```typescript
|
||||
internal_createTopic: async (params) => {
|
||||
const tmpId = Date.now().toString();
|
||||
|
||||
// 1. Immediately update frontend (optimistic)
|
||||
get().internal_dispatchTopic(
|
||||
{ type: 'addTopic', value: { ...params, id: tmpId } },
|
||||
'internal_createTopic'
|
||||
);
|
||||
|
||||
// 2. Call backend service
|
||||
const topicId = await topicService.createTopic(params);
|
||||
|
||||
// 3. Refresh for consistency
|
||||
await get().refreshTopic();
|
||||
return topicId;
|
||||
},
|
||||
```
|
||||
|
||||
**Delete operations**: Don't use optimistic updates (destructive, complex recovery)
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Actions:**
|
||||
|
||||
- Public: `createTopic`, `sendMessage`
|
||||
|
||||
- Internal: `internal_createTopic`, `internal_updateMessageContent`
|
||||
|
||||
- Dispatch: `internal_dispatchTopic`
|
||||
**State:**
|
||||
|
||||
- ID arrays: `topicEditingIds`
|
||||
|
||||
- Maps: `topicMaps`, `messagesMap`
|
||||
|
||||
- Active: `activeTopicId`
|
||||
|
||||
- Init flags: `topicsInit`
|
||||
|
||||
## Detailed Guides
|
||||
|
||||
- Action patterns: `references/action-patterns.md`
|
||||
- Slice organization: `references/slice-organization.md`
|
||||
|
||||
## Class-Based Action Implementation
|
||||
|
||||
We are migrating slices from plain `StateCreator` objects to **class-based actions**.
|
||||
|
||||
### Pattern
|
||||
|
||||
- Define a class that encapsulates actions and receives `(set, get, api)` in the constructor.
|
||||
- Use `#private` fields (e.g., `#set`, `#get`) to avoid leaking internals.
|
||||
- Prefer shared typing helpers:
|
||||
- `StoreSetter<T>` from `@/store/types` for `set`.
|
||||
- `Pick<ActionImpl, keyof ActionImpl>` to expose only public methods.
|
||||
- Export a `create*Slice` helper that returns a class instance.
|
||||
|
||||
```ts
|
||||
type Setter = StoreSetter<HomeStore>;
|
||||
export const createRecentSlice = (set: Setter, get: () => HomeStore, _api?: unknown) =>
|
||||
new RecentActionImpl(set, get, _api);
|
||||
|
||||
export class RecentActionImpl {
|
||||
readonly #get: () => HomeStore;
|
||||
readonly #set: Setter;
|
||||
|
||||
constructor(set: Setter, get: () => HomeStore, _api?: unknown) {
|
||||
void _api;
|
||||
this.#set = set;
|
||||
this.#get = get;
|
||||
}
|
||||
|
||||
useFetchRecentTopics = () => {
|
||||
// ...
|
||||
};
|
||||
}
|
||||
|
||||
export type RecentAction = Pick<RecentActionImpl, keyof RecentActionImpl>;
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
- In store files, merge class instances with `flattenActions` (do not spread class instances).
|
||||
- `flattenActions` binds methods to the original class instance and supports prototype methods and class fields.
|
||||
|
||||
```ts
|
||||
const createStore: StateCreator<HomeStore, [['zustand/devtools', never]]> = (...params) => ({
|
||||
...initialState,
|
||||
...flattenActions<HomeStoreAction>([
|
||||
createRecentSlice(...params),
|
||||
createHomeInputSlice(...params),
|
||||
]),
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Class Slices
|
||||
|
||||
- For large slices that need multiple action classes, compose them in the slice entry using `flattenActions`.
|
||||
- Use a local `PublicActions<T>` helper if you need to combine multiple classes and hide private fields.
|
||||
|
||||
```ts
|
||||
type PublicActions<T> = { [K in keyof T]: T[K] };
|
||||
|
||||
export type ChatGroupAction = PublicActions<
|
||||
ChatGroupInternalAction & ChatGroupLifecycleAction & ChatGroupMemberAction & ChatGroupCurdAction
|
||||
>;
|
||||
|
||||
export const chatGroupAction: StateCreator<
|
||||
ChatGroupStore,
|
||||
[['zustand/devtools', never]],
|
||||
[],
|
||||
ChatGroupAction
|
||||
> = (...params) =>
|
||||
flattenActions<ChatGroupAction>([
|
||||
new ChatGroupInternalAction(...params),
|
||||
new ChatGroupLifecycleAction(...params),
|
||||
new ChatGroupMemberAction(...params),
|
||||
new ChatGroupCurdAction(...params),
|
||||
]);
|
||||
```
|
||||
|
||||
### Store-Access Types
|
||||
|
||||
- For class methods that depend on actions in other classes, define explicit store augmentations:
|
||||
- `ChatGroupStoreWithSwitchTopic` for lifecycle `switchTopic`
|
||||
- `ChatGroupStoreWithRefresh` for member refresh
|
||||
- `ChatGroupStoreWithInternal` for curd `internal_dispatchChatGroup`
|
||||
|
||||
### Do / Don't
|
||||
|
||||
- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.
|
||||
- **Do**: use `#private` to avoid `set/get` being exposed.
|
||||
- **Do**: use `flattenActions` instead of spreading class instances.
|
||||
- **Don't**: keep both old slice objects and class actions active at the same time.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Zustand Action Patterns
|
||||
|
||||
## Optimistic Update Implementation
|
||||
|
||||
### Standard Flow
|
||||
|
||||
```typescript
|
||||
internal_updateMessageContent: async (id, content, extra) => {
|
||||
const { internal_dispatchMessage, refreshMessages } = get();
|
||||
|
||||
// 1. Immediately update frontend
|
||||
internal_dispatchMessage({
|
||||
id,
|
||||
type: 'updateMessage',
|
||||
value: { content },
|
||||
});
|
||||
|
||||
// 2. Call backend
|
||||
await messageService.updateMessage(id, { content });
|
||||
|
||||
// 3. Refresh for consistency
|
||||
await refreshMessages();
|
||||
},
|
||||
```
|
||||
|
||||
### Create Operations
|
||||
|
||||
```typescript
|
||||
internal_createMessage: async (message, context) => {
|
||||
let tempId = context?.tempMessageId;
|
||||
if (!tempId) {
|
||||
tempId = internal_createTmpMessage(message);
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await messageService.createMessage(message);
|
||||
await refreshMessages();
|
||||
return id;
|
||||
} catch (e) {
|
||||
internal_dispatchMessage({
|
||||
id: tempId,
|
||||
type: 'updateMessage',
|
||||
value: { error: { type: ChatErrorType.CreateMessageError } },
|
||||
});
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### Delete Operations (No Optimistic Update)
|
||||
|
||||
```typescript
|
||||
internal_removeGenerationTopic: async (id: string) => {
|
||||
get().internal_updateGenerationTopicLoading(id, true);
|
||||
|
||||
try {
|
||||
await generationTopicService.deleteTopic(id);
|
||||
await get().refreshGenerationTopics();
|
||||
} finally {
|
||||
get().internal_updateGenerationTopicLoading(id, false);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
## Loading State Management
|
||||
|
||||
```typescript
|
||||
// Define in initialState.ts
|
||||
export interface ChatMessageState {
|
||||
messageEditingIds: string[];
|
||||
}
|
||||
|
||||
// Manage in action
|
||||
toggleMessageEditing: (id, editing) => {
|
||||
set(
|
||||
{ messageEditingIds: toggleBooleanList(get().messageEditingIds, id, editing) },
|
||||
false,
|
||||
'toggleMessageEditing',
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## SWR Integration
|
||||
|
||||
```typescript
|
||||
useFetchMessages: (enable, sessionId, activeTopicId) =>
|
||||
useClientDataSWR<ChatMessage[]>(
|
||||
enable ? [SWR_USE_FETCH_MESSAGES, sessionId, activeTopicId] : null,
|
||||
async ([, sessionId, topicId]) => messageService.getMessages(sessionId, topicId),
|
||||
{
|
||||
onSuccess: (messages) => {
|
||||
const nextMap = { ...get().messagesMap, [messageMapKey(sessionId, activeTopicId)]: messages };
|
||||
if (get().messagesInit && isEqual(nextMap, get().messagesMap)) return;
|
||||
set({ messagesInit: true, messagesMap: nextMap }, false, n('useFetchMessages'));
|
||||
},
|
||||
}
|
||||
),
|
||||
|
||||
// Cache invalidation
|
||||
refreshMessages: async () => {
|
||||
await mutate([SWR_USE_FETCH_MESSAGES, get().activeId, get().activeTopicId]);
|
||||
};
|
||||
```
|
||||
|
||||
## Reducer Pattern
|
||||
|
||||
```typescript
|
||||
export const messagesReducer = (state: ChatMessage[], payload: MessageDispatch): ChatMessage[] => {
|
||||
switch (payload.type) {
|
||||
case 'updateMessage': {
|
||||
return produce(state, (draftState) => {
|
||||
const index = draftState.findIndex((i) => i.id === payload.id);
|
||||
if (index < 0) return;
|
||||
draftState[index] = merge(draftState[index], {
|
||||
...payload.value,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
// ...other cases
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
# Zustand Slice Organization
|
||||
|
||||
## Top-Level Store Structure
|
||||
|
||||
Key aggregation files:
|
||||
|
||||
- `src/store/chat/initialState.ts`: Aggregate all slice initial states
|
||||
- `src/store/chat/store.ts`: Define top-level `ChatStore`, combine all slice actions
|
||||
- `src/store/chat/selectors.ts`: Export all slice selectors
|
||||
- `src/store/chat/helpers.ts`: Chat helper functions
|
||||
|
||||
## Store Aggregation Pattern
|
||||
|
||||
```typescript
|
||||
// src/store/chat/initialState.ts
|
||||
import { ChatTopicState, initialTopicState } from './slices/topic/initialState';
|
||||
import { ChatMessageState, initialMessageState } from './slices/message/initialState';
|
||||
|
||||
export type ChatStoreState = ChatTopicState & ChatMessageState & ...
|
||||
|
||||
export const initialState: ChatStoreState = {
|
||||
...initialMessageState,
|
||||
...initialTopicState,
|
||||
...
|
||||
};
|
||||
|
||||
// src/store/chat/store.ts
|
||||
export interface ChatStoreAction
|
||||
extends ChatMessageAction, ChatTopicAction, ...
|
||||
|
||||
const createStore: StateCreator<ChatStore, [['zustand/devtools', never]]> = (...params) => ({
|
||||
...initialState,
|
||||
...chatMessage(...params),
|
||||
...chatTopic(...params),
|
||||
});
|
||||
|
||||
export const useChatStore = createWithEqualityFn<ChatStore>()(
|
||||
subscribeWithSelector(devtools(createStore)),
|
||||
shallow
|
||||
);
|
||||
```
|
||||
|
||||
## Single Slice Structure
|
||||
|
||||
```plaintext
|
||||
src/store/chat/slices/
|
||||
└── [sliceName]/
|
||||
├── action.ts # Define actions (or actions/ directory)
|
||||
├── initialState.ts # State structure and initial values
|
||||
├── reducer.ts # (Optional) Reducer pattern
|
||||
├── selectors.ts # Define selectors
|
||||
└── index.ts # (Optional) Re-exports
|
||||
```
|
||||
|
||||
### initialState.ts
|
||||
|
||||
```typescript
|
||||
export interface ChatTopicState {
|
||||
activeTopicId?: string;
|
||||
topicMaps: Record<string, ChatTopic[]>;
|
||||
topicsInit: boolean;
|
||||
topicLoadingIds: string[];
|
||||
}
|
||||
|
||||
export const initialTopicState: ChatTopicState = {
|
||||
activeTopicId: undefined,
|
||||
topicMaps: {},
|
||||
topicsInit: false,
|
||||
topicLoadingIds: [],
|
||||
};
|
||||
```
|
||||
|
||||
### selectors.ts
|
||||
|
||||
```typescript
|
||||
const currentTopics = (s: ChatStoreState): ChatTopic[] | undefined => s.topicMaps[s.activeId];
|
||||
|
||||
const getTopicById =
|
||||
(id: string) =>
|
||||
(s: ChatStoreState): ChatTopic | undefined =>
|
||||
currentTopics(s)?.find((topic) => topic.id === id);
|
||||
|
||||
// Core pattern: Use xxxSelectors aggregate
|
||||
export const topicSelectors = {
|
||||
currentTopics,
|
||||
getTopicById,
|
||||
};
|
||||
```
|
||||
|
||||
## Complex Actions Sub-directory
|
||||
|
||||
```plaintext
|
||||
src/store/chat/slices/aiChat/
|
||||
├── actions/
|
||||
│ ├── generateAIChat.ts
|
||||
│ ├── rag.ts
|
||||
│ ├── memory.ts
|
||||
│ └── index.ts
|
||||
├── initialState.ts
|
||||
└── selectors.ts
|
||||
```
|
||||
|
||||
## State Design Patterns
|
||||
|
||||
### Map Structure for Associated Data
|
||||
|
||||
```typescript
|
||||
topicMaps: Record<string, ChatTopic[]>;
|
||||
messagesMap: Record<string, ChatMessage[]>;
|
||||
```
|
||||
|
||||
### Arrays for Loading State
|
||||
|
||||
```typescript
|
||||
messageLoadingIds: string[]
|
||||
topicLoadingIds: string[]
|
||||
```
|
||||
|
||||
### Optional Fields for Active Items
|
||||
|
||||
```typescript
|
||||
activeId: string
|
||||
activeTopicId?: string
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Slice division**: By functional domain (message, topic, aiChat)
|
||||
2. **File naming**: camelCase for directories, consistent patterns
|
||||
3. **State structure**: Flat, avoid deep nesting
|
||||
4. **Type safety**: Clear TypeScript interfaces for each slice
|
||||
Reference in New Issue
Block a user