feat: support new ai provider in client pglite (#5488)

* update to pglite mode

* add service

* 新增 DevPanel

* 新增数据库预览 UI

* Update useCategory.tsx

* add postgres table viewer

* improve table detail

* fix

* fix list

* fix custom provider in client mode

* fix build

* fix tests

* fix url

* add test for service
This commit is contained in:
Arvin Xu
2025-01-17 19:36:53 +08:00
committed by GitHub
parent aa07c405a2
commit 08f505f1f9
48 changed files with 1682 additions and 188 deletions
+4 -3
View File
@@ -27,11 +27,11 @@
"sideEffects": false,
"scripts": {
"build": "next build",
"build:analyze": "ANALYZE=true next build",
"build:docker": "DOCKER=true next build && npm run build-sitemap",
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"build:analyze": "ANALYZE=true next build",
"build:docker": "DOCKER=true next build && npm run build-sitemap",
"db:generate": "drizzle-kit generate && npm run db:generate-client",
"db:generate-client": "tsx ./scripts/migrateClientDB/compile-migrations.ts",
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
@@ -59,11 +59,11 @@
"start": "next start -p 3210",
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "npm run test-app && npm run test-server",
"test:update": "vitest -u",
"test-app": "vitest run --config vitest.config.ts",
"test-app:coverage": "vitest run --config vitest.config.ts --coverage",
"test-server": "vitest run --config vitest.server.config.ts",
"test-server:coverage": "vitest run --config vitest.server.config.ts --coverage",
"test:update": "vitest -u",
"type-check": "tsc --noEmit",
"webhook:ngrok": "ngrok http http://localhost:3011",
"workflow:cdn": "tsx ./scripts/cdnWorkflow/index.ts",
@@ -206,6 +206,7 @@
"react-layout-kit": "^1.9.1",
"react-lazy-load": "^4.0.1",
"react-pdf": "^9.2.1",
"react-rnd": "^10.4.14",
"react-scan": "^0.0.54",
"react-virtuoso": "^4.12.3",
"react-wrap-balancer": "^1.1.1",
@@ -10,7 +10,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FlexboxProps } from 'react-layout-kit';
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { DiscoverProviderItem } from '@/types/discover';
const useStyles = createStyles(({ css }) => ({
@@ -32,7 +32,7 @@ const ProviderConfig = memo<ProviderConfigProps>(({ data, identifier }) => {
const router = useRouter();
const openSettings = () => {
router.push(!isServerMode ? '/settings/llm' : `/settings/provider/${identifier}`);
router.push(isDeprecatedEdition ? '/settings/llm' : `/settings/provider/${identifier}`);
};
const icon = <Icon icon={SquareArrowOutUpRight} size={{ fontSize: 16 }} />;
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import type { MenuProps } from '@/components/Menu';
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { SettingsTabs } from '@/store/global/initialState';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
@@ -53,7 +53,7 @@ export const useCategory = () => {
},
showLLM &&
// TODO: Remove /llm when v2.0
!isServerMode
(isDeprecatedEdition
? {
icon: <Icon icon={Brain} />,
key: SettingsTabs.LLM,
@@ -71,7 +71,7 @@ export const useCategory = () => {
{t('tab.provider')}
</Link>
),
},
}),
enableSTT && {
icon: <Icon icon={Mic2} />,
@@ -0,0 +1,25 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import useSWR from 'swr';
import { aiProviderService } from '@/services/aiProvider';
import ModelList from '../../features/ModelList';
import ProviderConfig from '../../features/ProviderConfig';
const ClientMode = memo<{ id: string }>(({ id }) => {
const { data, isLoading } = useSWR('get-client-provider', () =>
aiProviderService.getAiProviderById(id),
);
return (
<Flexbox gap={24} paddingBlock={8}>
{!isLoading && data && <ProviderConfig {...data} />}
<ModelList id={id} />
</Flexbox>
);
});
export default ClientMode;
@@ -8,6 +8,7 @@ import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { PagePropsWithId } from '@/types/next';
import { getUserAuth } from '@/utils/server/auth';
import ClientMode from './ClientMode';
import ProviderDetail from './index';
const Page = async (props: PagePropsWithId) => {
@@ -33,7 +34,7 @@ const Page = async (props: PagePropsWithId) => {
return <ProviderDetail {...userCard} />;
}
return <div>not found</div>;
return <ClientMode id={params.id} />;
};
export default Page;
@@ -72,7 +72,6 @@ const ConfigGroupModal = memo<ConfigGroupModalProps>(({ open, onCancel, defaultI
id: item.id,
sort: index,
}));
console.log(sortMap);
setLoading(true);
await updateAiProviderSort(sortMap);
setLoading(false);
+11
View File
@@ -282,5 +282,16 @@
"bps": true,
"folderMillis": 1731858381716,
"hash": "d8263bfefe296ed366379c7b7fc65195d12e6a1c0a9f1c96097ea28f2123fe50"
},
{
"sql": [
"CREATE TABLE \"ai_models\" (\n\t\"id\" varchar(150) NOT NULL,\n\t\"display_name\" varchar(200),\n\t\"description\" text,\n\t\"organization\" varchar(100),\n\t\"enabled\" boolean,\n\t\"provider_id\" varchar(64) NOT NULL,\n\t\"type\" varchar(20) DEFAULT 'chat' NOT NULL,\n\t\"sort\" integer,\n\t\"user_id\" text NOT NULL,\n\t\"pricing\" jsonb,\n\t\"parameters\" jsonb DEFAULT '{}'::jsonb,\n\t\"config\" jsonb,\n\t\"abilities\" jsonb DEFAULT '{}'::jsonb,\n\t\"context_window_tokens\" integer,\n\t\"source\" varchar(20),\n\t\"released_at\" varchar(10),\n\t\"accessed_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updated_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"ai_models_id_provider_id_user_id_pk\" PRIMARY KEY(\"id\",\"provider_id\",\"user_id\")\n);\n",
"\nCREATE TABLE \"ai_providers\" (\n\t\"id\" varchar(64) NOT NULL,\n\t\"name\" text,\n\t\"user_id\" text NOT NULL,\n\t\"sort\" integer,\n\t\"enabled\" boolean,\n\t\"fetch_on_client\" boolean,\n\t\"check_model\" text,\n\t\"logo\" text,\n\t\"description\" text,\n\t\"key_vaults\" text,\n\t\"source\" varchar(20),\n\t\"settings\" jsonb,\n\t\"accessed_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"updated_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\tCONSTRAINT \"ai_providers_id_user_id_pk\" PRIMARY KEY(\"id\",\"user_id\")\n);\n",
"\nALTER TABLE \"ai_models\" ADD CONSTRAINT \"ai_models_user_id_users_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;",
"\nALTER TABLE \"ai_providers\" ADD CONSTRAINT \"ai_providers_user_id_users_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;"
],
"bps": true,
"folderMillis": 1735834653361,
"hash": "845a692ceabbfc3caf252a97d3e19a213bc0c433df2689900135f9cfded2cf49"
}
]
@@ -0,0 +1,256 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clientDB, initializeDB } from '@/database/client/db';
import { TableViewerRepo } from './index';
const userId = 'user-table-viewer';
const repo = new TableViewerRepo(clientDB as any, userId);
// Mock database execution
const mockExecute = vi.fn();
const mockDB = {
execute: mockExecute,
};
beforeEach(async () => {
await initializeDB();
vi.clearAllMocks();
});
describe('TableViewerRepo', () => {
describe('getAllTables', () => {
it('should return all tables with counts', async () => {
const result = await repo.getAllTables();
expect(result.length).toEqual(39);
expect(result[0]).toEqual({ name: 'agents', count: 0, type: 'BASE TABLE' });
});
it('should handle custom schema', async () => {
const result = await repo.getAllTables('custom_schema');
expect(result).toBeDefined();
});
});
describe('getTableDetails', () => {
it('should return table column details', async () => {
const tableName = 'test_table';
const mockColumns = {
rows: [
{
column_name: 'id',
data_type: 'uuid',
is_nullable: 'NO',
column_default: null,
is_primary_key: true,
foreign_key: null,
},
],
};
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce(mockColumns);
const result = await testRepo.getTableDetails(tableName);
expect(result).toEqual([
{
name: 'id',
type: 'uuid',
nullable: false,
defaultValue: null,
isPrimaryKey: true,
foreignKey: null,
},
]);
});
});
describe('getTableData', () => {
it('should return paginated data with filters', async () => {
const tableName = 'test_table';
const pagination = {
page: 1,
pageSize: 10,
sortBy: 'id',
sortOrder: 'desc' as const,
};
const filters = [
{
column: 'name',
operator: 'contains' as const,
value: 'test',
},
];
const mockData = {
rows: [{ id: 1, name: 'test' }],
};
const mockCount = {
rows: [{ total: 1 }],
};
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce(mockData).mockResolvedValueOnce(mockCount);
const result = await testRepo.getTableData(tableName, pagination, filters);
expect(result).toEqual({
data: mockData.rows,
pagination: {
page: 1,
pageSize: 10,
total: 1,
},
});
});
});
describe('updateRow', () => {
it('should update and return row data', async () => {
const tableName = 'test_table';
const id = '123';
const primaryKeyColumn = 'id';
const data = { name: 'updated' };
const mockResult = {
rows: [{ id: '123', name: 'updated' }],
};
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce(mockResult);
const result = await testRepo.updateRow(tableName, id, primaryKeyColumn, data);
expect(result).toEqual(mockResult.rows[0]);
expect(mockExecute).toHaveBeenCalledTimes(1);
});
});
describe('deleteRow', () => {
it('should delete a row', async () => {
const tableName = 'test_table';
const id = '123';
const primaryKeyColumn = 'id';
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce({ rows: [] });
await testRepo.deleteRow(tableName, id, primaryKeyColumn);
expect(mockExecute).toHaveBeenCalledTimes(1);
});
});
describe('insertRow', () => {
it('should insert and return new row data', async () => {
const tableName = 'test_table';
const data = { name: 'new row' };
const mockResult = {
rows: [{ id: '123', name: 'new row' }],
};
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce(mockResult);
const result = await testRepo.insertRow(tableName, data);
expect(result).toEqual(mockResult.rows[0]);
expect(mockExecute).toHaveBeenCalledTimes(1);
});
});
describe('getTableCount', () => {
it('should return table count', async () => {
const tableName = 'test_table';
const mockResult = {
rows: [{ total: 42 }],
};
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce(mockResult);
const result = await testRepo.getTableCount(tableName);
expect(result).toBe(42);
expect(mockExecute).toHaveBeenCalledTimes(1);
});
});
describe('batchDelete', () => {
it('should delete multiple rows', async () => {
const tableName = 'test_table';
const ids = ['1', '2', '3'];
const primaryKeyColumn = 'id';
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce({ rows: [] });
await testRepo.batchDelete(tableName, ids, primaryKeyColumn);
expect(mockExecute).toHaveBeenCalledTimes(1);
});
});
describe('exportTableData', () => {
it('should export table data with default pagination', async () => {
const tableName = 'test_table';
const mockData = {
rows: [{ id: 1, name: 'test' }],
};
const mockCount = {
rows: [{ total: 1 }],
};
const testRepo = new TableViewerRepo(mockDB as any, userId);
mockExecute.mockResolvedValueOnce(mockData).mockResolvedValueOnce(mockCount);
const result = await testRepo.exportTableData(tableName);
expect(result).toEqual({
data: mockData.rows,
pagination: {
page: 1,
pageSize: 1000,
total: 1,
},
});
});
it('should export table data with custom pagination and filters', async () => {
const tableName = 'test_table';
const pagination = { page: 2, pageSize: 50 };
const filters = [
{
column: 'status',
operator: 'equals' as const,
value: 'active',
},
];
const mockData = {
rows: [{ id: 1, status: 'active' }],
};
const mockCount = {
rows: [{ total: 1 }],
};
mockExecute.mockResolvedValueOnce(mockData).mockResolvedValueOnce(mockCount);
const testRepo = new TableViewerRepo(mockDB as any, userId);
const result = await testRepo.exportTableData(tableName, pagination, filters);
expect(result).toEqual({
data: mockData.rows,
pagination: {
page: 2,
pageSize: 50,
total: 1,
},
});
});
});
});
@@ -0,0 +1,251 @@
import { sql } from 'drizzle-orm';
import pMap from 'p-map';
import { LobeChatDatabase } from '@/database/type';
import {
FilterCondition,
PaginationParams,
TableBasicInfo,
TableColumnInfo,
} from '@/types/tableViewer';
export class TableViewerRepo {
private userId: string;
private db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.userId = userId;
this.db = db;
}
/**
* 获取数据库中所有的表
*/
async getAllTables(schema = 'public'): Promise<TableBasicInfo[]> {
const query = sql`
SELECT
table_name as name,
table_type as type
FROM information_schema.tables
WHERE table_schema = ${schema}
ORDER BY table_name;
`;
const tables = await this.db.execute(query);
const tableNames = tables.rows.map((row) => row.name) as string[];
const counts = await pMap(tableNames, async (name) => this.getTableCount(name), {
concurrency: 10,
});
return tables.rows.map((row, index) => ({
count: counts[index],
name: row.name,
type: row.type,
})) as TableBasicInfo[];
}
/**
* 获取指定表的详细结构信息
*/
async getTableDetails(tableName: string): Promise<TableColumnInfo[]> {
const query = sql`
SELECT
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
-- 主键信息
(
SELECT true
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = c.table_name
AND kcu.column_name = c.column_name
AND tc.constraint_type = 'PRIMARY KEY'
) is_primary_key,
-- 外键信息
(
SELECT json_build_object(
'table', ccu.table_name,
'column', ccu.column_name
)
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.table_name = c.table_name
AND kcu.column_name = c.column_name
AND tc.constraint_type = 'FOREIGN KEY'
) foreign_key
FROM information_schema.columns c
WHERE c.table_name = ${tableName}
AND c.table_schema = 'public'
ORDER BY c.ordinal_position;
`;
const columns = await this.db.execute(query);
return columns.rows.map((col: any) => ({
defaultValue: col.column_default,
foreignKey: col.foreign_key,
isPrimaryKey: !!col.is_primary_key,
name: col.column_name,
nullable: col.is_nullable === 'YES',
type: col.data_type,
}));
}
/**
* 获取表数据,支持分页、排序和筛选
*/
async getTableData(tableName: string, pagination: PaginationParams, filters?: FilterCondition[]) {
const offset = (pagination.page - 1) * pagination.pageSize;
// 构建基础查询
let baseQuery = sql`SELECT * FROM ${sql.identifier(tableName)}`;
// 添加筛选条件
if (filters && filters.length > 0) {
const whereConditions = filters.map((filter) => {
const column = sql.identifier(filter.column);
switch (filter.operator) {
case 'equals': {
return sql`${column} = ${filter.value}`;
}
case 'contains': {
return sql`${column} ILIKE ${`%${filter.value}%`}`;
}
case 'startsWith': {
return sql`${column} ILIKE ${`${filter.value}%`}`;
}
case 'endsWith': {
return sql`${column} ILIKE ${`%${filter.value}`}`;
}
default: {
return sql`1=1`;
}
}
});
baseQuery = sql`${baseQuery} WHERE ${sql.join(whereConditions, sql` AND `)}`;
}
// 添加排序
if (pagination.sortBy) {
const direction = pagination.sortOrder === 'desc' ? sql`DESC` : sql`ASC`;
baseQuery = sql`${baseQuery} ORDER BY ${sql.identifier(pagination.sortBy)} ${direction}`;
}
// 添加分页
const query = sql`${baseQuery} LIMIT ${pagination.pageSize} OFFSET ${offset}`;
// 获取总数
const countQuery = sql`SELECT COUNT(*) as total FROM ${sql.identifier(tableName)}`;
// 并行执行查询
const [data, count] = await Promise.all([this.db.execute(query), this.db.execute(countQuery)]);
return {
data: data.rows,
pagination: {
page: pagination.page,
pageSize: pagination.pageSize,
total: Number(count.rows[0].total),
},
};
}
/**
* 更新表中的一行数据
*/
async updateRow(
tableName: string,
id: string,
primaryKeyColumn: string,
data: Record<string, any>,
) {
const setColumns = Object.entries(data).map(([key, value]) => {
return sql`${sql.identifier(key)} = ${value}`;
});
const query = sql`
UPDATE ${sql.identifier(tableName)}
SET ${sql.join(setColumns, sql`, `)}
WHERE ${sql.identifier(primaryKeyColumn)} = ${id}
RETURNING *
`;
const result = await this.db.execute(query);
return result.rows[0];
}
/**
* 删除表中的一行数据
*/
async deleteRow(tableName: string, id: string, primaryKeyColumn: string) {
const query = sql`
DELETE FROM ${sql.identifier(tableName)}
WHERE ${sql.identifier(primaryKeyColumn)} = ${id}
`;
await this.db.execute(query);
}
/**
* 插入新行数据
*/
async insertRow(tableName: string, data: Record<string, any>) {
const columns = Object.keys(data).map((key) => sql.identifier(key));
const values = Object.values(data);
const query = sql`
INSERT INTO ${sql.identifier(tableName)}
(${sql.join(columns, sql`, `)})
VALUES (${sql.join(
values.map((v) => sql`${v}`),
sql`, `,
)})
RETURNING *
`;
const result = await this.db.execute(query);
return result.rows[0];
}
/**
* 获取表的总记录数
*/
async getTableCount(tableName: string): Promise<number> {
const query = sql`SELECT COUNT(*) as total FROM ${sql.identifier(tableName)}`;
const result = await this.db.execute(query);
return Number(result.rows[0].total);
}
/**
* 批量删除数据
*/
async batchDelete(tableName: string, ids: string[], primaryKeyColumn: string) {
const query = sql`
DELETE FROM ${sql.identifier(tableName)}
WHERE ${sql.identifier(primaryKeyColumn)} = ANY(${ids})
`;
await this.db.execute(query);
}
/**
* 导出表数据(支持分页导出)
*/
async exportTableData(
tableName: string,
pagination?: PaginationParams,
filters?: FilterCondition[],
) {
return this.getTableData(tableName, pagination || { page: 1, pageSize: 1000 }, filters);
}
}
+2 -2
View File
@@ -165,7 +165,7 @@ export class AiProviderModel {
getAiProviderById = async (
id: string,
decryptor: DecryptUserKeyVaults,
decryptor?: DecryptUserKeyVaults,
): Promise<AiProviderDetailItem | undefined> => {
const query = this.db
.select({
@@ -205,7 +205,7 @@ export class AiProviderModel {
return { ...result, keyVaults } as AiProviderDetailItem;
};
getAiProviderRuntimeConfig = async (decryptor: DecryptUserKeyVaults) => {
getAiProviderRuntimeConfig = async (decryptor?: DecryptUserKeyVaults) => {
const result = await this.db
.select({
fetchOnClient: aiProviders.fetchOnClient,
-34
View File
@@ -1,34 +0,0 @@
import { Icon } from '@lobehub/ui';
import { App, FloatButton, Spin } from 'antd';
import { DatabaseIcon, Loader2 } from 'lucide-react';
import { memo, useState } from 'react';
import { debugService } from '@/services/debug';
const DebugUI = memo(() => {
const [loading, setLoading] = useState(false);
const { message } = App.useApp();
return (
<>
{loading && <Spin fullscreen />}
<FloatButton
icon={<Icon icon={loading ? Loader2 : DatabaseIcon} spin={loading} />}
onClick={async () => {
setLoading(true);
const startTime = Date.now();
await debugService.insertLargeDataToDB();
const duration = Date.now() - startTime;
setLoading(false);
message.success(`插入成功,耗时:${(duration / 1000).toFixed(1)} s`);
}}
tooltip={'性能压测,插入100w数据'}
/>
</>
);
});
export default DebugUI;
-20
View File
@@ -1,20 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
import { FC } from 'react';
import { getDebugConfig } from '@/config/debug';
let DebugUI: FC = () => null;
// we need use Constant Folding to remove code below in production
// refs: https://webpack.js.org/plugins/internal-plugins/#constplugin
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line unicorn/no-lonely-if
if (getDebugConfig().DEBUG_MODE) {
// @ts-ignore
DebugUI = dynamic(() => import('./Content'), { ssr: false });
}
}
export default DebugUI;
+136
View File
@@ -0,0 +1,136 @@
import { ActionIcon, Icon } from '@lobehub/ui';
import { FloatButton } from 'antd';
import { createStyles } from 'antd-style';
import { BugIcon, BugOff, XIcon } from 'lucide-react';
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { Rnd } from 'react-rnd';
// 定义样式
const useStyles = createStyles(({ token, css }) => {
return {
collapsed: css`
pointer-events: none;
transform: scale(0.8);
opacity: 0;
`,
content: css`
overflow: auto;
flex: 1;
height: 100%;
color: ${token.colorText};
`,
expanded: css`
pointer-events: auto;
transform: scale(1);
opacity: 1;
`,
header: css`
cursor: move;
user-select: none;
padding-block: 8px;
padding-inline: 16px;
border-block-end: 1px solid ${token.colorBorderSecondary};
border-start-start-radius: 12px;
border-start-end-radius: 12px;
font-weight: ${token.fontWeightStrong};
color: ${token.colorText};
background: ${token.colorFillAlter};
`,
panel: css`
position: fixed;
z-index: 1000;
display: flex;
border-radius: 12px;
overflow: hidden;
background: ${token.colorBgContainer};
box-shadow: ${token.boxShadow};
transition: opacity ${token.motionDurationMid} ${token.motionEaseInOut};
`,
};
});
const minWidth = 800;
const minHeight = 600;
const CollapsibleFloatPanel = ({ children }: PropsWithChildren) => {
const { styles } = useStyles();
const [isExpanded, setIsExpanded] = useState(false);
const [position, setPosition] = useState({ x: 100, y: 100 });
const [size, setSize] = useState({ height: minHeight, width: minWidth });
useEffect(() => {
try {
const localStoragePosition = localStorage.getItem('debug-panel-position');
if (localStoragePosition && JSON.parse(localStoragePosition)) {
setPosition(JSON.parse(localStoragePosition));
}
} catch {
/* empty */
}
try {
const localStorageSize = localStorage.getItem('debug-panel-size');
if (localStorageSize && JSON.parse(localStorageSize)) {
setSize(JSON.parse(localStorageSize));
}
} catch {
/* empty */
}
}, []);
return (
<>
<FloatButton
icon={<Icon icon={isExpanded ? BugOff : BugIcon} />}
onClick={() => setIsExpanded(!isExpanded)}
style={{ bottom: 24, right: 24 }}
/>
{isExpanded && (
<Rnd
bounds="window"
className={`${styles.panel} ${isExpanded ? styles.expanded : styles.collapsed}`}
dragHandleClassName="panel-drag-handle"
minHeight={minHeight}
minWidth={minWidth}
onDragStop={(e, d) => {
setPosition({ x: d.x, y: d.y });
}}
onResizeStop={(e, direction, ref, delta, position) => {
setSize({
height: Number(ref.style.height),
width: Number(ref.style.width),
});
setPosition(position);
}}
position={position}
size={size}
>
<Flexbox height={'100%'}>
<Flexbox
align={'center'}
className={`panel-drag-handle ${styles.header}`}
horizontal
justify={'space-between'}
>
<ActionIcon icon={XIcon} onClick={() => setIsExpanded(false)} />
</Flexbox>
<Flexbox className={styles.content}>{children}</Flexbox>
</Flexbox>
</Rnd>
)}
</>
);
};
export default CollapsibleFloatPanel;
@@ -0,0 +1,155 @@
import { createStyles } from 'antd-style';
import React from 'react';
import { Center } from 'react-layout-kit';
import { TableVirtuoso } from 'react-virtuoso';
import useSWR from 'swr';
import { tableViewerService } from '@/services/tableViewer';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useTableColumns } from '../useTableColumns';
import TableCell from './TableCell';
const useStyles = createStyles(({ token, css }) => ({
columnList: css`
margin-inline-start: 32px;
font-size: ${token.fontSizeSM}px;
color: ${token.colorTextSecondary};
> div {
padding-block: ${token.paddingXS}px;
padding-inline: 0;
}
`,
table: css`
overflow: scroll hidden;
flex: 1;
table {
border-collapse: collapse;
width: 100%;
font-family: ${token.fontFamilyCode};
margin-right: 12px;
}
thead {
tr {
outline: 1px solid ${token.colorBorderSecondary};
}
}
th,
td {
padding: 8px 12px;
font-size: 12px;
border-right: 1px solid ${token.colorBorderSecondary};
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
th {
position: sticky;
z-index: 1;
inset-block-start: 0;
border-block-end: 1px solid ${token.colorBorderSecondary};
font-weight: ${token.fontWeightStrong};
text-align: start;
text-wrap: nowrap;
background: ${token.colorBgElevated};
}
td {
border-block-end: 1px solid ${token.colorBorderSecondary};
text-wrap: nowrap;
}
tbody {
tr:hover {
background: ${token.colorFillTertiary};
}
}
`,
tableItem: css`
cursor: pointer;
display: flex;
gap: ${token.padding}px;
align-items: center;
padding: 12px;
border-radius: ${token.borderRadius}px;
color: ${token.colorText};
`,
}));
interface TableProps {
tableName?: string;
}
const Table = ({ tableName }: TableProps) => {
const { styles } = useStyles();
const tableColumns = useTableColumns(tableName);
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
const tableData = useSWR(
isDBInited && tableName ? ['fetch-table-data', tableName] : null,
([, table]) => tableViewerService.getTableData(table),
);
const columns = tableColumns.data?.map((t) => t.name) || [];
const isLoading = tableColumns.isLoading || tableData.isLoading;
if (!tableName) return <Center height={'80%'}>Select a table to view data</Center>;
if (isLoading) return <Center height={'80%'}>Loading...</Center>;
const dataSource = tableData.data?.data || [];
const header = (
<tr>
{columns.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
);
return (
<div className={styles.table}>
{dataSource.length === 0 ? (
<>
<table>
<thead>{header}</thead>
</table>
<Center height={400}>no rows</Center>
</>
) : (
<TableVirtuoso
data={dataSource}
fixedHeaderContent={() => header}
itemContent={(index, row) => (
<>
{columns.map((column) => (
<TableCell
column={column}
dataItem={row}
key={`${column}_${index}`}
rowIndex={index}
/>
))}
</>
)}
/>
)}
</div>
);
};
export default Table;
@@ -0,0 +1,34 @@
import React, { useMemo } from 'react';
interface TableCellProps {
column: string;
dataItem: any;
rowIndex: number;
}
const TableCell = ({ dataItem, column, rowIndex }: TableCellProps) => {
const data = dataItem[column];
const content = useMemo(() => {
switch (typeof data) {
case 'object': {
return JSON.stringify(data);
}
case 'boolean': {
return data ? 'True' : 'False';
}
default: {
return data;
}
}
}, [data]);
return (
<td key={column} onDoubleClick={() => console.log('Edit cell:', rowIndex, column)}>
{content}
</td>
);
};
export default TableCell;
@@ -0,0 +1,67 @@
import { ActionIcon, Icon } from '@lobehub/ui';
import { Button } from 'antd';
import { createStyles } from 'antd-style';
import { Download, Filter, RefreshCw } from 'lucide-react';
import React from 'react';
import Table from './Table';
const useStyles = createStyles(({ token, css }) => ({
dataPanel: css`
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
background: ${token.colorBgContainer};
`,
toolbar: css`
display: flex;
align-items: center;
justify-content: space-between;
padding-block: 12px;
padding-inline: 16px;
border-block-end: 1px solid ${token.colorBorderSecondary};
`,
toolbarButtons: css`
display: flex;
gap: 4px;
`,
toolbarTitle: css`
font-size: ${token.fontSizeLG}px;
font-weight: ${token.fontWeightStrong};
color: ${token.colorText};
`,
}));
interface DataTableProps {
tableName: string;
}
const DataTable = ({ tableName }: DataTableProps) => {
const { styles } = useStyles();
return (
<div className={styles.dataPanel}>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>{tableName || 'Select a table'}</div>
<div className={styles.toolbarButtons}>
<Button color={'default'} icon={<Icon icon={Filter} />} variant={'filled'}>
Filter
</Button>
<ActionIcon icon={Download} title={'Export'} />
<ActionIcon icon={RefreshCw} title={'Refresh'} />
</div>
</div>
{/* Table */}
<Table tableName={tableName} />
</div>
);
};
export default DataTable;
@@ -0,0 +1,196 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ChevronDown, ChevronRight, Database, Table as TableIcon } from 'lucide-react';
import React, { useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import useSWR from 'swr';
import { tableViewerService } from '@/services/tableViewer';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import TableColumns from './TableColumns';
// 样式定义
const useStyles = createStyles(({ token, css }) => ({
button: css`
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
padding-block: ${token.paddingXS}px;
padding-inline: ${token.padding}px;
border: none;
border-radius: ${token.borderRadius}px;
color: ${token.colorText};
background: ${token.colorFillSecondary};
transition: all ${token.motionDurationMid};
&:hover {
background: ${token.colorFillTertiary};
}
`,
count: css`
font-size: 12px;
color: ${token.colorTextTertiary};
`,
dataPanel: css`
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
background: ${token.colorBgContainer};
`,
schema: css`
font-size: 12px;
color: ${token.colorTextTertiary};
font-weight: normal;
font-family: ${token.fontFamilyCode};
`,
schemaHeader: css`
display: flex;
gap: 8px;
align-items: center;
padding-block: 12px;
padding-inline: 16px;
font-weight: ${token.fontWeightStrong};
color: ${token.colorText};
`,
schemaPanel: css`
overflow: scroll;
width: 280px;
height: 100%;
border-inline-end: 1px solid ${token.colorBorderSecondary};
background: ${token.colorBgContainer};
`,
selected: css`
background: ${token.colorFillSecondary};
`,
table: css`
overflow: hidden;
flex: 1;
table {
border-collapse: collapse;
width: 100%;
}
th {
position: sticky;
z-index: 1;
inset-block-start: 0;
padding: ${token.padding}px;
border-block-end: 1px solid ${token.colorBorderSecondary};
font-weight: ${token.fontWeightStrong};
text-align: start;
background: ${token.colorFillQuaternary};
}
td {
padding: ${token.padding}px;
border-block-end: 1px solid ${token.colorBorderSecondary};
transition: all ${token.motionDurationMid};
&:hover {
background: ${token.colorFillQuaternary};
}
}
`,
tableItem: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
margin-inline: 8px;
padding: 8px;
border-radius: ${token.borderRadius}px;
color: ${token.colorText};
&:hover {
background: ${token.colorFillSecondary};
}
`,
}));
interface SchemaPanelProps {
onTableSelect: (tableName: string) => void;
selectedTable?: string;
}
const SchemaPanel = ({ onTableSelect, selectedTable }: SchemaPanelProps) => {
const { styles, cx } = useStyles();
const [expandedTables, setExpandedTables] = useState(new Set());
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
const { data, isLoading } = useSWR(isDBInited ? 'fetch-tables' : null, () =>
tableViewerService.getAllTables(),
);
const toggleTable = (tableName: string) => {
const newExpanded = new Set(expandedTables);
if (newExpanded.has(tableName)) {
newExpanded.delete(tableName);
} else {
newExpanded.add(tableName);
}
setExpandedTables(newExpanded);
};
return (
<div className={styles.schemaPanel}>
<div className={styles.schemaHeader}>
<Database size={16} />
<Flexbox align={'center'} horizontal justify={'space-between'}>
<span>Tables {data?.length}</span>
<span className={styles.schema}>public</span>
</Flexbox>
</div>
{isLoading ? (
<div>Loading...</div>
) : (
<Flexbox>
{data?.map((table) => (
<div key={table.name}>
<Flexbox
className={cx(styles.tableItem, selectedTable === table.name && styles.selected)}
horizontal
onClick={() => {
toggleTable(table.name);
onTableSelect(table.name);
}}
>
<Icon icon={expandedTables.has(table.name) ? ChevronDown : ChevronRight} />
<TableIcon size={16} />
<Flexbox align={'center'} horizontal justify={'space-between'}>
<span>{table.name}</span>
<span className={styles.count}>{table.count}</span>
</Flexbox>
</Flexbox>
{expandedTables.has(table.name) && <TableColumns tableName={table.name} />}
</div>
))}
</Flexbox>
)}
</div>
);
};
export default SchemaPanel;
@@ -0,0 +1,68 @@
import { Tag } from 'antd';
import { createStyles } from 'antd-style';
import React from 'react';
import { Flexbox } from 'react-layout-kit';
import { useTableColumns } from './useTableColumns';
const useStyles = createStyles(({ token, css }) => ({
container: css`
margin-inline: 40px 4px;
font-size: ${token.fontSizeSM}px;
color: ${token.colorTextSecondary};
`,
item: css`
padding-block: 4px;
padding-inline: 0;
font-family: ${token.fontFamilyCode};
`,
type: css`
color: ${token.red9};
font-size: 10px;
`,
}));
interface TableColumnsProps {
tableName: string;
}
const TableColumns = ({ tableName }: TableColumnsProps) => {
const { styles } = useStyles();
const { data, isLoading } = useTableColumns(tableName);
return (
<div className={styles.container}>
{isLoading ? (
<div>Loading...</div>
) : (
<Flexbox>
{data?.map((column) => (
<Flexbox
align={'center'}
className={styles.item}
horizontal
justify={'space-between'}
key={column.name}
>
<Flexbox>
<Flexbox>{column.name}</Flexbox>
<span className={styles.type}>{column.type}</span>
</Flexbox>
{column.isPrimaryKey && (
<div>
<Tag bordered={false} color={'cyan'}>
Primary
</Tag>
</div>
)}
</Flexbox>
))}
</Flexbox>
)}
</div>
);
};
export default TableColumns;
@@ -0,0 +1,19 @@
import React, { useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import DataTable from './DataTable';
import SchemaPanel from './Schema';
// Main Database Panel Component
const DatabasePanel = () => {
const [selectedTable, setSelectedTable] = useState<string>('');
return (
<Flexbox height={'100%'} horizontal>
<SchemaPanel onTableSelect={setSelectedTable} selectedTable={selectedTable} />
<DataTable tableName={selectedTable} />
</Flexbox>
);
};
export default DatabasePanel;
@@ -0,0 +1,13 @@
import useSWR from 'swr';
import { tableViewerService } from '@/services/tableViewer';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
export const useTableColumns = (tableName?: string) => {
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
return useSWR(isDBInited && tableName ? ['fetch-table-columns', tableName] : null, ([, table]) =>
tableViewerService.getTableDetails(table),
);
};
+12
View File
@@ -0,0 +1,12 @@
'use client';
import FloatPanel from './FloatPanel';
import PostgresViewer from './PostgresViewer';
const DevPanel = () => (
<FloatPanel>
<PostgresViewer />
</FloatPanel>
);
export default DevPanel;
+4 -2
View File
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
@@ -76,7 +76,9 @@ const ModelSwitchPanel = memo<PropsWithChildren>(({ children }) => {
</Flexbox>
),
onClick: () => {
router.push(!isServerMode ? '/settings/llm' : `/settings/provider/${provider.id}`);
router.push(
isDeprecatedEdition ? '/settings/llm' : `/settings/provider/${provider.id}`,
);
},
},
];
+2 -2
View File
@@ -1,6 +1,6 @@
import isEqual from 'fast-deep-equal';
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
import { modelProviderSelectors } from '@/store/user/selectors';
@@ -10,7 +10,7 @@ export const useEnabledChatModels = (): EnabledProviderWithModels[] => {
const enabledList = useUserStore(modelProviderSelectors.modelProviderListForModelSelect, isEqual);
const enabledChatModelList = useAiInfraStore((s) => s.enabledChatModelList, isEqual);
if (!isServerMode) {
if (isDeprecatedEdition) {
return enabledList;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
import { modelProviderSelectors } from '@/store/user/selectors';
@@ -8,7 +8,7 @@ export const useModelContextWindowTokens = (model: string, provider: string) =>
// TODO: remove this in V2.0
const oldValue = useUserStore(modelProviderSelectors.modelMaxToken(model));
if (!isServerMode) return oldValue;
if (isDeprecatedEdition) return oldValue;
//
return newValue as number;
+2 -2
View File
@@ -1,4 +1,4 @@
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/slices/chat';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
@@ -12,7 +12,7 @@ export const useModelHasContextWindowToken = () => {
// TODO: remove this in V2.0
const oldValue = useUserStore(modelProviderSelectors.isModelHasMaxToken(model));
if (!isServerMode) return oldValue;
if (isDeprecatedEdition) return oldValue;
//
return newValue;
+2 -2
View File
@@ -1,4 +1,4 @@
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
import { modelProviderSelectors } from '@/store/user/selectors';
@@ -8,7 +8,7 @@ export const useModelSupportToolUse = (model: string, provider: string) => {
// TODO: remove this in V2.0
const oldValue = useUserStore(modelProviderSelectors.isModelEnabledFunctionCall(model));
if (!isServerMode) return oldValue;
if (isDeprecatedEdition) return oldValue;
//
return newValue;
+2 -2
View File
@@ -1,4 +1,4 @@
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
import { modelProviderSelectors } from '@/store/user/selectors';
@@ -8,7 +8,7 @@ export const useModelSupportVision = (model: string, provider: string) => {
// TODO: remove this in V2.0
const oldValue = useUserStore(modelProviderSelectors.isModelEnabledVision(model));
if (!isServerMode) return oldValue;
if (isDeprecatedEdition) return oldValue;
//
return newValue;
+2 -2
View File
@@ -9,7 +9,7 @@ import {
LOBE_THEME_NEUTRAL_COLOR,
LOBE_THEME_PRIMARY_COLOR,
} from '@/const/theme';
import DebugUI from '@/features/DebugUI';
import DevPanel from '@/features/DevPanel';
import { getServerGlobalConfig } from '@/server/globalConfig';
import { ServerConfigStoreProvider } from '@/store/serverConfig';
import { getAntdLocale, parseBrowserLanguage } from '@/utils/locale';
@@ -65,9 +65,9 @@ const GlobalLayout = async ({ children }: PropsWithChildren) => {
<Suspense>
<StoreInitialization />
<ReactScan />
{process.env.NODE_ENV === 'development' && <DevPanel />}
</Suspense>
</ServerConfigStoreProvider>
<DebugUI />
</AppTheme>
<AntdV5MonkeyPatch />
</Locale>
+2 -2
View File
@@ -1,5 +1,5 @@
import { JWTPayload, LOBE_CHAT_AUTH_HEADER } from '@/const/auth';
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { ModelProvider } from '@/libs/agent-runtime';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
@@ -94,7 +94,7 @@ export const createPayloadWithKeyVaults = (provider: string) => {
let keyVaults = {};
// TODO: remove this condition in V2.0
if (!isServerMode) {
if (isDeprecatedEdition) {
keyVaults = keyVaultsConfigSelectors.getVaultByProvider(provider as any)(
useUserStore.getState(),
);
-52
View File
@@ -1,52 +0,0 @@
import { lambdaClient } from '@/libs/trpc/client';
import {
AiModelSortMap,
AiProviderModelListItem,
CreateAiModelParams,
ToggleAiModelEnableParams,
UpdateAiModelParams,
} from '@/types/aiModel';
class AiModelService {
createAiModel = async (params: CreateAiModelParams) => {
return lambdaClient.aiModel.createAiModel.mutate(params);
};
getAiProviderModelList = async (id: string): Promise<AiProviderModelListItem[]> => {
return lambdaClient.aiModel.getAiProviderModelList.query({ id });
};
getAiModelById = async (id: string) => {
return lambdaClient.aiModel.getAiModelById.query({ id });
};
toggleModelEnabled = async (params: ToggleAiModelEnableParams) => {
return lambdaClient.aiModel.toggleModelEnabled.mutate(params);
};
updateAiModel = async (id: string, providerId: string, value: UpdateAiModelParams) => {
return lambdaClient.aiModel.updateAiModel.mutate({ id, providerId, value });
};
batchUpdateAiModels = async (id: string, models: AiProviderModelListItem[]) => {
return lambdaClient.aiModel.batchUpdateAiModels.mutate({ id, models });
};
batchToggleAiModels = async (id: string, models: string[], enabled: boolean) => {
return lambdaClient.aiModel.batchToggleAiModels.mutate({ enabled, id, models });
};
clearRemoteModels = async (providerId: string) => {
return lambdaClient.aiModel.clearRemoteModels.mutate({ providerId });
};
updateAiModelOrder = async (providerId: string, items: AiModelSortMap[]) => {
return lambdaClient.aiModel.updateAiModelOrder.mutate({ providerId, sortMap: items });
};
deleteAiModel = async (params: { id: string; providerId: string }) => {
return lambdaClient.aiModel.removeAiModel.mutate(params);
};
}
export const aiModelService = new AiModelService();
+60
View File
@@ -0,0 +1,60 @@
import { clientDB } from '@/database/client/db';
import { AiInfraRepos } from '@/database/repositories/aiInfra';
import { AiModelModel } from '@/database/server/models/aiModel';
import { BaseClientService } from '@/services/baseClientService';
import { IAiModelService } from './type';
export class ClientService extends BaseClientService implements IAiModelService {
private get aiModel(): AiModelModel {
return new AiModelModel(clientDB as any, this.userId);
}
private get aiInfraRepos(): AiInfraRepos {
return new AiInfraRepos(clientDB as any, this.userId, {});
}
createAiModel: IAiModelService['createAiModel'] = async (params) => {
const data = await this.aiModel.create(params);
return data?.id;
};
getAiProviderModelList: IAiModelService['getAiProviderModelList'] = async (id) => {
return this.aiInfraRepos.getAiProviderModelList(id);
};
getAiModelById: IAiModelService['getAiModelById'] = async (id) => {
return this.aiModel.findById(id);
};
toggleModelEnabled: IAiModelService['toggleModelEnabled'] = async (params) => {
return this.aiModel.toggleModelEnabled(params);
};
updateAiModel: IAiModelService['updateAiModel'] = async (id, providerId, value) => {
return this.aiModel.update(id, providerId, value);
};
batchUpdateAiModels: IAiModelService['batchUpdateAiModels'] = async (id, models) => {
return this.aiModel.batchUpdateAiModels(id, models);
};
batchToggleAiModels: IAiModelService['batchToggleAiModels'] = async (id, models, enabled) => {
return this.aiModel.batchToggleAiModels(id, models, enabled);
};
clearRemoteModels: IAiModelService['clearRemoteModels'] = async (providerId) => {
return this.aiModel.clearRemoteModels(providerId);
};
updateAiModelOrder: IAiModelService['updateAiModelOrder'] = async (providerId, items) => {
return this.aiModel.updateModelsOrder(providerId, items);
};
deleteAiModel: IAiModelService['deleteAiModel'] = async (params: {
id: string;
providerId: string;
}) => {
return this.aiModel.delete(params.id, params.providerId);
};
}
+10
View File
@@ -0,0 +1,10 @@
import { testService } from '~test-utils';
import { ClientService } from './client';
import { ServerService } from './server';
describe('aiModelService', () => {
testService(ServerService);
testService(ClientService);
});
+5
View File
@@ -0,0 +1,5 @@
import { ClientService } from './client';
import { ServerService } from './server';
export const aiModelService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
+47
View File
@@ -0,0 +1,47 @@
import { lambdaClient } from '@/libs/trpc/client';
import { IAiModelService } from '@/services/aiModel/type';
export class ServerService implements IAiModelService {
createAiModel: IAiModelService['createAiModel'] = async (params) => {
return lambdaClient.aiModel.createAiModel.mutate(params);
};
getAiProviderModelList: IAiModelService['getAiProviderModelList'] = async (id) => {
return lambdaClient.aiModel.getAiProviderModelList.query({ id });
};
getAiModelById: IAiModelService['getAiModelById'] = async (id) => {
return lambdaClient.aiModel.getAiModelById.query({ id });
};
toggleModelEnabled: IAiModelService['toggleModelEnabled'] = async (params) => {
return lambdaClient.aiModel.toggleModelEnabled.mutate(params);
};
updateAiModel: IAiModelService['updateAiModel'] = async (id, providerId, value) => {
return lambdaClient.aiModel.updateAiModel.mutate({ id, providerId, value });
};
batchUpdateAiModels: IAiModelService['batchUpdateAiModels'] = async (id, models) => {
return lambdaClient.aiModel.batchUpdateAiModels.mutate({ id, models });
};
batchToggleAiModels: IAiModelService['batchToggleAiModels'] = async (id, models, enabled) => {
return lambdaClient.aiModel.batchToggleAiModels.mutate({ enabled, id, models });
};
clearRemoteModels: IAiModelService['clearRemoteModels'] = async (providerId) => {
return lambdaClient.aiModel.clearRemoteModels.mutate({ providerId });
};
updateAiModelOrder: IAiModelService['updateAiModelOrder'] = async (providerId, items) => {
return lambdaClient.aiModel.updateAiModelOrder.mutate({ providerId, sortMap: items });
};
deleteAiModel: IAiModelService['deleteAiModel'] = async (params: {
id: string;
providerId: string;
}) => {
return lambdaClient.aiModel.removeAiModel.mutate(params);
};
}
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable typescript-sort-keys/interface */
import {
AiModelSortMap,
AiProviderModelListItem,
CreateAiModelParams,
ToggleAiModelEnableParams,
UpdateAiModelParams,
} from '@/types/aiModel';
export interface IAiModelService {
createAiModel: (params: CreateAiModelParams) => Promise<any>;
getAiProviderModelList: (id: string) => Promise<AiProviderModelListItem[]>;
getAiModelById: (id: string) => Promise<any>;
toggleModelEnabled: (params: ToggleAiModelEnableParams) => Promise<any>;
updateAiModel: (id: string, providerId: string, value: UpdateAiModelParams) => Promise<any>;
batchUpdateAiModels: (id: string, models: AiProviderModelListItem[]) => Promise<any>;
batchToggleAiModels: (id: string, models: string[], enabled: boolean) => Promise<any>;
clearRemoteModels: (providerId: string) => Promise<any>;
updateAiModelOrder: (providerId: string, items: AiModelSortMap[]) => Promise<any>;
deleteAiModel: (params: { id: string; providerId: string }) => Promise<any>;
}
-47
View File
@@ -1,47 +0,0 @@
import { lambdaClient } from '@/libs/trpc/client';
import {
AiProviderRuntimeState,
AiProviderSortMap,
CreateAiProviderParams,
UpdateAiProviderConfigParams,
} from '@/types/aiProvider';
class AiProviderService {
createAiProvider = async (params: CreateAiProviderParams) => {
return lambdaClient.aiProvider.createAiProvider.mutate(params);
};
getAiProviderList = async () => {
return lambdaClient.aiProvider.getAiProviderList.query();
};
getAiProviderById = async (id: string) => {
return lambdaClient.aiProvider.getAiProviderById.query({ id });
};
toggleProviderEnabled = async (id: string, enabled: boolean) => {
return lambdaClient.aiProvider.toggleProviderEnabled.mutate({ enabled, id });
};
updateAiProvider = async (id: string, value: any) => {
return lambdaClient.aiProvider.updateAiProvider.mutate({ id, value });
};
updateAiProviderConfig = async (id: string, value: UpdateAiProviderConfigParams) => {
return lambdaClient.aiProvider.updateAiProviderConfig.mutate({ id, value });
};
updateAiProviderOrder = async (items: AiProviderSortMap[]) => {
return lambdaClient.aiProvider.updateAiProviderOrder.mutate({ sortMap: items });
};
deleteAiProvider = async (id: string) => {
return lambdaClient.aiProvider.removeAiProvider.mutate({ id });
};
getAiProviderRuntimeState = async (isLogin?: boolean): Promise<AiProviderRuntimeState> => {
return lambdaClient.aiProvider.getAiProviderRuntimeState.query({ isLogin });
};
}
export const aiProviderService = new AiProviderService();
+64
View File
@@ -0,0 +1,64 @@
import { clientDB } from '@/database/client/db';
import { AiInfraRepos } from '@/database/repositories/aiInfra';
import { AiProviderModel } from '@/database/server/models/aiProvider';
import { BaseClientService } from '@/services/baseClientService';
import { IAiProviderService } from './type';
export class ClientService extends BaseClientService implements IAiProviderService {
private get aiProviderModel(): AiProviderModel {
return new AiProviderModel(clientDB as any, this.userId);
}
private get aiInfraRepos(): AiInfraRepos {
let config = {};
if (typeof window !== 'undefined') {
config = window.global_serverConfigStore.getState().serverConfig.aiProvider || {};
}
return new AiInfraRepos(clientDB as any, this.userId, config);
}
createAiProvider: IAiProviderService['createAiProvider'] = async (params) => {
const data = await this.aiProviderModel.create(params);
return data?.id;
};
getAiProviderById: IAiProviderService['getAiProviderById'] = async (id) => {
return this.aiProviderModel.getAiProviderById(id);
};
getAiProviderList: IAiProviderService['getAiProviderList'] = async () => {
return await this.aiInfraRepos.getAiProviderList();
};
getAiProviderRuntimeState: IAiProviderService['getAiProviderRuntimeState'] = async () => {
const runtimeConfig = await this.aiProviderModel.getAiProviderRuntimeConfig();
const enabledAiProviders = await this.aiInfraRepos.getUserEnabledProviderList();
const enabledAiModels = await this.aiInfraRepos.getEnabledModels();
return { enabledAiModels, enabledAiProviders, runtimeConfig };
};
toggleProviderEnabled: IAiProviderService['toggleProviderEnabled'] = async (id, enabled) => {
return this.aiProviderModel.toggleProviderEnabled(id, enabled);
};
updateAiProvider: IAiProviderService['updateAiProvider'] = async (id, value) => {
return this.aiProviderModel.update(id, value);
};
updateAiProviderConfig: IAiProviderService['updateAiProviderConfig'] = async (id, value) => {
return this.aiProviderModel.updateConfig(id, value);
};
updateAiProviderOrder: IAiProviderService['updateAiProviderOrder'] = async (items) => {
return this.aiProviderModel.updateOrder(items);
};
deleteAiProvider: IAiProviderService['deleteAiProvider'] = async (id) => {
return this.aiProviderModel.delete(id);
};
}
+10
View File
@@ -0,0 +1,10 @@
import { testService } from '~test-utils';
import { ClientService } from './client';
import { ServerService } from './server';
describe('aiProviderService', () => {
testService(ServerService);
testService(ClientService);
});
+5
View File
@@ -0,0 +1,5 @@
import { ClientService } from './client';
import { ServerService } from './server';
export const aiProviderService =
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : new ClientService();
+43
View File
@@ -0,0 +1,43 @@
import { lambdaClient } from '@/libs/trpc/client';
import { IAiProviderService } from './type';
export class ServerService implements IAiProviderService {
createAiProvider: IAiProviderService['createAiProvider'] = async (params) => {
return lambdaClient.aiProvider.createAiProvider.mutate(params);
};
getAiProviderList: IAiProviderService['getAiProviderList'] = async () => {
return lambdaClient.aiProvider.getAiProviderList.query();
};
getAiProviderById: IAiProviderService['getAiProviderById'] = async (id) => {
return lambdaClient.aiProvider.getAiProviderById.query({ id });
};
toggleProviderEnabled: IAiProviderService['toggleProviderEnabled'] = async (id, enabled) => {
return lambdaClient.aiProvider.toggleProviderEnabled.mutate({ enabled, id });
};
updateAiProvider: IAiProviderService['updateAiProvider'] = async (id, value) => {
return lambdaClient.aiProvider.updateAiProvider.mutate({ id, value });
};
updateAiProviderConfig: IAiProviderService['updateAiProviderConfig'] = async (id, value) => {
return lambdaClient.aiProvider.updateAiProviderConfig.mutate({ id, value });
};
updateAiProviderOrder: IAiProviderService['updateAiProviderOrder'] = async (items) => {
return lambdaClient.aiProvider.updateAiProviderOrder.mutate({ sortMap: items });
};
deleteAiProvider: IAiProviderService['deleteAiProvider'] = async (id) => {
return lambdaClient.aiProvider.removeAiProvider.mutate({ id });
};
getAiProviderRuntimeState: IAiProviderService['getAiProviderRuntimeState'] = async (
isLogin?: boolean,
) => {
return lambdaClient.aiProvider.getAiProviderRuntimeState.query({ isLogin });
};
}
+26
View File
@@ -0,0 +1,26 @@
import {
AiProviderRuntimeState,
AiProviderSortMap,
CreateAiProviderParams,
UpdateAiProviderConfigParams,
} from '@/types/aiProvider';
export interface IAiProviderService {
createAiProvider: (params: CreateAiProviderParams) => Promise<any>;
deleteAiProvider: (id: string) => Promise<any>;
getAiProviderById: (id: string) => Promise<any>;
getAiProviderList: () => Promise<any>;
getAiProviderRuntimeState: (isLogin?: boolean) => Promise<AiProviderRuntimeState>;
toggleProviderEnabled: (id: string, enabled: boolean) => Promise<any>;
updateAiProvider: (id: string, value: any) => Promise<any>;
updateAiProviderConfig: (id: string, value: UpdateAiProviderConfigParams) => Promise<any>;
updateAiProviderOrder: (items: AiProviderSortMap[]) => Promise<any>;
}
+5 -5
View File
@@ -7,7 +7,7 @@ import { INBOX_GUIDE_SYSTEMROLE } from '@/const/guide';
import { INBOX_SESSION_ID } from '@/const/session';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { TracePayload, TraceTagMap } from '@/const/trace';
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition, isServerMode } from '@/const/version';
import {
AgentRuntime,
AgentRuntimeError,
@@ -42,7 +42,7 @@ import { API_ENDPOINTS } from './_url';
const isCanUseFC = (model: string, provider: string) => {
// TODO: remove isDeprecatedEdition condition in V2.0
if (!isServerMode) {
if (isDeprecatedEdition) {
return modelProviderSelectors.isModelEnabledFunctionCall(model)(useUserStore.getState());
}
@@ -53,7 +53,7 @@ const findAzureDeploymentName = (model: string) => {
let deploymentId = model;
// TODO: remove isDeprecatedEdition condition in V2.0
if (!isServerMode) {
if (isDeprecatedEdition) {
const chatModelCards = modelProviderSelectors.getModelCardsById(ModelProvider.Azure)(
useUserStore.getState(),
);
@@ -74,7 +74,7 @@ const findAzureDeploymentName = (model: string) => {
const isEnableFetchOnClient = (provider: string) => {
// TODO: remove this condition in V2.0
if (!isServerMode) {
if (isDeprecatedEdition) {
return modelConfigSelectors.isProviderFetchOnClient(provider)(useUserStore.getState());
} else {
return aiProviderSelectors.isProviderFetchOnClient(provider)(useAiInfraStore.getState());
@@ -341,7 +341,7 @@ class ChatService {
const isBuiltin = Object.values(ModelProvider).includes(provider as any);
// TODO: remove `!isDeprecatedEdition` condition in V2.0
if (isServerMode && !isBuiltin) {
if (!isDeprecatedEdition && !isBuiltin) {
const providerConfig = aiProviderSelectors.providerConfigById(provider)(
useAiInfraStore.getState(),
);
+16
View File
@@ -0,0 +1,16 @@
import { clientDB } from '@/database/client/db';
import { TableViewerRepo } from '@/database/repositories/tableViewer';
import { BaseClientService } from '@/services/baseClientService';
export class ClientService extends BaseClientService {
private get tableViewerRepo(): TableViewerRepo {
return new TableViewerRepo(clientDB as any, this.userId);
}
getAllTables = async () => this.tableViewerRepo.getAllTables();
getTableDetails = async (tableName: string) => this.tableViewerRepo.getTableDetails(tableName);
getTableData = async (tableName: string) =>
this.tableViewerRepo.getTableData(tableName, { page: 1, pageSize: 300 });
}
+3
View File
@@ -0,0 +1,3 @@
import { ClientService } from './client';
export const tableViewerService = new ClientService();
@@ -3,7 +3,7 @@ import { SWRResponse, mutate } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
import { isServerMode } from '@/const/version';
import { isDeprecatedEdition } from '@/const/version';
import { useClientDataSWR } from '@/libs/swr';
import { aiProviderService } from '@/services/aiProvider';
import { AiInfraStore } from '@/store/aiInfra/store';
@@ -170,7 +170,7 @@ export const createAiProviderSlice: StateCreator<
useFetchAiProviderRuntimeState: (isLogin) =>
useClientDataSWR<AiProviderRuntimeState | undefined>(
isServerMode ? [AiProviderSwrKey.fetchAiProviderRuntimeState, isLogin] : null,
!isDeprecatedEdition ? [AiProviderSwrKey.fetchAiProviderRuntimeState, isLogin] : null,
async ([, isLogin]) => {
if (isLogin) return aiProviderService.getAiProviderRuntimeState();
+6
View File
@@ -24,7 +24,13 @@ export interface GlobalServerConfig {
defaultAgent?: DeepPartial<UserDefaultAgent>;
enableUploadFileToServer?: boolean;
enabledAccessCode?: boolean;
/**
* @deprecated
*/
enabledOAuthSSO?: boolean;
/**
* @deprecated
*/
languageModel?: ServerLanguageModel;
oAuthSSOProviders?: string[];
systemAgent?: DeepPartial<UserSystemAgentConfig>;
+30
View File
@@ -0,0 +1,30 @@
export interface TableBasicInfo {
count: number;
name: string;
type: 'BASE TABLE' | 'VIEW';
}
export interface TableColumnInfo {
defaultValue?: string;
foreignKey?: {
column: string;
table: string;
};
isPrimaryKey: boolean;
name: string;
nullable: boolean;
type: string;
}
export interface PaginationParams {
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface FilterCondition {
column: string;
operator: 'equals' | 'contains' | 'startsWith' | 'endsWith';
value: any;
}
+46
View File
@@ -9,3 +9,49 @@ const swrConfig = {
export const withSWR = ({ children }: PropsWithChildren) => (
<SWRConfig value={swrConfig}>{children}</SWRConfig>
);
interface TestServiceOptions {
/** 是否检查 async */
checkAsync?: boolean;
/** 自定义的额外检查 */
extraChecks?: (method: string, func: () => any) => void;
/** 是否跳过某些方法 */
skipMethods?: string[];
}
const builtinSkipProps = new Set(['userId']);
export const testService = (ServiceClass: new () => any, options: TestServiceOptions = {}) => {
const { checkAsync = true, skipMethods = ['userId'], extraChecks } = options;
describe(ServiceClass.name, () => {
it('should implement all methods as arrow functions', () => {
const service = new ServiceClass();
const methods = Object.getOwnPropertyNames(service).filter(
(method) => !builtinSkipProps.has(method) || !skipMethods.includes(method),
);
methods.forEach((method) => {
const func = service[method];
// 检查是否为函数
expect(typeof func).toBe('function');
const funcString = func.toString();
// 验证是否是箭头函数
expect(funcString).toContain('=>');
// 可选的 async 检查
if (checkAsync) {
expect(funcString).toMatch(/^async.*=>/);
}
// 运行额外的自定义检查
if (extraChecks) {
extraChecks(method, func);
}
});
});
});
};