Back to Lobehub

LobeHub 功能开发完全指南

docs/development/basic/feature-development.zh-CN.mdx

2.1.5613.0 KB
Original Source

LobeHub 功能开发完全指南

本文档旨在指导开发者了解如何在 LobeHub 中开发一块完整的功能需求。

我们将以 RFC 021 - 自定义助手开场引导 为例,阐述完整的实现流程。

一、更新 Schema

LobeHub 使用 PostgreSQL 数据库,项目使用 Drizzle ORM 来操作数据库。

Schemas 统一放在 packages/database/src/schemas/ 下,我们需要调整 agents 表增加两个配置项对应的字段:

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

需要注意的是,有些时候我们可能还需要更新索引,但对于这个需求我们没有相关的性能瓶颈问题,所以不需要更新索引。

数据库迁移

调整完 schema 后需要生成并优化迁移文件,详细步骤请参阅 数据库迁移指南

二、更新数据模型

数据模型定义在 packages/types/src/ 下,我们并没有直接使用 Drizzle schema 导出的类型(例如 typeof agents.$inferInsert),而是根据前端需求定义了独立的数据模型。

更新 packages/types/src/agent/index.tsLobeAgentConfig 类型:

diff
export interface LobeAgentConfig {
  // ...
  chatConfig: LobeAgentChatConfig;
  /**
   * 角色所使用的语言模型
   * @default gpt-4o-mini
   */
  model: string;

+  /**
+   * 开场白
+   */
+  openingMessage?: string;
+  /**
+   * 开场问题
+   */
+  openingQuestions?: string[];

  /**
   * 语言模型参数
   */
  params: LLMParams;
  // ...
}

三、Service / Model 各层实现

项目按职责分为前端和后端多层,完整的分层如下:

plaintext
+-------------------+--------------------------------------+------------------------------------------------------+
| Layer             | Location                             | Responsibility                                       |
+-------------------+--------------------------------------+------------------------------------------------------+
| Client Service    | src/services/                        | 封装前端可复用的业务逻辑,一般涉及多个后端请求(tRPC)  |
| WebAPI            | src/app/(backend)/webapi/            | REST API 端点                                         |
| tRPC Router       | src/server/routers/                  | tRPC 入口,校验输入,路由到 service                   |
| Server Service    | src/server/services/                 | 服务端业务逻辑,可访问数据库                          |
| Server Module     | src/server/modules/                  | 服务端模块,不直接访问数据库                          |
| Repository        | packages/database/src/repositories/  | 封装复杂查询、跨表操作                                |
| DB Model          | packages/database/src/models/        | 封装单表的 CRUD 操作                                  |
+-------------------+--------------------------------------+------------------------------------------------------+

Client Service 是前端代码,封装可复用的业务逻辑,通过 tRPC 客户端调用后端。例如 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 是后端入口(src/server/routers/lambda/),校验输入后调用 Server Service 处理业务逻辑:

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

对于本次需求,updateSessionConfig 只是简单 merge config,并没有细粒度到具体字段,因此各层都不需要修改。

四、前端实现

数据流 Store 实现

LobeHub 使用 zustand 作为全局状态管理框架,对于状态管理的详细实践介绍,可以查阅 📘 状态管理最佳实践

和 agent 相关的 store 有两个:

  • src/features/AgentSetting/store 服务于 agent 设置的局部 store
  • src/store/agent 用于获取当前会话 agent 的 store

后者通过 src/features/AgentSetting/AgentSettings.tsxAgentSettings 组件的 onConfigChange 监听并更新当前会话的 agent 配置。

更新 AgentSetting/store

首先我们更新 initialState,阅读 src/features/AgentSetting/store/initialState.ts 后得知初始 agent 配置保存在 src/const/settings/agent.ts 中的 DEFAULT_AGENT_CONFIG

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

其实你这里不更新都可以,因为 openingQuestions 类型本来就是可选的,openingMessage 我这里就不更新了。

因为我们增加了两个新字段,为了方便在 src/features/AgentSetting/AgentOpening 文件夹中组件访问和性能优化,我们在 src/features/AgentSetting/store/selectors.ts 增加相关的 selectors:

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

这里我们就不增加额外的 action 用于更新 agent config 了,因为已有的代码也是直接使用统一的 setAgentConfig

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

更新 store/agent

在展示组件中我们使用 src/store/agent 获取当前 agent 配置,简单加两个 selectors:

更新 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 处理

LobeHub 是国际化项目,使用 react-i18next 作为 i18n 框架。新增的 UI 文案需要:

  1. src/locales/default/ 对应的 namespace 文件中添加 key(默认语言为英文):
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. 如果新增了 namespace,需要在 src/locales/default/index.ts 中导出
  2. 开发预览时手动翻译 locales/zh-CN/locales/en-US/ 对应的 JSON 文件
  3. CI 会自动运行 pnpm i18n 生成其他语言的翻译

key 的命名规范为扁平的 dot notation:{feature}.{context}.{action|status}

UI 实现和 action 绑定

我们这次要新增一个类别的设置。在 src/features/AgentSetting 中定义了 agent 的各种设置 UI 组件,增加一个文件夹 AgentOpening 存放开场设置相关的组件。

以子组件 OpeningQuestions.tsx 为例,展示关键逻辑(省略样式代码):

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('');

  // 使用 selector 访问对应配置
  const openingQuestions = useStore(selectors.openingQuestions);
  // 使用 action 更新配置
  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],
  );

  // 渲染 Input + SortableList,具体 UI 参考组件库文档
  // ...
});

关键点:

  • 通过 selectors 读取 store 中的配置
  • 通过 setAgentConfig action 更新配置
  • 使用 useTranslation('setting') 获取 i18n 文案

同时我们需要将用户设置的开场配置展示出来,这个是在 chat 页面,对应组件在 src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatList/WelcomeChatItem/WelcomeMessage.tsx

typescript
const WelcomeMessage = () => {
  const { t } = useTranslation('chat');

  // 从 store/agent 获取当前开场配置
  const openingMessage = useAgentStore(agentSelectors.openingMessage);
  const openingQuestions = useAgentStore(agentSelectors.openingQuestions);

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

  const message = useMemo(() => {
    // 用户设置了就用用户设置的
    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" />
  );
};

五、测试

项目使用 Vitest 进行单元测试,相关指南详见 测试技能文档

运行测试:

bash
# 运行指定测试文件(不要运行 bun run test,全量测试耗时很长)
bunx vitest run --silent='passed-only' '[file-path]'

# database 包的测试
cd packages/database && bunx vitest run --silent='passed-only' '[file]'

添加新功能的测试建议:

由于我们目前两个新的配置字段都是可选的,理论上不更新测试也能跑通。但如果修改了默认配置(如 DEFAULT_AGENT_CONFIG 增加了 openingQuestions 字段),可能导致一些测试快照不匹配,需要更新。

建议先本地跑下相关测试,看哪些失败了再针对性更新。例如:

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

如果只是想确认现有测试是否通过而不想本地跑,也可以直接查看 GitHub Actions 的测试结果。

更多测试场景指南:

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

总结

以上就是 LobeHub 开场设置功能的完整实现流程,涵盖了从数据库 schema → 数据模型 → Service/Model → Store → i18n → UI → 测试的全链路。开发者可以参考本文档进行相关功能的开发。