feat(share): add topic sharing functionality (#11448)

This commit is contained in:
YuTengjing 2026-01-13 22:10:48 +08:00 committed by GitHub
parent 97a091d358
commit ddca1652bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 12470 additions and 696 deletions

View file

@ -43,4 +43,4 @@ DROP TABLE "old_table";
CREATE INDEX "users_email_idx" ON "users" ("email");
```
**Important**: After modifying migration SQL (e.g., adding `IF NOT EXISTS` clauses), run `bun run db:generate-client` to update the hash in `packages/database/src/core/migrations.json`.
**Important**: After modifying migration SQL (e.g., adding `IF NOT EXISTS` clauses), run `bun run db:generate:client` to update the hash in `packages/database/src/core/migrations.json`.

View file

@ -3,9 +3,10 @@ description: 包含添加 console.log 日志请求时
globs:
alwaysApply: false
---
# Debug 包使用指南
本项目使用 [debug](mdc:https:/github.com/debug-js/debug) 包进行调试日志记录。使用此规则来确保团队成员统一调试日志格式。
本项目使用 `debug` 包进行调试日志记录。使用此规则来确保团队成员统一调试日志格式。
## 基本用法
@ -15,14 +16,14 @@ alwaysApply: false
import debug from 'debug';
```
2. 创建一个命名空间的日志记录器:
1. 创建一个命名空间的日志记录器:
```typescript
// 格式: lobe:[模块]:[子模块]
const log = debug('lobe-[模块名]:[子模块名]');
```
3. 使用日志记录器:
1. 使用日志记录器:
```typescript
log('简单消息');
@ -46,7 +47,7 @@ log('格式化数字: %d', number);
## 示例
查看 [market/index.ts](mdc:src/server/routers/edge/market/index.ts) 中的使用示例:
查看 `src/server/routers/edge/market/index.ts` 中的使用示例:
```typescript
import debug from 'debug';
@ -63,8 +64,9 @@ log('getAgent input: %O', input);
### 在浏览器中
在控制台执行:
```javascript
localStorage.debug = 'lobe-*'
localStorage.debug = 'lobe-*';
```
### 在 Node.js 环境中

View file

@ -3,13 +3,14 @@ description: 桌面端测试
globs:
alwaysApply: false
---
# 桌面端控制器单元测试指南
## 测试框架与目录结构
LobeChat 桌面端使用 Vitest 作为测试框架。控制器的单元测试应放置在对应控制器文件同级的 `__tests__` 目录下,并以原控制器文件名加 `.test.ts` 作为文件名。
```
```plaintext
apps/desktop/src/main/controllers/
├── __tests__/
│ ├── index.test.ts

View file

@ -3,7 +3,8 @@ description: 当要做 electron 相关工作时
globs:
alwaysApply: false
---
**桌面端新功能实现指南**
# 桌面端新功能实现指南
## 桌面端应用架构概述
@ -26,6 +27,7 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
### 1. 确定功能需求与设计
首先确定新功能的需求和设计,包括:
- 功能描述和用例
- 是否需要系统级API如文件系统、网络等
- UI/UX设计如必要
@ -64,9 +66,10 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
```typescript
// src/services/electron/newFeatureService.ts
import { ensureElectronIpc } from '@/utils/electron/ipc';
import type { NewFeatureParams } from '@lobechat/electron-client-ipc';
import { ensureElectronIpc } from '@/utils/electron/ipc';
const ipc = ensureElectronIpc();
export const newFeatureService = async (params: NewFeatureParams) => {
@ -84,7 +87,7 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
### 5. 如果是新增内置工具,遵循工具实现流程
参考 [desktop-local-tools-implement.mdc](mdc:desktop-local-tools-implement.mdc) 了解更多关于添加内置工具的详细步骤。
参考 `desktop-local-tools-implement.mdc` 了解更多关于添加内置工具的详细步骤。
### 6. 添加测试
@ -120,12 +123,13 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
```typescript
// apps/desktop/src/main/controllers/NotificationCtr.ts
import { Notification } from 'electron';
import { ControllerModule, IpcMethod } from '@/controllers';
import type {
DesktopNotificationResult,
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
import { Notification } from 'electron';
import { ControllerModule, IpcMethod } from '@/controllers';
export default class NotificationCtr extends ControllerModule {
static override readonly groupName = 'notification';

View file

@ -3,78 +3,79 @@ description:
globs:
alwaysApply: false
---
**新增桌面端工具流程:**
1. **定义工具接口 (Manifest):**
* **文件:** `src/tools/[tool_category]/index.ts` (例如: `src/tools/local-files/index.ts`)
* **操作:**
* 在 `ApiName` 对象(例如 `LocalFilesApiName`)中添加一个新的、唯一的 API 名称。
* 在 `Manifest` 对象(例如 `LocalFilesManifest`)的 `api` 数组中,新增一个对象来定义新工具的接口。
* **关键字段:**
* `name`: 使用上一步定义的 API 名称。
* `description`: 清晰描述工具的功能,供 Agent 理解和向用户展示。
* `parameters`: 使用 JSON Schema 定义工具所需的输入参数。
* `type`: 通常是 'object'。
* `properties`: 定义每个参数的名称、`description`、`type` (string, number, boolean, array, etc.),使用英文。
* `required`: 一个字符串数组,列出必须提供的参数名称。
1. **定义工具接口 (Manifest):**
- **文件:** `src/tools/[tool_category]/index.ts` (例如: `src/tools/local-files/index.ts`)
- **操作:**
- 在 `ApiName` 对象(例如 `LocalFilesApiName`)中添加一个新的、唯一的 API 名称。
- 在 `Manifest` 对象(例如 `LocalFilesManifest`)的 `api` 数组中,新增一个对象来定义新工具的接口。
- **关键字段:**
- `name`: 使用上一步定义的 API 名称。
- `description`: 清晰描述工具的功能,供 Agent 理解和向用户展示。
- `parameters`: 使用 JSON Schema 定义工具所需的输入参数。
- `type`: 通常是 'object'。
- `properties`: 定义每个参数的名称、`description`、`type` (string, number, boolean, array, etc.),使用英文。
- `required`: 一个字符串数组,列出必须提供的参数名称。
2. **定义相关类型:**
* **文件 1:** `packages/electron-client-ipc/src/types.ts` (或类似的共享 IPC 类型文件)
* **操作:** 定义传递给 IPC 事件的参数类型接口 (例如: `RenameLocalFileParams`, `MoveLocalFileParams`)。确保与 Manifest 中定义的 `parameters` 一致。
* **文件 2:** `src/tools/[tool_category]/type.ts` (例如: `src/tools/local-files/type.ts`)
* **操作:** 定义此工具执行后,存储在前端 Zustand Store 中的状态类型接口 (例如: `LocalRenameFileState`, `LocalMoveFileState`)。这通常包含操作结果(成功/失败)、错误信息以及相关数据(如旧路径、新路径等)。
2. **定义相关类型:**
- **文件 1:** `packages/electron-client-ipc/src/types.ts` (或类似的共享 IPC 类型文件)
- **操作:** 定义传递给 IPC 事件的参数类型接口 (例如: `RenameLocalFileParams`, `MoveLocalFileParams`)。确保与 Manifest 中定义的 `parameters` 一致。
- **文件 2:** `src/tools/[tool_category]/type.ts` (例如: `src/tools/local-files/type.ts`)
- **操作:** 定义此工具执行后,存储在前端 Zustand Store 中的状态类型接口 (例如: `LocalRenameFileState`, `LocalMoveFileState`)。这通常包含操作结果(成功/失败)、错误信息以及相关数据(如旧路径、新路径等)。
3. **实现前端状态管理 (Store Action):**
* **文件:** `src/store/chat/slices/builtinTool/actions/[tool_category].ts` (例如: `src/store/chat/slices/builtinTool/actions/localFile.ts`)
* **操作:**
* 导入在步骤 2 中定义的 IPC 参数类型和状态类型。
* 在 Action 接口 (例如: `LocalFileAction`) 中添加新 Action 的方法签名,使用对应的 IPC 参数类型。
* 在 `createSlice` (例如: `localFileSlice`) 中实现该 Action 方法:
* 接收 `id` (消息 ID) 和 `params` (符合 IPC 参数类型)。
* 设置加载状态 (`toggleLocalFileLoading(id, true)`)。
* 调用对应的 `Service` 层方法 (见步骤 4),传递 `params`。
* 使用 `try...catch` 处理 `Service` 调用可能发生的错误。
* **成功时:**
* 调用 `updatePluginState(id, {...})` 更新插件状态,使用步骤 2 中定义的状态类型。
* 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,通常包含成功确认信息。
* **失败时:**
* 记录错误 (`console.error`)。
* 调用 `updatePluginState(id, {...})` 更新插件状态,包含错误信息。
* 调用 `internal_updateMessagePluginError(id, {...})` 设置消息的错误状态。
* 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,包含错误信息。
* 在 `finally` 块中取消加载状态 (`toggleLocalFileLoading(id, false)`)。
* 返回操作是否成功 (`boolean`)。
3. **实现前端状态管理 (Store Action):**
- **文件:** `src/store/chat/slices/builtinTool/actions/[tool_category].ts` (例如: `src/store/chat/slices/builtinTool/actions/localFile.ts`)
- **操作:**
- 导入在步骤 2 中定义的 IPC 参数类型和状态类型。
- 在 Action 接口 (例如: `LocalFileAction`) 中添加新 Action 的方法签名,使用对应的 IPC 参数类型。
- 在 `createSlice` (例如: `localFileSlice`) 中实现该 Action 方法:
- 接收 `id` (消息 ID) 和 `params` (符合 IPC 参数类型)。
- 设置加载状态 (`toggleLocalFileLoading(id, true)`)。
- 调用对应的 `Service` 层方法 (见步骤 4),传递 `params`。
- 使用 `try...catch` 处理 `Service` 调用可能发生的错误。
- **成功时:**
- 调用 `updatePluginState(id, {...})` 更新插件状态,使用步骤 2 中定义的状态类型。
- 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,通常包含成功确认信息。
- **失败时:**
- 记录错误 (`console.error`)。
- 调用 `updatePluginState(id, {...})` 更新插件状态,包含错误信息。
- 调用 `internal_updateMessagePluginError(id, {...})` 设置消息的错误状态。
- 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,包含错误信息。
- 在 `finally` 块中取消加载状态 (`toggleLocalFileLoading(id, false)`)。
- 返回操作是否成功 (`boolean`)。
4. **实现 Service 层 (调用 IPC):**
* **文件:** `src/services/electron/[tool_category]Service.ts` (例如: `src/services/electron/localFileService.ts`)
* **操作:**
* 导入在步骤 2 中定义的 IPC 参数类型。
* 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
* 方法接收 `params` (符合 IPC 参数类型)。
* 通过 `ensureElectronIpc()` 获取 IPC 代理 (`const ipc = ensureElectronIpc();`),调用与 Manifest 中 `name` 字段匹配的链式方法,并将 `params` 传递过去。
* 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
4. **实现 Service 层 (调用 IPC):**
- **文件:** `src/services/electron/[tool_category]Service.ts` (例如: `src/services/electron/localFileService.ts`)
- **操作:**
- 导入在步骤 2 中定义的 IPC 参数类型。
- 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
- 方法接收 `params` (符合 IPC 参数类型)。
- 通过 `ensureElectronIpc()` 获取 IPC 代理 (`const ipc = ensureElectronIpc();`),调用与 Manifest 中 `name` 字段匹配的链式方法,并将 `params` 传递过去。
- 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
5. **实现后端逻辑 (Controller / IPC Handler):**
* **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
* **操作:**
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`、参数类型等)。
* 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
* 使用 `@IpcMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
* 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
* 实现核心业务逻辑:
* 进行必要的输入验证。
* 执行文件系统操作或其他后端任务 (例如: `fs.promises.rename`)。
* 使用 `try...catch` 捕获执行过程中的错误。
* 处理特定错误码 (`error.code`) 以提供更友好的错误消息。
* 返回一个包含 `success` (boolean) 和可选 `error` (string) 字段的对象。
5. **实现后端逻辑 (Controller / IPC Handler):**
- **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
- **操作:**
- 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`、参数类型等)。
- 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
- 使用 `@IpcMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
- 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
- 实现核心业务逻辑:
- 进行必要的输入验证。
- 执行文件系统操作或其他后端任务 (例如: `fs.promises.rename`)。
- 使用 `try...catch` 捕获执行过程中的错误。
- 处理特定错误码 (`error.code`) 以提供更友好的错误消息。
- 返回一个包含 `success` (boolean) 和可选 `error` (string) 字段的对象。
6. **更新 Agent 文档 (System Role):**
* **文件:** `src/tools/[tool_category]/systemRole.ts` (例如: `src/tools/local-files/systemRole.ts`)
* **操作:**
* 在 `<core_capabilities>` 部分添加新工具的简要描述。
* 如果需要,更新 `<workflow>`。
* 在 `<tool_usage_guidelines>` 部分为新工具添加详细的使用说明,解释其参数、用途和预期行为。
* 如有必要,更新 `<security_considerations>`。
* 如有必要(例如工具返回了新的数据结构或路径),更新 `<response_format>` 中的示例。
6. **更新 Agent 文档 (System Role):**
- **文件:** `src/tools/[tool_category]/systemRole.ts` (例如: `src/tools/local-files/systemRole.ts`)
- **操作:**
- 在 `<core_capabilities>` 部分添加新工具的简要描述。
- 如果需要,更新 `<workflow>`。
- 在 `<tool_usage_guidelines>` 部分为新工具添加详细的使用说明,解释其参数、用途和预期行为。
- 如有必要,更新 `<security_considerations>`。
- 如有必要(例如工具返回了新的数据结构或路径),更新 `<response_format>` 中的示例。
通过遵循这些步骤,可以系统地将新的桌面端工具集成到 LobeChat 的插件系统中。

View file

@ -3,7 +3,8 @@ description:
globs:
alwaysApply: false
---
**桌面端菜单配置指南**
# 桌面端菜单配置指南
## 菜单系统概述
@ -15,7 +16,7 @@ LobeChat 桌面应用有三种主要的菜单类型:
## 菜单相关文件结构
```
```plaintext
apps/desktop/src/main/
├── menus/ # 菜单定义
│ ├── appMenu.ts # 应用菜单配置
@ -33,8 +34,9 @@ apps/desktop/src/main/
应用菜单在 `apps/desktop/src/main/menus/appMenu.ts` 中定义:
1. **导入依赖**
```typescript
import { app, BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions } from 'electron';
import { BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, app } from 'electron';
import { is } from 'electron-util';
```
@ -43,6 +45,7 @@ apps/desktop/src/main/
- 每个菜单项可以包含label, accelerator (快捷键), role, submenu, click 等属性
3. **创建菜单工厂函数**
```typescript
export const createAppMenu = (win: BrowserWindow) => {
const template = [
@ -61,6 +64,7 @@ apps/desktop/src/main/
上下文菜单通常在特定元素上右键点击时显示:
1. **在主进程中定义菜单模板**
```typescript
// apps/desktop/src/main/menus/contextMenu.ts
export const createContextMenu = () => {
@ -73,6 +77,7 @@ apps/desktop/src/main/
```
2. **在适当的事件处理器中显示菜单**
```typescript
const menu = createContextMenu();
menu.popup();
@ -83,11 +88,13 @@ apps/desktop/src/main/
托盘菜单在 `TrayMenuCtr.ts` 中配置:
1. **创建托盘图标**
```typescript
this.tray = new Tray(trayIconPath);
```
2. **定义托盘菜单**
```typescript
const contextMenu = Menu.buildFromTemplate([
{ label: '显示主窗口', click: this.showMainWindow },
@ -97,6 +104,7 @@ apps/desktop/src/main/
```
3. **设置托盘菜单**
```typescript
this.tray.setContextMenu(contextMenu);
```
@ -106,11 +114,13 @@ apps/desktop/src/main/
为菜单添加多语言支持:
1. **导入本地化工具**
```typescript
import { i18n } from '../locales';
```
2. **使用翻译函数**
```typescript
const template = [
{
@ -118,14 +128,13 @@ apps/desktop/src/main/
submenu: [
{ label: i18n.t('menu.new'), click: createNew },
// ...
]
],
},
// ...
];
```
3. **在语言切换时更新菜单**
在 `MenuCtr.ts` 中监听语言变化事件并重新创建菜单
3. **在语言切换时更新菜单** 在 `MenuCtr.ts` 中监听语言变化事件并重新创建菜单
## 添加新菜单项流程
@ -134,6 +143,7 @@ apps/desktop/src/main/
- 确定在菜单中的位置(主菜单项或子菜单项)
2. **定义菜单项**
```typescript
const newMenuItem: MenuItemConstructorOptions = {
label: '新功能',
@ -141,12 +151,11 @@ apps/desktop/src/main/
click: (_, window) => {
// 处理点击事件
if (window) window.webContents.send('trigger-new-feature');
}
},
};
```
3. **添加到菜单模板**
将新菜单项添加到相应的菜单模板中
3. **添加到菜单模板** 将新菜单项添加到相应的菜单模板中
4. **对于与渲染进程交互的功能**
- 使用 `window.webContents.send()` 发送 IPC 消息到渲染进程
@ -157,6 +166,7 @@ apps/desktop/src/main/
动态控制菜单项状态:
1. **保存对菜单项的引用**
```typescript
this.menuItems = {};
const menu = Menu.buildFromTemplate(template);
@ -164,6 +174,7 @@ apps/desktop/src/main/
```
2. **根据条件更新状态**
```typescript
updateMenuState(state) {
if (this.menuItems.newFeature) {
@ -179,6 +190,7 @@ apps/desktop/src/main/
2. **平台特定菜单**
- 使用 `process.platform` 检查为不同平台提供不同菜单
```typescript
if (process.platform === 'darwin') {
template.unshift({ role: 'appMenu' });

View file

@ -3,7 +3,8 @@ description:
globs:
alwaysApply: false
---
**桌面端窗口管理指南**
# 桌面端窗口管理指南
## 窗口管理概述
@ -16,7 +17,7 @@ LobeChat 桌面应用使用 Electron 的 `BrowserWindow` 管理应用窗口。
## 相关文件结构
```
```plaintext
apps/desktop/src/main/
├── appBrowsers.ts # 窗口管理的核心文件
├── controllers/
@ -63,6 +64,7 @@ export const createMainWindow = () => {
实现窗口状态持久化保存和恢复:
1. **保存窗口状态**
```typescript
const saveWindowState = (window: BrowserWindow) => {
if (!window.isMinimized() && !window.isMaximized()) {
@ -80,6 +82,7 @@ export const createMainWindow = () => {
```
2. **恢复窗口状态**
```typescript
const restoreWindowState = (window: BrowserWindow) => {
const savedState = settings.get('windowState');
@ -96,6 +99,7 @@ export const createMainWindow = () => {
```
3. **监听窗口事件**
```typescript
window.on('close', () => saveWindowState(window));
window.on('moved', () => saveWindowState(window));
@ -107,6 +111,7 @@ export const createMainWindow = () => {
对于需要多窗口支持的功能:
1. **跟踪窗口**
```typescript
export class WindowManager {
private windows: Map<string, BrowserWindow> = new Map();
@ -133,6 +138,7 @@ export const createMainWindow = () => {
```
2. **窗口间通信**
```typescript
// 从一个窗口向另一个窗口发送消息
sendMessageToWindow(targetWindowId, channel, data) {
@ -148,9 +154,11 @@ export const createMainWindow = () => {
通过 IPC 实现窗口操作:
1. **在主进程中注册 IPC 处理器**
```typescript
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
import { BrowserWindow } from 'electron';
import { ControllerModule, IpcMethod } from '@/controllers';
export default class BrowserWindowsCtr extends ControllerModule {
@ -178,10 +186,12 @@ export const createMainWindow = () => {
}
}
```
- `@IpcMethod()` 根据控制器的 `groupName` 自动将方法映射为 `windows.minimizeWindow` 形式的通道名称。
- 控制器需继承 `ControllerModule`,并在 `controllers/registry.ts` 中通过 `controllerIpcConstructors` 注册,便于类型生成。
2. **在渲染进程中调用**
```typescript
// src/services/electron/windowService.ts
import { ensureElectronIpc } from '@/utils/electron/ipc';
@ -194,6 +204,7 @@ export const createMainWindow = () => {
close: () => ipc.windows.closeWindow(),
};
```
- `ensureElectronIpc()` 会基于 `DesktopIpcServices` 运行时生成 Proxy并通过 `window.electronAPI.invoke` 与主进程通信;不再直接使用 `dispatch`。
### 5. 自定义窗口控制 (无边框窗口)
@ -201,6 +212,7 @@ export const createMainWindow = () => {
对于自定义窗口标题栏:
1. **创建无边框窗口**
```typescript
const window = new BrowserWindow({
frame: false,
@ -210,6 +222,7 @@ export const createMainWindow = () => {
```
2. **在渲染进程中实现拖拽区域**
```css
/* CSS */
.titlebar {
@ -229,6 +242,7 @@ export const createMainWindow = () => {
2. **安全性**
- 始终设置适当的 `webPreferences` 确保安全
```typescript
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
@ -255,6 +269,7 @@ export const createMainWindow = () => {
```typescript
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
import type { OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
import { ControllerModule, IpcMethod } from '@/controllers';
export default class BrowserWindowsCtr extends ControllerModule {

View file

@ -10,14 +10,14 @@ This document outlines the conventions and best practices for defining PostgreSQ
## Configuration
- Drizzle configuration is managed in [drizzle.config.ts](mdc:drizzle.config.ts)
- Drizzle configuration is managed in `drizzle.config.ts`
- Schema files are located in the src/database/schemas/ directory
- Migration files are output to `src/database/migrations/`
- The project uses `postgresql` dialect with `strict: true`
## Helper Functions
Commonly used column definitions, especially for timestamps, are centralized in [src/database/schemas/\_helpers.ts](mdc:src/database/schemas/_helpers.ts):
Commonly used column definitions, especially for timestamps, are centralized in `src/database/schemas/_helpers.ts`:
- `timestamptz(name: string)`: Creates a timestamp column with timezone
- `createdAt()`, `updatedAt()`, `accessedAt()`: Helper functions for standard timestamp columns
@ -46,7 +46,7 @@ Commonly used column definitions, especially for timestamps, are centralized in
### Timestamps
- Consistently use the `...timestamps` spread from [\_helpers.ts](mdc:src/database/schemas/_helpers.ts) for `created_at`, `updated_at`, and `accessed_at` columns
- Consistently use the `...timestamps` spread from `_helpers.ts` for `created_at`, `updated_at`, and `accessed_at` columns
### Default Values
@ -74,12 +74,12 @@ Commonly used column definitions, especially for timestamps, are centralized in
## Relations
- Table relationships are defined centrally in [src/database/schemas/relations.ts](mdc:src/database/schemas/relations.ts) using the `relations()` utility from `drizzle-orm`
- Table relationships are defined centrally in `src/database/schemas/relations.ts` using the `relations()` utility from `drizzle-orm`
## Code Style & Structure
- **File Organization**: Each main database entity typically has its own schema file (e.g., [user.ts](mdc:src/database/schemas/user.ts), [agent.ts](mdc:src/database/schemas/agent.ts))
- All schemas are re-exported from [src/database/schemas/index.ts](mdc:src/database/schemas/index.ts)
- **File Organization**: Each main database entity typically has its own schema file (e.g., `user.ts`, `agent.ts`)
- All schemas are re-exported from `src/database/schemas/index.ts`
- **ESLint**: Files often start with `/* eslint-disable sort-keys-fix/sort-keys-fix */`
- **Comments**: Use JSDoc-style comments to explain the purpose of tables and complex columns, fields that are self-explanatory do not require jsdoc explanations, such as id, user_id, etc.

View file

@ -1,6 +1,7 @@
---
alwaysApply: false
---
# 如何添加新的快捷键:开发者指南
本指南将带您一步步地向 LobeChat 添加一个新的快捷键功能。我们将通过一个完整示例,演示从定义到实现的整个过程。

View file

@ -37,6 +37,7 @@ export default {
- `sync.status.ready` - Feature + group + status
**Parameters:** Use `{{variableName}}` syntax
```typescript
'alert.cloud.desc': '我们提供 {{credit}} 额度积分',
```

View file

@ -1,6 +1,5 @@
---
description: Project directory structure overview
alwaysApply: false
alwaysApply: true
---
# LobeChat Project Structure
@ -27,6 +26,11 @@ lobe-chat/
│ ├── agent-runtime/
│ ├── builtin-agents/
│ ├── builtin-tool-*/ # builtin tool packages
│ ├── business/ # cloud-only business logic packages
│ │ ├── config/
│ │ ├── const/
│ │ └── model-runtime/
│ ├── config/
│ ├── const/
│ ├── context-engine/
│ ├── conversation-flow/
@ -36,6 +40,8 @@ lobe-chat/
│ │ ├── schemas/
│ │ └── repositories/
│ ├── desktop-bridge/
│ ├── edge-config/
│ ├── editor-runtime/
│ ├── electron-client-ipc/
│ ├── electron-server-ipc/
│ ├── fetch-sse/
@ -46,7 +52,7 @@ lobe-chat/
│ │ └── src/
│ │ ├── core/
│ │ └── providers/
│ ├── obervability-otel/
│ ├── observability-otel/
│ ├── prompts/
│ ├── python-interpreter/
│ ├── ssrf-safe-fetch/
@ -72,6 +78,10 @@ lobe-chat/
│ │ │ ├── onboarding/
│ │ │ └── router/
│ │ └── desktop/
│ ├── business/ # cloud-only business logic (client/server)
│ │ ├── client/
│ │ ├── locales/
│ │ └── server/
│ ├── components/
│ ├── config/
│ ├── const/
@ -130,6 +140,9 @@ lobe-chat/
- Repository (bff-queries): `packages/database/src/repositories`
- Third-party Integrations: `src/libs` — analytics, oidc etc.
- Builtin Tools: `src/tools`, `packages/builtin-tool-*`
- Business (cloud-only): Code specific to LobeHub cloud service, only expose empty interfaces for opens-source version.
- `src/business/*`
- `packages/business/*`
## Data Flow Architecture

View file

@ -107,7 +107,7 @@ This project uses a **hybrid routing architecture**: Next.js App Router for stat
| Router | oauth, reset-password, etc.) | src/app/[variants]/(auth)/ |
+------------------+--------------------------------+--------------------------------+
| React Router | Main SPA features | BrowserRouter + Routes |
| DOM | (chat, discover, settings) | desktopRouter.config.tsx |
| DOM | (chat, community, settings) | desktopRouter.config.tsx |
| | | mobileRouter.config.tsx |
+------------------+--------------------------------+--------------------------------+
```
@ -122,16 +122,16 @@ This project uses a **hybrid routing architecture**: Next.js App Router for stat
### Router Utilities
```tsx
import { dynamicElement, redirectElement, ErrorBoundary, RouteConfig } from '@/utils/router';
import { ErrorBoundary, RouteConfig, dynamicElement, redirectElement } from '@/utils/router';
// Lazy load a page component
element: dynamicElement(() => import('./chat'), 'Desktop > Chat')
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
// Create a redirect
element: redirectElement('/settings/profile')
element: redirectElement('/settings/profile');
// Error boundary for route
errorElement: <ErrorBoundary resetPath="/chat" />
errorElement: <ErrorBoundary resetPath="/chat" />;
```
### Adding New Routes
@ -142,6 +142,18 @@ errorElement: <ErrorBoundary resetPath="/chat" />
### Navigation
**Important**: For SPA pages (React Router DOM routes), use `Link` from `react-router-dom`, NOT from `next/link`.
```tsx
// ❌ Wrong - next/link in SPA pages
import Link from 'next/link';
<Link href="/">Home</Link>
// ✅ Correct - react-router-dom Link in SPA pages
import { Link } from 'react-router-dom';
<Link to="/">Home</Link>
```
```tsx
// In components - use react-router-dom hooks
import { useNavigate, useParams } from 'react-router-dom';

View file

@ -42,7 +42,7 @@ const Component = () => {
return (
<div>
{recentTopics.map(topic => (
{recentTopics.map((topic) => (
<div key={topic.id}>{topic.title}</div>
))}
</div>
@ -81,6 +81,7 @@ const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
```
**RecentTopic 类型:**
```typescript
interface RecentTopic {
agent: {

View file

@ -3,173 +3,199 @@ globs: *.test.ts,*.test.tsx
alwaysApply: false
---
# 测试指南 - LobeChat Testing Guide
# LobeChat Testing Guide
## 测试环境概览
## Test Overview
LobeChat 项目使用 Vitest 测试库,配置了两种不同的测试环境:
LobeChat testing consists of **E2E tests** and **Unit tests**. This guide focuses on **Unit tests**.
### 客户端数据库测试环境 (DOM Environment)
Unit tests are organized into three main categories:
- **配置文件**: [vitest.config.ts](mdc:vitest.config.ts)
- **环境**: Happy DOM (浏览器环境模拟)
- **数据库**: PGLite (浏览器环境的 PostgreSQL)
- **用途**: 测试前端组件、客户端逻辑、React 组件等
- **设置文件**: [tests/setup.ts](mdc:tests/setup.ts)
```plaintext
+---------------------+---------------------------+-----------------------------+
| Category | Location | Config File |
+---------------------+---------------------------+-----------------------------+
| Next.js Webapp | src/**/*.test.ts(x) | vitest.config.ts |
| Packages | packages/*/**/*.test.ts | packages/*/vitest.config.ts |
| Desktop App | apps/desktop/**/*.test.ts | apps/desktop/vitest.config.ts |
+---------------------+---------------------------+-----------------------------+
```
### 服务端数据库测试环境 (Node Environment)
### Next.js Webapp Tests
目前只有 `packages/database` 下的测试可以通过配置 `TEST_SERVER_DB=1` 环境变量来使用服务端数据库测试
- **Config File**: `vitest.config.ts`
- **Environment**: Happy DOM (browser environment simulation)
- **Database**: PGLite (PostgreSQL for browser environments)
- **Setup File**: `tests/setup.ts`
- **Purpose**: Testing React components, hooks, stores, utilities, and client-side logic
- **配置文件**: [packages/database/vitest.config.mts](mdc:packages/database/vitest.config.mts) 并且设置环境变量 `TEST_SERVER_DB=1`
- **环境**: Node.js
- **数据库**: 真实的 PostgreSQL 数据库
- **并发限制**: 单线程运行 (`singleFork: true`)
- **用途**: 测试数据库模型、服务端逻辑、API 端点等
- **设置文件**: [packages/database/tests/setup-db.ts](mdc:packages/database/tests/setup-db.ts)
### Packages Tests
## 测试运行命令
Most packages use standard Vitest configuration. However, the `database` package is special:
** 性能警告**: 项目包含 3000+ 测试用例,完整运行需要约 10 分钟。务必使用文件过滤或测试名称过滤。
#### Database Package (Special Case)
### 正确的命令格式
The database package supports **dual-environment testing**:
| Environment | Database | Config | Use Case |
|------------------|-----------------|---------------------------------------|-----------------------------------|
| Client (Default) | PGLite | `packages/database/vitest.config.mts` | Fast local development |
| Server | Real PostgreSQL | Set `TEST_SERVER_DB=1` | CI/CD, compatibility verification |
Server environment details:
- **Concurrency**: Single-threaded (`singleFork: true`)
- **Setup File**: `packages/database/tests/setup-db.ts`
- **Requirement**: `DATABASE_TEST_URL` environment variable must be set
### Desktop App Tests
- **Config File**: `apps/desktop/vitest.config.ts`
- **Environment**: Node.js
- **Purpose**: Testing Electron main process controllers, IPC handlers, and desktop-specific logic
## Test Commands
**Performance Warning**: The project contains 3000+ test cases. A full run takes approximately 10 minutes. Always use file filtering or test name filtering.
### Recommended Command Format
```bash
# 运行所有客户端/服务端测试
bunx vitest run --silent='passed-only' # 客户端测试
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' # 服务端测试
# Run all client/server tests
bunx vitest run --silent='passed-only' # Client tests
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' # Server tests
# 运行特定测试文件 (支持模糊匹配)
# Run specific test file (supports fuzzy matching)
bunx vitest run --silent='passed-only' user.test.ts
# 运行特定测试用例名称 (使用 -t 参数)
# Run specific test case by name (using -t flag)
bunx vitest run --silent='passed-only' -t "test case name"
# 组合使用文件和测试名称过滤
# Combine file and test name filtering
bunx vitest run --silent='passed-only' filename.test.ts -t "specific test"
# 生成覆盖率报告 (使用 --coverage 参数)
# Generate coverage report (using --coverage flag)
bunx vitest run --silent='passed-only' --coverage
```
### 避免的命令格式
### Commands to Avoid
```bash
# 这些命令会运行所有 3000+ 测试用例,耗时约 10 分钟!
# ❌ These commands run all 3000+ test cases, taking ~10 minutes!
npm test
npm test some-file.test.ts
# 不要使用裸 vitest (会进入 watch 模式)
# ❌ Don't use bare vitest (enters watch mode)
vitest test-file.test.ts
```
## 测试修复原则
## Test Fixing Principles
### 核心原则
### Core Principles
1. **收集足够的上下文**
在修复测试之前,务必做到:
- 完整理解测试的意图和实现
- 强烈建议阅读当前的 git diff 和 PR diff
1. **Gather Sufficient Context**
Before fixing tests, ensure you:
- Fully understand the test's intent and implementation
- Strongly recommended: review the current git diff and PR diff
2. **测试优先修复**
如果是测试本身写错了,应优先修改测试,而不是实现代码。
2. **Prioritize Test Fixes**
If the test itself is incorrect, fix the test first rather than the implementation code.
3. **专注单一问题**
只修复指定的测试,不要顺带添加额外测试。
3. **Focus on a Single Issue**
Only fix the specified test; don't add extra tests along the way.
4. **不自作主张**
发现其他问题时,不要直接修改,需先提出并讨论。
4. **Don't Act Unilaterally**
When discovering other issues, don't modify them directly—raise and discuss first.
### 测试协作最佳实践
### Testing Collaboration Best Practices
基于实际开发经验总结的重要协作原则:
Important collaboration principles based on real development experience:
#### 1. 失败处理策略
#### 1. Failure Handling Strategy
**核心原则**: 避免盲目重试,快速识别问题并寻求帮助。
**Core Principle**: Avoid blind retries; quickly identify problems and seek help.
- **失败阈值**: 当连续尝试修复测试 1-2 次都失败后,应立即停止继续尝试
- **问题总结**: 分析失败原因,整理已尝试的解决方案及其失败原因
- **寻求帮助**: 带着清晰的问题摘要和尝试记录向团队寻求帮助
- **避免陷阱**: 不要陷入"不断尝试相同或类似方法"的循环
- **Failure Threshold**: After 1-2 consecutive failed fix attempts, stop immediately
- **Problem Summary**: Analyze failure reasons and document attempted solutions with their failure causes
- **Seek Help**: Approach the team with a clear problem summary and attempt history
- **Avoid the Trap**: Don't fall into the loop of repeatedly trying the same or similar approaches
```typescript
// 错误做法:连续失败后继续盲目尝试
// 第3次、第4次仍在用相似的方法修复同一个问题
// ❌ Wrong approach: Keep blindly trying after consecutive failures
// 3rd, 4th attempts still using similar methods to fix the same problem
// 正确做法失败1-2次后总结问题
// ✅ Correct approach: Summarize after 1-2 failures
/*
问题总结:
1. 尝试过的方法:修改 mock 数据结构
2. 失败原因:仍然提示类型不匹配
3. 具体错误:Expected 'UserData' but received 'UserProfile'
4. 需要帮助:不确定最新的 UserData 接口定义
Problem Summary:
1. Attempted method: Modified mock data structure
2. Failure reason: Still getting type mismatch error
3. Specific error: Expected 'UserData' but received 'UserProfile'
4. Help needed: Unsure about the latest UserData interface definition
*/
```
#### 2. 测试用例命名规范
#### 2. Test Case Naming Conventions
**核心原则**: 测试应该关注"行为",而不是"实现细节"。
**Core Principle**: Tests should focus on "behavior," not "implementation details."
- **描述业务场景**: `describe` 和 `it` 的标题应该描述具体的业务场景和预期行为
- **避免实现绑定**: 不要在测试名称中提及具体的代码行号、覆盖率目标或实现细节
- **保持稳定性**: 测试名称应该在代码重构后仍然有意义
- **Describe Business Scenarios**: `describe` and `it` titles should describe specific business scenarios and expected behaviors
- **Avoid Implementation Binding**: Don't mention specific line numbers, coverage goals, or implementation details in test names
- **Maintain Stability**: Test names should remain meaningful after code refactoring
```typescript
// 错误的测试命名
// ❌ Poor test naming
describe('User component coverage', () => {
it('covers line 45-50 in getUserData', () => {
// 为了覆盖第45-50行而写的测试
// Test written just to cover lines 45-50
});
it('tests the else branch', () => {
// 仅为了测试某个分支而存在
// Exists only to test a specific branch
});
});
// 正确的测试命名
// ✅ Good test naming
describe('<UserAvatar />', () => {
it('should render fallback icon when image url is not provided', () => {
// 测试具体的业务场景,自然会覆盖相关代码分支
// Tests a specific business scenario, naturally covering relevant code branches
});
it('should display user initials when avatar image fails to load', () => {
// 描述用户行为和预期结果
// Describes user behavior and expected outcome
});
});
```
**覆盖率提升的正确思路**:
**The Right Approach to Improving Coverage**:
- 通过设计各种业务场景(正常流程、边缘情况、错误处理)来自然提升覆盖率
- 不要为了达到覆盖率数字而写测试,更不要在测试中注释"为了覆盖 xxx 行"
- Naturally improve coverage by designing various business scenarios (happy paths, edge cases, error handling)
- Don't write tests just to hit coverage numbers, and never comment "to cover line xxx" in tests
#### 3. 测试组织结构
#### 3. Test Organization Structure
**核心原则**: 维护清晰的测试层次结构,避免冗余的顶级测试块。
**Core Principle**: Maintain a clear test hierarchy; avoid redundant top-level test blocks.
- **复用现有结构**: 添加新测试时,优先在现有的 `describe` 块中寻找合适的位置
- **逻辑分组**: 相关的测试用例应该组织在同一个 `describe` 块内
- **避免碎片化**: 不要为了单个测试用例就创建新的顶级 `describe` 块
- **Reuse Existing Structure**: When adding new tests, first look for an appropriate place in existing `describe` blocks
- **Logical Grouping**: Related test cases should be organized within the same `describe` block
- **Avoid Fragmentation**: Don't create a new top-level `describe` block for a single test case
```typescript
// 错误的组织方式:创建过多顶级块
// ❌ Poor organization: Too many top-level blocks
describe('<UserProfile />', () => {
it('should render user name', () => {});
});
describe('UserProfile new prop test', () => {
// 不必要的新块
// Unnecessary new block
it('should handle email display', () => {});
});
describe('UserProfile edge cases', () => {
// 不必要的新块
// Unnecessary new block
it('should handle missing avatar', () => {});
});
// 正确的组织方式:合并相关测试
// ✅ Good organization: Merge related tests
describe('<UserProfile />', () => {
it('should render user name', () => {});
@ -178,78 +204,78 @@ describe('<UserProfile />', () => {
it('should handle missing avatar', () => {});
describe('when user data is incomplete', () => {
// 只有在有多个相关子场景时才创建子组
// Only create sub-groups when there are multiple related sub-scenarios
it('should show placeholder for missing name', () => {});
it('should hide email section when email is undefined', () => {});
});
});
```
**组织决策流程**:
**Organization Decision Flow**:
1. 是否存在逻辑相关的现有 `describe` 块? → 如果有,添加到其中
2. 是否有多个3个以上相关的测试用例 → 如果有,可以考虑创建新的子 `describe`
3. 是否是独立的、无关联的功能模块? → 如果是,才考虑创建新的顶级 `describe`
1. Is there a logically related existing `describe` block? → If yes, add to it
2. Are there multiple (3+) related test cases? → If yes, consider creating a new sub-`describe`
3. Is it an independent, unrelated feature module? → Only then consider creating a new top-level `describe`
### 测试修复流程
### Test Fixing Workflow
1. **复现问题**: 定位并运行失败的测试,确认能在本地复现
2. **分析原因**: 阅读测试代码、错误日志和相关文件的 Git 修改历史
3. **建立假设**: 判断问题出在测试逻辑、实现代码还是环境配置
4. **修复验证**: 根据假设进行修复,重新运行测试确认通过
5. **扩大验证**: 运行当前文件内所有测试,确保没有引入新问题
6. **撰写总结**: 说明错误原因和修复方法
1. **Reproduce the Issue**: Locate and run the failing test; confirm it can be reproduced locally
2. **Analyze the Cause**: Read test code, error logs, and Git history of related files
3. **Form a Hypothesis**: Determine if the problem is in test logic, implementation code, or environment configuration
4. **Fix and Verify**: Apply the fix based on your hypothesis; rerun the test to confirm it passes
5. **Expand Verification**: Run all tests in the current file to ensure no new issues were introduced
6. **Write a Summary**: Document the error cause and fix method
### 修复完成后的总结
### Post-Fix Summary
测试修复完成后,应该提供简要说明,包括:
After completing a test fix, provide a brief explanation including:
1. **错误原因分析**: 说明测试失败的根本原因
- 测试逻辑错误
- 实现代码bug
- 环境配置问题
- 依赖变更导致的问题
1. **Root Cause Analysis**: Explain the fundamental reason for the test failure
- Test logic error
- Implementation bug
- Environment configuration issue
- Dependency change
2. **修复方法说明**: 简述采用的修复方式
- 修改了哪些文件
- 采用了什么解决方案
- 为什么选择这种修复方式
2. **Fix Description**: Briefly describe the fix approach
- Which files were modified
- What solution was applied
- Why this fix approach was chosen
**示例格式**:
**Example Format**:
```markdown
## 测试修复总结
## Test Fix Summary
**错误原因**: 测试中的 mock 数据格式与实际 API 返回格式不匹配,导致断言失败。
**Root Cause**: The mock data format in the test didn't match the actual API response format, causing assertion failures.
**修复方法**: 更新了测试文件中的 mock 数据结构,使其与最新的 API 响应格式保持一致。具体修改了 `user.test.ts` 中的 `mockUserData` 对象结构。
**Fix**: Updated the mock data structure in the test file to match the latest API response format. Specifically modified the `mockUserData` object structure in `user.test.ts`.
```
## 测试编写最佳实践
## Test Writing Best Practices
### Mock 数据策略:追求"低成本的真实性"
### Mock Data Strategy: Aim for "Low-Cost Authenticity"
**核心原则**: 测试数据应默认追求真实性,只有在引入"高昂的测试成本"时才进行简化。
**Core Principle**: Test data should default to authenticity; only simplify when it introduces "high testing costs."
#### 什么是"高昂的测试成本"
#### What Are "High Testing Costs"?
"高成本"指的是测试中引入了外部依赖,使测试变慢、不稳定或复杂:
"High cost" refers to introducing external dependencies in tests that make them slow, unstable, or complex:
- **文件 I/O 操作**:读写硬盘文件
- **网络请求**HTTP 调用、数据库连接
- **系统调用**:获取系统时间、环境变量等
- **File I/O Operations**: Reading/writing disk files
- **Network Requests**: HTTP calls, database connections
- **System Calls**: Getting system time, environment variables, etc.
#### 推荐做法Mock 依赖,保留真实数据
#### Recommended Approach: Mock Dependencies, Keep Real Data
```typescript
// 好的做法Mock I/O 操作,但使用真实的文件内容格式
// ✅ Good approach: Mock I/O operations but use real file content formats
describe('parseContentType', () => {
beforeEach(() => {
// Mock 文件读取操作(避免真实 I/O
// Mock file read operation (avoid real I/O)
vi.spyOn(fs, 'readFileSync').mockImplementation((path) => {
// 但返回真实的文件内容格式
if (path.includes('.pdf')) return '%PDF-1.4\n%âãÏÓ'; // 真实 PDF 文件头
if (path.includes('.png')) return '\x89PNG\r\n\x1a\n'; // 真实 PNG 文件头
// But return real file content formats
if (path.includes('.pdf')) return '%PDF-1.4\n%âãÏÓ'; // Real PDF header
if (path.includes('.png')) return '\x89PNG\r\n\x1a\n'; // Real PNG header
return '';
});
});
@ -260,40 +286,38 @@ describe('parseContentType', () => {
});
});
// 过度简化:使用不真实的数据
// ❌ Over-simplified: Using unrealistic data
describe('parseContentType', () => {
it('should detect PDF content type correctly', () => {
// 这种简化数据没有测试价值
// This simplified data has no test value
const result = parseContentType('fake-pdf-content');
expect(result).toBe('application/pdf');
});
});
```
#### 真实标识符的价值
#### The Value of Real Identifiers
```typescript
// ✅ 使用真实标识符
// ✅ Use real identifiers
const result = parseModelString('openai', '+gpt-4,+gpt-3.5-turbo');
// ❌ 使用占位符(价值较低)
// ❌ Use placeholders (lower value)
const result = parseModelString('test-provider', '+model1,+model2');
```
### 现代化Mock技巧环境设置与Mock方法
### Modern Mocking Techniques: Environment Setup and Mock Methods
**环境设置 + Mock方法结合使用**
客户端代码测试时推荐使用环境注释配合现代化Mock方法
When testing client-side code, use environment annotations with modern mock methods:
```typescript
/**
* @vitest-environment happy-dom // 提供浏览器API
* @vitest-environment happy-dom // Provides browser APIs
*/
import { beforeEach, vi } from 'vitest';
beforeEach(() => {
// 现代方法1使用vi.stubGlobal替代global.xxx = ...
// Modern method 1: Use vi.stubGlobal instead of global.xxx = ...
const mockImage = vi.fn().mockImplementation(() => ({
addEventListener: vi.fn(),
naturalHeight: 600,
@ -301,72 +325,72 @@ beforeEach(() => {
}));
vi.stubGlobal('Image', mockImage);
// 现代方法2使用vi.spyOn保留原功能只mock特定方法
// Modern method 2: Use vi.spyOn to preserve original functionality, only mock specific methods
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
});
```
**环境选择优先级**
#### Environment Selection Priority
1. **@vitest-environment happy-dom** (推荐) - 轻量、快速,项目已安装
2. **@vitest-environment jsdom** - 功能完整但需要额外安装jsdom包
3. **不设置环境** - Node.js环境需要手动mock所有浏览器API
1. **@vitest-environment happy-dom** (Recommended) - Lightweight, fast, already installed in the project
2. **@vitest-environment jsdom** - Full-featured, but requires additional jsdom package installation
3. **No environment set** - Node.js environment, requires manually mocking all browser APIs
**Mock方法对比**
#### Mock Method Comparison
```typescript
// ❌ 旧方法直接操作global对象类型问题
// ❌ Old method: Directly manipulating global object (type issues)
global.Image = mockImage;
global.URL = { ...global.URL, createObjectURL: mockFn };
// ✅ 现代方法:类型安全的vi API
vi.stubGlobal('Image', mockImage); // 完全替换全局对象
vi.spyOn(URL, 'createObjectURL'); // 部分mock保留其他功能
// ✅ Modern method: Type-safe vi API
vi.stubGlobal('Image', mockImage); // Completely replace global object
vi.spyOn(URL, 'createObjectURL'); // Partial mock, preserve other functionality
```
### 测试覆盖率原则:代码分支优于用例数量
### Test Coverage Principles: Code Branches Over Test Quantity
**核心原则**: 优先覆盖所有代码分支,而非编写大量重复用例
**Core Principle**: Prioritize covering all code branches rather than writing many repetitive test cases.
```typescript
// ❌ 过度测试29个测试用例都验证相同分支
// ❌ Over-testing: 29 test cases all validating the same branch
describe('getImageDimensions', () => {
it('should reject .txt files');
it('should reject .pdf files');
// ... 25个类似测试,都走相同的验证分支
// ... 25 similar tests, all hitting the same validation branch
});
// ✅ 精简测试4个核心用例覆盖所有分支
// ✅ Lean testing: 4 core cases covering all branches
describe('getImageDimensions', () => {
it('should return dimensions for valid File object'); // 成功路径 - File
it('should return dimensions for valid data URI'); // 成功路径 - String
it('should return undefined for invalid inputs'); // 输入验证分支
it('should return undefined when image fails to load'); // 错误处理分支
it('should return dimensions for valid File object'); // Success path - File
it('should return dimensions for valid data URI'); // Success path - String
it('should return undefined for invalid inputs'); // Input validation branch
it('should return undefined when image fails to load'); // Error handling branch
});
```
**分支覆盖策略**
#### Branch Coverage Strategy
1. **成功路径** - 每种输入类型1个测试即可
2. **边界条件** - 合并类似场景到单个测试
3. **错误处理** - 测试代表性错误即可
4. **业务逻辑** - 覆盖所有if/else分支
1. **Success Paths** - One test per input type is sufficient
2. **Boundary Conditions** - Consolidate similar scenarios into a single test
3. **Error Handling** - Test representative errors only
4. **Business Logic** - Cover all if/else branches
**合理测试数量**
#### Reasonable Test Counts
- 简单工具函数2-5个测试
- 复杂业务逻辑5-10个测试
- 核心安全功能:适当增加,但避免重复路径
- Simple utility functions: 2-5 tests
- Complex business logic: 5-10 tests
- Core security features: Add more as needed, but avoid duplicate paths
### 错误处理测试:测试"行为"而非"文本"
### Error Handling Tests: Test "Behavior" Not "Text"
**核心原则**: 测试应该验证程序在错误发生时的行为是可预测的,而不是验证易变的错误信息文本。
**Core Principle**: Tests should verify that program behavior is predictable when errors occur, not verify error message text that may change.
#### 推荐的错误测试方式
#### Recommended Error Testing Approach
```typescript
// ✅ 测试错误类型和属性
// ✅ Test error types and properties
expect(() => validateUser({})).toThrow(ValidationError);
expect(() => processPayment({})).toThrow(
expect.objectContaining({
@ -375,136 +399,136 @@ expect(() => processPayment({})).toThrow(
}),
);
// ❌ 避免测试具体错误文本
expect(() => processUser({})).toThrow('用户数据不能为空,请检查输入参数');
// ❌ Avoid testing specific error text
expect(() => processUser({})).toThrow('User data cannot be empty, please check input parameters');
```
### 疑难解答:警惕模块污染
### Troubleshooting: Beware of Module Pollution
**识别信号**: 当你的测试出现以下"灵异"现象时,优先怀疑模块污染:
**Warning Signs**: When your tests exhibit these "mysterious" behaviors, suspect module pollution first:
- 单独运行某个测试通过,但和其他测试一起运行就失败
- 测试的执行顺序影响结果
- Mock 设置看起来正确,但实际使用的是旧的 Mock 版本
- A test passes when run alone but fails when run with other tests
- Test execution order affects results
- Mock setup appears correct but actually uses an old mock version
#### 典型场景:动态 Mock 同一模块
#### Typical Scenario: Dynamic Mocking of the Same Module
```typescript
// ❌ 问题动态Mock同一模块
// ❌ Problem: Dynamic mocking of the same module
it('dev mode', async () => {
vi.doMock('./config', () => ({ isDev: true }));
const { getSettings } = await import('./service'); // 可能使用缓存
const { getSettings } = await import('./service'); // May use cache
});
// ✅ 解决:清除模块缓存
// ✅ Solution: Clear module cache
beforeEach(() => {
vi.resetModules(); // 确保每个测试都是干净环境
vi.resetModules(); // Ensure each test has a clean environment
});
```
**记住**: `vi.resetModules()` 是解决测试"灵异"失败的终极武器。
**Remember**: `vi.resetModules()` is the ultimate weapon for resolving "mysterious" test failures.
## 测试文件组织
## Test File Organization
### 文件命名约定
### File Naming Convention
`*.test.ts`, `*.test.tsx` (任意位置)
`*.test.ts`, `*.test.tsx` (any location)
### 测试文件组织风格
### Test File Organization Style
项目采用 **测试文件与源文件同目录** 的组织风格:
The project uses a **co-located test files** organization style:
- 测试文件放在对应源文件的同一目录下
- 命名格式:`原文件名.test.ts` 或 `原文件名.test.tsx`
- Test files are placed in the same directory as the corresponding source files
- Naming format: `originalFileName.test.ts` or `originalFileName.test.tsx`
例如:
Example:
```plaintext
src/components/Button/
├── index.tsx # 源文件
└── index.test.tsx # 测试文件
├── index.tsx # Source file
└── index.test.tsx # Test file
```
- 也有少数情况会统一放到 `__tests__` 文件夹, 例如 `packages/database/src/models/__tests__`
- 测试使用的辅助文件放到 fixtures 文件夹
- In some cases, tests are consolidated in a `__tests__` folder, e.g., `packages/database/src/models/__tests__`
- Test helper files are placed in a fixtures folder
## 测试调试技巧
## Test Debugging Tips
### 测试调试步骤
### Test Debugging Steps
1. **确定测试环境**: 根据文件路径选择正确的配置文件
2. **隔离问题**: 使用 `-t` 参数只运行失败的测试用例
3. **分析错误**: 仔细阅读错误信息、堆栈跟踪和最近的文件修改记录
4. **添加调试**: 在测试中添加 `console.log` 了解执行流程
1. **Determine Test Environment**: Select the correct config file based on file path
2. **Isolate the Problem**: Use the `-t` flag to run only the failing test case
3. **Analyze the Error**: Carefully read error messages, stack traces, and recent file modification history
4. **Add Debugging**: Add `console.log` statements in tests to understand execution flow
### TypeScript 类型处理
### TypeScript Type Handling
在测试中,为了提高编写效率和可读性,可以适当放宽 TypeScript 类型检测:
In tests, you can relax TypeScript type checking to improve writing efficiency and readability:
#### 推荐的类型放宽策略
#### Recommended Type Relaxation Strategies
```typescript
// 使用非空断言访问测试中确定存在的属性
// Use non-null assertion to access properties you're certain exist in tests
const result = await someFunction();
expect(result!.data).toBeDefined();
expect(result!.status).toBe('success');
// 使用 any 类型简化复杂的 Mock 设置
// Use any type to simplify complex mock setups
const mockStream = new ReadableStream() as any;
mockStream.toReadableStream = () => mockStream;
// 访问私有成员
await instance['getFromCache']('key'); // 推荐中括号
await (instance as any).getFromCache('key'); // 避免as any
// Access private members
await instance['getFromCache']('key'); // Bracket notation recommended
await (instance as any).getFromCache('key'); // Avoid as any
```
#### 适用场景
#### Applicable Scenarios
- **Mock 对象**: 对于测试用的 Mock 数据,使用 `as any` 避免复杂的类型定义
- **第三方库**: 处理复杂的第三方库类型时,适当使用 `any` 提高效率
- **测试断言**: 在确定对象存在的测试场景中,使用 `!` 非空断言
- **私有成员访问**: 优先使用中括号 `instance['privateMethod']()` 而不是 `(instance as any).privateMethod()`
- **临时调试**: 快速编写测试时,先用 `any` 保证功能,后续可选择性地优化类型
- **Mock Objects**: Use `as any` for test mock data to avoid complex type definitions
- **Third-Party Libraries**: Use `any` appropriately when handling complex third-party library types
- **Test Assertions**: Use `!` non-null assertion in test scenarios where you're certain the object exists
- **Private Member Access**: Prefer bracket notation `instance['privateMethod']()` over `(instance as any).privateMethod()`
- **Temporary Debugging**: When quickly writing tests, use `any` first to ensure functionality, then optionally optimize types later
#### 注意事项
#### Important Notes
- **适度使用**: 不要过度依赖 `any`,核心业务逻辑的类型仍应保持严格
- **私有成员访问优先级**: 中括号访问 > `as any` 转换,保持更好的类型安全性
- **文档说明**: 对于使用 `any` 的复杂场景,添加注释说明原因
- **测试覆盖**: 确保即使使用了 `any`,测试仍能有效验证功能正确性
- **Use Moderately**: Don't over-rely on `any`; core business logic types should remain strict
- **Private Member Access Priority**: Bracket notation > `as any` casting for better type safety
- **Documentation**: Add comments explaining the reason for complex `any` usage scenarios
- **Test Coverage**: Ensure tests still effectively verify correctness even when using `any`
### 检查最近修改记录
### Checking Recent Modifications
**核心原则**:测试突然失败时,优先检查最近的代码修改。
**Core Principle**: When tests suddenly fail, first check recent code changes.
#### 快速检查方法
#### Quick Check Methods
```bash
git status # 查看当前修改状态
git diff HEAD -- '*.test.*' # 检查测试文件改动
git diff main...HEAD # 对比主分支差异
gh pr diff # 查看PR中的所有改动
git status # View current modification status
git diff HEAD -- '*.test.*' # Check test file changes
git diff main...HEAD # Compare with main branch
gh pr diff # View all changes in the PR
```
#### 常见原因与解决
#### Common Causes and Solutions
- **最新提交引入bug** → 检查并修复实现代码
- **分支代码滞后** → `git rebase main` 同步主分支
- **Latest commit introduced a bug** → Check and fix the implementation code
- **Branch code is outdated** → `git rebase main` to sync with main branch
## 特殊场景的测试
## Special Testing Scenarios
针对一些特殊场景的测试,需要阅读相关 rules
For special testing scenarios, refer to the related rules:
- [Electron IPC 接口测试策略](mdc:./electron-ipc-test.mdc)
- [数据库 Model 测试指南](mdc:./db-model-test.mdc)
- `electron-ipc-test.mdc` - Electron IPC Interface Testing Strategy
- `db-model-test.mdc` - Database Model Testing Guide
## 核心要点
## Key Takeaways
- **命令格式**: 使用 `bunx vitest run --silent='passed-only'` 并指定文件过滤
- **修复原则**: 失败1-2次后寻求帮助测试命名关注行为而非实现细节
- **调试流程**: 复现 → 分析 → 假设 → 修复 → 验证 → 总结
- **文件组织**: 优先在现有 `describe` 块中添加测试,避免创建冗余顶级块
- **数据策略**: 默认追求真实性只有高成本I/O、网络等时才简化
- **错误测试**: 测试错误类型和行为,避免依赖具体的错误信息文本
- **模块污染**: 测试"灵异"失败时,优先怀疑模块污染,使用 `vi.resetModules()` 解决
- **安全要求**: Model 测试必须包含权限检查,并在双环境下验证通过
- **Command Format**: Use `bunx vitest run --silent='passed-only'` with file filtering
- **Fix Principles**: Seek help after 1-2 failures; focus test naming on behavior, not implementation details
- **Debug Workflow**: Reproduce → Analyze → Hypothesize → Fix → Verify → Summarize
- **File Organization**: Prefer adding tests to existing `describe` blocks; avoid creating redundant top-level blocks
- **Data Strategy**: Default to authenticity; only simplify for high-cost scenarios (I/O, network, etc.)
- **Error Testing**: Test error types and behavior; avoid depending on specific error message text
- **Module Pollution**: When tests fail "mysteriously," suspect module pollution first; use `vi.resetModules()` to resolve
- **Security Requirements**: Model tests must include permission checks and pass in both environments

View file

@ -1,6 +1,6 @@
---
description: Best practices for testing Zustand store actions
globs: 'src/store/**/*.test.ts'
globs: src/store/**/*.test.ts
alwaysApply: false
---

View file

@ -16,7 +16,7 @@ Main interfaces exposed for UI component consumption:
- Naming: Verb form (`createTopic`, `sendMessage`, `updateTopicTitle`)
- Responsibilities: Parameter validation, flow orchestration, calling internal actions
- Example: [src/store/chat/slices/topic/action.ts](mdc:src/store/chat/slices/topic/action.ts)
- Example: `src/store/chat/slices/topic/action.ts`
```typescript
// Public Action example

View file

@ -105,7 +105,7 @@ export const initialTopicState: ChatTopicState = {
};
```
2. `reducer.ts` (复杂状态使用):
1. `reducer.ts` (复杂状态使用):
- 定义纯函数 reducer处理同步状态转换
- 使用 `immer` 确保不可变更新
@ -151,7 +151,7 @@ export const topicReducer = (state: ChatTopic[] = [], payload: ChatTopicDispatch
};
```
3. `selectors.ts`:
1. `selectors.ts`:
- 提供状态查询和计算函数
- 供 UI 组件使用的状态订阅接口
- 重要: 使用 `export const xxxSelectors` 模式聚合所有 selectors
@ -186,7 +186,7 @@ export const topicSelectors = {
当 slice 的 actions 过于复杂时,可以拆分到子目录:
```
```plaintext
src/store/chat/slices/aiChat/
├── actions/
│ ├── generateAIChat.ts # AI 对话生成
@ -204,7 +204,7 @@ src/store/chat/slices/aiChat/
管理多种内置工具的状态:
```
```plaintext
src/store/chat/slices/builtinTool/
├── actions/
│ ├── dalle.ts # DALL-E 图像生成

View file

@ -34,7 +34,7 @@ see @.cursor/rules/typescript.mdc
### Testing
- **Required Rule**: read `@.cursor/rules/testing-guide/testing-guide.mdc` before writing tests
- **Required Rule**: read `.cursor/rules/testing-guide/testing-guide.mdc` before writing tests
- **Command**:
- web: `bunx vitest run --silent='passed-only' '[file-path-pattern]'`
- packages(eg: database): `cd packages/database && bunx vitest run --silent='passed-only' '[file-path-pattern]'`

View file

@ -34,7 +34,7 @@ see @.cursor/rules/typescript.mdc
### Testing
- **Required Rule**: read `@.cursor/rules/testing-guide/testing-guide.mdc` before writing tests
- **Required Rule**: read `.cursor/rules/testing-guide/testing-guide.mdc` before writing tests
- **Command**:
- web: `bunx vitest run --silent='passed-only' '[file-path-pattern]'`
- packages(eg: database): `cd packages/database && bunx vitest run --silent='passed-only' '[file-path-pattern]'`

View file

@ -1093,6 +1093,22 @@ table topic_documents {
}
}
table topic_shares {
id text [pk, not null]
topic_id text [not null]
user_id text [not null]
visibility text [not null, default: 'private']
page_view_count integer [not null, default: 0]
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
topic_id [name: 'topic_shares_topic_id_unique', unique]
user_id [name: 'topic_shares_user_id_idx']
}
}
table topics {
id text [pk, not null]
title text

View file

@ -253,6 +253,8 @@
"sessionGroup.sorting": "Group sorting updating...",
"sessionGroup.tooLong": "Group name length should be between 1-20",
"shareModal.copy": "Copy",
"shareModal.copyLink": "Copy Link",
"shareModal.copyLinkSuccess": "Link copied",
"shareModal.download": "Download Screenshot",
"shareModal.downloadError": "Download failed",
"shareModal.downloadFile": "Download File",
@ -268,12 +270,26 @@
"shareModal.imageType": "Image Format",
"shareModal.includeTool": "Include Skill messages",
"shareModal.includeUser": "Include User Messages",
"shareModal.link": "Link",
"shareModal.link.linkHint": "Anyone with the link can view this topic",
"shareModal.link.noTopic": "Start a conversation first to share",
"shareModal.link.permissionLink": "Anyone with the link",
"shareModal.link.permissionPrivate": "Private",
"shareModal.link.privateHint": "Only you can access this link",
"shareModal.link.updateError": "Failed to update sharing settings",
"shareModal.link.visibilityUpdated": "Visibility updated",
"shareModal.loadingPdf": "Loading PDF...",
"shareModal.noPdfData": "No PDF data available",
"shareModal.pdf": "PDF",
"shareModal.pdfErrorDescription": "An error occurred while generating the PDF, please try again",
"shareModal.pdfGenerationError": "PDF generation failed",
"shareModal.pdfReady": "PDF is ready",
"shareModal.popover.moreOptions": "More share options",
"shareModal.popover.privacyWarning.confirm": "I understand, continue",
"shareModal.popover.privacyWarning.content": "Please ensure the conversation does not contain any private or sensitive information before sharing. LobeHub is not responsible for any security issues that may arise from sharing.",
"shareModal.popover.privacyWarning.title": "Privacy Notice",
"shareModal.popover.title": "Share Topic",
"shareModal.popover.visibility": "Visibility",
"shareModal.regeneratePdf": "Regenerate PDF",
"shareModal.screenshot": "Screenshot",
"shareModal.settings": "Export Settings",
@ -286,6 +302,14 @@
"shareModal.withPluginInfo": "Include Skill Information",
"shareModal.withRole": "Include Message Role",
"shareModal.withSystemRole": "Include Agent Profile",
"sharePage.error.forbidden.subtitle": "This share is private and not accessible.",
"sharePage.error.forbidden.title": "Access Denied",
"sharePage.error.notFound.subtitle": "This topic does not exist or has been removed.",
"sharePage.error.notFound.title": "Topic Not Found",
"sharePage.error.unauthorized.action": "Sign In",
"sharePage.error.unauthorized.subtitle": "Please sign in to view this shared topic.",
"sharePage.error.unauthorized.title": "Sign In Required",
"sharePageDisclaimer": "This content is shared by a user and does not represent the views of LobeHub. LobeHub is not responsible for any consequences arising from this shared content.",
"stt.action": "Voice Input",
"stt.loading": "Recognizing...",
"stt.prettifying": "Polishing...",

View file

@ -253,6 +253,8 @@
"sessionGroup.sorting": "正在更新排序…",
"sessionGroup.tooLong": "分组名称长度需为 120 个字符",
"shareModal.copy": "复制",
"shareModal.copyLink": "复制链接",
"shareModal.copyLinkSuccess": "链接已复制",
"shareModal.download": "下载截图",
"shareModal.downloadError": "下载失败,请检查网络后重试",
"shareModal.downloadFile": "下载文件",
@ -268,12 +270,26 @@
"shareModal.imageType": "图片格式",
"shareModal.includeTool": "包含技能消息",
"shareModal.includeUser": "包含用户消息",
"shareModal.link": "链接",
"shareModal.link.linkHint": "任何拿到链接的人都可以查看此话题",
"shareModal.link.noTopic": "先开始对话,才能分享",
"shareModal.link.permissionLink": "有链接即可访问",
"shareModal.link.permissionPrivate": "私密",
"shareModal.link.privateHint": "仅你自己可以访问此链接",
"shareModal.link.updateError": "更新分享设置失败",
"shareModal.link.visibilityUpdated": "可见性已更新",
"shareModal.loadingPdf": "正在加载 PDF…",
"shareModal.noPdfData": "暂无 PDF 数据",
"shareModal.pdf": "PDF",
"shareModal.pdfErrorDescription": "生成 PDF 时出错,请重试或联系支持",
"shareModal.pdfGenerationError": "PDF 生成失败",
"shareModal.pdfReady": "PDF 已准备就绪",
"shareModal.popover.moreOptions": "更多分享方式",
"shareModal.popover.privacyWarning.confirm": "我已了解,继续",
"shareModal.popover.privacyWarning.content": "分享前请确保对话中不包含任何隐私或敏感信息。因分享而可能产生的任何安全问题LobeHub 概不负责。",
"shareModal.popover.privacyWarning.title": "隐私提醒",
"shareModal.popover.title": "分享话题",
"shareModal.popover.visibility": "可见性",
"shareModal.regeneratePdf": "重新生成 PDF",
"shareModal.screenshot": "截图",
"shareModal.settings": "导出设置",
@ -286,6 +302,14 @@
"shareModal.withPluginInfo": "包含技能信息",
"shareModal.withRole": "包含消息角色",
"shareModal.withSystemRole": "包含助理档案",
"sharePage.error.forbidden.subtitle": "此分享为私密状态,无法访问。",
"sharePage.error.forbidden.title": "无权访问",
"sharePage.error.notFound.subtitle": "该话题不存在或已被删除。",
"sharePage.error.notFound.title": "话题不存在",
"sharePage.error.unauthorized.action": "登录",
"sharePage.error.unauthorized.subtitle": "请登录后查看此分享话题。",
"sharePage.error.unauthorized.title": "需要登录",
"sharePageDisclaimer": "此内容由用户分享,不代表 LobeHub 观点。LobeHub 不对该分享内容产生的任何后果承担责任。",
"stt.action": "语音输入",
"stt.loading": "识别中…",
"stt.prettifying": "润色中…",

View file

@ -4,3 +4,6 @@ export * from './llm';
export * from './url';
export const ENABLE_BUSINESS_FEATURES = false;
export const ENABLE_TOPIC_LINK_SHARE =
ENABLE_BUSINESS_FEATURES ||
(process.env.NODE_ENV === 'development' && !!process.env.NEXT_PUBLIC_ENABLE_TOPIC_LINK_SHARE);

View file

@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "topic_shares" (
"id" text PRIMARY KEY NOT NULL,
"topic_id" text NOT NULL,
"user_id" text NOT NULL,
"visibility" text DEFAULT 'private' NOT NULL,
"page_view_count" integer DEFAULT 0 NOT NULL,
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "topic_shares" ADD CONSTRAINT "topic_shares_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "topic_shares" ADD CONSTRAINT "topic_shares_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "topic_shares_topic_id_unique" ON "topic_shares" USING btree ("topic_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "topic_shares_user_id_idx" ON "topic_shares" USING btree ("user_id");

File diff suppressed because it is too large Load diff

View file

@ -483,6 +483,13 @@
"when": 1768189437504,
"tag": "0068_update_group_data",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1768303764632,
"tag": "0069_add_topic_shares_table",
"breakpoints": true
}
],
"version": "6"

View file

@ -0,0 +1,318 @@
// @vitest-environment node
import { TRPCError } from '@trpc/server';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { agents, sessions, topicShares, topics, users } from '../../schemas';
import { LobeChatDatabase } from '../../type';
import { TopicShareModel } from '../topicShare';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'topic-share-test-user-id';
const userId2 = 'topic-share-test-user-id-2';
const sessionId = 'topic-share-test-session';
const topicId = 'topic-share-test-topic';
const topicId2 = 'topic-share-test-topic-2';
const agentId = 'topic-share-test-agent';
const topicShareModel = new TopicShareModel(serverDB, userId);
const topicShareModel2 = new TopicShareModel(serverDB, userId2);
describe('TopicShareModel', () => {
beforeEach(async () => {
await serverDB.delete(users);
// Create test users, sessions, agents and topics
await serverDB.transaction(async (tx) => {
await tx.insert(users).values([{ id: userId }, { id: userId2 }]);
await tx.insert(sessions).values([
{ id: sessionId, userId },
{ id: `${sessionId}-2`, userId: userId2 },
]);
await tx.insert(agents).values([{ id: agentId, userId }]);
await tx.insert(topics).values([
{ id: topicId, sessionId, userId, agentId, title: 'Test Topic' },
{ id: topicId2, sessionId, userId, title: 'Test Topic 2' },
{ id: 'user2-topic', sessionId: `${sessionId}-2`, userId: userId2, title: 'User 2 Topic' },
]);
});
});
afterEach(async () => {
await serverDB.delete(topicShares);
await serverDB.delete(topics);
await serverDB.delete(agents);
await serverDB.delete(sessions);
await serverDB.delete(users);
});
describe('create', () => {
it('should create a share for a topic with default visibility', async () => {
const result = await topicShareModel.create(topicId);
expect(result).toBeDefined();
expect(result.topicId).toBe(topicId);
expect(result.userId).toBe(userId);
expect(result.visibility).toBe('private');
expect(result.id).toBeDefined();
});
it('should create a share with link visibility', async () => {
const result = await topicShareModel.create(topicId, 'link');
expect(result.visibility).toBe('link');
});
it('should throw error when topic does not exist', async () => {
await expect(topicShareModel.create('non-existent-topic')).rejects.toThrow(
'Topic not found or not owned by user',
);
});
it('should throw error when trying to share another users topic', async () => {
await expect(topicShareModel.create('user2-topic')).rejects.toThrow(
'Topic not found or not owned by user',
);
});
});
describe('updateVisibility', () => {
it('should update share visibility', async () => {
await topicShareModel.create(topicId, 'private');
const result = await topicShareModel.updateVisibility(topicId, 'link');
expect(result).toBeDefined();
expect(result!.visibility).toBe('link');
});
it('should return null when share does not exist', async () => {
const result = await topicShareModel.updateVisibility('non-existent-topic', 'link');
expect(result).toBeNull();
});
it('should not update other users share', async () => {
// Create share for user2
await topicShareModel2.create('user2-topic', 'private');
// User1 tries to update user2's share
const result = await topicShareModel.updateVisibility('user2-topic', 'link');
expect(result).toBeNull();
// Verify user2's share is unchanged
const share = await topicShareModel2.getByTopicId('user2-topic');
expect(share!.visibility).toBe('private');
});
});
describe('deleteByTopicId', () => {
it('should delete share by topic id', async () => {
await topicShareModel.create(topicId);
await topicShareModel.deleteByTopicId(topicId);
const share = await topicShareModel.getByTopicId(topicId);
expect(share).toBeNull();
});
it('should not delete other users share', async () => {
await topicShareModel2.create('user2-topic');
// User1 tries to delete user2's share
await topicShareModel.deleteByTopicId('user2-topic');
// User2's share should still exist
const share = await topicShareModel2.getByTopicId('user2-topic');
expect(share).not.toBeNull();
});
});
describe('getByTopicId', () => {
it('should get share info by topic id', async () => {
const created = await topicShareModel.create(topicId, 'link');
const result = await topicShareModel.getByTopicId(topicId);
expect(result).toBeDefined();
expect(result!.id).toBe(created.id);
expect(result!.topicId).toBe(topicId);
expect(result!.visibility).toBe('link');
});
it('should return null when share does not exist', async () => {
const result = await topicShareModel.getByTopicId(topicId);
expect(result).toBeNull();
});
it('should not return other users share', async () => {
await topicShareModel2.create('user2-topic');
const result = await topicShareModel.getByTopicId('user2-topic');
expect(result).toBeNull();
});
});
describe('findByShareId (static)', () => {
it('should find share by share id with topic and agent info', async () => {
const created = await topicShareModel.create(topicId, 'link');
const result = await TopicShareModel.findByShareId(serverDB, created.id);
expect(result).toBeDefined();
expect(result!.shareId).toBe(created.id);
expect(result!.topicId).toBe(topicId);
expect(result!.title).toBe('Test Topic');
expect(result!.ownerId).toBe(userId);
expect(result!.visibility).toBe('link');
expect(result!.agentId).toBe(agentId);
});
it('should return null when share does not exist', async () => {
const result = await TopicShareModel.findByShareId(serverDB, 'non-existent-share');
expect(result).toBeNull();
});
it('should return share without agent info when topic has no agent', async () => {
const created = await topicShareModel.create(topicId2);
const result = await TopicShareModel.findByShareId(serverDB, created.id);
expect(result).toBeDefined();
expect(result!.agentId).toBeNull();
});
});
describe('incrementPageViewCount (static)', () => {
it('should increment page view count', async () => {
const created = await topicShareModel.create(topicId);
// Initial page view count is 0
const initial = await serverDB.query.topicShares.findFirst({
where: (t, { eq }) => eq(t.id, created.id),
});
expect(initial!.pageViewCount).toBe(0);
// Increment page view count
await TopicShareModel.incrementPageViewCount(serverDB, created.id);
const after = await serverDB.query.topicShares.findFirst({
where: (t, { eq }) => eq(t.id, created.id),
});
expect(after!.pageViewCount).toBe(1);
});
it('should increment page view count multiple times', async () => {
const created = await topicShareModel.create(topicId);
await TopicShareModel.incrementPageViewCount(serverDB, created.id);
await TopicShareModel.incrementPageViewCount(serverDB, created.id);
await TopicShareModel.incrementPageViewCount(serverDB, created.id);
const result = await serverDB.query.topicShares.findFirst({
where: (t, { eq }) => eq(t.id, created.id),
});
expect(result!.pageViewCount).toBe(3);
});
});
describe('findByShareIdWithAccessCheck (static)', () => {
it('should return share for owner regardless of visibility', async () => {
const created = await topicShareModel.create(topicId, 'private');
const result = await TopicShareModel.findByShareIdWithAccessCheck(
serverDB,
created.id,
userId,
);
expect(result).toBeDefined();
expect(result.shareId).toBe(created.id);
});
it('should return share for anonymous user when visibility is link', async () => {
const created = await topicShareModel.create(topicId, 'link');
const result = await TopicShareModel.findByShareIdWithAccessCheck(
serverDB,
created.id,
undefined,
);
expect(result).toBeDefined();
expect(result.shareId).toBe(created.id);
});
it('should throw NOT_FOUND when share does not exist', async () => {
await expect(
TopicShareModel.findByShareIdWithAccessCheck(serverDB, 'non-existent', userId),
).rejects.toThrow(TRPCError);
try {
await TopicShareModel.findByShareIdWithAccessCheck(serverDB, 'non-existent', userId);
} catch (error) {
expect((error as TRPCError).code).toBe('NOT_FOUND');
}
});
it('should throw FORBIDDEN when visibility is private and user is not owner', async () => {
const created = await topicShareModel.create(topicId, 'private');
await expect(
TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, userId2),
).rejects.toThrow(TRPCError);
try {
await TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, userId2);
} catch (error) {
expect((error as TRPCError).code).toBe('FORBIDDEN');
}
});
it('should throw FORBIDDEN when visibility is private and user is anonymous', async () => {
const created = await topicShareModel.create(topicId, 'private');
await expect(
TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, undefined),
).rejects.toThrow(TRPCError);
try {
await TopicShareModel.findByShareIdWithAccessCheck(serverDB, created.id, undefined);
} catch (error) {
expect((error as TRPCError).code).toBe('FORBIDDEN');
}
});
});
describe('user isolation', () => {
it('should enforce user data isolation for all operations', async () => {
// User1 creates a share
await topicShareModel.create(topicId, 'private');
// User2 creates a share
await topicShareModel2.create('user2-topic', 'link');
// User1 cannot access user2's share via getByTopicId
const user1Access = await topicShareModel.getByTopicId('user2-topic');
expect(user1Access).toBeNull();
// User2 cannot access user1's share via getByTopicId
const user2Access = await topicShareModel2.getByTopicId(topicId);
expect(user2Access).toBeNull();
// User1 cannot update user2's share
const updateResult = await topicShareModel.updateVisibility('user2-topic', 'private');
expect(updateResult).toBeNull();
// User1 cannot delete user2's share
await topicShareModel.deleteByTopicId('user2-topic');
const stillExists = await topicShareModel2.getByTopicId('user2-topic');
expect(stillExists).not.toBeNull();
});
});
});

View file

@ -0,0 +1,177 @@
import type { ShareVisibility } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { and, asc, eq, sql } from 'drizzle-orm';
import { agents, chatGroups, chatGroupsAgents, topicShares, topics } from '../schemas';
import { LobeChatDatabase } from '../type';
export type TopicShareData = NonNullable<
Awaited<ReturnType<(typeof TopicShareModel)['findByShareId']>>
>;
export class TopicShareModel {
private userId: string;
private db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.userId = userId;
this.db = db;
}
/**
* Create a new share for a topic.
* Each topic can only have one share record (enforced by unique constraint).
*/
create = async (topicId: string, visibility: ShareVisibility = 'private') => {
// First verify the topic belongs to the user
const topic = await this.db.query.topics.findFirst({
where: and(eq(topics.id, topicId), eq(topics.userId, this.userId)),
});
if (!topic) {
throw new Error('Topic not found or not owned by user');
}
const [result] = await this.db
.insert(topicShares)
.values({
topicId,
userId: this.userId,
visibility,
})
.returning();
return result;
};
/**
* Update share visibility
*/
updateVisibility = async (topicId: string, visibility: ShareVisibility) => {
const [result] = await this.db
.update(topicShares)
.set({ updatedAt: new Date(), visibility })
.where(and(eq(topicShares.topicId, topicId), eq(topicShares.userId, this.userId)))
.returning();
return result || null;
};
/**
* Delete a share by topic ID
*/
deleteByTopicId = async (topicId: string) => {
return this.db
.delete(topicShares)
.where(and(eq(topicShares.topicId, topicId), eq(topicShares.userId, this.userId)));
};
/**
* Get share info by topic ID (for the owner)
*/
getByTopicId = async (topicId: string) => {
const result = await this.db
.select({
id: topicShares.id,
topicId: topicShares.topicId,
visibility: topicShares.visibility,
})
.from(topicShares)
.where(and(eq(topicShares.topicId, topicId), eq(topicShares.userId, this.userId)))
.limit(1);
return result[0] || null;
};
/**
* Find shared topic by share ID.
* Returns share info including ownerId for permission checking by caller.
*/
static findByShareId = async (db: LobeChatDatabase, shareId: string) => {
const result = await db
.select({
agentAvatar: agents.avatar,
agentBackgroundColor: agents.backgroundColor,
agentId: topics.agentId,
agentMarketIdentifier: agents.marketIdentifier,
agentSlug: agents.slug,
agentTitle: agents.title,
groupAvatar: chatGroups.avatar,
groupBackgroundColor: chatGroups.backgroundColor,
groupId: topics.groupId,
groupTitle: chatGroups.title,
ownerId: topicShares.userId,
shareId: topicShares.id,
title: topics.title,
topicId: topics.id,
visibility: topicShares.visibility,
})
.from(topicShares)
.innerJoin(topics, eq(topicShares.topicId, topics.id))
.leftJoin(agents, eq(topics.agentId, agents.id))
.leftJoin(chatGroups, eq(topics.groupId, chatGroups.id))
.where(eq(topicShares.id, shareId))
.limit(1);
if (!result[0]) return null;
const share = result[0];
// Fetch group members if this is a group topic
let groupMembers: { avatar: string | null; backgroundColor: string | null }[] | undefined;
if (share.groupId) {
const members = await db
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
})
.from(chatGroupsAgents)
.innerJoin(agents, eq(chatGroupsAgents.agentId, agents.id))
.where(eq(chatGroupsAgents.chatGroupId, share.groupId))
.orderBy(asc(chatGroupsAgents.order))
.limit(4);
groupMembers = members;
}
return { ...share, groupMembers };
};
/**
* Increment page view count for a share.
* Should be called after permission check passes.
*/
static incrementPageViewCount = async (db: LobeChatDatabase, shareId: string) => {
await db
.update(topicShares)
.set({ pageViewCount: sql`${topicShares.pageViewCount} + 1` })
.where(eq(topicShares.id, shareId));
};
/**
* Find shared topic by share ID with visibility check.
* Throws TRPCError if access is denied.
*/
static findByShareIdWithAccessCheck = async (
db: LobeChatDatabase,
shareId: string,
accessUserId?: string,
): Promise<TopicShareData> => {
const share = await TopicShareModel.findByShareId(db, shareId);
if (!share) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Share not found' });
}
const isOwner = accessUserId && share.ownerId === accessUserId;
// Only check visibility for non-owners
// 'private' - only owner can view
// 'link' - anyone with the link can view
if (!isOwner && share.visibility === 'private') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'This share is private' });
}
return share;
};
}

View file

@ -1,10 +1,19 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import type { ChatTopicMetadata, ThreadMetadata } from '@lobechat/types';
import { sql } from 'drizzle-orm';
import { boolean, index, jsonb, pgTable, primaryKey, text, uniqueIndex } from 'drizzle-orm/pg-core';
import {
boolean,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { createInsertSchema } from 'drizzle-zod';
import { idGenerator } from '../utils/idGenerator';
import { createNanoId, idGenerator } from '../utils/idGenerator';
import { createdAt, timestamps, timestamptz } from './_helpers';
import { agents } from './agent';
import { chatGroups } from './chatGroup';
@ -133,3 +142,36 @@ export const topicDocuments = pgTable(
export type NewTopicDocument = typeof topicDocuments.$inferInsert;
export type TopicDocumentItem = typeof topicDocuments.$inferSelect;
/**
* Topic sharing table - Manages public sharing links for topics
*/
export const topicShares = pgTable(
'topic_shares',
{
id: text('id')
.$defaultFn(() => createNanoId(8)())
.primaryKey(),
topicId: text('topic_id')
.notNull()
.references(() => topics.id, { onDelete: 'cascade' }),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
visibility: text('visibility').default('private').notNull(), // 'private' | 'link'
pageViewCount: integer('page_view_count').default(0).notNull(),
...timestamps,
},
(t) => [
uniqueIndex('topic_shares_topic_id_unique').on(t.topicId),
index('topic_shares_user_id_idx').on(t.userId),
],
);
export type NewTopicShare = typeof topicShares.$inferInsert;
export type TopicShareItem = typeof topicShares.$inferSelect;

View file

@ -182,4 +182,9 @@ export interface ConversationContext {
* Topic ID
*/
topicId?: string | null;
/**
* Topic share ID for public access (used by shared topic pages)
* When present, allows unauthenticated access to topic messages
*/
topicShareId?: string;
}

View file

@ -1,6 +1,8 @@
import type { BaseDataModel } from '../meta';
// Type definitions
export type ShareVisibility = 'private' | 'link';
export type TimeGroupId =
| 'today'
| 'yesterday'
@ -126,3 +128,47 @@ export interface QueryTopicParams {
isInbox?: boolean;
pageSize?: number;
}
/**
* Shared message data for public sharing
*/
export interface SharedMessage {
content: string;
createdAt: Date;
id: string;
role: string;
}
/**
* Shared topic data returned by public API
*/
export interface SharedTopicData {
agentId: string | null;
agentMeta?: {
avatar?: string | null;
backgroundColor?: string | null;
marketIdentifier?: string | null;
slug?: string | null;
title?: string | null;
};
groupId: string | null;
groupMeta?: {
avatar?: string | null;
backgroundColor?: string | null;
members?: { avatar: string | null; backgroundColor: string | null }[];
title?: string | null;
};
shareId: string;
title: string | null;
topicId: string;
visibility: ShareVisibility;
}
/**
* Topic share info returned to the owner
*/
export interface TopicShareInfo {
id: string;
topicId: string;
visibility: ShareVisibility;
}

View file

@ -1,5 +1,6 @@
'use client';
import { ENABLE_TOPIC_LINK_SHARE } from '@lobechat/business-const';
import { ActionIcon } from '@lobehub/ui';
import { Share2 } from 'lucide-react';
import dynamic from 'next/dynamic';
@ -8,8 +9,10 @@ import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { useWorkspaceModal } from '@/hooks/useWorkspaceModal';
import { useChatStore } from '@/store/chat';
const ShareModal = dynamic(() => import('@/features/ShareModal'));
const SharePopover = dynamic(() => import('@/features/SharePopover'));
interface ShareButtonProps {
mobile?: boolean;
@ -20,18 +23,30 @@ interface ShareButtonProps {
const ShareButton = memo<ShareButtonProps>(({ mobile, setOpen, open }) => {
const [isModalOpen, setIsModalOpen] = useWorkspaceModal(open, setOpen);
const { t } = useTranslation('common');
const activeTopicId = useChatStore((s) => s.activeTopicId);
// Hide share button when no topic exists (no messages sent yet)
if (!activeTopicId) return null;
const iconButton = (
<ActionIcon
icon={Share2}
onClick={ENABLE_TOPIC_LINK_SHARE ? undefined : () => setIsModalOpen(true)}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('share')}
tooltipProps={{
placement: 'bottom',
}}
/>
);
return (
<>
<ActionIcon
icon={Share2}
onClick={() => setIsModalOpen(true)}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('share')}
tooltipProps={{
placement: 'bottom',
}}
/>
{ENABLE_TOPIC_LINK_SHARE ? (
<SharePopover onOpenModal={() => setIsModalOpen(true)}>{iconButton}</SharePopover>
) : (
iconButton
)}
<ShareModal onCancel={() => setIsModalOpen(false)} open={isModalOpen} />
</>
);

View file

@ -1,5 +1,6 @@
'use client';
import { ENABLE_TOPIC_LINK_SHARE } from '@lobechat/business-const';
import { ActionIcon } from '@lobehub/ui';
import { Share2 } from 'lucide-react';
import dynamic from 'next/dynamic';
@ -8,8 +9,12 @@ import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { useWorkspaceModal } from '@/hooks/useWorkspaceModal';
import { useChatStore } from '@/store/chat';
console.log('ENABLE_TOPIC_LINK_SHARE', ENABLE_TOPIC_LINK_SHARE);
const ShareModal = dynamic(() => import('@/features/ShareModal'));
const SharePopover = dynamic(() => import('@/features/SharePopover'));
interface ShareButtonProps {
mobile?: boolean;
@ -20,18 +25,30 @@ interface ShareButtonProps {
const ShareButton = memo<ShareButtonProps>(({ mobile, setOpen, open }) => {
const [isModalOpen, setIsModalOpen] = useWorkspaceModal(open, setOpen);
const { t } = useTranslation('common');
const activeTopicId = useChatStore((s) => s.activeTopicId);
// Hide share button when no topic exists (no messages sent yet)
if (!activeTopicId) return null;
const iconButton = (
<ActionIcon
icon={Share2}
onClick={ENABLE_TOPIC_LINK_SHARE ? undefined : () => setIsModalOpen(true)}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('share')}
tooltipProps={{
placement: 'bottom',
}}
/>
);
return (
<>
<ActionIcon
icon={Share2}
onClick={() => setIsModalOpen(true)}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('share')}
tooltipProps={{
placement: 'bottom',
}}
/>
{ENABLE_TOPIC_LINK_SHARE ? (
<SharePopover onOpenModal={() => setIsModalOpen(true)}>{iconButton}</SharePopover>
) : (
iconButton
)}
<ShareModal onCancel={() => setIsModalOpen(false)} open={isModalOpen} />
</>
);

View file

@ -29,8 +29,7 @@ const AspectRatioSelect = memo<AspectRatioSelectProps>(
{options?.map((item) => {
const [width, height] = item.value.split(':').map(Number);
const isWidthGreater = width > height;
const isEqual = width === height;
const isActive = isEqual ? item.value === '1:1' : active === item.value;
const isActive = active === item.value;
return (
<Block
align={'center'}

View file

@ -1,116 +1,15 @@
'use client';
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { ActionIcon, Flexbox, InputNumber } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { ActionIcon, Flexbox, InputNumber, Segmented } from '@lobehub/ui';
import { Check, Plus, X } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useImageStore } from '@/store/image';
import { imageGenerationConfigSelectors } from '@/store/image/selectors';
const DEFAULT_IMAGE_NUM_MAX = ENABLE_BUSINESS_FEATURES ? 8 : 50;
const styles = createStaticStyles(({ css, cssVar }) => ({
actionButton: css`
flex-shrink: 0;
`,
button: css`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 32px;
padding-block: 0;
padding-inline: 12px;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadius}px;
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorText};
background: ${cssVar.colorBgContainer};
transition: all 0.2s ease;
&:hover {
border-color: ${cssVar.colorPrimary};
background: ${cssVar.colorBgTextHover};
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
border-color: ${cssVar.colorBorder};
background: ${cssVar.colorBgContainer};
}
}
`,
cancelButton: css`
border-color: ${cssVar.colorBorder};
color: ${cssVar.colorTextTertiary};
&:hover {
border-color: ${cssVar.colorBorderSecondary};
color: ${cssVar.colorText};
background: ${cssVar.colorBgTextHover};
}
`,
confirmButton: css`
border-color: ${cssVar.colorSuccess};
color: ${cssVar.colorSuccess};
&:hover {
border-color: ${cssVar.colorSuccessHover};
color: ${cssVar.colorSuccessHover};
background: ${cssVar.colorSuccessBg};
}
`,
container: css`
display: flex;
gap: 8px;
align-items: center;
`,
editContainer: css`
display: flex;
gap: 8px;
align-items: center;
width: 100%;
`,
input: css`
flex: 1;
min-width: 80px;
.ant-input {
font-weight: 500;
text-align: center;
}
`,
selectedButton: css`
border-color: ${cssVar.colorPrimary};
color: ${cssVar.colorPrimary};
background: ${cssVar.colorPrimaryBg};
&:hover {
border-color: ${cssVar.colorPrimary};
color: ${cssVar.colorPrimary};
background: ${cssVar.colorPrimaryBgHover};
}
`,
}));
const CUSTOM_VALUE = '__custom__';
interface ImageNumSelectorProps {
disabled?: boolean;
@ -130,34 +29,52 @@ const ImageNum = memo<ImageNumSelectorProps>(
const isCustomValue = !presetCounts.includes(imageNum);
// 处理预设按钮点击
const handlePresetClick = useCallback(
(count: number) => {
const options = useMemo(() => {
const items = presetCounts.map((count) => ({
label: String(count),
value: count,
}));
// Add custom option or show current custom value
if (isCustomValue) {
items.push({
label: String(imageNum),
value: imageNum,
});
} else {
items.push({
label: <Plus size={16} style={{ verticalAlign: 'middle' }} />,
value: CUSTOM_VALUE,
} as any);
}
return items;
}, [presetCounts, isCustomValue, imageNum]);
const handleChange = useCallback(
(value: number | string) => {
if (disabled) return;
setImageNum(count);
if (value === CUSTOM_VALUE || (isCustomValue && value === imageNum)) {
// Enter edit mode
setCustomCount(imageNum);
customCountRef.current = imageNum;
setIsEditing(true);
} else {
setImageNum(value as number);
}
},
[disabled, setImageNum],
[disabled, isCustomValue, imageNum, setImageNum],
);
// 进入编辑模式
const handleEditStart = useCallback(() => {
if (disabled) return;
setCustomCount(imageNum);
customCountRef.current = imageNum;
setIsEditing(true);
}, [disabled, imageNum]);
// 确认自定义输入
const handleCustomConfirm = useCallback(() => {
let count = customCountRef.current;
// 如果解析失败或输入为空,使用当前值
if (count === null) {
setIsEditing(false);
return;
}
// 智能处理超出范围的值 (作为二次保险)
if (count > max) {
count = max;
} else if (count < min) {
@ -174,10 +91,7 @@ const ImageNum = memo<ImageNumSelectorProps>(
setCustomCount(null);
}, []);
// 处理输入变化
const handleInputChange = useCallback((value: number | string | null) => {
console.log('handleInputChange', value);
if (value === null) {
setCustomCount(null);
customCountRef.current = null;
@ -192,10 +106,8 @@ const ImageNum = memo<ImageNumSelectorProps>(
}
}, []);
// 自动聚焦和选择输入框内容
useEffect(() => {
if (isEditing) {
// 延迟聚焦以确保 input 已渲染
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
@ -205,16 +117,12 @@ const ImageNum = memo<ImageNumSelectorProps>(
}
}, [isEditing]);
// 验证输入是否有效
const isValidInput = useCallback(() => {
return customCount !== null;
}, [customCount]);
const isValidInput = customCount !== null;
if (isEditing) {
return (
<div className={styles.editContainer}>
<Flexbox gap={8} horizontal style={{ width: '100%' }}>
<InputNumber
className={styles.input}
max={max}
min={min}
onChange={handleInputChange}
@ -228,59 +136,32 @@ const ImageNum = memo<ImageNumSelectorProps>(
placeholder={`${min}-${max}`}
ref={inputRef}
size="small"
style={{ flex: 1 }}
value={customCount}
/>
<ActionIcon
className={cx(styles.actionButton, styles.confirmButton)}
disabled={!isValidInput()}
color="success"
disabled={!isValidInput}
icon={Check}
onClick={handleCustomConfirm}
size="small"
variant="filled"
/>
<ActionIcon
className={cx(styles.actionButton, styles.cancelButton)}
icon={X}
onClick={handleCustomCancel}
size="small"
/>
</div>
<ActionIcon icon={X} onClick={handleCustomCancel} size="small" variant="filled" />
</Flexbox>
);
}
return (
<Flexbox className={styles.container} horizontal>
{presetCounts.map((count) => (
<button
className={cx(styles.button, imageNum === count && styles.selectedButton)}
disabled={disabled}
key={count}
onClick={() => handlePresetClick(count)}
type="button"
>
{count}
</button>
))}
{isCustomValue ? (
<button
className={cx(styles.button, styles.selectedButton)}
disabled={disabled}
onClick={handleEditStart}
type="button"
>
{imageNum}
</button>
) : (
<button
className={styles.button}
disabled={disabled}
onClick={handleEditStart}
type="button"
>
<Plus size={16} />
</button>
)}
</Flexbox>
<Segmented
block
disabled={disabled}
onChange={handleChange}
options={options}
style={{ width: '100%' }}
value={isCustomValue ? imageNum : imageNum}
variant="filled"
/>
);
},
);

View file

@ -1,86 +1,41 @@
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { Segmented } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
const styles = createStaticStyles(({ css, cssVar }) => ({
button: css`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-width: 60px;
height: 32px;
padding-block: 0;
padding-inline: 16px;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadius}px;
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorText};
background: ${cssVar.colorBgContainer};
transition: all 0.2s ease;
&:hover {
border-color: ${cssVar.colorPrimary};
background: ${cssVar.colorBgTextHover};
}
`,
container: css`
display: flex;
gap: 8px;
align-items: center;
`,
selectedButton: css`
border-color: ${cssVar.colorPrimary};
color: ${cssVar.colorPrimary};
background: ${cssVar.colorPrimaryBg};
&:hover {
border-color: ${cssVar.colorPrimary};
color: ${cssVar.colorPrimary};
background: ${cssVar.colorPrimaryBgHover};
}
`,
}));
const ResolutionSelect = memo(() => {
const { t } = useTranslation('image');
const { value, setValue, enumValues } = useGenerationConfigParam('resolution');
const handleClick = useCallback(
(resolution: string) => {
setValue(resolution);
const handleChange = useCallback(
(resolution: string | number) => {
setValue(String(resolution));
},
[setValue],
);
if (!enumValues || enumValues.length === 0) {
const options = useMemo(() => {
if (!enumValues || enumValues.length === 0) return [];
return enumValues.map((resolution) => ({
label: t(`config.resolution.options.${resolution}`, { defaultValue: resolution }),
value: resolution,
}));
}, [enumValues, t]);
if (options.length === 0) {
return null;
}
return (
<Flexbox className={styles.container} horizontal>
{enumValues.map((resolution) => (
<button
className={cx(styles.button, value === resolution && styles.selectedButton)}
key={resolution}
onClick={() => handleClick(resolution)}
type="button"
>
{t(`config.resolution.options.${resolution}`, { defaultValue: resolution })}
</button>
))}
</Flexbox>
<Segmented
block
onChange={handleChange}
options={options}
style={{ width: '100%' }}
value={value}
variant="filled"
/>
);
});

View file

@ -254,4 +254,22 @@ export const mobileRoutes: RouteConfig[] = [
path: '/onboarding',
},
...BusinessMobileRoutesWithoutMainLayout,
// Share topic route (outside main layout)
{
children: [
{
element: dynamicElement(
() => import('../../share/t/[id]'),
'Mobile > Share > Topic',
),
path: ':id',
},
],
element: dynamicElement(
() => import('../../share/t/[id]/_layout'),
'Mobile > Share > Topic > Layout',
),
path: '/share/t',
},
];

View file

@ -398,6 +398,24 @@ export const desktopRoutes: RouteConfig[] = [
// Onboarding route (outside main layout)
...BusinessDesktopRoutesWithoutMainLayout,
// Share topic route (outside main layout)
{
children: [
{
element: dynamicElement(
() => import('../share/t/[id]'),
'Desktop > Share > Topic',
),
path: ':id',
},
],
element: dynamicElement(
() => import('../share/t/[id]/_layout'),
'Desktop > Share > Topic > Layout',
),
path: '/share/t',
},
];
// Desktop onboarding route (SPA-only)

View file

@ -0,0 +1,54 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { ChatList, ConversationProvider, MessageItem } from '@/features/Conversation';
import { useChatStore } from '@/store/chat';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
interface SharedMessageListProps {
agentId: string | null;
groupId: string | null;
shareId: string;
topicId: string;
}
const SharedMessageList = memo<SharedMessageListProps>(({ agentId, groupId, shareId, topicId }) => {
const context = useMemo(
() => ({
agentId: agentId ?? '',
groupId: groupId ?? undefined,
topicId,
topicShareId: shareId,
}),
[agentId, groupId, shareId, topicId],
);
// Sync messages to chatStore for artifact selectors to work
const chatKey = useMemo(() => messageMapKey(context), [context]);
const replaceMessages = useChatStore((s) => s.replaceMessages);
const messages = useChatStore((s) => s.dbMessagesMap[chatKey]);
const itemContent = useCallback(
(index: number, id: string) => <MessageItem disableEditing id={id} index={index} key={id} />,
[],
);
return (
<ConversationProvider
context={context}
hasInitMessages={!!messages}
messages={messages}
onMessagesChange={(messages) => {
replaceMessages(messages, { context });
}}
>
<Flexbox flex={1}>
<ChatList disableActionsBar itemContent={itemContent} />
</Flexbox>
</ConversationProvider>
);
});
export default SharedMessageList;

View file

@ -0,0 +1,170 @@
'use client';
import { Avatar, Flexbox } from '@lobehub/ui';
import { Typography } from 'antd';
import { createStyles, cssVar } from 'antd-style';
import NextLink from 'next/link';
import { PropsWithChildren, memo, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, Outlet, useParams } from 'react-router-dom';
import useSWR from 'swr';
import { ProductLogo } from '@/components/Branding';
import { DEFAULT_AVATAR } from '@/const/meta';
import GroupAvatar from '@/features/GroupAvatar';
import UserAvatar from '@/features/User/UserAvatar';
import { lambdaClient } from '@/libs/trpc/client';
import { useAgentStore } from '@/store/agent';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
import SharePortal from '../features/Portal';
const useStyles = createStyles(({ css, token }) => ({
container: css`
width: 100vw;
min-height: 100vh;
background: ${token.colorBgLayout};
`,
content: css`
flex: 1;
width: 100%;
padding-block: 24px;
padding-inline: 24px;
`,
footer: css`
padding-block: 16px;
padding-inline: 24px;
color: ${token.colorTextTertiary};
text-align: center;
`,
header: css`
height: 52px;
padding: 8px;
`,
}));
const ShareTopicLayout = memo<PropsWithChildren>(({ children }) => {
const { styles } = useStyles();
const { t } = useTranslation('chat');
const { id } = useParams<{ id: string }>();
const dispatchAgentMap = useAgentStore((s) => s.internal_dispatchAgentMap);
const isLogin = useUserStore(authSelectors.isLogin);
const { data } = useSWR(
id ? ['shared-topic', id] : null,
() => lambdaClient.share.getSharedTopic.query({ shareId: id! }),
{ revalidateOnFocus: false },
);
// Set agent meta to agentStore for avatar display
useEffect(() => {
if (data?.agentId && data.agentMeta) {
const meta = {
avatar: data.agentMeta.avatar ?? undefined,
backgroundColor: data.agentMeta.backgroundColor ?? undefined,
title: data.agentMeta.title ?? undefined,
};
dispatchAgentMap(data.agentId, meta);
}
}, [data?.agentId, data?.agentMeta, dispatchAgentMap]);
const isGroup = !!data?.groupId;
const isInboxAgent = !isGroup && data?.agentMeta?.slug === 'inbox';
const agentOrGroupTitle =
data?.groupMeta?.title || (isInboxAgent ? 'LobeAI' : data?.agentMeta?.title);
const agentMarketIdentifier = data?.agentMeta?.marketIdentifier;
// Build group avatars for GroupAvatar component
const groupAvatars = useMemo(() => {
if (!isGroup || !data?.groupMeta?.members) return [];
return data.groupMeta.members.map((member) => ({
avatar: member.avatar || DEFAULT_AVATAR,
backgroundColor: member.backgroundColor || undefined,
}));
}, [isGroup, data?.groupMeta?.members]);
const renderAgentOrGroupAvatar = () => {
// For group: use GroupAvatar with members
if (isGroup && groupAvatars.length > 0) {
return <GroupAvatar avatars={groupAvatars} size={24} />;
}
// For inbox agent: skip avatar as it's the same as product icon
if (isInboxAgent) {
return null;
}
// For agent: use single Avatar
if (data?.agentMeta?.avatar) {
return (
<Avatar
avatar={data.agentMeta.avatar}
background={data.agentMeta.backgroundColor || cssVar.colorFillTertiary}
shape="square"
size={24}
/>
);
}
return null;
};
const renderAgentOrGroupTitle = () => {
if (!agentOrGroupTitle) return null;
// If agent has marketIdentifier, render as link to assistant page
if (agentMarketIdentifier && !data?.groupMeta?.title) {
return (
<a href={`/community/assistant/${agentMarketIdentifier}`} rel="noreferrer" target="_blank">
<Typography.Text ellipsis strong>
{agentOrGroupTitle}
</Typography.Text>
</a>
);
}
return (
<Typography.Text ellipsis strong>
{agentOrGroupTitle}
</Typography.Text>
);
};
return (
<Flexbox className={styles.container}>
<Flexbox align="center" className={styles.header} gap={12} horizontal justify="space-between">
<Flexbox align="center" flex={1} gap={12} horizontal>
{isLogin ? (
<Link to="/">
<ProductLogo size={24} />
</Link>
) : (
<NextLink href="/login">
<ProductLogo size={24} />
</NextLink>
)}
{renderAgentOrGroupAvatar()}
{renderAgentOrGroupTitle()}
</Flexbox>
{data?.title && (
<Typography.Text ellipsis strong style={{ textAlign: 'center' }}>
{data.title}
</Typography.Text>
)}
<Flexbox align="center" flex={1} horizontal justify="flex-end">
{isLogin && <UserAvatar size={24} />}
</Flexbox>
</Flexbox>
<Flexbox className={styles.content} horizontal style={{ overflow: 'hidden' }}>
<Flexbox flex={1} style={{ overflow: 'hidden' }}>
{children ?? <Outlet />}
</Flexbox>
<SharePortal />
</Flexbox>
<Typography.Text className={styles.footer}>{t('sharePageDisclaimer')}</Typography.Text>
</Flexbox>
);
});
export default ShareTopicLayout;

View file

@ -0,0 +1,66 @@
'use client';
import { DraggablePanel } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { CHAT_PORTAL_TOOL_UI_WIDTH } from '@/const/layoutTokens';
import { PortalContent } from '@/features/Portal/router';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
const useStyles = createStyles(({ css, token }) => ({
body: css`
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
height: 0;
padding-block-end: 12px;
`,
content: css`
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
max-height: 100%;
background: ${token.colorBgContainer};
`,
drawer: css`
z-index: 10;
height: 100%;
background: ${token.colorBgContainer};
`,
}));
const SharePortal = memo(() => {
const { styles } = useStyles();
const showPortal = useChatStore(chatPortalSelectors.showPortal);
return (
<DraggablePanel
className={styles.drawer}
classNames={{ content: styles.content }}
defaultSize={{ width: CHAT_PORTAL_TOOL_UI_WIDTH }}
expand={showPortal}
expandable={false}
minWidth={CHAT_PORTAL_TOOL_UI_WIDTH}
placement="right"
showHandleWhenCollapsed={false}
showHandleWideArea={false}
size={{ height: '100%', width: CHAT_PORTAL_TOOL_UI_WIDTH }}
>
<PortalContent renderBody={(body) => <div className={styles.body}>{body}</div>} />
</DraggablePanel>
);
});
SharePortal.displayName = 'SharePortal';
export default SharePortal;

View file

@ -0,0 +1,112 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { TRPCClientError } from '@trpc/client';
import { Button, Result, Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import { lambdaClient } from '@/libs/trpc/client';
import SharedMessageList from './SharedMessageList';
const useStyles = createStyles(({ css }) => ({
container: css`
flex: 1;
`,
errorContainer: css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 48px;
text-align: center;
`,
}));
const ShareTopicPage = memo(() => {
const { styles } = useStyles();
const { t } = useTranslation('chat');
const { id } = useParams<{ id: string }>();
const { data, error, isLoading } = useSWR(
id ? ['shared-topic', id] : null,
() => lambdaClient.share.getSharedTopic.query({ shareId: id! }),
{ revalidateOnFocus: false },
);
if (isLoading) {
return (
<Flexbox className={styles.container} gap={16}>
<Skeleton active paragraph={{ rows: 1 }} title={false} />
<Skeleton active paragraph={{ rows: 6 }} />
</Flexbox>
);
}
if (error) {
const trpcError = error instanceof TRPCClientError ? error : null;
const errorCode = trpcError?.data?.code;
if (errorCode === 'UNAUTHORIZED') {
return (
<Flexbox className={styles.errorContainer}>
<Result
extra={
<Button href="/login" type="primary">
{t('sharePage.error.unauthorized.action')}
</Button>
}
status="403"
subTitle={t('sharePage.error.unauthorized.subtitle')}
title={t('sharePage.error.unauthorized.title')}
/>
</Flexbox>
);
}
if (errorCode === 'FORBIDDEN') {
return (
<Flexbox className={styles.errorContainer}>
<Result
status="403"
subTitle={t('sharePage.error.forbidden.subtitle')}
title={t('sharePage.error.forbidden.title')}
/>
</Flexbox>
);
}
// NOT_FOUND or other errors
return (
<Flexbox className={styles.errorContainer}>
<Result
status="404"
subTitle={t('sharePage.error.notFound.subtitle')}
title={t('sharePage.error.notFound.title')}
/>
</Flexbox>
);
}
if (!data) return null;
return (
<Flexbox className={styles.container}>
<SharedMessageList
agentId={data.agentId}
groupId={data.groupId}
shareId={data.shareId}
topicId={data.topicId}
/>
</Flexbox>
);
});
export default ShareTopicPage;

View file

@ -26,7 +26,7 @@ const robots = (): MetadataRoute.Robots => {
},
{
allow: ['/'],
disallow: ['/api/*', '/login', '/signup', '/knowledge/*'],
disallow: ['/api/*', '/login', '/signup', '/knowledge/*', '/share/*'],
userAgent: '*',
},
],

View file

@ -1,4 +1,4 @@
import { RouteConfig } from '@/utils/router';
import { type RouteConfig } from '@/utils/router';
export const BusinessMobileRoutesWithMainLayout: RouteConfig[] = [];
export const BusinessMobileRoutesWithSettingsLayout: RouteConfig[] = [];

View file

@ -15,6 +15,10 @@ import { dataSelectors, useConversationStore } from '../store';
import VirtualizedList from './components/VirtualizedList';
export interface ChatListProps {
/**
* Disable the actions bar for all messages (e.g., in share page)
*/
disableActionsBar?: boolean;
/**
* Custom item renderer. If not provided, uses default ChatItem.
*/
@ -29,7 +33,7 @@ export interface ChatListProps {
*
* Uses ConversationStore for message data and fetching.
*/
const ChatList = memo<ChatListProps>(({ welcome, itemContent }) => {
const ChatList = memo<ChatListProps>(({ disableActionsBar, welcome, itemContent }) => {
// Fetch messages (SWR key is null when skipFetch is true)
const context = useConversationStore((s) => s.context);
const enableUserMemories = useUserStore(settingsSelectors.memoryEnabled);
@ -39,9 +43,12 @@ const ChatList = memo<ChatListProps>(({ welcome, itemContent }) => {
]);
useFetchMessages(context, skipFetch);
// Fetch notebook documents when topic is selected
useFetchNotebookDocuments(context.topicId!);
useFetchTopicMemories(enableUserMemories ? context.topicId : undefined);
// Skip fetching notebook and memories for share pages (they require authentication)
const isSharePage = !!context.topicShareId;
// Fetch notebook documents when topic is selected (skip for share pages)
useFetchNotebookDocuments(isSharePage ? undefined : context.topicId!);
useFetchTopicMemories(enableUserMemories && !isSharePage ? context.topicId : undefined);
// Use selectors for data
@ -77,7 +84,7 @@ const ChatList = memo<ChatListProps>(({ welcome, itemContent }) => {
}
return (
<MessageActionProvider withSingletonActionsBar>
<MessageActionProvider withSingletonActionsBar={!disableActionsBar}>
<VirtualizedList
dataSource={displayMessageIds}
// isGenerating={isGenerating}

View file

@ -16,6 +16,7 @@ import RejectedResponse from './RejectedResponse';
interface RenderProps {
apiName: string;
arguments?: string;
disableEditing?: boolean;
identifier: string;
intervention?: ToolIntervention;
isArgumentsStreaming?: boolean;
@ -43,6 +44,7 @@ const Render = memo<RenderProps>(
toolCallId,
messageId,
arguments: requestArgs,
disableEditing,
showPluginRender,
setShowPluginRender,
identifier,
@ -54,7 +56,7 @@ const Render = memo<RenderProps>(
isArgumentsStreaming,
isToolCalling,
}) => {
if (toolMessageId && intervention?.status === 'pending') {
if (toolMessageId && intervention?.status === 'pending' && !disableEditing) {
return (
<Intervention
apiName={apiName}
@ -150,9 +152,11 @@ const Render = memo<RenderProps>(
showPluginRender={showPluginRender}
toolCallId={toolCallId}
/>
<div>
<ModeSelector />
</div>
{!disableEditing && (
<div>
<ModeSelector />
</div>
)}
</Flexbox>
</Suspense>
);

View file

@ -30,6 +30,7 @@ export interface GroupToolProps {
apiName: string;
arguments?: string;
assistantMessageId: string;
disableEditing?: boolean;
id: string;
identifier: string;
intervention?: ToolIntervention;
@ -43,6 +44,7 @@ const Tool = memo<GroupToolProps>(
arguments: requestArgs,
apiName,
assistantMessageId,
disableEditing,
id,
intervention,
identifier,
@ -106,16 +108,18 @@ const Tool = memo<GroupToolProps>(
return (
<AccordionItem
action={
<Actions
assistantMessageId={assistantMessageId}
handleExpand={handleExpand}
identifier={identifier}
setShowDebug={setShowDebug}
setShowPluginRender={setShowPluginRender}
showCustomPluginRender={showCustomPluginRender}
showDebug={showDebug}
showPluginRender={showPluginRender}
/>
!disableEditing && (
<Actions
assistantMessageId={assistantMessageId}
handleExpand={handleExpand}
identifier={identifier}
setShowDebug={setShowDebug}
setShowPluginRender={setShowPluginRender}
showCustomPluginRender={showCustomPluginRender}
showDebug={showDebug}
showPluginRender={showPluginRender}
/>
)
}
allowExpand={hasCustomRender}
expand={isToolRenderExpand}
@ -150,6 +154,7 @@ const Tool = memo<GroupToolProps>(
<Render
apiName={apiName}
arguments={requestArgs}
disableEditing={disableEditing}
identifier={identifier}
intervention={intervention}
isArgumentsStreaming={isArgumentsStreaming}

View file

@ -5,11 +5,12 @@ import { memo } from 'react';
import Tool from './Tool';
interface ToolsRendererProps {
disableEditing?: boolean;
messageId: string;
tools: ChatToolPayloadWithResult[];
}
export const Tools = memo<ToolsRendererProps>(({ messageId, tools }) => {
export const Tools = memo<ToolsRendererProps>(({ disableEditing, messageId, tools }) => {
if (!tools || tools.length === 0) return null;
return (
@ -19,6 +20,7 @@ export const Tools = memo<ToolsRendererProps>(({ messageId, tools }) => {
apiName={tool.apiName}
arguments={tool.arguments}
assistantMessageId={messageId}
disableEditing={disableEditing}
id={tool.id}
identifier={tool.identifier}
intervention={tool.intervention}

View file

@ -14,9 +14,10 @@ import MessageContent from './MessageContent';
interface ContentBlockProps extends AssistantContentBlock {
assistantId: string;
disableEditing?: boolean;
}
const ContentBlock = memo<ContentBlockProps>(
({ id, tools, content, imageList, reasoning, error, assistantId }) => {
({ id, tools, content, imageList, reasoning, error, assistantId, disableEditing }) => {
const errorContent = useErrorContent(error);
const showImageItems = !!imageList && imageList.length > 0;
const [isReasoning, deleteMessage, continueGeneration] = useConversationStore((s) => [
@ -70,7 +71,7 @@ const ContentBlock = memo<ContentBlockProps>(
{showImageItems && <ImageFileListViewer items={imageList} />}
{/* Tools */}
{hasTools && <Tools messageId={id} tools={tools} />}
{hasTools && <Tools disableEditing={disableEditing} messageId={id} tools={tools} />}
</Flexbox>
);
},

View file

@ -25,10 +25,10 @@ const GroupItem = memo<GroupItemProps>(
toggleMessageEditing(item.id, true);
}}
>
<ContentBlock {...item} assistantId={assistantId} error={error} />
<ContentBlock {...item} assistantId={assistantId} disableEditing={disableEditing} error={error} />
</Flexbox>
) : (
<ContentBlock {...item} assistantId={assistantId} error={error} />
<ContentBlock {...item} assistantId={assistantId} disableEditing={disableEditing} error={error} />
);
},
isEqual,

View file

@ -11,34 +11,33 @@ import { Tools } from '../../AssistantGroup/Tools';
import Reasoning from '../../components/Reasoning';
import MessageContent from './MessageContent';
const ContentBlock = memo<AssistantContentBlock>(({ id, tools, content, reasoning, error }) => {
const errorContent = useErrorContent(error);
const isReasoning = useConversationStore(messageStateSelectors.isMessageInReasoning(id));
const hasTools = tools && tools.length > 0;
const showReasoning =
(!!reasoning && reasoning.content?.trim() !== '') || (!reasoning && isReasoning);
interface ContentBlockProps extends AssistantContentBlock {
disableEditing?: boolean;
}
const ContentBlock = memo<ContentBlockProps>(
({ id, tools, content, reasoning, error, disableEditing }) => {
const errorContent = useErrorContent(error);
const isReasoning = useConversationStore(messageStateSelectors.isMessageInReasoning(id));
const hasTools = tools && tools.length > 0;
const showReasoning =
(!!reasoning && reasoning.content?.trim() !== '') || (!reasoning && isReasoning);
if (error && (content === LOADING_FLAT || !content))
return <ErrorContent error={errorContent} id={id} />;
if (error && (content === LOADING_FLAT || !content))
return (
<ErrorContent
error={
errorContent && error && (content === LOADING_FLAT || !content) ? errorContent : undefined
}
id={id}
/>
<Flexbox gap={8} id={id}>
{showReasoning && <Reasoning {...reasoning} id={id} />}
{/* Content - markdown text */}
<MessageContent content={content} hasTools={hasTools} id={id} />
{/* Tools */}
{hasTools && <Tools disableEditing={disableEditing} messageId={id} tools={tools} />}
</Flexbox>
);
return (
<Flexbox gap={8} id={id}>
{showReasoning && <Reasoning {...reasoning} id={id} />}
{/* Content - markdown text */}
<MessageContent content={content} hasTools={hasTools} id={id} />
{/* Tools */}
{hasTools && <Tools messageId={id} tools={tools} />}
</Flexbox>
);
});
},
);
export default ContentBlock;

View file

@ -29,7 +29,7 @@ interface GroupChildrenProps {
messageIndex: number;
}
const Group = memo<GroupChildrenProps>(({ blocks, id, content }) => {
const Group = memo<GroupChildrenProps>(({ blocks, id, content, disableEditing }) => {
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const contextValue = useMemo(() => ({ assistantGroupId: id }), [id]);
@ -46,7 +46,9 @@ const Group = memo<GroupChildrenProps>(({ blocks, id, content }) => {
<MessageAggregationContext value={contextValue}>
<Flexbox className={styles.container} gap={8}>
{blocks.map((item) => {
return <ContentBlock {...item} key={id + '.' + item.id} />;
return (
<ContentBlock {...item} disableEditing={disableEditing} key={id + '.' + item.id} />
);
})}
</Flexbox>
</MessageAggregationContext>

View file

@ -20,6 +20,7 @@ const Render = dynamic(() => import('../../AssistantGroup/Tool/Render'), {
export interface InspectorProps {
apiName: string;
arguments?: string;
disableEditing?: boolean;
identifier: string;
index: number;
messageId: string;
@ -32,7 +33,7 @@ export interface InspectorProps {
* Tool message component - adapts Tool message data to use AssistantGroup/Tool components
*/
const Tool = memo<InspectorProps>(
({ arguments: requestArgs, apiName, messageId, toolCallId, index, identifier, type }) => {
({ arguments: requestArgs, apiName, disableEditing, messageId, toolCallId, index, identifier, type }) => {
const [showDebug, setShowDebug] = useState(false);
const [showPluginRender, setShowPluginRender] = useState(false);
const [expand, setExpand] = useState(true);
@ -66,16 +67,18 @@ const Tool = memo<InspectorProps>(
>
<AccordionItem
action={
<Actions
assistantMessageId={messageId}
handleExpand={(expand) => setExpand(!!expand)}
identifier={identifier}
setShowDebug={setShowDebug}
setShowPluginRender={setShowPluginRender}
showCustomPluginRender={false}
showDebug={showDebug}
showPluginRender={showPluginRender}
/>
!disableEditing && (
<Actions
assistantMessageId={messageId}
handleExpand={(expand) => setExpand(!!expand)}
identifier={identifier}
setShowDebug={setShowDebug}
setShowPluginRender={setShowPluginRender}
showCustomPluginRender={false}
showDebug={showDebug}
showPluginRender={showPluginRender}
/>
)
}
itemKey={'tool'}
paddingBlock={4}
@ -83,7 +86,7 @@ const Tool = memo<InspectorProps>(
title={<Inspectors apiName={apiName} identifier={identifier} result={result} />}
>
<Flexbox gap={8} paddingBlock={8}>
{showDebug && (
{showDebug && !disableEditing && (
<Debug
apiName={apiName}
identifier={identifier}
@ -96,6 +99,7 @@ const Tool = memo<InspectorProps>(
<Render
apiName={apiName}
arguments={requestArgs}
disableEditing={disableEditing}
identifier={identifier}
messageId={messageId}
result={result}

View file

@ -8,11 +8,12 @@ import { dataSelectors, useConversationStore } from '../../store';
import Tool from './Tool';
interface ToolMessageProps {
disableEditing?: boolean;
id: string;
index: number;
}
const ToolMessage = memo<ToolMessageProps>(({ id, index }) => {
const ToolMessage = memo<ToolMessageProps>(({ disableEditing, id, index }) => {
const { t } = useTranslation('plugin');
const item = useConversationStore(dataSelectors.getDbMessageById(id), isEqual) as UIChatMessage;
const deleteToolMessage = useConversationStore((s) => s.deleteToolMessage);
@ -29,17 +30,25 @@ const ToolMessage = memo<ToolMessageProps>(({ id, index }) => {
return (
<Flexbox gap={4} paddingBlock={12}>
<Alert
action={
<Button loading={loading} onClick={handleDelete} size={'small'} type={'primary'}>
{t('inspector.delete')}
</Button>
}
title={t('inspector.orphanedToolCall')}
type={'secondary'}
/>
{!disableEditing && (
<Alert
action={
<Button loading={loading} onClick={handleDelete} size={'small'} type={'primary'}>
{t('inspector.delete')}
</Button>
}
title={t('inspector.orphanedToolCall')}
type={'secondary'}
/>
)}
{item.plugin && (
<Tool {...item.plugin} index={index} messageId={id} toolCallId={item.tool_call_id!} />
<Tool
{...item.plugin}
disableEditing={disableEditing}
index={index}
messageId={id}
toolCallId={item.tool_call_id!}
/>
)}
</Flexbox>
);

View file

@ -158,7 +158,7 @@ const MessageItem = memo<MessageItemProps>(
}
case 'tool': {
return <ToolMessage id={id} index={index} />;
return <ToolMessage disableEditing={disableEditing} id={id} index={index} />;
}
}

View file

@ -103,13 +103,14 @@ export const dataSlice: StateCreator<
useFetchMessages: (context, skipFetch) => {
// When skipFetch is true, SWR key is null - no fetch occurs
// This is used when external messages are provided (e.g., creating new thread)
// Allow fetch if: has agentId (both agent topics and group topics have agentId)
const shouldFetch = !skipFetch && !!context.agentId;
return useClientDataSWRWithSync<UIChatMessage[]>(
shouldFetch ? ['CONVERSATION_FETCH_MESSAGES', context] : null,
async () => {
return messageService.getMessages(context as any);
return messageService.getMessages(context);
},
{
onData: (data) => {

View file

@ -0,0 +1,215 @@
'use client';
import { Button, Flexbox, Popover, copyToClipboard, usePopoverContext } from '@lobehub/ui';
import { App, Divider, Select, Skeleton, Typography } from 'antd';
import { CopyIcon, ExternalLinkIcon, LinkIcon, LockIcon } from 'lucide-react';
import { type ReactNode, memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import { useIsMobile } from '@/hooks/useIsMobile';
import { topicService } from '@/services/topic';
import { useChatStore } from '@/store/chat';
import { useStyles } from './style';
type Visibility = 'private' | 'link';
interface SharePopoverContentProps {
onOpenModal?: () => void;
}
const SharePopoverContent = memo<SharePopoverContentProps>(({ onOpenModal }) => {
const { t } = useTranslation('chat');
const { message, modal } = App.useApp();
const { styles } = useStyles();
const [updating, setUpdating] = useState(false);
const { close } = usePopoverContext();
const containerRef = useRef<HTMLDivElement>(null);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const {
data: shareInfo,
isLoading,
mutate,
} = useSWR(
activeTopicId ? ['topic-share-info', activeTopicId] : null,
() => topicService.getShareInfo(activeTopicId!),
{ revalidateOnFocus: false },
);
// Auto-create share record if not exists
useEffect(() => {
if (!isLoading && !shareInfo && activeTopicId) {
topicService.enableSharing(activeTopicId, 'private').then(() => mutate());
}
}, [isLoading, shareInfo, activeTopicId, mutate]);
const shareUrl = shareInfo?.id ? `${window.location.origin}/share/t/${shareInfo.id}` : '';
const currentVisibility = (shareInfo?.visibility as Visibility) || 'private';
const updateVisibility = useCallback(
async (visibility: Visibility) => {
if (!activeTopicId) return;
setUpdating(true);
try {
await topicService.updateShareVisibility(activeTopicId, visibility);
await mutate();
message.success(t('shareModal.link.visibilityUpdated'));
} catch {
message.error(t('shareModal.link.updateError'));
} finally {
setUpdating(false);
}
},
[activeTopicId, mutate, message, t],
);
const handleVisibilityChange = useCallback(
(visibility: Visibility) => {
// Show confirmation when changing from private to link
if (currentVisibility === 'private' && visibility === 'link') {
modal.confirm({
cancelText: t('cancel', { ns: 'common' }),
content: t('shareModal.popover.privacyWarning.content'),
okText: t('shareModal.popover.privacyWarning.confirm'),
onOk: () => updateVisibility(visibility),
title: t('shareModal.popover.privacyWarning.title'),
type: 'warning',
});
} else {
updateVisibility(visibility);
}
},
[currentVisibility, modal, t, updateVisibility],
);
const handleCopyLink = useCallback(async () => {
if (!shareUrl) return;
await copyToClipboard(shareUrl);
message.success(t('shareModal.copyLinkSuccess'));
}, [shareUrl, message, t]);
const handleOpenModal = useCallback(() => {
close();
onOpenModal?.();
}, [close, onOpenModal]);
// Loading state
if (isLoading || !shareInfo) {
return (
<Flexbox className={styles.container} gap={16}>
<Typography.Text strong>{t('share', { ns: 'common' })}</Typography.Text>
<Skeleton active paragraph={{ rows: 2 }} />
</Flexbox>
);
}
const visibilityOptions = [
{
icon: <LockIcon size={14} />,
label: t('shareModal.link.permissionPrivate'),
value: 'private',
},
{
icon: <LinkIcon size={14} />,
label: t('shareModal.link.permissionLink'),
value: 'link',
},
];
const getVisibilityHint = () => {
switch (currentVisibility) {
case 'private': {
return t('shareModal.link.privateHint');
}
case 'link': {
return t('shareModal.link.linkHint');
}
}
};
return (
<Flexbox className={styles.container} gap={12} ref={containerRef}>
<Typography.Text strong>{t('shareModal.popover.title')}</Typography.Text>
<Flexbox gap={4}>
<Typography.Text type="secondary">{t('shareModal.popover.visibility')}</Typography.Text>
<Select
disabled={updating}
getPopupContainer={() => containerRef.current || document.body}
labelRender={({ value }) => {
const option = visibilityOptions.find((o) => o.value === value);
return (
<Flexbox align="center" gap={8} horizontal>
{option?.icon}
{option?.label}
</Flexbox>
);
}}
onChange={handleVisibilityChange}
optionRender={(option) => (
<Flexbox align="center" gap={8} horizontal>
{visibilityOptions.find((o) => o.value === option.value)?.icon}
{option.label}
</Flexbox>
)}
options={visibilityOptions}
style={{ width: '100%' }}
value={currentVisibility}
/>
</Flexbox>
<Typography.Text className={styles.hint} type="secondary">
{getVisibilityHint()}
</Typography.Text>
<Divider style={{ margin: '4px 0' }} />
<Flexbox align="center" horizontal justify="space-between">
<Button
icon={ExternalLinkIcon}
onClick={handleOpenModal}
size="small"
type="text"
variant="text"
>
{t('shareModal.popover.moreOptions')}
</Button>
<Button icon={CopyIcon} onClick={handleCopyLink} size="small" type="primary">
{t('shareModal.copyLink')}
</Button>
</Flexbox>
</Flexbox>
);
});
interface SharePopoverProps {
children?: ReactNode;
onOpenModal?: () => void;
}
const SharePopover = memo<SharePopoverProps>(({ children, onOpenModal }) => {
const isMobile = useIsMobile();
return (
<Popover
arrow={false}
content={<SharePopoverContent onOpenModal={onOpenModal} />}
placement={isMobile ? 'top' : 'bottomRight'}
styles={{
content: {
padding: 0,
width: isMobile ? '100vw' : 366,
},
}}
trigger={['click']}
>
{children}
</Popover>
);
});
export default SharePopover;

View file

@ -0,0 +1,10 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css }) => ({
container: css`
padding: 16px;
`,
hint: css`
font-size: 12px;
`,
}));

View file

@ -8,7 +8,7 @@ import { auth } from '@/auth';
import { LOBE_LOCALE_COOKIE } from '@/const/locale';
import { isDesktop } from '@/const/version';
import { appEnv } from '@/envs/app';
import { OAUTH_AUTHORIZED , authEnv } from '@/envs/auth';
import { OAUTH_AUTHORIZED, authEnv } from '@/envs/auth';
import NextAuth from '@/libs/next-auth';
import { type Locales } from '@/locales/resources';
import { parseBrowserLanguage } from '@/utils/locale';
@ -107,6 +107,7 @@ export function defineConfig() {
'/me',
'/desktop-onboarding',
'/onboarding',
'/share',
];
const isSpaRoute = spaRoutes.some((route) => url.pathname.startsWith(route));
@ -184,6 +185,8 @@ export function defineConfig() {
'/oidc/token',
// market
'/market-auth-callback',
// public share pages
'/share(.*)',
]);
const isProtectedRoute = createRouteMatcher([

View file

@ -283,6 +283,8 @@ export default {
'sessionGroup.sorting': 'Group sorting updating...',
'sessionGroup.tooLong': 'Group name length should be between 1-20',
'shareModal.copy': 'Copy',
'shareModal.copyLink': 'Copy Link',
'shareModal.copyLinkSuccess': 'Link copied',
'shareModal.download': 'Download Screenshot',
'shareModal.downloadError': 'Download failed',
'shareModal.downloadFile': 'Download File',
@ -298,12 +300,27 @@ export default {
'shareModal.imageType': 'Image Format',
'shareModal.includeTool': 'Include Skill messages',
'shareModal.includeUser': 'Include User Messages',
'shareModal.link': 'Link',
'shareModal.link.linkHint': 'Anyone with the link can view this topic',
'shareModal.link.noTopic': 'Start a conversation first to share',
'shareModal.link.permissionLink': 'Anyone with the link',
'shareModal.link.permissionPrivate': 'Private',
'shareModal.link.privateHint': 'Only you can access this link',
'shareModal.link.updateError': 'Failed to update sharing settings',
'shareModal.link.visibilityUpdated': 'Visibility updated',
'shareModal.loadingPdf': 'Loading PDF...',
'shareModal.noPdfData': 'No PDF data available',
'shareModal.pdf': 'PDF',
'shareModal.pdfErrorDescription': 'An error occurred while generating the PDF, please try again',
'shareModal.pdfGenerationError': 'PDF generation failed',
'shareModal.pdfReady': 'PDF is ready',
'shareModal.popover.moreOptions': 'More share options',
'shareModal.popover.privacyWarning.confirm': 'I understand, continue',
'shareModal.popover.privacyWarning.content':
'Please ensure the conversation does not contain any private or sensitive information before sharing. LobeHub is not responsible for any security issues that may arise from sharing.',
'shareModal.popover.privacyWarning.title': 'Privacy Notice',
'shareModal.popover.title': 'Share Topic',
'shareModal.popover.visibility': 'Visibility',
'shareModal.regeneratePdf': 'Regenerate PDF',
'shareModal.screenshot': 'Screenshot',
'shareModal.settings': 'Export Settings',
@ -316,6 +333,15 @@ export default {
'shareModal.withPluginInfo': 'Include Skill Information',
'shareModal.withRole': 'Include Message Role',
'shareModal.withSystemRole': 'Include Agent Profile',
'sharePage.error.forbidden.subtitle': 'This share is private and not accessible.',
'sharePage.error.forbidden.title': 'Access Denied',
'sharePage.error.notFound.subtitle': 'This topic does not exist or has been removed.',
'sharePage.error.notFound.title': 'Topic Not Found',
'sharePage.error.unauthorized.action': 'Sign In',
'sharePage.error.unauthorized.subtitle': 'Please sign in to view this shared topic.',
'sharePage.error.unauthorized.title': 'Sign In Required',
'sharePageDisclaimer':
'This content is shared by a user and does not represent the views of LobeHub. LobeHub is not responsible for any consequences arising from this shared content.',
'stt.action': 'Voice Input',
'stt.loading': 'Recognizing...',
'stt.prettifying': 'Polishing...',

View file

@ -26,6 +26,7 @@ export const config = {
'/page(.*)',
'/me',
'/me(.*)',
'/share(.*)',
'/desktop-onboarding',
'/desktop-onboarding(.*)',
'/onboarding',

View file

@ -1,13 +1,21 @@
import { CreateMessageParams, UIChatMessage, UpdateMessageRAGParams } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import { MessageModel } from '@/database/models/message';
import { TopicShareModel } from '@/database/models/topicShare';
import { FileService } from '@/server/services/file';
vi.mock('@/database/models/message', () => ({
MessageModel: vi.fn(),
}));
vi.mock('@/database/models/topicShare', () => ({
TopicShareModel: {
findByShareIdWithAccessCheck: vi.fn(),
},
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn(),
}));
@ -345,4 +353,148 @@ describe('messageRouter', () => {
expect(result.rowCount).toBe(5);
});
});
describe('topicShareId support', () => {
it('should get messages via topicShareId for link share', async () => {
const mockShare = {
visibility: 'link',
ownerId: 'owner-user',
shareId: 'share-123',
topicId: 'topic-1',
};
const mockMessages = [
{ id: 'msg1', content: 'Hello', role: 'user' },
{ id: 'msg2', content: 'Hi there', role: 'assistant' },
];
const mockQuery = vi.fn().mockResolvedValue(mockMessages);
const mockGetFullFileUrl = vi
.fn()
.mockImplementation((path: string) => `https://cdn/${path}`);
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
vi.mocked(MessageModel).mockImplementation(
() =>
({
query: mockQuery,
}) as any,
);
vi.mocked(FileService).mockImplementation(
() =>
({
getFullFileUrl: mockGetFullFileUrl,
}) as any,
);
// Simulate the router logic
const share = await TopicShareModel.findByShareIdWithAccessCheck(
{} as any,
'share-123',
undefined,
);
expect(share).toBeDefined();
expect(share.topicId).toBe('topic-1');
expect(share.ownerId).toBe('owner-user');
// Create model using owner's id
const messageModel = new MessageModel({} as any, share.ownerId);
const result = await messageModel.query(
{ topicId: share.topicId },
{ postProcessUrl: mockGetFullFileUrl },
);
expect(result).toEqual(mockMessages);
});
it('should allow owner to access private share messages', async () => {
const mockShare = {
visibility: 'private',
ownerId: 'owner-user',
shareId: 'private-share',
topicId: 'topic-private',
};
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
const share = await TopicShareModel.findByShareIdWithAccessCheck(
{} as any,
'private-share',
'owner-user', // Owner accessing
);
expect(share).toBeDefined();
expect(share.visibility).toBe('private');
});
it('should throw FORBIDDEN for private share accessed by non-owner', async () => {
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
new TRPCError({ code: 'FORBIDDEN', message: 'This share is private' }),
);
await expect(
TopicShareModel.findByShareIdWithAccessCheck({} as any, 'private-share', 'other-user'),
).rejects.toThrow(TRPCError);
try {
await TopicShareModel.findByShareIdWithAccessCheck(
{} as any,
'private-share',
'other-user',
);
} catch (error) {
expect((error as TRPCError).code).toBe('FORBIDDEN');
}
});
it('should throw NOT_FOUND for non-existent share', async () => {
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
new TRPCError({ code: 'NOT_FOUND', message: 'Share not found' }),
);
await expect(
TopicShareModel.findByShareIdWithAccessCheck({} as any, 'non-existent', 'user1'),
).rejects.toThrow(TRPCError);
try {
await TopicShareModel.findByShareIdWithAccessCheck({} as any, 'non-existent', 'user1');
} catch (error) {
expect((error as TRPCError).code).toBe('NOT_FOUND');
}
});
it('should use owner id to query messages for shared topic', async () => {
const mockShare = {
visibility: 'link',
ownerId: 'topic-owner',
shareId: 'share-abc',
topicId: 'shared-topic',
};
const mockQuery = vi.fn().mockResolvedValue([{ id: 'msg1' }]);
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
vi.mocked(MessageModel).mockImplementation(
() =>
({
query: mockQuery,
}) as any,
);
const share = await TopicShareModel.findByShareIdWithAccessCheck(
{} as any,
'share-abc',
undefined,
);
// Verify we use the owner's id to create MessageModel
const messageModel = new MessageModel({} as any, share.ownerId);
await messageModel.query({ topicId: share.topicId }, {});
// Verify MessageModel was instantiated with owner's id
expect(MessageModel).toHaveBeenCalledWith({} as any, 'topic-owner');
expect(mockQuery).toHaveBeenCalledWith({ topicId: 'shared-topic' }, {});
});
});
});

View file

@ -0,0 +1,227 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import { TopicShareModel } from '@/database/models/topicShare';
vi.mock('@/database/models/topicShare', () => ({
TopicShareModel: {
findByShareIdWithAccessCheck: vi.fn(),
incrementPageViewCount: vi.fn(),
},
}));
vi.mock('@/database/server', () => ({
getServerDB: vi.fn(),
}));
describe('shareRouter', () => {
describe('getSharedTopic', () => {
it('should return shared topic data for valid share', async () => {
const mockShare = {
agentAvatar: 'avatar.png',
agentBackgroundColor: '#fff',
agentId: 'agent-1',
agentMarketIdentifier: 'market-id',
agentSlug: 'agent-slug',
agentTitle: 'Test Agent',
groupAvatar: null,
groupBackgroundColor: null,
groupId: null,
groupMembers: undefined,
groupTitle: null,
ownerId: 'user-1',
shareId: 'share-123',
title: 'Test Topic',
topicId: 'topic-1',
visibility: 'link',
};
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare);
vi.mocked(TopicShareModel.incrementPageViewCount).mockResolvedValue(undefined);
const ctx = {
serverDB: {} as any,
userId: 'user-1',
};
const share = await TopicShareModel.findByShareIdWithAccessCheck(
ctx.serverDB,
'share-123',
ctx.userId,
);
expect(share).toBeDefined();
expect(share.shareId).toBe('share-123');
expect(share.topicId).toBe('topic-1');
expect(share.title).toBe('Test Topic');
expect(share.visibility).toBe('link');
// Verify incrementPageViewCount would be called
await TopicShareModel.incrementPageViewCount(ctx.serverDB, 'share-123');
expect(TopicShareModel.incrementPageViewCount).toHaveBeenCalledWith(
ctx.serverDB,
'share-123',
);
});
it('should return agent meta when share has agent', async () => {
const mockShare = {
agentAvatar: 'avatar.png',
agentBackgroundColor: '#ffffff',
agentId: 'agent-1',
agentMarketIdentifier: 'market-agent',
agentSlug: 'test-agent',
agentTitle: 'Test Agent Title',
groupAvatar: null,
groupBackgroundColor: null,
groupId: null,
groupMembers: undefined,
groupTitle: null,
ownerId: 'user-1',
shareId: 'share-123',
title: 'Topic with Agent',
topicId: 'topic-1',
visibility: 'link',
};
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare);
const ctx = {
serverDB: {} as any,
userId: null,
};
const share = await TopicShareModel.findByShareIdWithAccessCheck(
ctx.serverDB,
'share-123',
undefined,
);
expect(share.agentId).toBe('agent-1');
expect(share.agentAvatar).toBe('avatar.png');
expect(share.agentTitle).toBe('Test Agent Title');
expect(share.agentMarketIdentifier).toBe('market-agent');
expect(share.agentSlug).toBe('test-agent');
});
it('should return group meta when share has group', async () => {
const mockShare = {
agentAvatar: null,
agentBackgroundColor: null,
agentId: null,
agentMarketIdentifier: null,
agentSlug: null,
agentTitle: null,
groupAvatar: 'group-avatar.png',
groupBackgroundColor: '#000000',
groupId: 'group-1',
groupMembers: [
{ avatar: 'member1.png', backgroundColor: '#111' },
{ avatar: 'member2.png', backgroundColor: '#222' },
],
groupTitle: 'Test Group',
ownerId: 'user-1',
shareId: 'share-456',
title: 'Group Topic',
topicId: 'topic-2',
visibility: 'link',
};
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare);
const ctx = {
serverDB: {} as any,
userId: 'user-2',
};
const share = await TopicShareModel.findByShareIdWithAccessCheck(
ctx.serverDB,
'share-456',
ctx.userId,
);
expect(share.groupId).toBe('group-1');
expect(share.groupTitle).toBe('Test Group');
expect(share.groupAvatar).toBe('group-avatar.png');
expect(share.groupMembers).toHaveLength(2);
});
it('should throw NOT_FOUND for non-existent share', async () => {
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
new TRPCError({ code: 'NOT_FOUND', message: 'Share not found' }),
);
const ctx = {
serverDB: {} as any,
userId: 'user-1',
};
await expect(
TopicShareModel.findByShareIdWithAccessCheck(ctx.serverDB, 'non-existent', ctx.userId),
).rejects.toThrow(TRPCError);
});
it('should throw FORBIDDEN for private share accessed by non-owner', async () => {
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
new TRPCError({ code: 'FORBIDDEN', message: 'This share is private' }),
);
const ctx = {
serverDB: {} as any,
userId: 'other-user',
};
await expect(
TopicShareModel.findByShareIdWithAccessCheck(ctx.serverDB, 'private-share', ctx.userId),
).rejects.toThrow(TRPCError);
try {
await TopicShareModel.findByShareIdWithAccessCheck(
ctx.serverDB,
'private-share',
ctx.userId,
);
} catch (error) {
expect((error as TRPCError).code).toBe('FORBIDDEN');
}
});
it('should allow owner to access private share', async () => {
const mockShare = {
agentAvatar: null,
agentBackgroundColor: null,
agentId: null,
agentMarketIdentifier: null,
agentSlug: null,
agentTitle: null,
groupAvatar: null,
groupBackgroundColor: null,
groupId: null,
groupMembers: undefined,
groupTitle: null,
ownerId: 'owner-user',
shareId: 'private-share',
title: 'Private Topic',
topicId: 'topic-private',
visibility: 'private',
};
vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare);
const ctx = {
serverDB: {} as any,
userId: 'owner-user',
};
const share = await TopicShareModel.findByShareIdWithAccessCheck(
ctx.serverDB,
'private-share',
ctx.userId,
);
expect(share).toBeDefined();
expect(share.ownerId).toBe('owner-user');
expect(share.visibility).toBe('private');
});
});
});

View file

@ -1,11 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { TopicModel } from '@/database/models/topic';
import { TopicShareModel } from '@/database/models/topicShare';
vi.mock('@/database/models/topic', () => ({
TopicModel: vi.fn(),
}));
vi.mock('@/database/models/topicShare', () => ({
TopicShareModel: vi.fn(),
}));
vi.mock('@/database/server', () => ({
getServerDB: vi.fn(),
}));
@ -260,4 +265,173 @@ describe('topicRouter', () => {
expect(result).toEqual([{ id: 'topic1', title: 'Test' }]);
});
});
describe('topic sharing', () => {
it('should handle enableSharing with default visibility', async () => {
const mockCreate = vi.fn().mockResolvedValue({
id: 'share-123',
topicId: 'topic1',
userId: 'user1',
visibility: 'private',
});
vi.mocked(TopicShareModel).mockImplementation(
() =>
({
create: mockCreate,
}) as any,
);
const ctx = {
topicShareModel: new TopicShareModel({} as any, 'user1'),
};
const result = await ctx.topicShareModel.create('topic1');
expect(mockCreate).toHaveBeenCalledWith('topic1');
expect(result.id).toBe('share-123');
expect(result.visibility).toBe('private');
});
it('should handle enableSharing with link visibility', async () => {
const mockCreate = vi.fn().mockResolvedValue({
id: 'share-456',
topicId: 'topic1',
userId: 'user1',
visibility: 'link',
});
vi.mocked(TopicShareModel).mockImplementation(
() =>
({
create: mockCreate,
}) as any,
);
const ctx = {
topicShareModel: new TopicShareModel({} as any, 'user1'),
};
const result = await ctx.topicShareModel.create('topic1', 'link');
expect(mockCreate).toHaveBeenCalledWith('topic1', 'link');
expect(result.visibility).toBe('link');
});
it('should handle disableSharing', async () => {
const mockDeleteByTopicId = vi.fn().mockResolvedValue(undefined);
vi.mocked(TopicShareModel).mockImplementation(
() =>
({
deleteByTopicId: mockDeleteByTopicId,
}) as any,
);
const ctx = {
topicShareModel: new TopicShareModel({} as any, 'user1'),
};
await ctx.topicShareModel.deleteByTopicId('topic1');
expect(mockDeleteByTopicId).toHaveBeenCalledWith('topic1');
});
it('should handle updateShareVisibility', async () => {
const mockUpdateVisibility = vi.fn().mockResolvedValue({
id: 'share-123',
topicId: 'topic1',
visibility: 'link',
});
vi.mocked(TopicShareModel).mockImplementation(
() =>
({
updateVisibility: mockUpdateVisibility,
}) as any,
);
const ctx = {
topicShareModel: new TopicShareModel({} as any, 'user1'),
};
const result = await ctx.topicShareModel.updateVisibility('topic1', 'link');
expect(mockUpdateVisibility).toHaveBeenCalledWith('topic1', 'link');
expect(result.visibility).toBe('link');
});
it('should handle getShareInfo', async () => {
const mockGetByTopicId = vi.fn().mockResolvedValue({
id: 'share-123',
topicId: 'topic1',
visibility: 'link',
});
vi.mocked(TopicShareModel).mockImplementation(
() =>
({
getByTopicId: mockGetByTopicId,
}) as any,
);
const ctx = {
topicShareModel: new TopicShareModel({} as any, 'user1'),
};
const result = await ctx.topicShareModel.getByTopicId('topic1');
expect(mockGetByTopicId).toHaveBeenCalledWith('topic1');
expect(result).toEqual({
id: 'share-123',
topicId: 'topic1',
visibility: 'link',
});
});
it('should return null when getShareInfo for non-shared topic', async () => {
const mockGetByTopicId = vi.fn().mockResolvedValue(null);
vi.mocked(TopicShareModel).mockImplementation(
() =>
({
getByTopicId: mockGetByTopicId,
}) as any,
);
const ctx = {
topicShareModel: new TopicShareModel({} as any, 'user1'),
};
const result = await ctx.topicShareModel.getByTopicId('non-shared-topic');
expect(mockGetByTopicId).toHaveBeenCalledWith('non-shared-topic');
expect(result).toBeNull();
});
it('should handle all visibility types', async () => {
const mockCreate = vi.fn();
vi.mocked(TopicShareModel).mockImplementation(
() =>
({
create: mockCreate,
}) as any,
);
const ctx = {
topicShareModel: new TopicShareModel({} as any, 'user1'),
};
// Test private visibility
mockCreate.mockResolvedValueOnce({ visibility: 'private' });
await ctx.topicShareModel.create('topic1', 'private');
expect(mockCreate).toHaveBeenLastCalledWith('topic1', 'private');
// Test link visibility
mockCreate.mockResolvedValueOnce({ visibility: 'link' });
await ctx.topicShareModel.create('topic2', 'link');
expect(mockCreate).toHaveBeenLastCalledWith('topic2', 'link');
});
});
});

View file

@ -37,6 +37,7 @@ import { ragEvalRouter } from './ragEval';
import { searchRouter } from './search';
import { sessionRouter } from './session';
import { sessionGroupRouter } from './sessionGroup';
import { shareRouter } from './share';
import { threadRouter } from './thread';
import { topicRouter } from './topic';
import { uploadRouter } from './upload';
@ -77,6 +78,7 @@ export const lambdaRouter = router({
search: searchRouter,
session: sessionRouter,
sessionGroup: sessionGroupRouter,
share: shareRouter,
thread: threadRouter,
topic: topicRouter,
upload: uploadRouter,

View file

@ -4,10 +4,12 @@ import {
UpdateMessagePluginSchema,
UpdateMessageRAGParamsSchema,
} from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { MessageModel } from '@/database/models/message';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { TopicShareModel } from '@/database/models/topicShare';
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
@ -89,7 +91,8 @@ export const messageRouter = router({
return ctx.messageModel.getHeatmaps();
}),
getMessages: messageProcedure
getMessages: publicProcedure
.use(serverDatabase)
.input(
z.object({
agentId: z.string().nullable().optional(),
@ -99,11 +102,41 @@ export const messageRouter = router({
sessionId: z.string().nullable().optional(),
threadId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
topicShareId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
return ctx.messageModel.query(input, {
postProcessUrl: (path) => ctx.fileService.getFullFileUrl(path),
const { topicShareId, ...queryParams } = input;
// Public access via topicShareId
if (topicShareId) {
const share = await TopicShareModel.findByShareIdWithAccessCheck(
ctx.serverDB,
topicShareId,
ctx.userId ?? undefined,
);
const messageModel = new MessageModel(ctx.serverDB, share.ownerId);
const fileService = new FileService(ctx.serverDB, share.ownerId);
return messageModel.query(
{ ...queryParams, topicId: share.topicId },
{
postProcessUrl: (path) => fileService.getFullFileUrl(path),
},
);
}
// Authenticated access - require userId
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Authentication required' });
}
const messageModel = new MessageModel(ctx.serverDB, ctx.userId);
const fileService = new FileService(ctx.serverDB, ctx.userId);
return messageModel.query(queryParams, {
postProcessUrl: (path) => fileService.getFullFileUrl(path),
});
}),

View file

@ -0,0 +1,55 @@
import type { SharedTopicData } from '@lobechat/types';
import { z } from 'zod';
import { TopicShareModel } from '@/database/models/topicShare';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
export const shareRouter = router({
/**
* Get shared topic metadata for public access
* Uses shareId (not topicId) for access
* Visibility check: owner can always access, others depend on visibility setting
*/
getSharedTopic: publicProcedure
.use(serverDatabase)
.input(z.object({ shareId: z.string() }))
.query(async ({ input, ctx }): Promise<SharedTopicData> => {
const share = await TopicShareModel.findByShareIdWithAccessCheck(
ctx.serverDB,
input.shareId,
ctx.userId ?? undefined,
);
// Increment page view count after visibility check passes
await TopicShareModel.incrementPageViewCount(ctx.serverDB, input.shareId);
return {
agentId: share.agentId,
agentMeta: share.agentId
? {
avatar: share.agentAvatar,
backgroundColor: share.agentBackgroundColor,
marketIdentifier: share.agentMarketIdentifier,
slug: share.agentSlug,
title: share.agentTitle,
}
: undefined,
groupId: share.groupId,
groupMeta: share.groupId
? {
avatar: share.groupAvatar,
backgroundColor: share.groupBackgroundColor,
members: share.groupMembers,
title: share.groupTitle,
}
: undefined,
shareId: share.shareId,
title: share.title,
topicId: share.topicId,
visibility: share.visibility as SharedTopicData['visibility'],
};
}),
});
export type ShareRouter = typeof shareRouter;

View file

@ -8,6 +8,7 @@ import { after } from 'next/server';
import { z } from 'zod';
import { TopicModel } from '@/database/models/topic';
import { TopicShareModel } from '@/database/models/topicShare';
import { AgentMigrationRepo } from '@/database/repositories/agentMigration';
import { TopicImporterRepo } from '@/database/repositories/topicImporter';
import { agents, chatGroups, chatGroupsAgents } from '@/database/schemas';
@ -30,6 +31,7 @@ const topicProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
agentMigrationRepo: new AgentMigrationRepo(ctx.serverDB, ctx.userId),
topicImporterRepo: new TopicImporterRepo(ctx.serverDB, ctx.userId),
topicModel: new TopicModel(ctx.serverDB, ctx.userId),
topicShareModel: new TopicShareModel(ctx.serverDB, ctx.userId),
},
});
});
@ -138,6 +140,29 @@ export const topicRouter = router({
return data.id;
}),
/**
* Disable sharing for a topic (deletes share record)
*/
disableSharing: topicProcedure
.input(z.object({ topicId: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.topicShareModel.deleteByTopicId(input.topicId);
}),
/**
* Enable sharing for a topic (creates share record)
*/
enableSharing: topicProcedure
.input(
z.object({
topicId: z.string(),
visibility: z.enum(['private', 'link']).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
return ctx.topicShareModel.create(input.topicId, input.visibility);
}),
getAllTopics: topicProcedure.query(async ({ ctx }) => {
return ctx.topicModel.queryAll();
}),
@ -148,6 +173,12 @@ export const topicRouter = router({
return ctx.topicModel.getCronTopicsGroupedByCronJob(input.agentId);
}),
getShareInfo: topicProcedure
.input(z.object({ topicId: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.topicShareModel.getByTopicId(input.topicId);
}),
getTopics: topicProcedure
.input(
z.object({
@ -419,6 +450,20 @@ export const topicRouter = router({
return ctx.topicModel.queryByKeyword(input.keywords, resolved.sessionId);
}),
/**
* Update share visibility
*/
updateShareVisibility: topicProcedure
.input(
z.object({
topicId: z.string(),
visibility: z.enum(['private', 'link']),
}),
)
.mutation(async ({ input, ctx }) => {
return ctx.topicShareModel.updateVisibility(input.topicId, input.visibility);
}),
updateTopic: topicProcedure
.input(
z.object({

View file

@ -28,6 +28,7 @@ export interface MessageQueryContext {
groupId?: string;
threadId?: string | null;
topicId?: string | null;
topicShareId?: string;
}
export class MessageService {

View file

@ -85,6 +85,22 @@ export class TopicService {
return lambdaClient.topic.updateTopicMetadata.mutate({ id, metadata });
};
getShareInfo = (topicId: string) => {
return lambdaClient.topic.getShareInfo.query({ topicId });
};
enableSharing = (topicId: string, visibility?: 'private' | 'link') => {
return lambdaClient.topic.enableSharing.mutate({ topicId, visibility });
};
updateShareVisibility = (topicId: string, visibility: 'private' | 'link') => {
return lambdaClient.topic.updateShareVisibility.mutate({ topicId, visibility });
};
disableSharing = (topicId: string) => {
return lambdaClient.topic.disableSharing.mutate({ topicId });
};
removeTopic = (id: string) => {
return lambdaClient.topic.removeTopic.mutate({ id });
};