UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

464 lines (371 loc) • 17.1 kB
# LobeChat Feature Development Complete Guide This document aims to guide developers on how to develop a complete feature in LobeChat. We will use [RFC 021 - Custom Assistant Opening Guidance](https://github.com/lobehub/lobe-chat/discussions/891) as an example to illustrate the complete implementation process. ## 1. Update Schema lobe-chat uses a postgres database, with the browser-side local database using [pglite](https://pglite.dev/) (wasm version of postgres). The project also uses [drizzle](https://orm.drizzle.team/) ORM to operate the database. Compared to the old solution where the browser side used indexDB, having both the browser side and server side use postgres has the advantage that the model layer code can be completely reused. All schemas are uniformly placed in `src/database/schemas`. We need to adjust the `agents` table to add two fields corresponding to the configuration items: ```diff // src/database/schemas/agent.ts export const agents = pgTable( 'agents', { id: text('id') .primaryKey() .$defaultFn(() => idGenerator('agents')) .notNull(), avatar: text('avatar'), backgroundColor: text('background_color'), plugins: jsonb('plugins').$type<string[]>().default([]), // ... tts: jsonb('tts').$type<LobeAgentTTSConfig>(), + openingMessage: text('opening_message'), + openingQuestions: text('opening_questions').array().default([]), ...timestamps, }, (t) => ({ // ... // !: update index here }), ); ``` Note that sometimes we may also need to update the index, but for this feature, we don't have any related performance bottleneck issues, so we don't need to update the index. After adjusting the schema, we need to run `npm run db:generate` to use drizzle-kit's built-in database migration capability to generate the corresponding SQL code for migrating to the latest schema. After execution, four files will be generated: - src/database/migrations/meta/\_journal.json: Saves information about each migration - src/database/migrations/0021\_add\_agent\_opening\_settings.sql: SQL commands for this migration - src/database/client/migrations.json: SQL commands for this migration used by pglite - src/database/migrations/meta/0021\_snapshot.json: The current latest complete database snapshot Note that the migration SQL filename generated by the script by default is not semantically clear like `0021_add_agent_opening_settings.sql`. You need to manually rename it and update `src/database/migrations/meta/_journal.json`. Previously, client-side storage using indexDB made database migration relatively complicated. Now with pglite on the local side, database migration is a simple command, very quick and easy. You can also check if there's any room for optimization in the generated migration SQL and make manual adjustments. ## 2. Update Data Model Data models used in our project are defined in `src/types`. We don't directly use the types exported from the drizzle schema, such as `export type NewAgent = typeof agents.$inferInsert;`, but instead define corresponding data models based on frontend requirements and data types of the corresponding fields in the db schema definition. Data model definitions are placed in the `src/types` folder. Update the `LobeAgentConfig` type in `src/types/agent/index.ts`: ```diff export interface LobeAgentConfig { // ... chatConfig: LobeAgentChatConfig; /** * The language model used by the agent * @default gpt-4o-mini */ model: string; + /** + * Opening message + */ + openingMessage?: string; + /** + * Opening questions + */ + openingQuestions?: string[]; /** * Language model parameters */ params: LLMParams; /** * Enabled plugins */ plugins?: string[]; /** * Model provider */ provider?: string; /** * System role */ systemRole: string; /** * Text-to-speech service */ tts: LobeAgentTTSConfig; } ``` ## 3. Service Implementation / Model Implementation - The `model` layer encapsulates reusable operations on the DB - The `service` layer implements application business logic Both have corresponding top-level folders in the `src` directory. We need to check if we need to update their implementation. Agent configuration in the frontend is abstracted as session configuration. In `src/services/session/server.ts` we can see a service function `updateSessionConfig`: ```typescript export class ServerService implements ISessionService { // ... updateSessionConfig: ISessionService['updateSessionConfig'] = (id, config, signal) => { return lambdaClient.session.updateSessionConfig.mutate({ id, value: config }, { signal }); }; } ``` Jumping to the implementation of `lambdaClient.session.updateSessionConfig`, we find that it simply **merges** the new config with the old config. ```typescript export const sessionRouter = router({ // .. updateSessionConfig: sessionProcedure .input( z.object({ id: z.string(), value: z.object({}).passthrough().partial(), }), ) .mutation(async ({ input, ctx }) => { const session = await ctx.sessionModel.findByIdOrSlug(input.id); // ... const mergedValue = merge(session.agent, input.value); return ctx.sessionModel.updateConfig(session.agent.id, mergedValue); }), }); ``` Foreseeably, the frontend will add two inputs, calling updateSessionConfig upon user modification. As the current implementation lacks field-level granularity for the config update, the service and model layers remain unaffected. ## 4. Frontend Implementation ### Data Flow Store Implementation lobe-chat uses [zustand](https://zustand.docs.pmnd.rs/getting-started/introduction) as the global state management framework. For detailed practices on state management, you can refer to [📘 State Management Best Practices](/docs/development/state-management/state-management-intro). There are two stores related to the agent: - `src/features/AgentSetting/store` serves the local store for agent settings - `src/store/agent` is used to get the current session agent's store The latter listens for and updates the current session's agent configuration through the `onConfigChange` in the `AgentSettings` component in `src/features/AgentSetting/AgentSettings.tsx`. #### Update AgentSetting/store First, we update the initialState. After reading `src/features/AgentSetting/store/initialState.ts`, we learn that the initial agent configuration is saved in `DEFAULT_AGENT_CONFIG` in `src/const/settings/agent.ts`: ```diff export const DEFAULT_AGENT_CONFIG: LobeAgentConfig = { chatConfig: DEFAULT_AGENT_CHAT_CONFIG, model: DEFAULT_MODEL, + openingQuestions: [], params: { frequency_penalty: 0, presence_penalty: 0, temperature: 1, top_p: 1, }, plugins: [], provider: DEFAULT_PROVIDER, systemRole: '', tts: DEFAUTT_AGENT_TTS_CONFIG, }; ``` Actually, you don't even need to update this since the `openingQuestions` type is already optional. I'm not updating `openingMessage` here. Because we've added two new fields, to facilitate access by components in the `src/features/AgentSetting/AgentOpening` folder and for performance optimization, we add related selectors in `src/features/AgentSetting/store/selectors.ts`: ```diff import { DEFAULT_AGENT_CHAT_CONFIG } from '@/const/settings'; import { LobeAgentChatConfig } from '@/types/agent'; import { Store } from './action'; const chatConfig = (s: Store): LobeAgentChatConfig => s.config.chatConfig || DEFAULT_AGENT_CHAT_CONFIG; +export const DEFAULT_OPENING_QUESTIONS: string[] = []; export const selectors = { chatConfig, + openingMessage: (s: Store) => s.config.openingMessage, + openingQuestions: (s: Store) => s.config.openingQuestions || DEFAULT_OPENING_QUESTIONS, }; ``` We won't add additional actions to update the agent config here, as I've observed that other existing code also directly uses the unified `setChatConfig` in the existing code: ```typescript export const store: StateCreator<Store, [['zustand/devtools', never]]> = (set, get) => ({ setAgentConfig: (config) => { get().dispatchConfig({ config, type: 'update' }); }, }); ``` #### Update store/agent In the component `src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatList/WelcomeChatItem/WelcomeMessage.tsx`, we use this store to get the current agent configuration to render user-customized opening messages and guiding questions. Since we only need to read two configuration items, we'll simply add two selectors: Update `src/store/agent/slices/chat/selectors/agent.ts`: ```diff // ... +const openingQuestions = (s: AgentStoreState) => + currentAgentConfig(s).openingQuestions || DEFAULT_OPENING_QUESTIONS; +const openingMessage = (s: AgentStoreState) => currentAgentConfig(s).openingMessage || ''; export const agentSelectors = { // ... isInboxSession, + openingMessage, + openingQuestions, }; ``` ### UI Implementation and Action Binding We're adding a new category of settings this time. In `src/features/AgentSetting`, various UI components for agent settings are defined. This time we're adding a setting type: opening settings. We'll add a folder `AgentOpening` to store opening settings-related components. The project uses: - [ant-design](https://ant.design/) and [lobe-ui](https://github.com/lobehub/lobe-ui): component libraries - [antd-style](https://ant-design.github.io/antd-style): css-in-js solution - [react-layout-kit](https://github.com/arvinxx/react-layout-kit): responsive layout components - [@ant-design/icons](https://ant.design/components/icon-cn) and [lucide](https://lucide.dev/icons/): icon libraries - [react-i18next](https://github.com/i18next/react-i18next) and [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/tree/master/packages/lobe-i18n): i18n framework and multi-language automatic translation tool lobe-chat is an internationalized project, so newly added text needs to update the default `locale` file: `src/locales/default/setting.ts`. Let's take the subcomponent `OpeningQuestion.tsx` as an example. Component implementation: ```typescript // src/features/AgentSetting/AgentOpening/OpeningQuestions.tsx 'use client'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { SortableList } from '@lobehub/ui'; import { Button, Empty, Input } from 'antd'; import { createStyles } from 'antd-style'; import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Flexbox } from 'react-layout-kit'; import { useStore } from '../store'; import { selectors } from '../store/selectors'; const useStyles = createStyles(({ css, token }) => ({ empty: css` margin-block: 24px; margin-inline: 0; `, questionItemContainer: css` margin-block-end: 8px; padding-block: 2px; padding-inline: 10px 0; background: ${token.colorBgContainer}; `, questionItemContent: css` flex: 1; `, questionsList: css` width: 100%; margin-block-start: 16px; `, repeatError: css` margin: 0; color: ${token.colorErrorText}; `, })); interface QuestionItem { content: string; id: number; } const OpeningQuestions = memo(() => { const { t } = useTranslation('setting'); const { styles } = useStyles(); const [questionInput, setQuestionInput] = useState(''); // Use selector to access corresponding configuration const openingQuestions = useStore(selectors.openingQuestions); // Use action to update configuration const updateConfig = useStore((s) => s.setAgentConfig); const setQuestions = useCallback( (questions: string[]) => { updateConfig({ openingQuestions: questions }); }, [updateConfig], ); const addQuestion = useCallback(() => { if (!questionInput.trim()) return; setQuestions([...openingQuestions, questionInput.trim()]); setQuestionInput(''); }, [openingQuestions, questionInput, setQuestions]); const removeQuestion = useCallback( (content: string) => { const newQuestions = [...openingQuestions]; const index = newQuestions.indexOf(content); newQuestions.splice(index, 1); setQuestions(newQuestions); }, [openingQuestions, setQuestions], ); // Handle logic after drag and drop sorting const handleSortEnd = useCallback( (items: QuestionItem[]) => { setQuestions(items.map((item) => item.content)); }, [setQuestions], ); const items: QuestionItem[] = useMemo(() => { return openingQuestions.map((item, index) => ({ content: item, id: index, })); }, [openingQuestions]); const isRepeat = openingQuestions.includes(questionInput.trim()); return ( <Flexbox gap={8}> <Flexbox gap={4}> <Input addonAfter={ <Button // don't allow repeat disabled={openingQuestions.includes(questionInput.trim())} icon={<PlusOutlined />} onClick={addQuestion} size="small" type="text" /> } onChange={(e) => setQuestionInput(e.target.value)} onPressEnter={addQuestion} placeholder={t('settingOpening.openingQuestions.placeholder')} value={questionInput} /> {isRepeat && ( <p className={styles.repeatError}>{t('settingOpening.openingQuestions.repeat')}</p> )} </Flexbox> <div className={styles.questionsList}> {openingQuestions.length > 0 ? ( <SortableList items={items} onChange={handleSortEnd} renderItem={(item) => ( <SortableList.Item className={styles.questionItemContainer} id={item.id}> <SortableList.DragHandle /> <div className={styles.questionItemContent}>{item.content}</div> <Button icon={<DeleteOutlined />} onClick={() => removeQuestion(item.content)} type="text" /> </SortableList.Item> )} /> ) : ( <Empty className={styles.empty} description={t('settingOpening.openingQuestions.empty')} /> )} </div> </Flexbox> ); }); export default OpeningQuestions; ``` At the same time, we need to display the opening configuration set by the user, which is on the chat page. The corresponding component is in `src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatList/WelcomeChatItem/WelcomeMessage.tsx`: ```typescript const WelcomeMessage = () => { const { t } = useTranslation('chat'); // Get current opening configuration const openingMessage = useAgentStore(agentSelectors.openingMessage); const openingQuestions = useAgentStore(agentSelectors.openingQuestions); const meta = useSessionStore(sessionMetaSelectors.currentAgentMeta, isEqual); const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors); const activeId = useChatStore((s) => s.activeId); const agentSystemRoleMsg = t('agentDefaultMessageWithSystemRole', { name: meta.title || t('defaultAgent'), systemRole: meta.description, }); const agentMsg = t(isAgentEditable ? 'agentDefaultMessage' : 'agentDefaultMessageWithoutEdit', { name: meta.title || t('defaultAgent'), url: `/chat/settings?session=${activeId}`, }); const message = useMemo(() => { // Use user-set message if available if (openingMessage) return openingMessage; return !!meta.description ? agentSystemRoleMsg : agentMsg; }, [openingMessage, agentSystemRoleMsg, agentMsg, meta.description]); const chatItem = ( <ChatItem avatar={meta} editing={false} message={message} placement={'left'} type={type === 'chat' ? 'block' : 'pure'} /> ); return openingQuestions.length > 0 ? ( <Flexbox> {chatItem} {/* Render guiding questions */} <OpeningQuestions mobile={mobile} questions={openingQuestions} /> </Flexbox> ) : ( chatItem ); }; export default WelcomeMessage; ``` ## 5. Testing The project uses vitest for unit testing. Since our two new configuration fields are both optional, theoretically you could pass the tests without updating them. However, since we added the `openingQuestions` field to the `DEFAULT_AGENT_CONFIG` mentioned earlier, this causes many tests to calculate configurations that include this field, so we still need to update some test snapshots. For the current scenario, I recommend running the tests locally to see which tests fail, and then update them as needed. For example, for the test file `src/store/agent/slices/chat/selectors/agent.test.ts`, you need to run `npx vitest -u src/store/agent/slices/chat/selectors/agent.test.ts` to update the snapshot. ## Summary The above is the complete implementation process for the LobeChat opening settings feature. Developers can refer to this document for the development and testing of related features.