-
-
Notifications
You must be signed in to change notification settings - Fork 9.9k
Feature Development
This document aims to guide developers on how to develop a complete feature requirement in LobeChat.
We will use the implementation of sessionGroup as an example: ✨ feat: add session group manager, and explain the complete implementation process through the following six main sections:
- Data Model / Database Definition
- Service Implementation / Model Implementation
- Frontend Data Flow Store Implementation
- UI Implementation and Action Binding
- Data Migration
- Data Import and Export
To implement the Session Group feature, it is necessary to define the relevant data model and indexes at the database level.
Define a new sessionGroup table in 3 steps:
Define the data model of DB_SessionGroup
in src/database/schema/sessionGroup.ts
:
import { z } from 'zod';
export const DB_SessionGroupSchema = z.object({
name: z.string(),
sort: z.number().optional(),
});
export type DB_SessionGroup = z.infer<typeof DB_SessionGroupSchema>;
Since a new table needs to be added, an index needs to be added to the database schema for the sessionGroup
table.
Add dbSchemaV4
in src/database/core/schema.ts
:
// ... previous implementations
// ************************************** //
// ******* Version 3 - 2023-12-06 ******* //
// ************************************** //
// - Added `plugin` table
export const dbSchemaV3 = {
...dbSchemaV2,
plugins:
'&identifier, type, manifest.type, manifest.meta.title, manifest.meta.description, manifest.meta.author, createdAt, updatedAt',
};
+ // ************************************** //
+ // ******* Version 4 - 2024-01-21 ******* //
+ // ************************************** //
+ // - Added `sessionGroup` table
+ export const dbSchemaV4 = {
+ ...dbSchemaV3,
+ sessionGroups: '&id, name, sort, createdAt, updatedAt',
+ sessions: '&id, type, group, pinned, meta.title, meta.description, meta.tags, createdAt, updatedAt',
};
Note
In addition to sessionGroups
, the definition of sessions
has also been modified here due to data migration. However, as this section only focuses on schema definition and does not delve into the implementation of data migration, please refer to section five for details.
Important
If you are unfamiliar with the need to create indexes here and the syntax of schema definition, you may need to familiarize yourself with the basics of Dexie.js. You can refer to the 📘 Local Database section for relevant information.
Extend the local database class to include the new sessionGroups
table:
import { dbSchemaV1, dbSchemaV2, dbSchemaV3, dbSchemaV4 } from './schemas';
interface LobeDBSchemaMap {
files: DB_File;
messages: DB_Message;
plugins: DB_Plugin;
+ sessionGroups: DB_SessionGroup;
sessions: DB_Session;
topics: DB_Topic;
}
// Define a local DB
export class LocalDB extends Dexie {
public files: LobeDBTable<'files'>;
public sessions: LobeDBTable<'sessions'>;
public messages: LobeDBTable<'messages'>;
public topics: LobeDBTable<'topics'>;
public plugins: LobeDBTable<'plugins'>;
+ public sessionGroups: LobeDBTable<'sessionGroups'>;
constructor() {
super(LOBE_CHAT_LOCAL_DB_NAME);
this.version(1).stores(dbSchemaV1);
this.version(2).stores(dbSchemaV2);
this.version(3).stores(dbSchemaV3);
+ this.version(4).stores(dbSchemaV4);
this.files = this.table('files');
this.sessions = this.table('sessions');
this.messages = this.table('messages');
this.topics = this.table('topics');
this.plugins = this.table('plugins');
+ this.sessionGroups = this.table('sessionGroups');
}
}
As a result, you can now view the sessionGroups
table in the LOBE_CHAT_DB
in Application
-> Storage
-> IndexedDB
.
When building the LobeChat application, the Model is responsible for interacting with the database. It defines how to read, insert, update, and delete data from the database, as well as defining specific business logic.
In src/database/model/sessionGroup.ts
, the SessionGroupModel
is defined as follows:
import { BaseModel } from '@/database/client/core';
import { DB_SessionGroup, DB_SessionGroupSchema } from '@/database/client/schemas/sessionGroup';
import { nanoid } from '@/utils/uuid';
class _SessionGroupModel extends BaseModel {
constructor() {
super('sessions', DB_SessionGroupSchema);
}
async create(name: string, sort?: number, id = nanoid()) {
return this._add({ name, sort }, id);
}
// ... Implementation of other CRUD methods
}
export const SessionGroupModel = new _SessionGroupModel();
In LobeChat, the Service layer is mainly responsible for communicating with the backend service, encapsulating business logic, and providing data to other layers in the frontend. SessionService
is a service class specifically handling business logic related to sessions. It encapsulates operations such as creating sessions, querying sessions, and updating sessions.
To maintain code maintainability and extensibility, we place the logic related to session grouping in the SessionService
. This helps to keep the business logic of the session domain cohesive. When business requirements increase or change, it becomes easier to modify and extend within this domain.
SessionService
implements session group-related request logic by calling methods from SessionGroupModel
. The following is the implementation of Session Group-related request logic in sessionService
:
class SessionService {
// ... Omitted session business logic
// ************************************** //
// *********** SessionGroup *********** //
// ************************************** //
async createSessionGroup(name: string, sort?: number) {
const item = await SessionGroupModel.create(name, sort);
if (!item) {
throw new Error('session group create Error');
}
return item.id;
}
// ... Other SessionGroup related implementations
}
In the LobeChat application, the Store module is used to manage the frontend state of the application. The Actions within it are functions that trigger state updates, usually by calling methods in the service layer to perform actual data processing operations and then updating the state in the Store. We use zustand
as the underlying dependency for the Store module. For a detailed practical introduction to state management, you can refer to 📘 Best Practices for State Management.
CRUD operations for session groups are the core behaviors for managing session group data. In src/store/session/slice/sessionGroup
, we will implement the state logic related to session groups, including adding, deleting, updating session groups, and their sorting.
The following are the methods of the SessionGroupAction
interface that need to be implemented in the action.ts
file:
export interface SessionGroupAction {
// Add session group
addSessionGroup: (name: string) => Promise<string>;
// Remove session group
removeSessionGroup: (id: string) => Promise<void>;
// Update session group ID for a session
updateSessionGroupId: (sessionId: string, groupId: string) => Promise<void>;
// Update session group name
updateSessionGroupName: (id: string, name: string) => Promise<void>;
// Update session group sorting
updateSessionGroupSort: (items: SessionGroupItem[]) => Promise<void>;
}
Taking the addSessionGroup
method as an example, we first call the createSessionGroup
method of sessionService
to create a new session group, and then use the refreshSessions
method to refresh the sessions state:
export const createSessionGroupSlice: StateCreator<
SessionStore,
[['zustand/devtools', never]],
[],
SessionGroupAction
> = (set, get) => ({
// Implement the logic for adding a session group
addSessionGroup: async (name) => {
// Call the createSessionGroup method in the service layer and pass in the session group name
const id = await sessionService.createSessionGroup(name);
// Call the get method to get the current Store state and execute the refreshSessions method to refresh the session data
await get().refreshSessions();
// Return the ID of the newly created session group
return id;
},
// ... Other action implementations
});
With the above implementation, we can ensure that after adding a new session group, the application's state will be updated in a timely manner, and the relevant components will receive the latest state and re-render. This approach improves the predictability and maintainability of the data flow, while also simplifying communication between components.
This requirement involves upgrading the Sessions feature to transform it from a single list to three different groups: pinnedSessions
(pinned list), customSessionGroups
(custom groups), and defaultSessions
(default list).
To handle these groups, we need to refactor the implementation logic of useFetchSessions
. Here are the key changes:
- Use the
sessionService.getGroupedSessions
method to call the backend API and retrieve the grouped session data. - Save the retrieved data into three different state fields:
pinnedSessions
,customSessionGroups
, anddefaultSessions
.
This method is defined in createSessionSlice
as follows:
export const createSessionSlice: StateCreator<
SessionStore,
[['zustand/devtools', never]],
[],
SessionAction
> = (set, get) => ({
// ... other methods
useFetchSessions: () =>
useSWR<ChatSessionList>(FETCH_SESSIONS_KEY, sessionService.getGroupedSessions, {
onSuccess: (data) => {
set(
{
customSessionGroups: data.customGroup,
defaultSessions: data.default,
isSessionsFirstFetchFinished: true,
pinnedSessions: data.pinned,
sessions: data.all,
},
false,
n('useFetchSessions/onSuccess', data),
);
},
}),
});
After successfully retrieving the data, we use the set
method to update the customSessionGroups
, defaultSessions
, pinnedSessions
, and sessions
states. This ensures that the states are synchronized with the latest session data.
The sessionService.getGroupedSessions
method is responsible for calling the backend API SessionModel.queryWithGroups()
.
class SessionService {
// ... other SessionGroup related implementations
async getGroupedSessions(): Promise<ChatSessionList> {
return SessionModel.queryWithGroups();
}
}
This method is the core method called by sessionService.getGroupedSessions
, and it is responsible for querying and organizing session data. The code is as follows:
class _SessionModel extends BaseModel {
// ... other methods
/**
* Query session data and categorize sessions based on groups.
* @returns {Promise<ChatSessionList>} An object containing all sessions and categorized session lists.
*/
async queryWithGroups(): Promise<ChatSessionList> {
// Query session group data
const groups = await SessionGroupModel.query();
// Query custom session groups based on session group IDs
const customGroups = await this.queryByGroupIds(groups.map((item) => item.id));
// Query default session list
const defaultItems = await this.querySessionsByGroupId(SessionDefaultGroup.Default);
// Query pinned sessions
const pinnedItems = await this.getPinnedSessions();
// Query all sessions
const all = await this.query();
// Combine and return all sessions and their group information
return {
all, // Array containing all sessions
customGroup: groups.map((group) => ({ ...group, children: customGroups[group.id] })), // Custom groups
default: defaultItems, // Default session list
pinned: pinnedItems, // Pinned session list
};
}
}
The queryWithGroups
method first queries all session groups, then based on the IDs of these groups, it queries custom session groups, as well as default and pinned sessions. Finally, it returns an object containing all sessions and categorized session lists.
Due to changes in the logic of grouping within sessions, we need to adjust the logic of the sessions
selectors to ensure they can correctly handle the new data structure.
Original selectors:
// Default group
const defaultSessions = (s: SessionStore): LobeSessions => s.sessions;
// Pinned group
const pinnedSessionList = (s: SessionStore) =>
defaultSessions(s).filter((s) => s.group === SessionGroupDefaultKeys.Pinned);
// Unpinned group
const unpinnedSessionList = (s: SessionStore) =>
defaultSessions(s).filter((s) => s.group === SessionGroupDefaultKeys.Default);
Revised:
const defaultSessions = (s: SessionStore): LobeSessions => s.defaultSessions;
const pinnedSessions = (s: SessionStore): LobeSessions => s.pinnedSessions;
const customSessionGroups = (s: SessionStore): CustomSessionGroup[] => s.customSessionGroups;
Since all data retrieval in the UI is implemented using syntax like useSessionStore(sessionSelectors.defaultSessions)
, we only need to modify the selector implementation of defaultSessions
to complete the data structure change. The data retrieval code in the UI layer does not need to be changed at all, which can greatly reduce the cost and risk of refactoring.
![Important]
If you are not familiar with the concept and functionality of selectors, you can refer to the section 📘 Data Storage and Retrieval Module for relevant information.
Bind Store Action in the UI component to implement interactive logic, for example CreateGroupModal
:
const CreateGroupModal = () => {
// ... Other logic
const [updateSessionGroup, addCustomGroup] = useSessionStore((s) => [
s.updateSessionGroupId,
s.addSessionGroup,
]);
return (
<Modal
onOk={async () => {
// ... Other logic
const groupId = await addCustomGroup(name);
await updateSessionGroup(sessionId, groupId);
}}
>
{/* ... */}
</Modal>
);
};
In the process of software development, data migration is an inevitable issue, especially when the existing data structure cannot meet the new business requirements. For this iteration of SessionGroup, we need to handle the migration of the group
field in the session
, which is a typical data migration case.
In the old data structure, the group
field was used to mark whether the session was "pinned" or belonged to a "default" group. However, when support for multiple session groups is needed, the original data structure becomes inflexible.
For example:
before pin: group = abc
after pin: group = pinned
after unpin: group = default
From the above example, it can be seen that once a session is unpinned from the "pinned" state, the group
field cannot be restored to its original abc
value. This is because we do not have a separate field to maintain the pinned state. Therefore, we have introduced a new field pinned
to indicate whether the session is pinned, while the group
field will be used solely to identify the session group.
The core logic of this migration is as follows:
- When the user's
group
field ispinned
, set theirpinned
field totrue
, and set the group todefault
.
However, data migration in LobeChat typically involves two parts: configuration file migration and database migration. Therefore, the above logic will need to be implemented separately in these two areas.
For configuration file migration, we recommend performing it before database migration, as configuration file migration is usually easier to test and validate. LobeChat's file migration configuration is located in the src/migrations/index.ts
file, which defines the various versions of configuration file migration and their corresponding migration scripts.
// Current latest version number
- export const CURRENT_CONFIG_VERSION = 2;
+ export const CURRENT_CONFIG_VERSION = 3;
// Historical version upgrade module
const ConfigMigrations = [
+ /**
+ * 2024.01.22
+ * from `group = pinned` to `pinned:true`
+ */
+ MigrationV2ToV3,
/**
* 2023.11.27
* Migrate from single key database to dexie-based relational structure
*/
MigrationV1ToV2,
/**
* 2023.07.11
* just the first version, Nothing to do
*/
MigrationV0ToV1,
];
The logic for this configuration file migration is defined in src/migrations/FromV2ToV3/index.ts
, simplified as follows:
export class MigrationV2ToV3 implements Migration {
// Specify the version from which to upgrade
version = 2;
migrate(data: MigrationData<V2ConfigState>): MigrationData<V3ConfigState> {
const { sessions } = data.state;
return {
...data,
state: {
...data.state,
sessions: sessions.map((s) => this.migrateSession(s)),
},
};
}
migrateSession = (session: V2Session): V3Session => {
return {
...session,
group: 'default',
pinned: session.group === 'pinned',
};
};
}
It can be seen that the migration implementation is very simple. However, it is important to ensure the correctness of the migration, so corresponding test cases need to be written in src/migrations/FromV2ToV3/migrations.test.ts
:
import { MigrationData, VersionController } from '@/migrations/VersionController';
import { MigrationV1ToV2 } from '../FromV1ToV2';
import inputV1Data from '../FromV1ToV2/fixtures/input-v1-session.json';
import inputV2Data from './fixtures/input-v2-session.json';
import outputV3DataFromV1 from './fixtures/output-v3-from-v1.json';
import outputV3Data from './fixtures/output-v3.json';
import { MigrationV2ToV3 } from './index';
describe('MigrationV2ToV3', () => {
let migrations;
let versionController: VersionController<any>;
beforeEach(() => {
migrations = [MigrationV2ToV3];
versionController = new VersionController(migrations, 3);
});
it('should migrate data correctly through multiple versions', () => {
const data: MigrationData = inputV2Data;
const migratedData = versionController.migrate(data);
expect(migratedData.version).toEqual(outputV3Data.version);
expect(migratedData.state.sessions).toEqual(outputV3Data.state.sessions);
expect(migratedData.state.topics).toEqual(outputV3Data.state.topics);
expect(migratedData.state.messages).toEqual(outputV3Data.state.messages);
});
it('should work correct from v1 to v3', () => {
const data: MigrationData = inputV1Data;
versionController = new VersionController([MigrationV2ToV3, MigrationV1ToV2], 3);
const migratedData = versionController.migrate(data);
expect(migratedData.version).toEqual(outputV3DataFromV1.version);
expect(migratedData.state.sessions).toEqual(outputV3DataFromV1.state.sessions);
expect(migratedData.state.topics).toEqual(outputV3DataFromV1.state.topics);
expect(migratedData.state.messages).toEqual(outputV3DataFromV1.state.messages);
});
});
Unit tests require the use of fixtures
to fix the test data. The test cases include verification logic for two parts: 1) the correctness of a single migration (v2 -> v3) and 2) the correctness of a complete migration (v1 -> v3).
Important
The version number in the configuration file may not match the database version number, as database version updates do not always involve changes to the data structure (such as adding tables or fields), while configuration file version updates usually involve data migration.
#### Database Migration
Database migration needs to be implemented in the `LocalDB` class, which is defined in the `src/database/core/db.ts` file. The migration process involves adding a new `pinned` field for each record in the `sessions` table and resetting the `group` field:
```diff
export class LocalDB extends Dexie {
public files: LobeDBTable<'files'>;
public sessions: LobeDBTable<'sessions'>;
public messages: LobeDBTable<'messages'>;
public topics: LobeDBTable<'topics'>;
public plugins: LobeDBTable<'plugins'>;
public sessionGroups: LobeDBTable<'sessionGroups'>;
constructor() {
super(LOBE_CHAT_LOCAL_DB_NAME);
this.version(1).stores(dbSchemaV1);
this.version(2).stores(dbSchemaV2);
this.version(3).stores(dbSchemaV3);
this.version(4)
.stores(dbSchemaV4)
+ .upgrade((trans) => this.upgradeToV4(trans));
this.files = this.table('files');
this.sessions = this.table('sessions');
this.messages = this.table('messages');
this.topics = this.table('topics');
this.plugins = this.table('plugins');
this.sessionGroups = this.table('sessionGroups');
}
+ /**
+ * 2024.01.22
+ *
+ * DB V3 to V4
+ * from `group = pinned` to `pinned:true`
+ */
+ upgradeToV4 = async (trans: Transaction) => {
+ const sessions = trans.table('sessions');
+ await sessions.toCollection().modify((session) => {
+ // translate boolean to number
+ session.pinned = session.group === 'pinned' ? 1 : 0;
session.group = 'default';
});
+ };
}
This is our data migration strategy. When performing the migration, it is essential to ensure the correctness of the migration script and validate the migration results through thorough testing.
In LobeChat, the data import and export feature is designed to ensure that users can migrate their data between different devices. This includes session, topic, message, and settings data. In the implementation of the Session Group feature, we also need to handle data import and export to ensure that the complete exported data can be restored exactly the same on other devices.
The core implementation of data import and export is in the ConfigService
in src/service/config.ts
, with key methods as follows:
Method Name | Description |
---|---|
importConfigState |
Import configuration data |
exportAgents |
Export all agent data |
exportSessions |
Export all session data |
exportSingleSession |
Export single session data |
exportSingleAgent |
Export single agent data |
exportSettings |
Export settings data |
exportAll |
Export all data |
In LobeChat, when a user chooses to export data, the current session, topic, message, and settings data are packaged into a JSON file and provided for download. The standard structure of this JSON file is as follows:
{
"exportType": "sessions",
"state": {
"sessions": [],
"topics": [],
"messages": []
},
"version": 3
}
Where:
-
exportType
: Identifies the type of data being exported, currently includingsessions
,agent
,settings
, andall
. -
state
: Stores the actual data, with different data types for differentexportType
. -
version
: Indicates the data version.
In the implementation of the Session Group feature, we need to add sessionGroups
data to the state
field. This way, when users export data, their Session Group data will also be included.
For example, when exporting sessions, the relevant implementation code modification is as follows:
class ConfigService {
// ... Other code omitted
exportSessions = async () => {
const sessions = await sessionService.getAllSessions();
+ const sessionGroups = await sessionService.getSessionGroups();
const messages = await messageService.getAllMessages();
const topics = await topicService.getAllTopics();
- const config = createConfigFile('sessions', { messages, sessions, topics });
+ const config = createConfigFile('sessions', { messages, sessionGroups, sessions, topics });
exportConfigFile(config, 'sessions');
};
}
The data import functionality is implemented through ConfigService.importConfigState
. When users choose to import data, they need to provide a JSON file that conforms to the above structure specification. The importConfigState
method accepts the data of the configuration file and imports it into the application.
In the implementation of the Session Group feature, we need to handle the sessionGroups
data during the data import process. This way, when users import data, their Session Group data will also be imported correctly.
The following is the modified code for the import implementation in importConfigState
:
class ConfigService {
// ... Other code omitted
+ importSessionGroups = async (sessionGroups: SessionGroupItem[]) => {
+ return sessionService.batchCreateSessionGroups(sessionGroups);
+ };
importConfigState = async (config: ConfigFile): Promise<ImportResults | undefined> => {
switch (config.exportType) {
case 'settings': {
await this.importSettings(config.state.settings);
break;
}
case 'agents': {
+ const sessionGroups = await this.importSessionGroups(config.state.sessionGroups);
const data = await this.importSessions(config.state.sessions);
return {
+ sessionGroups: this.mapImportResult(sessionGroups),
sessions: this.mapImportResult(data),
};
}
case 'all': {
await this.importSettings(config.state.settings);
+ const sessionGroups = await this.importSessionGroups(config.state.sessionGroups);
const [sessions, messages, topics] = await Promise.all([
this.importSessions(config.state.sessions),
this.importMessages(config.state.messages),
this.importTopics(config.state.topics),
]);
return {
messages: this.mapImportResult(messages),
+ sessionGroups: this.mapImportResult(sessionGroups),
sessions: this.mapImportResult(sessions),
topics: this.mapImportResult(topics),
};
}
case 'sessions': {
+ const sessionGroups = await this.importSessionGroups(config.state.sessionGroups);
const [sessions, messages, topics] = await Promise.all([
this.importSessions(config.state.sessions),
this.importMessages(config.state.messages),
this.importTopics(config.state.topics),
]);
return {
messages: this.mapImportResult(messages),
+ sessionGroups: this.mapImportResult(sessionGroups),
sessions: this.mapImportResult(sessions),
topics: this.mapImportResult(topics),
};
}
}
};
}
One key point of the above modification is to import sessionGroup first, because if sessions are imported first and the corresponding SessionGroup Id is not found in the current database, the group of this session will default to be modified to the default value. This will prevent the correct association of the sessionGroup's ID with the session.
This is the implementation of the LobeChat Session Group feature in the data import and export process. This approach ensures that users' Session Group data is correctly handled during the import and export process.
The above is the complete implementation process of the LobeChat Session Group feature. Developers can refer to this document for the development and testing of related functionalities.
This is the 🤯 / 🤖 Lobe Chat wiki. Wiki Home
- Architecture Design | 架构设计
- Code Style and Contribution Guidelines | 代码风格与贡献指南
- Complete Guide to LobeChat Feature Development | LobeChat 功能开发完全指南
- Conversation API Implementation Logic | 会话 API 实现逻辑
- Directory Structure | 目录架构
- Environment Setup Guide | 环境设置指南
- How to Develop a New Feature | 如何开发一个新功能:前端实现
- New Authentication Provider Guide | 新身份验证方式开发指南
- Resources and References | 资源与参考
- Technical Development Getting Started Guide | 技术开发上手指南
- Testing Guide | 测试指南