mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ feat(share): add topic sharing functionality (#11448)
This commit is contained in:
parent
97a091d358
commit
ddca1652bb
70 changed files with 12470 additions and 696 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 环境中
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ description: 桌面端测试
|
|||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 桌面端控制器单元测试指南
|
||||
|
||||
## 测试框架与目录结构
|
||||
|
||||
LobeChat 桌面端使用 Vitest 作为测试框架。控制器的单元测试应放置在对应控制器文件同级的 `__tests__` 目录下,并以原控制器文件名加 `.test.ts` 作为文件名。
|
||||
|
||||
```
|
||||
```plaintext
|
||||
apps/desktop/src/main/controllers/
|
||||
├── __tests__/
|
||||
│ ├── index.test.ts
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 的插件系统中。
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 如何添加新的快捷键:开发者指南
|
||||
|
||||
本指南将带您一步步地向 LobeChat 添加一个新的快捷键功能。我们将通过一个完整示例,演示从定义到实现的整个过程。
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export default {
|
|||
- `sync.status.ready` - Feature + group + status
|
||||
|
||||
**Parameters:** Use `{{variableName}}` syntax
|
||||
|
||||
```typescript
|
||||
'alert.cloud.desc': '我们提供 {{credit}} 额度积分',
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
description: Best practices for testing Zustand store actions
|
||||
globs: 'src/store/**/*.test.ts'
|
||||
globs: src/store/**/*.test.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 图像生成
|
||||
|
|
|
|||
|
|
@ -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]'`
|
||||
|
|
|
|||
|
|
@ -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]'`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -253,6 +253,8 @@
|
|||
"sessionGroup.sorting": "正在更新排序…",
|
||||
"sessionGroup.tooLong": "分组名称长度需为 1–20 个字符",
|
||||
"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": "润色中…",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
22
packages/database/migrations/0069_add_topic_shares_table.sql
Normal file
22
packages/database/migrations/0069_add_topic_shares_table.sql
Normal 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");
|
||||
9704
packages/database/migrations/meta/0069_snapshot.json
Normal file
9704
packages/database/migrations/meta/0069_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
318
packages/database/src/models/__tests__/topicShare.test.ts
Normal file
318
packages/database/src/models/__tests__/topicShare.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
177
packages/database/src/models/topicShare.ts
Normal file
177
packages/database/src/models/topicShare.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
54
src/app/[variants]/share/t/[id]/SharedMessageList.tsx
Normal file
54
src/app/[variants]/share/t/[id]/SharedMessageList.tsx
Normal 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;
|
||||
170
src/app/[variants]/share/t/[id]/_layout/index.tsx
Normal file
170
src/app/[variants]/share/t/[id]/_layout/index.tsx
Normal 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;
|
||||
66
src/app/[variants]/share/t/[id]/features/Portal/index.tsx
Normal file
66
src/app/[variants]/share/t/[id]/features/Portal/index.tsx
Normal 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;
|
||||
112
src/app/[variants]/share/t/[id]/index.tsx
Normal file
112
src/app/[variants]/share/t/[id]/index.tsx
Normal 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;
|
||||
|
|
@ -26,7 +26,7 @@ const robots = (): MetadataRoute.Robots => {
|
|||
},
|
||||
{
|
||||
allow: ['/'],
|
||||
disallow: ['/api/*', '/login', '/signup', '/knowledge/*'],
|
||||
disallow: ['/api/*', '/login', '/signup', '/knowledge/*', '/share/*'],
|
||||
userAgent: '*',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { RouteConfig } from '@/utils/router';
|
||||
import { type RouteConfig } from '@/utils/router';
|
||||
|
||||
export const BusinessMobileRoutesWithMainLayout: RouteConfig[] = [];
|
||||
export const BusinessMobileRoutesWithSettingsLayout: RouteConfig[] = [];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ const MessageItem = memo<MessageItemProps>(
|
|||
}
|
||||
|
||||
case 'tool': {
|
||||
return <ToolMessage id={id} index={index} />;
|
||||
return <ToolMessage disableEditing={disableEditing} id={id} index={index} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
215
src/features/SharePopover/index.tsx
Normal file
215
src/features/SharePopover/index.tsx
Normal 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;
|
||||
10
src/features/SharePopover/style.ts
Normal file
10
src/features/SharePopover/style.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createStyles } from 'antd-style';
|
||||
|
||||
export const useStyles = createStyles(({ css }) => ({
|
||||
container: css`
|
||||
padding: 16px;
|
||||
`,
|
||||
hint: css`
|
||||
font-size: 12px;
|
||||
`,
|
||||
}));
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const config = {
|
|||
'/page(.*)',
|
||||
'/me',
|
||||
'/me(.*)',
|
||||
'/share(.*)',
|
||||
'/desktop-onboarding',
|
||||
'/desktop-onboarding(.*)',
|
||||
'/onboarding',
|
||||
|
|
|
|||
|
|
@ -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' }, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
227
src/server/routers/lambda/__tests__/share.test.ts
Normal file
227
src/server/routers/lambda/__tests__/share.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}),
|
||||
|
||||
|
|
|
|||
55
src/server/routers/lambda/share.ts
Normal file
55
src/server/routers/lambda/share.ts
Normal 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;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface MessageQueryContext {
|
|||
groupId?: string;
|
||||
threadId?: string | null;
|
||||
topicId?: string | null;
|
||||
topicShareId?: string;
|
||||
}
|
||||
|
||||
export class MessageService {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue