mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🐛 fix: fix model edit icon missing (#11105)
* 🐛 fix: fix model edit icon missing
* fix stats welcome
* refactor pglite db case
* fix e2e tests
* update docs
This commit is contained in:
@@ -64,6 +64,36 @@ HEADLESS=false pnpm exec cucumber-js --config cucumber.config.js --tags "@smoke"
|
||||
|
||||
## 常见问题
|
||||
|
||||
### waitForLoadState ('networkidle') 超时
|
||||
|
||||
**原因**: `networkidle` 表示 500ms 内没有网络请求。在 CI 环境中,由于分析脚本、外部资源加载、轮询等持续网络活动,这个状态可能永远无法达到。
|
||||
|
||||
**错误示例**:
|
||||
|
||||
```
|
||||
page.waitForLoadState: Timeout 10000ms exceeded.
|
||||
=========================== logs ===========================
|
||||
"load" event fired
|
||||
============================================================
|
||||
```
|
||||
|
||||
**解决**:
|
||||
|
||||
- **避免使用 `networkidle`** - 这是不可靠的等待策略
|
||||
- **直接等待目标元素** - 使用 `expect(element).toBeVisible({ timeout: 30_000 })` 替代
|
||||
- 如果必须等待页面加载完成,使用 `domcontentloaded` 或 `load` 事件
|
||||
|
||||
```typescript
|
||||
// ❌ 不推荐 - networkidle 在 CI 中容易超时
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
const element = this.page.locator('[data-testid="my-element"]');
|
||||
await expect(element).toBeVisible();
|
||||
|
||||
// ✅ 推荐 - 直接等待目标元素
|
||||
const element = this.page.locator('[data-testid="my-element"]');
|
||||
await expect(element).toBeVisible({ timeout: 30_000 });
|
||||
```
|
||||
|
||||
### 测试超时 (function timed out)
|
||||
|
||||
**原因**: 元素定位失败或等待时间不足
|
||||
|
||||
@@ -9,50 +9,40 @@ import { CustomWorld } from '../../support/world';
|
||||
|
||||
// Home Page Steps
|
||||
Then('I should see the featured assistants section', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Look for "Featured Agents" heading text (i18n key: home.featuredAssistants)
|
||||
// Supports: en-US "Featured Agents", zh-CN "推荐助理"
|
||||
const featuredSection = this.page
|
||||
.getByRole('heading', { name: /featured agents|推荐助理/i })
|
||||
.first();
|
||||
await expect(featuredSection).toBeVisible({ timeout: 10_000 });
|
||||
await expect(featuredSection).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
Then('I should see the featured MCP tools section', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Look for "Featured Skills" heading text (i18n key: home.featuredTools)
|
||||
// Supports: en-US "Featured Skills", zh-CN "推荐技能"
|
||||
const mcpSection = this.page.getByRole('heading', { name: /featured skills|推荐技能/i }).first();
|
||||
await expect(mcpSection).toBeVisible({ timeout: 10_000 });
|
||||
await expect(mcpSection).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// Assistant List Page Steps
|
||||
Then('I should see the search bar', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// SearchBar component has data-testid="search-bar"
|
||||
const searchBar = this.page.locator('[data-testid="search-bar"]').first();
|
||||
await expect(searchBar).toBeVisible({ timeout: 10_000 });
|
||||
await expect(searchBar).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
Then('I should see the category menu', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// CategoryMenu component has data-testid="category-menu"
|
||||
const categoryMenu = this.page.locator('[data-testid="category-menu"]').first();
|
||||
await expect(categoryMenu).toBeVisible({ timeout: 10_000 });
|
||||
await expect(categoryMenu).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
Then('I should see assistant cards', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Look for assistant items by data-testid
|
||||
const assistantItems = this.page.locator('[data-testid="assistant-item"]');
|
||||
|
||||
// Wait for at least one item to be visible
|
||||
await expect(assistantItems.first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(assistantItems.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Check we have multiple items
|
||||
const count = await assistantItems.count();
|
||||
@@ -60,22 +50,18 @@ Then('I should see assistant cards', async function (this: CustomWorld) {
|
||||
});
|
||||
|
||||
Then('I should see pagination controls', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Pagination component has data-testid="pagination"
|
||||
const pagination = this.page.locator('[data-testid="pagination"]').first();
|
||||
await expect(pagination).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pagination).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// Model List Page Steps
|
||||
Then('I should see model cards', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Model items have data-testid="model-item"
|
||||
const modelItems = this.page.locator('[data-testid="model-item"]');
|
||||
|
||||
// Wait for at least one item to be visible
|
||||
await expect(modelItems.first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(modelItems.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Check we have multiple items
|
||||
const count = await modelItems.count();
|
||||
@@ -83,22 +69,18 @@ Then('I should see model cards', async function (this: CustomWorld) {
|
||||
});
|
||||
|
||||
Then('I should see the sort dropdown', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// SortButton has data-testid="sort-dropdown"
|
||||
const sortDropdown = this.page.locator('[data-testid="sort-dropdown"]').first();
|
||||
await expect(sortDropdown).toBeVisible({ timeout: 10_000 });
|
||||
await expect(sortDropdown).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// Provider List Page Steps
|
||||
Then('I should see provider cards', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Look for provider items by data-testid
|
||||
const providerItems = this.page.locator('[data-testid="provider-item"]');
|
||||
|
||||
// Wait for at least one item to be visible
|
||||
await expect(providerItems.first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(providerItems.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Check we have multiple items
|
||||
const count = await providerItems.count();
|
||||
@@ -107,13 +89,11 @@ Then('I should see provider cards', async function (this: CustomWorld) {
|
||||
|
||||
// MCP List Page Steps
|
||||
Then('I should see MCP cards', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Look for MCP items by data-testid
|
||||
const mcpItems = this.page.locator('[data-testid="mcp-item"]');
|
||||
|
||||
// Wait for at least one item to be visible
|
||||
await expect(mcpItems.first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(mcpItems.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Check we have multiple items
|
||||
const count = await mcpItems.count();
|
||||
@@ -121,9 +101,7 @@ Then('I should see MCP cards', async function (this: CustomWorld) {
|
||||
});
|
||||
|
||||
Then('I should see the category filter', async function (this: CustomWorld) {
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// CategoryMenu component has data-testid="category-menu" (shared across list pages)
|
||||
const categoryFilter = this.page.locator('[data-testid="category-menu"]').first();
|
||||
await expect(categoryFilter).toBeVisible({ timeout: 10_000 });
|
||||
await expect(categoryFilter).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
"stats.topicsRank.right": "消息数",
|
||||
"stats.topicsRank.title": "话题内容量",
|
||||
"stats.updatedAt": "更新至",
|
||||
"stats.welcome": "{{name}},这是你与 {{appName}} 一起记录协作的第 <span>{{days}}</span> 天",
|
||||
"stats.welcome": "{{username}},这是你与 {{appName}} 一起记录协作的第 <span>{{days}}</span> 天",
|
||||
"stats.words": "累计字数",
|
||||
"tab.apikey": "API Key",
|
||||
"tab.profile": "账号",
|
||||
|
||||
@@ -154,7 +154,6 @@
|
||||
"@codesandbox/sandpack-react": "^2.20.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electric-sql/pglite": "0.2.17",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fal-ai/client": "^1.8.0",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electric-sql/pglite": "^0.3.14",
|
||||
"dotenv": "^17.2.3",
|
||||
"fake-indexeddb": "^6.2.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@electric-sql/pglite": "^0.2.17",
|
||||
"dayjs": ">=1.11.19",
|
||||
"drizzle-orm": ">=0.44.7",
|
||||
"nanoid": ">=5.1.6",
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { ClientDBLoadingProgress, DatabaseLoadingState } from '@lobechat/types';
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DatabaseManager } from './db';
|
||||
|
||||
// Mock 所有外部依赖
|
||||
vi.mock('@electric-sql/pglite', () => ({
|
||||
default: vi.fn(),
|
||||
IdbFs: vi.fn(),
|
||||
PGlite: vi.fn(),
|
||||
MemoryFS: vi.fn(),
|
||||
PGlite: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@electric-sql/pglite/vector', () => ({
|
||||
default: vi.fn(),
|
||||
vector: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -24,154 +17,36 @@ vi.mock('drizzle-orm/pglite', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
let manager: DatabaseManager;
|
||||
let progressEvents: ClientDBLoadingProgress[] = [];
|
||||
let stateChanges: DatabaseLoadingState[] = [];
|
||||
|
||||
let callbacks = {
|
||||
onProgress: vi.fn((progress: ClientDBLoadingProgress) => {
|
||||
progressEvents.push(progress);
|
||||
}),
|
||||
onStateChange: vi.fn((state: DatabaseLoadingState) => {
|
||||
stateChanges.push(state);
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
progressEvents = [];
|
||||
stateChanges = [];
|
||||
|
||||
callbacks = {
|
||||
onProgress: vi.fn((progress: ClientDBLoadingProgress) => {
|
||||
progressEvents.push(progress);
|
||||
}),
|
||||
onStateChange: vi.fn((state: DatabaseLoadingState) => {
|
||||
stateChanges.push(state);
|
||||
}),
|
||||
};
|
||||
// @ts-expect-error
|
||||
DatabaseManager['instance'] = undefined;
|
||||
manager = DatabaseManager.getInstance();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('DatabaseManager', () => {
|
||||
describe('Callback Handling', () => {
|
||||
it(
|
||||
'should properly track loading states',
|
||||
async () => {
|
||||
await manager.initialize(callbacks);
|
||||
describe('initializeDB', () => {
|
||||
it('should initialize database with PGlite', async () => {
|
||||
const { initializeDB } = await import('./db');
|
||||
await initializeDB();
|
||||
|
||||
// 验证状态转换顺序
|
||||
expect(stateChanges).toEqual([
|
||||
DatabaseLoadingState.Initializing,
|
||||
DatabaseLoadingState.LoadingDependencies,
|
||||
DatabaseLoadingState.LoadingWasm,
|
||||
DatabaseLoadingState.Migrating,
|
||||
DatabaseLoadingState.Finished,
|
||||
DatabaseLoadingState.Ready,
|
||||
]);
|
||||
},
|
||||
{
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
|
||||
it('should report dependencies loading progress', async () => {
|
||||
await manager.initialize(callbacks);
|
||||
|
||||
// 验证依赖加载进度回调
|
||||
const dependencyProgress = progressEvents.filter((e) => e.phase === 'dependencies');
|
||||
expect(dependencyProgress.length).toBeGreaterThan(0);
|
||||
expect(dependencyProgress[dependencyProgress.length - 1]).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'dependencies',
|
||||
progress: 100,
|
||||
costTime: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should report WASM loading progress', async () => {
|
||||
await manager.initialize(callbacks);
|
||||
|
||||
// 验证 WASM 加载进度回调
|
||||
const wasmProgress = progressEvents.filter((e) => e.phase === 'wasm');
|
||||
// expect(wasmProgress.length).toBeGreaterThan(0);
|
||||
expect(wasmProgress[wasmProgress.length - 1]).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'wasm',
|
||||
progress: 100,
|
||||
costTime: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
// 模拟加载失败
|
||||
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await expect(manager.initialize(callbacks)).rejects.toThrow();
|
||||
expect(stateChanges).toContain(DatabaseLoadingState.Error);
|
||||
});
|
||||
|
||||
it('should only initialize once when called multiple times', async () => {
|
||||
const firstInit = manager.initialize(callbacks);
|
||||
const secondInit = manager.initialize(callbacks);
|
||||
|
||||
await Promise.all([firstInit, secondInit]);
|
||||
|
||||
// 验证回调只被调用一次
|
||||
const readyStateCount = stateChanges.filter(
|
||||
(state) => state === DatabaseLoadingState.Ready,
|
||||
).length;
|
||||
expect(readyStateCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress Calculation', () => {
|
||||
it('should report progress between 0 and 100', async () => {
|
||||
await manager.initialize(callbacks);
|
||||
|
||||
// 验证所有进度值都在有效范围内
|
||||
progressEvents.forEach((event) => {
|
||||
expect(event.progress).toBeGreaterThanOrEqual(0);
|
||||
expect(event.progress).toBeLessThanOrEqual(100);
|
||||
expect(PGlite).toHaveBeenCalledWith('idb://lobechat', {
|
||||
extensions: { vector: expect.any(Function) },
|
||||
relaxedDurability: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include timing information', async () => {
|
||||
await manager.initialize(callbacks);
|
||||
it('should only initialize once when called multiple times', async () => {
|
||||
const { initializeDB } = await import('./db');
|
||||
await Promise.all([initializeDB(), initializeDB()]);
|
||||
|
||||
// 验证最终进度回调包含耗时信息
|
||||
const finalProgress = progressEvents[progressEvents.length - 1];
|
||||
expect(finalProgress.costTime).toBeGreaterThan(0);
|
||||
expect(PGlite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle missing callbacks gracefully', async () => {
|
||||
// 测试没有提供回调的情况
|
||||
await expect(manager.initialize()).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle partial callbacks', async () => {
|
||||
// 只提供部分回调
|
||||
await expect(manager.initialize({ onProgress: callbacks.onProgress })).resolves.toBeDefined();
|
||||
await expect(
|
||||
manager.initialize({ onStateChange: callbacks.onStateChange }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Database Access', () => {
|
||||
it('should throw error when accessing database before initialization', () => {
|
||||
expect(() => manager.db).toThrow('Database not initialized');
|
||||
});
|
||||
|
||||
describe('clientDB proxy', () => {
|
||||
it('should provide access to database after initialization', async () => {
|
||||
await manager.initialize();
|
||||
expect(() => manager.db).not.toThrow();
|
||||
const { clientDB, initializeDB } = await import('./db');
|
||||
await initializeDB();
|
||||
expect(clientDB).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +1,24 @@
|
||||
import {
|
||||
type ClientDBLoadingProgress,
|
||||
DatabaseLoadingState,
|
||||
type MigrationSQL,
|
||||
type MigrationTableItem,
|
||||
} from '@lobechat/types';
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
import { vector } from '@electric-sql/pglite/vector';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { PgliteDatabase, drizzle } from 'drizzle-orm/pglite';
|
||||
import { Md5 } from 'ts-md5';
|
||||
|
||||
import { sleep } from '@/utils/sleep';
|
||||
|
||||
import migrations from '../core/migrations.json';
|
||||
import { DrizzleMigrationModel } from '../models/drizzleMigration';
|
||||
import * as schema from '../schemas';
|
||||
|
||||
const pgliteSchemaHashCache = 'LOBE_CHAT_PGLITE_SCHEMA_HASH';
|
||||
|
||||
const DB_NAME = 'lobechat';
|
||||
|
||||
type DrizzleInstance = PgliteDatabase<typeof schema>;
|
||||
|
||||
interface onErrorState {
|
||||
error: Error;
|
||||
migrationTableItems: MigrationTableItem[];
|
||||
migrationsSQL: MigrationSQL[];
|
||||
}
|
||||
|
||||
export interface DatabaseLoadingCallbacks {
|
||||
onError?: (error: onErrorState) => void;
|
||||
onProgress?: (progress: ClientDBLoadingProgress) => void;
|
||||
onStateChange?: (state: DatabaseLoadingState) => void;
|
||||
}
|
||||
|
||||
export class DatabaseManager {
|
||||
class DatabaseManager {
|
||||
private static instance: DatabaseManager;
|
||||
private dbInstance: DrizzleInstance | null = null;
|
||||
private initPromise: Promise<DrizzleInstance> | null = null;
|
||||
private callbacks?: DatabaseLoadingCallbacks;
|
||||
private isLocalDBSchemaSynced = false;
|
||||
|
||||
// CDN configuration
|
||||
private static WASM_CDN_URL =
|
||||
'https://registry.npmmirror.com/@electric-sql/pglite/0.2.17/files/dist/postgres.wasm';
|
||||
|
||||
private static FSBUNDLER_CDN_URL =
|
||||
'https://registry.npmmirror.com/@electric-sql/pglite/0.2.17/files/dist/postgres.data';
|
||||
|
||||
private static VECTOR_CDN_URL =
|
||||
'https://registry.npmmirror.com/@electric-sql/pglite/0.2.17/files/dist/vector.tar.gz';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance() {
|
||||
@@ -57,108 +28,8 @@ export class DatabaseManager {
|
||||
return DatabaseManager.instance;
|
||||
}
|
||||
|
||||
// Load and compile WASM module
|
||||
private async loadWasmModule(): Promise<WebAssembly.Module> {
|
||||
const start = Date.now();
|
||||
this.callbacks?.onStateChange?.(DatabaseLoadingState.LoadingWasm);
|
||||
|
||||
const response = await fetch(DatabaseManager.WASM_CDN_URL);
|
||||
|
||||
const contentLength = Number(response.headers.get('Content-Length')) || 0;
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) throw new Error('Failed to start WASM download');
|
||||
|
||||
let receivedLength = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
// Read data stream
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
|
||||
// Calculate and report progress
|
||||
const progress = Math.min(Math.round((receivedLength / contentLength) * 100), 100);
|
||||
this.callbacks?.onProgress?.({
|
||||
phase: 'wasm',
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge data chunks
|
||||
const wasmBytes = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
wasmBytes.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
|
||||
this.callbacks?.onProgress?.({
|
||||
costTime: Date.now() - start,
|
||||
phase: 'wasm',
|
||||
progress: 100,
|
||||
});
|
||||
|
||||
// Compile WASM module
|
||||
return WebAssembly.compile(wasmBytes);
|
||||
}
|
||||
|
||||
private fetchFsBundle = async () => {
|
||||
const res = await fetch(DatabaseManager.FSBUNDLER_CDN_URL);
|
||||
|
||||
return await res.blob();
|
||||
};
|
||||
|
||||
// Asynchronously load PGlite related dependencies
|
||||
private async loadDependencies() {
|
||||
const start = Date.now();
|
||||
this.callbacks?.onStateChange?.(DatabaseLoadingState.LoadingDependencies);
|
||||
|
||||
const imports = [
|
||||
import('@electric-sql/pglite').then((m) => ({
|
||||
IdbFs: m.IdbFs,
|
||||
MemoryFS: m.MemoryFS,
|
||||
PGlite: m.PGlite,
|
||||
})),
|
||||
import('@electric-sql/pglite/vector'),
|
||||
this.fetchFsBundle(),
|
||||
];
|
||||
|
||||
let loaded = 0;
|
||||
const results = await Promise.all(
|
||||
imports.map(async (importPromise) => {
|
||||
const result = await importPromise;
|
||||
loaded += 1;
|
||||
|
||||
// Calculate loading progress
|
||||
this.callbacks?.onProgress?.({
|
||||
phase: 'dependencies',
|
||||
progress: Math.min(Math.round((loaded / imports.length) * 100), 100),
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
|
||||
this.callbacks?.onProgress?.({
|
||||
costTime: Date.now() - start,
|
||||
phase: 'dependencies',
|
||||
progress: 100,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const [{ PGlite, IdbFs, MemoryFS }, { vector }, fsBundle] = results;
|
||||
|
||||
return { IdbFs, MemoryFS, PGlite, fsBundle, vector };
|
||||
}
|
||||
|
||||
// Database migration method
|
||||
private async migrate(skipMultiRun = false): Promise<DrizzleInstance> {
|
||||
if (this.isLocalDBSchemaSynced && skipMultiRun) return this.db;
|
||||
private async migrate(): Promise<DrizzleInstance> {
|
||||
if (this.isLocalDBSchemaSynced) return this.db;
|
||||
|
||||
let hash: string | undefined;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
@@ -179,17 +50,13 @@ export class DatabaseManager {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error checking table existence, proceeding with migration', error);
|
||||
// If query fails, continue migration to ensure safety
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
this.callbacks?.onStateChange?.(DatabaseLoadingState.Migrating);
|
||||
|
||||
// refs: https://github.com/drizzle-team/drizzle-orm/discussions/2532
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error - migrate internal API
|
||||
await this.db.dialect.migrate(migrations, this.db.session, {});
|
||||
|
||||
if (typeof localStorage !== 'undefined' && hash) {
|
||||
@@ -197,7 +64,6 @@ export class DatabaseManager {
|
||||
}
|
||||
|
||||
this.isLocalDBSchemaSynced = true;
|
||||
|
||||
console.info(`🗂 Migration success, take ${Date.now() - start}ms`);
|
||||
} catch (cause) {
|
||||
console.error('❌ Local database schema migration failed', cause);
|
||||
@@ -207,95 +73,32 @@ export class DatabaseManager {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
async initialize(callbacks?: DatabaseLoadingCallbacks): Promise<DrizzleInstance> {
|
||||
async initialize(): Promise<DrizzleInstance> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
this.callbacks = callbacks;
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
if (this.dbInstance) return this.dbInstance;
|
||||
if (this.dbInstance) return this.dbInstance;
|
||||
|
||||
const time = Date.now();
|
||||
// Initialize database
|
||||
this.callbacks?.onStateChange?.(DatabaseLoadingState.Initializing);
|
||||
const time = Date.now();
|
||||
|
||||
// Load dependencies
|
||||
const { fsBundle, PGlite, MemoryFS, IdbFs, vector } = await this.loadDependencies();
|
||||
// 直接使用 pglite,自动处理 wasm 加载
|
||||
const pglite = new PGlite(`idb://${DB_NAME}`, {
|
||||
extensions: { vector },
|
||||
relaxedDurability: true,
|
||||
});
|
||||
|
||||
// Load and compile WASM module
|
||||
const wasmModule = await this.loadWasmModule();
|
||||
this.dbInstance = drizzle({ client: pglite, schema });
|
||||
|
||||
const { initPgliteWorker } = await import('./pglite');
|
||||
await this.migrate();
|
||||
|
||||
let db: typeof PGlite;
|
||||
console.log(`✅ Database initialized in ${Date.now() - time}ms`);
|
||||
|
||||
// make db as web worker if worker is available
|
||||
// https://github.com/lobehub/lobe-chat/issues/5785
|
||||
if (typeof Worker !== 'undefined' && typeof navigator.locks !== 'undefined') {
|
||||
db = await initPgliteWorker({
|
||||
dbName: DB_NAME,
|
||||
fsBundle: fsBundle as Blob,
|
||||
vectorBundlePath: DatabaseManager.VECTOR_CDN_URL,
|
||||
wasmModule,
|
||||
});
|
||||
} else {
|
||||
// in edge runtime or test runtime, we don't have worker
|
||||
db = new PGlite({
|
||||
extensions: { vector },
|
||||
fs: typeof window === 'undefined' ? new MemoryFS(DB_NAME) : new IdbFs(DB_NAME),
|
||||
relaxedDurability: true,
|
||||
wasmModule,
|
||||
});
|
||||
}
|
||||
|
||||
this.dbInstance = drizzle({ client: db, schema });
|
||||
|
||||
await this.migrate(true);
|
||||
|
||||
this.callbacks?.onStateChange?.(DatabaseLoadingState.Finished);
|
||||
console.log(`✅ Database initialized in ${Date.now() - time}ms`);
|
||||
|
||||
await sleep(50);
|
||||
|
||||
this.callbacks?.onStateChange?.(DatabaseLoadingState.Ready);
|
||||
|
||||
return this.dbInstance as DrizzleInstance;
|
||||
} catch (e) {
|
||||
this.initPromise = null;
|
||||
this.callbacks?.onStateChange?.(DatabaseLoadingState.Error);
|
||||
const error = e as Error;
|
||||
|
||||
// Query migration table data
|
||||
let migrationsTableData: MigrationTableItem[] = [];
|
||||
try {
|
||||
// Attempt to query migration table
|
||||
const drizzleMigration = new DrizzleMigrationModel(this.db as any);
|
||||
migrationsTableData = await drizzleMigration.getMigrationList();
|
||||
} catch (queryError) {
|
||||
console.error('Failed to query migrations table:', queryError);
|
||||
}
|
||||
|
||||
this.callbacks?.onError?.({
|
||||
error: {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
},
|
||||
migrationTableItems: migrationsTableData,
|
||||
migrationsSQL: migrations,
|
||||
});
|
||||
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
return this.dbInstance;
|
||||
})();
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Get database instance
|
||||
get db(): DrizzleInstance {
|
||||
if (!this.dbInstance) {
|
||||
throw new Error('Database not initialized. Please call initialize() first.');
|
||||
@@ -303,7 +106,6 @@ export class DatabaseManager {
|
||||
return this.dbInstance;
|
||||
}
|
||||
|
||||
// Create proxy object
|
||||
createProxy(): DrizzleInstance {
|
||||
return new Proxy({} as DrizzleInstance, {
|
||||
get: (target, prop) => {
|
||||
@@ -313,7 +115,7 @@ export class DatabaseManager {
|
||||
}
|
||||
|
||||
async resetDatabase(): Promise<void> {
|
||||
// 1. Close existing PGlite connection (if exists)
|
||||
// 1. Close existing PGlite connection
|
||||
if (this.dbInstance) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
@@ -321,31 +123,28 @@ export class DatabaseManager {
|
||||
console.log('PGlite instance closed successfully.');
|
||||
} catch (e) {
|
||||
console.error('Error closing PGlite instance:', e);
|
||||
// Even if closing fails, continue with deletion attempt; IndexedDB onblocked or onerror will handle subsequent issues
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reset database instance and initialization state
|
||||
this.dbInstance = null;
|
||||
this.initPromise = null;
|
||||
this.isLocalDBSchemaSynced = false; // Reset sync state
|
||||
this.isLocalDBSchemaSynced = false;
|
||||
|
||||
// 3. Delete IndexedDB database
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Check if IndexedDB is available
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
console.warn('IndexedDB is not available, cannot delete database');
|
||||
resolve(); // Cannot delete in this environment, resolve directly
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const dbName = `/pglite/${DB_NAME}`; // Path used by PGlite IdbFs
|
||||
const dbName = `/pglite/${DB_NAME}`;
|
||||
const request = indexedDB.deleteDatabase(dbName);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log(`✅ Database '${dbName}' reset successfully`);
|
||||
|
||||
// Clear locally stored schema hash
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(pgliteSchemaHashCache);
|
||||
}
|
||||
@@ -365,14 +164,10 @@ export class DatabaseManager {
|
||||
};
|
||||
|
||||
request.onblocked = (event) => {
|
||||
// This event is triggered when other open connections block database deletion
|
||||
console.warn(
|
||||
`Deletion of database '${dbName}' is blocked. This usually means other connections (e.g., in other tabs) are still open. Event:`,
|
||||
event,
|
||||
);
|
||||
console.warn(`Deletion of database '${dbName}' is blocked.`, event);
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to reset database '${dbName}' because it is blocked by other open connections. Please close other tabs or applications using this database and try again.`,
|
||||
`Failed to reset database '${dbName}' because it is blocked by other open connections.`,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -383,12 +178,9 @@ export class DatabaseManager {
|
||||
// Export singleton
|
||||
const dbManager = DatabaseManager.getInstance();
|
||||
|
||||
// Keep original clientDB export unchanged
|
||||
export const clientDB = dbManager.createProxy();
|
||||
|
||||
// Export initialization method for application startup
|
||||
export const initializeDB = (callbacks?: DatabaseLoadingCallbacks) =>
|
||||
dbManager.initialize(callbacks);
|
||||
export const initializeDB = () => dbManager.initialize();
|
||||
|
||||
export const resetClientDatabase = async () => {
|
||||
await dbManager.resetDatabase();
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { PGliteWorker } from '@electric-sql/pglite/worker';
|
||||
|
||||
import { InitMeta } from './type';
|
||||
|
||||
export const initPgliteWorker = async (meta: InitMeta) => {
|
||||
const worker = await PGliteWorker.create(
|
||||
new Worker(new URL('pglite.worker.ts', import.meta.url)),
|
||||
{ meta },
|
||||
);
|
||||
|
||||
// Listen for worker status changes
|
||||
worker.onLeaderChange(() => {
|
||||
console.log('Worker leader changed, isLeader:', worker?.isLeader);
|
||||
});
|
||||
|
||||
return worker as PGliteWorker;
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { worker } from '@electric-sql/pglite/worker';
|
||||
|
||||
import { InitMeta } from './type';
|
||||
|
||||
worker({
|
||||
async init(options) {
|
||||
const { wasmModule, fsBundle, vectorBundlePath, dbName } = options.meta as InitMeta;
|
||||
const { PGlite } = await import('@electric-sql/pglite');
|
||||
|
||||
return new PGlite({
|
||||
dataDir: `idb://${dbName}`,
|
||||
extensions: {
|
||||
vector: {
|
||||
name: 'pgvector',
|
||||
setup: async (pglite, options) => {
|
||||
return { bundlePath: new URL(vectorBundlePath), options };
|
||||
},
|
||||
},
|
||||
},
|
||||
fsBundle,
|
||||
relaxedDurability: true,
|
||||
wasmModule,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,14 +1,30 @@
|
||||
import { clientDB, initializeDB } from '../../client/db';
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
import { vector } from '@electric-sql/pglite/vector';
|
||||
import { drizzle } from 'drizzle-orm/pglite';
|
||||
|
||||
import migrations from '../../core/migrations.json';
|
||||
import * as schema from '../../schemas';
|
||||
import { LobeChatDatabase } from '../../type';
|
||||
|
||||
const isServerDBMode = process.env.TEST_SERVER_DB === '1';
|
||||
|
||||
let testClientDB: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
|
||||
export const getTestDB = async () => {
|
||||
if (isServerDBMode) {
|
||||
const { getTestDBInstance } = await import('../../core/dbForTest');
|
||||
return await getTestDBInstance();
|
||||
}
|
||||
|
||||
await initializeDB();
|
||||
return clientDB as LobeChatDatabase;
|
||||
if (testClientDB) return testClientDB as unknown as LobeChatDatabase;
|
||||
|
||||
// 直接使用 pglite 内置资源,不需要从 CDN 下载
|
||||
const pglite = new PGlite({ extensions: { vector } });
|
||||
|
||||
testClientDB = drizzle({ client: pglite, schema });
|
||||
|
||||
// @ts-expect-error - migrate internal API
|
||||
await testClientDB.dialect.migrate(migrations, testClientDB.session, {});
|
||||
|
||||
return testClientDB as unknown as LobeChatDatabase;
|
||||
};
|
||||
|
||||
@@ -22,13 +22,14 @@ import ModelConfigModal from './ModelConfigModal';
|
||||
import { ProviderSettingsContext } from './ProviderSettingsContext';
|
||||
|
||||
const styles = createStaticStyles(({ css, cx }) => {
|
||||
const config = css`
|
||||
opacity: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
`;
|
||||
|
||||
return {
|
||||
config,
|
||||
config: cx(
|
||||
'model-item-config',
|
||||
css`
|
||||
opacity: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
`,
|
||||
),
|
||||
container: css`
|
||||
position: relative;
|
||||
border-radius: ${cssVar.borderRadiusLG}px;
|
||||
@@ -37,7 +38,7 @@ const styles = createStaticStyles(({ css, cx }) => {
|
||||
&:hover {
|
||||
background-color: ${cssVar.colorFillTertiary};
|
||||
|
||||
.${cx(config)} {
|
||||
.model-item-config {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ export function defineConfig(config: CustomNextConfig) {
|
||||
],
|
||||
|
||||
// when external packages in dev mode with turbopack, this config will lead to bundle error
|
||||
serverExternalPackages: isProd ? ['@electric-sql/pglite', 'pdfkit'] : ['pdfkit'],
|
||||
serverExternalPackages: ['pdfkit'],
|
||||
|
||||
transpilePackages: ['pdfjs-dist', 'mermaid', 'better-auth-harmony'],
|
||||
turbopack: {
|
||||
|
||||
Reference in New Issue
Block a user