Back to Lobehub

LobeHub Feature Development Complete Guide

docs/development/basic/feature-development.mdx

2.1.5613.4 KB
Original Source

LobeHub Feature Development Complete Guide

This document aims to guide developers on how to develop a complete feature in LobeHub.

We will use RFC 021 - Custom Assistant Opening Guidance as an example to illustrate the complete implementation process.

1. Update Schema

LobeHub uses a PostgreSQL database, with Drizzle ORM to operate the database.

All schemas are located in packages/database/src/schemas/. We need to adjust the agents table to add two fields corresponding to the configuration items:

diff
// packages/database/src/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.

Database Migration

After adjusting the schema, you need to generate and optimize migration files. See the Database Migration Guide for detailed steps.

2. Update Data Model

Data models are defined in packages/types/src/. We don't directly use the types exported from the Drizzle schema (e.g., typeof agents.$inferInsert), but instead define independent data models based on frontend requirements.

Update the LobeAgentConfig type in packages/types/src/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;
  // ...
}

3. Service / Model Layer Implementation

The project is divided into multiple frontend and backend layers by responsibility:

plaintext
+-------------------+--------------------------------------+------------------------------------------------------+
| Layer             | Location                             | Responsibility                                       |
+-------------------+--------------------------------------+------------------------------------------------------+
| Client Service    | src/services/                        | Reusable frontend business logic, often multiple tRPC |
| WebAPI            | src/app/(backend)/webapi/            | REST API endpoints                                    |
| tRPC Router       | src/server/routers/                  | tRPC entry, validates input, routes to service        |
| Server Service    | src/server/services/                 | Server-side business logic, with DB access            |
| Server Module     | src/server/modules/                  | Server-side modules, no direct DB access              |
| Repository        | packages/database/src/repositories/  | Complex queries, cross-table operations               |
| DB Model          | packages/database/src/models/        | Single-table CRUD operations                          |
+-------------------+--------------------------------------+------------------------------------------------------+

Client Service is frontend code that encapsulates reusable business logic, calling the backend via tRPC client. For example, src/services/session/index.ts:

typescript
export class SessionService {
  updateSessionConfig = (id: string, config: PartialDeep<LobeAgentConfig>, signal?: AbortSignal) => {
    return lambdaClient.session.updateSessionConfig.mutate({ id, value: config }, { signal });
  };
}

tRPC Router is the backend entry point (src/server/routers/lambda/), validates input and calls Server Service for business logic:

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

For this feature, updateSessionConfig simply merges config without field-level granularity, so none of the layers need modification.

4. Frontend Implementation

Data Flow Store Implementation

LobeHub uses zustand as the global state management framework. For detailed practices on state management, refer to State Management Best Practices.

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 component access in src/features/AgentSetting/AgentOpening and for performance optimization, we add related selectors in src/features/AgentSetting/store/selectors.ts:

diff
+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 existing code also directly uses the unified setAgentConfig:

typescript
export const store: StateCreator<Store, [['zustand/devtools', never]]> = (set, get) => ({
  setAgentConfig: (config) => {
    get().dispatchConfig({ config, type: 'update' });
  },
});

Update store/agent

In the display component we use src/store/agent to get the current agent configuration. 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,
};

i18n Handling

LobeHub is an internationalized project using react-i18next. Newly added UI text needs to:

  1. Add keys to the corresponding namespace file in src/locales/default/ (default language is English):
typescript
// src/locales/default/setting.ts
export default {
  // ...
  'settingOpening.title': 'Opening Settings',
  'settingOpening.openingMessage.title': 'Opening Message',
  'settingOpening.openingMessage.placeholder': 'Enter a custom opening message...',
  'settingOpening.openingQuestions.title': 'Opening Questions',
  'settingOpening.openingQuestions.placeholder': 'Enter a guiding question',
  'settingOpening.openingQuestions.empty': 'No opening questions yet',
  'settingOpening.openingQuestions.repeat': 'Question already exists',
};
  1. If a new namespace is added, export it in src/locales/default/index.ts
  2. For dev preview: manually translate the corresponding JSON files in locales/zh-CN/ and locales/en-US/
  3. CI will automatically run pnpm i18n to generate translations for other languages

Key naming convention uses flat dot notation: {feature}.{context}.{action|status}.

UI Implementation and Action Binding

We're adding a new category of settings. In src/features/AgentSetting, various UI components for agent settings are defined. We'll add an AgentOpening folder for opening settings components.

Taking the subcomponent OpeningQuestions.tsx as an example, here's the key logic (style code omitted):

typescript
// src/features/AgentSetting/AgentOpening/OpeningQuestions.tsx
'use client';

import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useStore } from '../store';
import { selectors } from '../store/selectors';

const OpeningQuestions = memo(() => {
  const { t } = useTranslation('setting');
  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],
  );

  // Render Input + SortableList, see component library docs for UI details
  // ...
});

Key points:

  • Read store config via selectors
  • Update config via the setAgentConfig action
  • Use useTranslation('setting') for i18n text

We also need to display the opening configuration set by the user 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 from store/agent
  const openingMessage = useAgentStore(agentSelectors.openingMessage);
  const openingQuestions = useAgentStore(agentSelectors.openingQuestions);

  const meta = useSessionStore(sessionMetaSelectors.currentAgentMeta, isEqual);

  const message = useMemo(() => {
    // Use user-set message if available
    if (openingMessage) return openingMessage;
    return !!meta.description ? agentSystemRoleMsg : agentMsg;
  }, [openingMessage, agentSystemRoleMsg, agentMsg, meta.description]);

  return openingQuestions.length > 0 ? (
    <Flexbox>
      <ChatItem avatar={meta} message={message} placement="left" />
      <OpeningQuestions questions={openingQuestions} />
   
</Flexbox>
  ) : (
    <ChatItem avatar={meta} message={message} placement="left" />
  );
};

5. Testing

The project uses Vitest for unit testing. See the Testing Skill Guide for details.

Running tests:

bash
# Run specific test file (never run bun run test — full suite is very slow)
bunx vitest run --silent='passed-only' '[file-path]'

# Database package tests
cd packages/database && bunx vitest run --silent='passed-only' '[file]'

Testing suggestions for new features:

Since our two new configuration fields are both optional, theoretically tests would pass without updates. However, if you modified default config (e.g., added openingQuestions to DEFAULT_AGENT_CONFIG), some test snapshots may become stale and need updating.

It's recommended to run related tests locally first to see which fail, then update as needed. For example:

bash
bunx vitest run --silent='passed-only' 'src/store/agent/slices/chat/selectors/agent.test.ts'

If you just want to check whether existing tests pass without running locally, you can also check the GitHub Actions test results directly.

More testing scenario guides:

  • DB Model testing: .agents/skills/testing/references/db-model-test.md
  • Zustand Store Action testing: .agents/skills/testing/references/zustand-store-action-test.md
  • Electron IPC testing: .agents/skills/testing/references/electron-ipc-test.md

Summary

The above is the complete implementation process for the LobeHub opening settings feature, covering the full chain from database schema → data model → Service/Model → Store → i18n → UI → testing. Developers can refer to this document for developing related features.