Compare commits

...

15 Commits

Author SHA1 Message Date
ONLY-yours 60f9320c31 chore: support market-oidc grant 2025-10-29 15:52:09 +08:00
ONLY-yours 01de2d07ee feat: update the knowledge base open judge 2025-10-28 20:40:41 +08:00
ONLY-yours 09b5be56b2 feat: close the knowledge/base settings page & update createNew onSuccess into Modal 2025-10-28 20:24:14 +08:00
ONLY-yours ec3640da1c Merge remote-tracking branch 'origin/main' into fix/filesPage 2025-10-28 17:59:51 +08:00
ONLY-yours e9b09d7189 feat: change all files and repo url to knowledge url 2025-10-28 17:48:08 +08:00
ONLY-yours 88838f6538 feat: update files?filesId to get files loading 2025-10-27 17:21:32 +08:00
ONLY-yours b69c6649a3 style: use flex 1 to get all width 2025-10-24 16:12:22 +08:00
ONLY-yours 133221823e feat: change the useNavigate to the router outer 2025-10-23 17:28:46 +08:00
ONLY-yours 9906ddc416 feat: change the dir name and the path 2025-10-23 16:23:08 +08:00
ONLY-yours 40f4ad735b feat: change download action to detail right place 2025-10-23 16:15:39 +08:00
ONLY-yours 8ed5326094 feat: update files preview from severs to client 2025-10-23 15:46:28 +08:00
ONLY-yours 3a8a464610 Merge remote-tracking branch 'origin/main' into fix/filesPage 2025-10-23 14:25:09 +08:00
ONLY-yours c19974fae8 fix: delete useless code 2025-10-22 23:26:58 +08:00
ONLY-yours 74679f984e feat: add height 100% 2025-10-22 22:43:24 +08:00
ONLY-yours f32dbaa4f0 feat: change files page to spa 2025-10-22 22:34:13 +08:00
126 changed files with 1164 additions and 1738 deletions
+7
View File
@@ -260,6 +260,13 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Authentication across different domains , use,to splite different origin
# NEXT_PUBLIC_CLERK_AUTH_ALLOW_ORIGINS='https://market.lobehub.com,https://lobehub.com'
# Comma separated redirect URIs for the marketplace OIDC client
# MARKET_OIDC_REDIRECT_URIS=https://market.lobehub.com/oidc/callback,http://localhost:8787/market-oidc/consent/callback
# Comma separated post logout redirect URIs for the marketplace OIDC client
# MARKET_OIDC_POST_LOGOUT_URIS=https://market.lobehub.com/oidc/logout,http://localhost:8787/market-oidc/logout
# NextAuth related configurations
# NEXT_PUBLIC_ENABLE_NEXT_AUTH=1
# NEXT_AUTH_SECRET=
@@ -358,6 +358,9 @@ export default class Browser {
session: browserWindow.webContents.session,
});
// Setup CORS bypass for local file server
this.setupCORSBypass(browserWindow);
logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
this.loadPlaceholder().then(() => {
this.loadUrl(path).catch((e) => {
@@ -491,4 +494,37 @@ export default class Browser {
logger.debug(`[${this.identifier}] Manually reapplying visual effects via Browser.`);
this.applyVisualEffects();
}
/**
* Setup CORS bypass for local file server (127.0.0.1:*)
* This is needed for Electron to access files from the local static file server
*/
private setupCORSBypass(browserWindow: BrowserWindow): void {
logger.debug(`[${this.identifier}] Setting up CORS bypass for local file server`);
const session = browserWindow.webContents.session;
// Intercept response headers to add CORS headers
session.webRequest.onHeadersReceived((details, callback) => {
const url = details.url;
// Only modify headers for local file server requests (127.0.0.1)
if (url.includes('127.0.0.1') || url.includes('lobe-desktop-file')) {
const responseHeaders = details.responseHeaders || {};
// Add CORS headers
responseHeaders['Access-Control-Allow-Origin'] = ['*'];
responseHeaders['Access-Control-Allow-Methods'] = ['GET, POST, PUT, DELETE, OPTIONS'];
responseHeaders['Access-Control-Allow-Headers'] = ['*'];
callback({
responseHeaders,
});
} else {
callback({ responseHeaders: details.responseHeaders });
}
});
logger.debug(`[${this.identifier}] CORS bypass setup completed`);
}
}
@@ -9,6 +9,21 @@ import type { App } from '../App';
const logger = createLogger('core:StaticFileServerManager');
const getAllowedOrigin = (rawOrigin?: string) => {
if (!rawOrigin) return '*';
try {
const url = new URL(rawOrigin);
const normalizedOrigin = `${url.protocol}//${url.host}`;
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' ? normalizedOrigin : '*';
} catch {
const normalizedOrigin = rawOrigin.replace(/\/$/, '');
return normalizedOrigin.includes('localhost') || normalizedOrigin.includes('127.0.0.1')
? normalizedOrigin
: '*';
}
};
export class StaticFileServerManager {
private app: App;
private fileService: FileService;
@@ -126,16 +141,38 @@ export class StaticFileServerManager {
return;
}
// 获取请求的 Origin 并设置 CORS
const origin = req.headers.origin || req.headers.referer;
const allowedOrigin = getAllowedOrigin(origin);
// 处理 CORS 预检请求
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Max-Age': '86400',
});
res.end();
return;
}
const url = new URL(req.url, `http://127.0.0.1:${this.serverPort}`);
logger.debug(`Processing HTTP file request: ${req.url}`);
logger.debug(`Request method: ${req.method}`);
logger.debug(`Request headers: ${JSON.stringify(req.headers)}`);
// 提取文件路径:从 /desktop-file/path/to/file.png 中提取相对路径
let filePath = decodeURIComponent(url.pathname.slice(1)); // 移除开头的 /
logger.debug(`Initial file path after decode: ${filePath}`);
// 如果路径以 desktop-file/ 开头,则移除该前缀
const prefixWithoutSlash = LOCAL_STORAGE_URL_PREFIX.slice(1) + '/'; // 移除开头的 / 并添加结尾的 /
logger.debug(`Prefix to remove: ${prefixWithoutSlash}`);
if (filePath.startsWith(prefixWithoutSlash)) {
filePath = filePath.slice(prefixWithoutSlash.length);
logger.debug(`File path after removing prefix: ${filePath}`);
}
if (!filePath) {
@@ -148,7 +185,12 @@ export class StaticFileServerManager {
}
// 使用 FileService 获取文件
const fileResult = await this.fileService.getFile(`desktop://${filePath}`);
const desktopPath = `desktop://${filePath}`;
logger.debug(`Attempting to get file: ${desktopPath}`);
const fileResult = await this.fileService.getFile(desktopPath);
logger.debug(
`File retrieved successfully, mime type: ${fileResult.mimeType}, size: ${fileResult.content.byteLength} bytes`,
);
// 再次检查响应状态
if (res.destroyed || res.headersSent) {
@@ -158,11 +200,8 @@ export class StaticFileServerManager {
// 设置响应头
res.writeHead(200, {
// 缓存一年
'Access-Control-Allow-Origin': 'http://localhost:*',
'Access-Control-Allow-Origin': allowedOrigin,
'Cache-Control': 'public, max-age=31536000',
// 允许 localhost 的任意端口
'Content-Length': Buffer.byteLength(fileResult.content),
'Content-Type': fileResult.mimeType,
});
@@ -173,16 +212,27 @@ export class StaticFileServerManager {
logger.debug(`HTTP file served successfully: desktop://${filePath}`);
} catch (error) {
logger.error(`Error serving HTTP file: ${error}`);
logger.error(`Error stack: ${error.stack}`);
// 检查响应是否仍然可写
if (!res.destroyed && !res.headersSent) {
try {
// 获取请求的 Origin 并设置 CORS(错误响应也需要!)
const origin = req.headers.origin || req.headers.referer;
const allowedOrigin = getAllowedOrigin(origin);
// 判断是否是文件未找到错误
if (error.name === 'FileNotFoundError') {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.writeHead(404, {
'Access-Control-Allow-Origin': allowedOrigin,
'Content-Type': 'text/plain',
});
res.end('File Not Found');
} else {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.writeHead(500, {
'Access-Control-Allow-Origin': allowedOrigin,
'Content-Type': 'text/plain',
});
res.end('Internal Server Error');
}
} catch (writeError) {
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V مبني على نموذج GLM-4.5-Air الأساسي، يرث التقنيات المثبتة من GLM-4.1V-Thinking، ويوسعها بفعالية من خلال بنية MoE القوية التي تضم 106 مليار معلمة."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V е изграден върху основния модел GLM-4.5-Air, наследявайки проверените технологии на GLM-4.1V-Thinking и постига ефективно мащабиране чрез мощната MoE архитектура с 106 милиарда параметри."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V basiert auf dem GLM-4.5-Air Basismodell, übernimmt bewährte Techniken von GLM-4.1V-Thinking und skaliert effektiv mit einer leistungsstarken MoE-Architektur mit 106 Milliarden Parametern."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V is built on the GLM-4.5-Air foundational model, inheriting the proven techniques of GLM-4.1V-Thinking while achieving efficient scaling through a powerful 106 billion parameter MoE architecture."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V está construido sobre el modelo base GLM-4.5-Air, heredando la tecnología verificada de GLM-4.1V-Thinking y logrando una escalabilidad eficiente mediante una potente arquitectura MoE de 106 mil millones de parámetros."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V بر پایه مدل پایه GLM-4.5-Air ساخته شده است، فناوری اثبات شده GLM-4.1V-Thinking را به ارث برده و در عین حال با معماری قدرتمند MoE با 106 میلیارد پارامتر به طور مؤثر مقیاس‌پذیر شده است."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V est construit sur le modèle de base GLM-4.5-Air, héritant des techniques éprouvées de GLM-4.1V-Thinking, tout en réalisant une mise à l'échelle efficace grâce à une puissante architecture MoE de 106 milliards de paramètres."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V は GLM-4.5-Air 基盤モデルに基づき、GLM-4.1V-Thinking の検証済み技術を継承しつつ、強力な1060億パラメータの MoE アーキテクチャで効率的にスケールアップしています。"
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V는 GLM-4.5-Air 기본 모델을 기반으로 구축되었으며, 검증된 GLM-4.1V-Thinking 기술을 계승하면서 강력한 1060억 매개변수 MoE 아키텍처를 통해 효율적인 확장을 실현했습니다."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V is gebouwd op het GLM-4.5-Air basismodel, erft de bewezen technologie van GLM-4.1V-Thinking en realiseert efficiënte schaalvergroting via een krachtige MoE-architectuur met 106 miljard parameters."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V zbudowany jest na bazie GLM-4.5-Air, dziedzicząc zweryfikowane technologie GLM-4.1V-Thinking, jednocześnie skutecznie skalując się dzięki potężnej architekturze MoE z 106 miliardami parametrów."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V построена на базе GLM-4.5-Air, наследуя проверенные технологии GLM-4.1V-Thinking и обеспечивая эффективное масштабирование благодаря мощной архитектуре MoE с 106 миллиардами параметров."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V, GLM-4.5-Air temel modeli üzerine inşa edilmiştir, GLM-4.1V-Thinking'in doğrulanmış teknolojisini devralır ve güçlü 106 milyar parametreli MoE mimarisi ile etkili ölçeklenebilirlik sağlar."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V được xây dựng trên mô hình nền tảng GLM-4.5-Air, kế thừa công nghệ đã được xác minh của GLM-4.1V-Thinking, đồng thời mở rộng hiệu quả với kiến trúc MoE 106 tỷ tham số mạnh mẽ."
}
}
}
+1 -1
View File
@@ -3314,4 +3314,4 @@
"zai/glm-4.5v": {
"description": "GLM-4.5V 基於 GLM-4.5-Air 基礎模型構建,繼承了 GLM-4.1V-Thinking 的經過驗證的技術,同時透過強大的 1060 億參數 MoE 架構實現了有效的擴展。"
}
}
}
@@ -35,7 +35,7 @@ export interface WindowsDispatchEvents {
* @param templateId Template identifier
* @returns Operation result
*/
closeWindowsByTemplate: (templateId: string) => { error?: string, success: boolean; };
closeWindowsByTemplate: (templateId: string) => { error?: string; success: boolean };
/**
* Create a new multi-instance window
+2 -1
View File
@@ -663,7 +663,8 @@ const aihubmixModels: AIChatModelCard[] = [
vision: true,
},
contextWindowTokens: 200_000,
description: 'Claude Haiku 4.5 是 Anthropic 最快且最智能的 Haiku 模型,具有闪电般的速度和扩展思考能力。',
description:
'Claude Haiku 4.5 是 Anthropic 最快且最智能的 Haiku 模型,具有闪电般的速度和扩展思考能力。',
displayName: 'Claude Haiku 4.5',
enabled: true,
id: 'claude-haiku-4-5-20251001',
+2 -2
View File
@@ -4,8 +4,8 @@ import { AIChatModelCard } from '../types/aiModel';
const novitaChatModels: AIChatModelCard[] = [
{
abilities: {
vision: true,
functionCall: true,
vision: true,
},
contextWindowTokens: 131_072,
displayName: 'Qwen3 VL 235B A22B Instruct',
@@ -21,8 +21,8 @@ const novitaChatModels: AIChatModelCard[] = [
},
{
abilities: {
vision: true,
reasoning: true,
vision: true,
},
contextWindowTokens: 131_072,
displayName: 'Qwen3 VL 235B A22B Thinking',
@@ -50,7 +50,7 @@ const siliconcloudChatModels: AIChatModelCard[] = [
abilities: {
vision: true,
},
contextWindowTokens: 8_192,
contextWindowTokens: 8192,
description:
'DeepSeek-OCR 是由深度求索(DeepSeek AI)推出的一个视觉语言模型,专注于光学字符识别(OCR)与"上下文光学压缩"。该模型旨在探索从图像中压缩上下文信息的边界,能够高效处理文档并将其转换为如 Markdown 等结构化文本格式。它能够准确识别图像中的文字内容,特别适用于文档数字化、文字提取和结构化处理等应用场景。',
displayName: 'DeepSeek OCR',
@@ -17,11 +17,11 @@ export const LobeMinimaxAI = createOpenAICompatibleRuntime({
const minimaxTools = enabledSearch
? [
...(tools || []),
{
type: 'web_search',
},
]
...(tools || []),
{
type: 'web_search',
},
]
: tools;
// Resolve parameters with constraints
@@ -15,6 +15,7 @@ export const params = {
models: async ({ client }) => {
const modelsPage = (await client.models.list()) as any;
const modelList = modelsPage.data.map((model: any) => {
// eslint-disable-next-line unused-imports/no-unused-vars, @typescript-eslint/no-unused-vars
const { created, ...rest } = model;
return rest;
});
@@ -32,6 +32,9 @@ const TopActions = memo<TopActionProps>(({ tab, isPinned }) => {
useServerConfigStore(featureFlagsSelectors);
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.NavigateToChat));
// Check if server mode is enabled
const isServerMode = process.env.NEXT_PUBLIC_SERVICE_MODE === 'server';
const isChatActive = tab === SidebarTabKey.Chat && !isPinned;
const isFilesActive = tab === SidebarTabKey.Files;
const isDiscoverActive = tab === SidebarTabKey.Discover;
@@ -66,8 +69,8 @@ const TopActions = memo<TopActionProps>(({ tab, isPinned }) => {
tooltipProps={{ placement: 'right' }}
/>
</Link>
{enableKnowledgeBase && (
<Link aria-label={t('tab.knowledgeBase')} href={'/files'}>
{enableKnowledgeBase && isServerMode && (
<Link aria-label={t('tab.knowledgeBase')} href={'/knowledge'}>
<ActionIcon
active={isFilesActive}
icon={FolderClosed}
@@ -1,16 +1,16 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { useParams } from 'react-router-dom';
import { withSuspense } from '@/components/withSuspense';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import NotFound from '../components/NotFound';
import Breadcrumb from '../features/Breadcrumb';
import { TocProvider } from '../features/Toc/useToc';
import NotFound from '../components/NotFound';
import { DetailProvider } from './[...slugs]/features/DetailProvider';
import Details from './[...slugs]/features/Details';
import Header from './[...slugs]/features/Header';
@@ -8,13 +8,7 @@ const NotFound = memo(() => {
const { t } = useTranslation('error', { keyPrefix: 'notFound' });
return (
<Flexbox
align="center"
height="100%"
justify="center"
style={{ minHeight: 400 }}
width="100%"
>
<Flexbox align="center" height="100%" justify="center" style={{ minHeight: 400 }} width="100%">
<h2>{t('title')}</h2>
</Flexbox>
);
@@ -1,8 +1,8 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { useParams } from 'react-router-dom';
import { withSuspense } from '@/components/withSuspense';
import { DetailProvider } from '@/features/MCPPluginDetail/DetailProvider';
@@ -12,9 +12,9 @@ import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import NotFound from '../components/NotFound';
import Breadcrumb from '../features/Breadcrumb';
import { TocProvider } from '../features/Toc/useToc';
import NotFound from '../components/NotFound';
import Details from './[slug]/features/Details';
import Loading from './[slug]/loading';
@@ -1,15 +1,15 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { useParams } from 'react-router-dom';
import { withSuspense } from '@/components/withSuspense';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import Breadcrumb from '../features/Breadcrumb';
import NotFound from '../components/NotFound';
import Breadcrumb from '../features/Breadcrumb';
import { DetailProvider } from './[...slugs]/features/DetailProvider';
import Details from './[...slugs]/features/Details';
import Header from './[...slugs]/features/Header';
@@ -1,15 +1,15 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { useParams } from 'react-router-dom';
import { withSuspense } from '@/components/withSuspense';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import Breadcrumb from '../features/Breadcrumb';
import NotFound from '../components/NotFound';
import Breadcrumb from '../features/Breadcrumb';
import { DetailProvider } from './[...slugs]/features/DetailProvider';
import Details from './[...slugs]/features/Details';
import Header from './[...slugs]/features/Header';
@@ -1,6 +1,6 @@
'use client';
import { memo, PropsWithChildren } from 'react';
import { PropsWithChildren, memo } from 'react';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
@@ -1,9 +1,9 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import { Link, useNavigate } from 'react-router-dom';
import qs from 'query-string';
import { memo, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { SCROLL_PARENT_ID } from '@/app/[variants]/(main)/discover/features/const';
import { withSuspense } from '@/components/withSuspense';
@@ -1,6 +1,6 @@
'use client';
import { memo, PropsWithChildren } from 'react';
import { PropsWithChildren, memo } from 'react';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
@@ -1,9 +1,9 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import { Link, useNavigate } from 'react-router-dom';
import qs from 'query-string';
import { memo, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { SCROLL_PARENT_ID } from '@/app/[variants]/(main)/discover/features/const';
import { withSuspense } from '@/components/withSuspense';
@@ -1,6 +1,6 @@
'use client';
import { memo, PropsWithChildren } from 'react';
import { PropsWithChildren, memo } from 'react';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
@@ -1,9 +1,9 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import { Link, useNavigate } from 'react-router-dom';
import qs from 'query-string';
import { memo, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { SCROLL_PARENT_ID } from '@/app/[variants]/(main)/discover/features/const';
import { withSuspense } from '@/components/withSuspense';
@@ -59,10 +59,7 @@ const Title = memo<TitleProps>(({ tag, children, moreLink, more }) => {
title
)}
{moreLink && (
<Link
target={moreLink.startsWith('http') ? '_blank' : undefined}
to={moreLink}
>
<Link target={moreLink.startsWith('http') ? '_blank' : undefined} to={moreLink}>
<Button className={styles.more} style={{ paddingInline: 6 }} type={'text'}>
<span>{more}</span>
<Icon icon={ChevronRight} />
@@ -1,27 +0,0 @@
'use client';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import PanelTitle from '@/components/PanelTitle';
import FileMenu from './features/FileMenu';
import KnowledgeBase from './features/KnowledgeBase';
const Menu = () => {
const { t } = useTranslation('file');
return (
<Flexbox gap={16} height={'100%'}>
<Flexbox paddingInline={8}>
<PanelTitle desc={t('desc')} title={t('title')} />
<FileMenu />
</Flexbox>
<KnowledgeBase />
</Flexbox>
);
};
Menu.displayName = 'Menu';
export default Menu;
@@ -1,19 +0,0 @@
import { PagePropsWithId } from '@/types/next';
import FileDetail from './FileDetail';
import FilePreview from './FilePreview';
import FullscreenModal from './FullscreenModal';
const Page = async (props: PagePropsWithId) => {
const params = await props.params;
return (
<FullscreenModal detail={<FileDetail id={params.id} />}>
<FilePreview id={params.id} />
</FullscreenModal>
);
};
export default Page;
export const dynamic = 'force-static';
@@ -1,3 +0,0 @@
// This ensures that the modal is not rendered when it's not active.
export default () => null;
@@ -1,161 +0,0 @@
'use client';
import { Icon, Text } from '@lobehub/ui';
import { createStyles, useTheme } from 'antd-style';
import { Database, FileImage, FileText, FileUpIcon, LibraryBig, SearchCheck } from 'lucide-react';
import Link from 'next/link';
import { Trans, useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import FeatureList from '@/components/FeatureList';
import { LOBE_CHAT_CLOUD } from '@/const/branding';
import { DATABASE_SELF_HOSTING_URL, OFFICIAL_URL, UTM_SOURCE } from '@/const/url';
const BLOCK_SIZE = 100;
const ICON_SIZE = { size: 72, strokeWidth: 1.5 };
const useStyles = createStyles(({ css, token }) => ({
actionTitle: css`
margin-block-start: 12px;
font-size: 16px;
color: ${token.colorTextSecondary};
`,
card: css`
cursor: pointer;
position: relative;
overflow: hidden;
width: 200px;
height: 140px;
border-radius: ${token.borderRadiusLG}px;
font-weight: 500;
text-align: center;
background: ${token.colorFillTertiary};
box-shadow: 0 0 0 1px ${token.colorFillTertiary} inset;
transition: background 0.3s ease-in-out;
&:hover {
background: ${token.colorFillSecondary};
}
`,
glow: css`
position: absolute;
inset-block-end: -12px;
inset-inline-end: 0;
width: 48px;
height: 48px;
opacity: 0.5;
filter: blur(24px);
`,
icon: css`
border-radius: ${token.borderRadiusLG}px;
color: ${token.colorTextLightSolid};
`,
iconGroup: css`
margin-block-start: -44px;
`,
}));
const NotSupportClient = () => {
const { t } = useTranslation('file');
const theme = useTheme();
const { styles } = useStyles();
const features = [
{
avatar: Database,
desc: t('notSupportGuide.features.allKind.desc'),
title: t('notSupportGuide.features.allKind.title'),
},
{
avatar: SearchCheck,
desc: t('notSupportGuide.features.embeddings.desc'),
title: t('notSupportGuide.features.embeddings.title'),
},
{
avatar: LibraryBig,
desc: t('notSupportGuide.features.repos.desc'),
title: t('notSupportGuide.features.repos.title'),
},
];
return (
<Center
gap={40}
height={'100%'}
style={{
overflow: 'scroll',
}}
width={'100%'}
>
<Flexbox className={styles.iconGroup} gap={12} horizontal>
<Center
className={styles.icon}
height={BLOCK_SIZE * 1.25}
style={{
background: theme.purple,
transform: 'rotateZ(-20deg) translateX(10px)',
}}
width={BLOCK_SIZE}
>
<Icon icon={FileImage} size={ICON_SIZE} />
</Center>
<Center
className={styles.icon}
height={BLOCK_SIZE * 1.25}
style={{
background: theme.gold,
transform: 'translateY(-22px)',
zIndex: 1,
}}
width={BLOCK_SIZE}
>
<Icon icon={FileUpIcon} size={ICON_SIZE} />
</Center>
<Center
className={styles.icon}
height={BLOCK_SIZE * 1.25}
style={{
background: theme.geekblue,
transform: 'rotateZ(20deg) translateX(-10px)',
}}
width={BLOCK_SIZE}
>
<Icon icon={FileText} size={ICON_SIZE} />
</Center>
</Flexbox>
<Flexbox justify={'center'} style={{ textAlign: 'center' }}>
<Text as={'h1'} style={{ fontSize: 32 }}>
{t('notSupportGuide.title')}
</Text>
<Text type={'secondary'}>
<Trans i18nKey={'notSupportGuide.desc'} ns={'file'}>
使
<Link href={DATABASE_SELF_HOSTING_URL}></Link>
使
<Link
href={`${OFFICIAL_URL}?utm_source=${UTM_SOURCE}&utm_medium=client_not_support_file`}
>
{LOBE_CHAT_CLOUD}
</Link>
</Trans>
</Text>
</Flexbox>
<Flexbox style={{ marginTop: 40 }}>
<FeatureList data={features} />
</Flexbox>
</Center>
);
};
export default NotSupportClient;
@@ -1,29 +0,0 @@
import { Flexbox } from 'react-layout-kit';
import FilePanel from '@/features/FileSidePanel';
import { LayoutProps } from '../type';
import Container from './Container';
import RegisterHotkeys from './RegisterHotkeys';
const Layout = ({ children, menu, modal }: LayoutProps) => {
return (
<>
<Flexbox
height={'100%'}
horizontal
style={{ maxWidth: '100%', overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<FilePanel>{menu}</FilePanel>
<Container>{children}</Container>
</Flexbox>
<RegisterHotkeys />
{modal}
</>
);
};
Layout.displayName = 'DesktopFileLayout';
export default Layout;
@@ -1,47 +0,0 @@
'use client';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useShowMobileWorkspace } from '@/hooks/useShowMobileWorkspace';
import { LayoutProps } from './type';
const useStyles = createStyles(({ css, token }) => ({
main: css`
position: relative;
overflow: hidden;
background: ${token.colorBgLayout};
`,
}));
const Layout = memo<LayoutProps>(({ children, menu }) => {
const showMobileWorkspace = useShowMobileWorkspace();
const { styles } = useStyles();
return (
<>
<Flexbox
className={styles.main}
height="100%"
style={showMobileWorkspace ? { display: 'none' } : undefined}
width="100%"
>
{menu}
</Flexbox>
<Flexbox
className={styles.main}
height="100%"
style={showMobileWorkspace ? undefined : { display: 'none' }}
width="100%"
>
{children}
</Flexbox>
</>
);
});
Layout.displayName = 'MobileChatLayout';
export default Layout;
@@ -1,7 +0,0 @@
import { ReactNode } from 'react';
export interface LayoutProps {
children: ReactNode;
menu: ReactNode;
modal: ReactNode;
}
@@ -1,18 +0,0 @@
import ServerLayout from '@/components/server/ServerLayout';
import { isServerMode } from '@/const/version';
import NotSupportClient from './NotSupportClient';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
import { LayoutProps } from './_layout/type';
const Layout = ServerLayout<LayoutProps>({ Desktop, Mobile });
Layout.displayName = 'FileLayout';
export default (props: LayoutProps) => {
// if there is client db mode , tell user to switch to server mode
if (!isServerMode) return <NotSupportClient />;
return <Layout {...props} />;
};
@@ -1,14 +0,0 @@
'use client';
import { useTranslation } from 'react-i18next';
import { useFileCategory } from '@/app/[variants]/(main)/files/hooks/useFileCategory';
import FileManager from '@/features/FileManager';
import { FilesTabs } from '@/types/files';
export default () => {
const { t } = useTranslation('file');
const [category] = useFileCategory();
return <FileManager category={category} title={t(`tab.${category as FilesTabs}`)} />;
};
@@ -1,63 +0,0 @@
'use client';
import { ActionIcon, Button, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { useTheme } from 'antd-style';
import { ArrowLeftIcon, DownloadIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { downloadFile } from '@/utils/client/downloadFile';
interface HeaderProps {
filename: string;
id: string;
url: string;
}
const Header = memo<HeaderProps>(({ filename, url }) => {
const { t } = useTranslation('common');
const router = useRouter();
const theme = useTheme();
return (
<Flexbox
align={'center'}
horizontal
justify={'space-between'}
paddingBlock={12}
paddingInline={12}
style={{ borderBottom: `1px solid ${theme.colorSplit}` }}
>
<Flexbox align={'baseline'} horizontal>
<Button
icon={ArrowLeftIcon}
onClick={() => {
router.push('/files');
}}
size={'small'}
type={'text'}
>
{t('back')}
</Button>
<Divider type={'vertical'} />
<Text
as={'h1'}
style={{ fontSize: 16, lineHeight: 1.5, marginBottom: 0, paddingInlineStart: 8 }}
>
{filename}
</Text>
</Flexbox>
<Flexbox>
<ActionIcon
icon={DownloadIcon}
onClick={() => {
downloadFile(url, filename);
}}
/>
</Flexbox>
</Flexbox>
);
});
export default Header;
@@ -1,41 +0,0 @@
import { getUserAuth } from '@lobechat/utils/server';
import { notFound } from 'next/navigation';
import { Flexbox } from 'react-layout-kit';
import FileViewer from '@/features/FileViewer';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { lambdaRouter } from '@/server/routers/lambda';
import { PagePropsWithId } from '@/types/next';
import FileDetail from '../features/FileDetail';
import Header from './Header';
const createCaller = createCallerFactory(lambdaRouter);
const FilePage = async (props: PagePropsWithId) => {
const params = await props.params;
const { userId } = await getUserAuth();
const caller = createCaller({ userId });
const file = await caller.file.getFileItemById({ id: params.id });
if (!file) return notFound();
return (
<Flexbox horizontal width={'100%'}>
<Flexbox flex={1}>
<Flexbox height={'100%'}>
<Header filename={file.name} id={params.id} url={file.url} />
<Flexbox height={'100%'} style={{ overflow: 'scroll' }}>
<FileViewer {...file} />
</Flexbox>
</Flexbox>
</Flexbox>
<FileDetail {...file} />
</Flexbox>
);
};
export default FilePage;
@@ -1,6 +0,0 @@
import { useQueryState } from 'nuqs';
import { FilesTabs } from '@/types/files';
export const useFileCategory = () =>
useQueryState('category', { clearOnDefault: true, defaultValue: FilesTabs.All });
@@ -1,3 +0,0 @@
import Loading from '@/components/Loading/BrandTextLoading';
export default () => <Loading />;
@@ -0,0 +1,73 @@
'use client';
import { App } from 'antd';
import { memo, useEffect } from 'react';
import { MemoryRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import KnowledgeBaseDetailPage from './routes/KnowledgeBaseDetail';
import KnowledgeBasesListPage from './routes/KnowledgeBasesList';
import KnowledgeHomePage from './routes/KnowledgeHome';
// Get initial path from URL
const getInitialPath = () => {
if (typeof window === 'undefined') return '/';
const fullPath = window.location.pathname;
const searchParams = window.location.search;
const knowledgeIndex = fullPath.indexOf('/knowledge');
if (knowledgeIndex !== -1) {
const pathAfterKnowledge = fullPath.slice(knowledgeIndex + '/knowledge'.length) || '/';
return pathAfterKnowledge + searchParams;
}
return '/';
};
// Helper component to sync URL with MemoryRouter
const UrlSynchronizer = () => {
const location = useLocation();
// Update browser URL when location changes
useEffect(() => {
const newUrl = `/knowledge${location.pathname}${location.search}`;
if (window.location.pathname + window.location.search !== newUrl) {
window.history.replaceState({}, '', newUrl);
}
}, [location.pathname, location.search]);
return null;
};
/**
* Main Knowledge Router component with MemoryRouter
* This serves as the entry point for all knowledge-related routes
* Uses MemoryRouter with URL synchronization to support query parameters like ?file=[id]
*
* Route structure:
* - / → Knowledge home (file list with categories)
* - /bases → Knowledge bases list
* - /bases/:id → Knowledge base detail (file list for specific base)
*/
const KnowledgeRouter = memo(() => {
return (
<App style={{ display: 'flex', flex: 1, height: '100%' }}>
<MemoryRouter initialEntries={[getInitialPath()]} initialIndex={0}>
<UrlSynchronizer />
<Routes>
{/* Knowledge home - file list page */}
<Route element={<KnowledgeHomePage />} path="/" />
{/* Knowledge bases routes */}
<Route element={<KnowledgeBasesListPage />} path="/bases" />
<Route element={<KnowledgeBaseDetailPage />} path="/bases/:id" />
{/* Fallback */}
<Route element={<Navigate replace to="/" />} path="*" />
</Routes>
</MemoryRouter>
</App>
);
});
KnowledgeRouter.displayName = 'KnowledgeRouter';
export default KnowledgeRouter;
@@ -0,0 +1,12 @@
'use client';
import dynamic from 'next/dynamic';
import { BrandTextLoading } from '@/components/Loading';
const KnowledgeRouter = dynamic(() => import('../KnowledgeRouter'), {
loading: BrandTextLoading,
ssr: false,
});
export default KnowledgeRouter;
@@ -1,21 +1,22 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import { ActionIcon, Icon, Tag } from '@lobehub/ui';
import { Descriptions, Divider } from 'antd';
import { useTheme } from 'antd-style';
import dayjs from 'dayjs';
import { BoltIcon } from 'lucide-react';
import { BoltIcon, DownloadIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FileListItem } from '@/types/files';
import { downloadFile } from '@/utils/client/downloadFile';
import { formatSize } from '@/utils/format';
export const DETAIL_PANEL_WIDTH = 300;
const FileDetail = memo<FileListItem>((props) => {
const { name, embeddingStatus, size, createdAt, updatedAt, chunkCount } = props || {};
const { name, embeddingStatus, size, createdAt, updatedAt, chunkCount, url } = props || {};
const { t } = useTranslation('file');
const theme = useTheme();
@@ -73,6 +74,17 @@ const FileDetail = memo<FileListItem>((props) => {
<Descriptions
colon={false}
column={1}
extra={
url && (
<ActionIcon
icon={DownloadIcon}
onClick={() => {
downloadFile(url, name);
}}
title={t('download', { ns: 'common' })}
/>
)
}
items={items}
labelStyle={{ width: 120 }}
size={'small'}
@@ -0,0 +1,60 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ArrowLeft } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useNavigate } from 'react-router-dom';
const useStyles = createStyles(({ css, token }) => {
return {
container: css`
cursor: pointer;
width: fit-content;
height: 24px;
padding-inline: 8px;
border-radius: 6px;
color: ${token.colorTextTertiary};
&:hover {
color: ${token.colorTextSecondary};
background: ${token.colorFillTertiary};
}
`,
};
});
interface GoBackProps {
/**
* The path to navigate to (relative to MemoryRouter)
* e.g., "/" for /knowledge, "/bases" for /knowledge/bases
*/
to: string;
}
/**
* GoBack component for react-router-dom
* Uses useNavigate instead of Next.js Link
*/
const GoBack = memo<GoBackProps>(({ to }) => {
const { t } = useTranslation('components');
const { styles } = useStyles();
const navigate = useNavigate();
const handleClick = () => {
navigate(to);
};
return (
<Flexbox align={'center'} className={styles.container} gap={4} horizontal onClick={handleClick}>
<Icon icon={ArrowLeft} />
<div>{t('GoBack.back')}</div>
</Flexbox>
);
});
GoBack.displayName = 'GoBack';
export default GoBack;
@@ -93,7 +93,7 @@ const Content = memo<KnowledgeBaseItemProps>(({ id, name, showMore }) => {
},
},
],
[],
[t, id, modal, removeKnowledgeBase],
);
return (
@@ -172,4 +172,6 @@ const Content = memo<KnowledgeBaseItemProps>(({ id, name, showMore }) => {
);
});
Content.displayName = 'KnowledgeBaseItemContent';
export default Content;
@@ -1,9 +1,7 @@
import { createStyles } from 'antd-style';
import Link from 'next/link';
import React, { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { useNavigate } from 'react-router-dom';
import Content, { knowledgeItemClass } from './Content';
@@ -46,31 +44,32 @@ export interface KnowledgeBaseItemProps {
const KnowledgeBaseItem = memo<KnowledgeBaseItemProps>(({ name, active, id }) => {
const { styles, cx } = useStyles();
const [isHover, setHovering] = useState(false);
const router = useQueryRoute();
const navigate = useNavigate();
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
router.push(`/repos/${id}`);
const handleClick = () => {
navigate(`/bases/${id}`);
};
return (
<Link href={`/repos/${id}`} onClick={handleClick}>
<Flexbox
align={'center'}
className={cx(styles.container, knowledgeItemClass, active && styles.active)}
distribution={'space-between'}
horizontal
onMouseEnter={() => {
setHovering(true);
}}
onMouseLeave={() => {
setHovering(false);
}}
>
<Content id={id} name={name} showMore={isHover} />
</Flexbox>
</Link>
<Flexbox
align={'center'}
className={cx(styles.container, knowledgeItemClass, active && styles.active)}
distribution={'space-between'}
horizontal
onClick={handleClick}
onMouseEnter={() => {
setHovering(true);
}}
onMouseLeave={() => {
setHovering(false);
}}
style={{ cursor: 'pointer' }}
>
<Content id={id} name={name} showMore={isHover} />
</Flexbox>
);
});
KnowledgeBaseItem.displayName = 'KnowledgeBaseItem';
export default KnowledgeBaseItem;
@@ -4,8 +4,8 @@ import { Virtuoso } from 'react-virtuoso';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
import KnowledgeBaseItem from '../KnowledgeBaseItem';
import EmptyStatus from './EmptyStatus';
import Item from './Item';
import { SkeletonList } from './SkeletonList';
const KnowledgeBaseList = () => {
@@ -21,7 +21,9 @@ const KnowledgeBaseList = () => {
<Virtuoso
data={data}
fixedItemHeight={36}
itemContent={(index, data) => <Item id={data.id} key={data.id} name={data.name} />}
itemContent={(index, item) => (
<KnowledgeBaseItem id={item.id} key={item.id} name={item.name} />
)}
/>
</Flexbox>
);
@@ -4,7 +4,7 @@ import { memo } from 'react';
import { fileManagerSelectors, useFileStore } from '@/store/file';
import Detail from '../../../features/FileDetail';
import Detail from '../FileDetail';
const FileDetail = memo<{ id: string }>(({ id }) => {
const file = useFileStore(fileManagerSelectors.getFileById(id));
@@ -3,10 +3,9 @@
import { Modal } from '@lobehub/ui';
import { ConfigProvider } from 'antd';
import { createStyles } from 'antd-style';
import { useRouter } from 'next/navigation';
import { ReactNode, useState } from 'react';
import { ReactNode, useCallback, useState } from 'react';
import { DETAIL_PANEL_WIDTH } from '@/app/[variants]/(main)/files/features/FileDetail';
import { DETAIL_PANEL_WIDTH } from '../FileDetail';
const useStyles = createStyles(({ css, token }, showDetail: boolean) => {
return {
@@ -53,14 +52,19 @@ const useStyles = createStyles(({ css, token }, showDetail: boolean) => {
interface FullscreenModalProps {
children: ReactNode;
detail?: ReactNode;
onClose?: () => void;
}
const FullscreenModal = ({ children, detail }: FullscreenModalProps) => {
const router = useRouter();
const FullscreenModal = ({ children, detail, onClose }: FullscreenModalProps) => {
const [open, setOpen] = useState(true);
const { styles } = useStyles(!!detail);
const handleCancel = useCallback(() => {
setOpen(false);
onClose?.();
}, [onClose]);
return (
<>
<ConfigProvider theme={{ token: { motion: false } }}>
@@ -68,10 +72,7 @@ const FullscreenModal = ({ children, detail }: FullscreenModalProps) => {
className={styles.modal}
classNames={{ body: styles.body, content: styles.content, header: styles.header }}
footer={false}
onCancel={() => {
router.back();
setOpen(false);
}}
onCancel={handleCancel}
open={open}
width={'auto'}
>
@@ -0,0 +1,32 @@
'use client';
import { useRouter } from 'next/navigation';
import { useCallback } from 'react';
import FileDetail from './FileDetail';
import FilePreview from './FilePreview';
import FullscreenModal from './FullscreenModal';
interface ModalPageClientProps {
id: string;
}
const ModalPageClient = ({ id }: ModalPageClientProps) => {
const router = useRouter();
const handleClose = useCallback(() => {
if (typeof window === 'undefined') return;
const { pathname, search } = window.location;
const basePath = pathname.replace(/\/modal\/?$/, '');
router.replace(`${basePath || '/'}${search}`);
}, [router]);
return (
<FullscreenModal detail={<FileDetail id={id} />} onClose={handleClose}>
<FilePreview id={id} />
</FullscreenModal>
);
};
export default ModalPageClient;
@@ -0,0 +1,13 @@
import { PagePropsWithId } from '@/types/next';
import ModalPageClient from './ModalPageClient';
const Page = async (props: PagePropsWithId) => {
const params = await props.params;
return <ModalPageClient id={params.id} />;
};
export default Page;
export const dynamic = 'force-static';
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
export const FILE_MODAL_QUERY_KEY = 'files';
const FILE_MODAL_QUERY_EVENT = 'lobe-files-querychange';
const getCurrentSearch = () => {
if (typeof window === 'undefined') return '';
return window.location.search;
};
export const getCurrentFileModalId = () => {
const search = getCurrentSearch();
if (!search) return undefined;
const params = new URLSearchParams(search);
return params.get(FILE_MODAL_QUERY_KEY) ?? undefined;
};
const pushStateWithParams = (params: URLSearchParams) => {
if (typeof window === 'undefined') return;
const search = params.toString();
const hash = window.location.hash;
const pathname = window.location.pathname;
const url = `${pathname}${search ? `?${search}` : ''}${hash}`;
window.history.pushState({}, '', url);
window.dispatchEvent(new Event(FILE_MODAL_QUERY_EVENT));
};
export const setFileModalId = (id?: string) => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(getCurrentSearch());
if (!id) {
params.delete(FILE_MODAL_QUERY_KEY);
} else {
params.set(FILE_MODAL_QUERY_KEY, id);
}
pushStateWithParams(params);
};
export const useFileModalId = (): string | undefined => {
const [fileId, setFileId] = useState<string | undefined>(() => getCurrentFileModalId());
useEffect(() => {
if (typeof window === 'undefined') return;
const handler = () => {
setFileId(getCurrentFileModalId());
};
window.addEventListener('popstate', handler);
window.addEventListener(FILE_MODAL_QUERY_EVENT, handler);
return () => {
window.removeEventListener('popstate', handler);
window.removeEventListener(FILE_MODAL_QUERY_EVENT, handler);
};
}, []);
return fileId;
};
@@ -0,0 +1,33 @@
import { useSearchParams } from 'react-router-dom';
import { FilesTabs } from '@/types/files';
/**
* Hook to manage file category filter in URL search params
* Uses react-router-dom instead of nuqs for MemoryRouter compatibility
*/
export const useFileCategory = (): [string, (value: string) => void] => {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') ?? FilesTabs.All;
const setCategory = (value: string) => {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
if (value === FilesTabs.All) {
// Clear on default
newParams.delete('category');
} else {
newParams.set('category', value);
}
return newParams;
},
{ replace: true },
);
};
return [category, setCategory];
};
@@ -0,0 +1,47 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useParams } from 'react-router-dom';
import FileModalQueryRoute from '@/app/[variants]/(main)/knowledge/shared/FileModalQueryRoute';
import { useSetFileModalId } from '@/app/[variants]/(main)/knowledge/shared/useFileQueryParam';
import FileManager from '@/features/FileManager';
import FilePanel from '@/features/FileSidePanel';
import { knowledgeBaseSelectors, useKnowledgeBaseStore } from '@/store/knowledgeBase';
import { useKnowledgeBaseItem } from '../../hooks/useKnowledgeItem';
import Menu from './menu/Menu';
/**
* Knowledge Base Detail Page
* Shows file list for a specific knowledge base
* Supports ?file=[fileId] query param for file preview modal
*/
const KnowledgeBaseDetailPage = memo(() => {
const { id } = useParams<{ id: string }>();
const setFileModalId = useSetFileModalId();
useKnowledgeBaseItem(id!);
const name = useKnowledgeBaseStore(knowledgeBaseSelectors.getKnowledgeBaseNameById(id!));
if (!id) {
return <div>Knowledge base ID is required</div>;
}
return (
<>
<FilePanel>
<Menu id={id} />
</FilePanel>
<Flexbox flex={1} style={{ overflow: 'hidden', position: 'relative' }}>
<FileManager knowledgeBaseId={id} onOpenFile={setFileModalId} title={name} />
</Flexbox>
<FileModalQueryRoute />
</>
);
});
KnowledgeBaseDetailPage.displayName = 'KnowledgeBaseDetailPage';
export default KnowledgeBaseDetailPage;
@@ -5,16 +5,17 @@ import { Skeleton } from 'antd';
import { memo } from 'react';
import { Center, Flexbox } from 'react-layout-kit';
import GoBack from '@/components/GoBack';
import RepoIcon from '@/components/RepoIcon';
import { knowledgeBaseSelectors, useKnowledgeBaseStore } from '@/store/knowledgeBase';
import GoBack from '../../../components/GoBack';
const Head = memo<{ id: string }>(({ id }) => {
const name = useKnowledgeBaseStore(knowledgeBaseSelectors.getKnowledgeBaseNameById(id));
return (
<Flexbox gap={8}>
<GoBack href={'/files'} />
<GoBack to="/" />
<Flexbox align={'center'} gap={8} height={36} horizontal>
<Center style={{ minWidth: 24 }} width={24}>
<RepoIcon />
@@ -31,4 +32,7 @@ const Head = memo<{ id: string }>(({ id }) => {
</Flexbox>
);
});
Head.displayName = 'Head';
export default Head;
@@ -1,18 +1,20 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import Head from './Head';
import Menu from './Menu';
import MenuItems from './MenuItems';
const ComposeMenu = memo<{ id: string }>(({ id }) => {
const Menu = memo<{ id: string }>(({ id }) => {
return (
<Flexbox gap={16} height={'100%'} paddingInline={12} style={{ paddingTop: 12 }}>
<Head id={id} />
<Menu />
<MenuItems />
</Flexbox>
);
});
ComposeMenu.displayName = 'ComposeMenu';
Menu.displayName = 'Menu';
export default ComposeMenu;
export default Menu;
@@ -0,0 +1,35 @@
'use client';
import { Icon } from '@lobehub/ui';
import { FileText } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import Menu from '@/components/Menu';
import type { MenuProps } from '@/components/Menu';
const MenuItems = () => {
const { t } = useTranslation('knowledgeBase');
const items: MenuProps['items'] = useMemo(
() => [
{
icon: <Icon icon={FileText} />,
key: 'files',
label: t('tab.files'),
},
],
[t],
);
return (
<Flexbox>
<Menu compact items={items} selectable selectedKeys={['files']} />
</Flexbox>
);
};
MenuItems.displayName = 'MenuItems';
export default MenuItems;
@@ -0,0 +1,37 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useParams } from 'react-router-dom';
import FilePanel from '@/features/FileSidePanel';
import Menu from '../KnowledgeBaseDetail/menu/Menu';
/**
* Knowledge Base Settings Page
* Configuration page for a specific knowledge base
*/
const KnowledgeBaseSettingsPage = memo(() => {
const { id } = useParams<{ id: string }>();
if (!id) {
return <div>Knowledge base ID is required</div>;
}
return (
<>
<FilePanel>
<Menu id={id} />
</FilePanel>
<Flexbox align="center" flex={1} justify="center" style={{ overflow: 'hidden' }}>
{/* TODO: Add settings form components here */}
<div>Settings page for knowledge base: {id}</div>
</Flexbox>
</>
);
});
KnowledgeBaseSettingsPage.displayName = 'KnowledgeBaseSettingsPage';
export default KnowledgeBaseSettingsPage;
@@ -0,0 +1,41 @@
'use client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import PanelTitle from '@/components/PanelTitle';
import FilePanel from '@/features/FileSidePanel';
import KnowledgeBaseList from '../../components/KnowledgeBaseList';
/**
* Knowledge Bases List Page
* Shows all available knowledge bases
*/
const KnowledgeBasesListPage = memo(() => {
const { t } = useTranslation('file');
return (
<>
<FilePanel>
<Flexbox gap={16} height={'100%'} paddingInline={8}>
<PanelTitle title={t('knowledgeBase.title')} />
<KnowledgeBaseList />
</Flexbox>
</FilePanel>
<Flexbox
align="center"
flex={1}
justify="center"
style={{ overflow: 'hidden', position: 'relative' }}
>
<div>Select a knowledge base to view details</div>
</Flexbox>
</>
);
});
KnowledgeBasesListPage.displayName = 'KnowledgeBasesListPage';
export default KnowledgeBasesListPage;
@@ -0,0 +1,131 @@
'use client';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useMediaQuery } from 'react-responsive';
import NProgress from '@/components/NProgress';
import PanelTitle from '@/components/PanelTitle';
import FileManager from '@/features/FileManager';
import FilePanel from '@/features/FileSidePanel';
import { useShowMobileWorkspace } from '@/hooks/useShowMobileWorkspace';
import { FilesTabs } from '@/types/files';
import { useFileCategory } from '../../hooks/useFileCategory';
import FileModalQueryRoute from '../../shared/FileModalQueryRoute';
import { useSetFileModalId } from '../../shared/useFileQueryParam';
import Container from './layout/Container';
import RegisterHotkeys from './layout/RegisterHotkeys';
import FileMenu from './menu/FileMenu';
import KnowledgeBase from './menu/KnowledgeBase';
const useStyles = createStyles(({ css, token }) => ({
main: css`
position: relative;
overflow: hidden;
background: ${token.colorBgLayout};
`,
}));
// Menu content component
const MenuContent = memo(() => {
const { t } = useTranslation('file');
return (
<Flexbox gap={16} height={'100%'}>
<Flexbox paddingInline={8}>
<PanelTitle desc={t('desc')} title={t('title')} />
<FileMenu />
</Flexbox>
<KnowledgeBase />
</Flexbox>
);
});
MenuContent.displayName = 'MenuContent';
// Main files list component
const FilesListPage = memo(() => {
const [category] = useFileCategory();
const setFileModalId = useSetFileModalId();
return (
<FileManager
category={category}
onOpenFile={setFileModalId}
title={`${category as FilesTabs}`}
/>
);
});
FilesListPage.displayName = 'FilesListPage';
// Desktop layout
const DesktopLayout = memo(() => {
return (
<>
<NProgress />
<Flexbox
height={'100%'}
horizontal
style={{ maxWidth: '100%', overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<FilePanel>
<MenuContent />
</FilePanel>
<Container>
<FilesListPage />
</Container>
</Flexbox>
<RegisterHotkeys />
<FileModalQueryRoute />
</>
);
});
DesktopLayout.displayName = 'DesktopLayout';
// Mobile layout
const MobileLayout = memo(() => {
const showMobileWorkspace = useShowMobileWorkspace();
const { styles } = useStyles();
return (
<>
<NProgress />
<Flexbox
className={styles.main}
height="100%"
style={showMobileWorkspace ? { display: 'none' } : undefined}
width="100%"
>
<MenuContent />
</Flexbox>
<Flexbox
className={styles.main}
height="100%"
style={showMobileWorkspace ? undefined : { display: 'none' }}
width="100%"
>
<FilesListPage />
</Flexbox>
<FileModalQueryRoute />
</>
);
});
MobileLayout.displayName = 'MobileLayout';
// Main Knowledge Home Page
const KnowledgeHomePage = memo(() => {
const isMobile = useMediaQuery({ maxWidth: 768 });
return isMobile ? <MobileLayout /> : <DesktopLayout />;
});
KnowledgeHomePage.displayName = 'KnowledgeHomePage';
export default KnowledgeHomePage;
@@ -21,4 +21,6 @@ const Container = memo<PropsWithChildren>(({ children }) => {
);
});
Container.displayName = 'Container';
export default Container;
@@ -2,16 +2,16 @@
import { Icon } from '@lobehub/ui';
import { FileText, Globe, ImageIcon, LayoutGrid, Mic2, SquarePlay } from 'lucide-react';
import Link from 'next/link';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useFileCategory } from '@/app/[variants]/(main)/files/hooks/useFileCategory';
import Menu from '@/components/Menu';
import type { MenuProps } from '@/components/Menu';
import { FilesTabs } from '@/types/files';
import { useFileCategory } from '../../../hooks/useFileCategory';
const FileMenu = memo(() => {
const { t } = useTranslation('file');
const [activeKey, setActiveKey] = useFileCategory();
@@ -22,56 +22,32 @@ const FileMenu = memo(() => {
{
icon: <Icon icon={LayoutGrid} />,
key: FilesTabs.All,
label: (
<Link href={'/files'} onClick={(e) => e.preventDefault()}>
{t('tab.all')}
</Link>
),
label: t('tab.all'),
},
{
icon: <Icon icon={FileText} />,
key: FilesTabs.Documents,
label: (
<Link href={'/files?category=documents'} onClick={(e) => e.preventDefault()}>
{t('tab.documents')}
</Link>
),
label: t('tab.documents'),
},
{
icon: <Icon icon={ImageIcon} />,
key: FilesTabs.Images,
label: (
<Link href={'/files?category=images'} onClick={(e) => e.preventDefault()}>
{t('tab.images')}
</Link>
),
label: t('tab.images'),
},
{
icon: <Icon icon={Mic2} />,
key: FilesTabs.Audios,
label: (
<Link href={'/files?category=audios'} onClick={(e) => e.preventDefault()}>
{t('tab.audios')}
</Link>
),
label: t('tab.audios'),
},
{
icon: <Icon icon={SquarePlay} />,
key: FilesTabs.Videos,
label: (
<Link href={'/files?category=videos'} onClick={(e) => e.preventDefault()}>
{t('tab.videos')}
</Link>
),
label: t('tab.videos'),
},
{
icon: <Icon icon={Globe} />,
key: FilesTabs.Websites,
label: (
<Link href={'/files?category=websites'} onClick={(e) => e.preventDefault()}>
{t('tab.websites')}
</Link>
),
label: t('tab.websites'),
},
]
.filter(Boolean)
@@ -94,4 +70,6 @@ const FileMenu = memo(() => {
);
});
FileMenu.displayName = 'FileMenu';
export default FileMenu;
@@ -5,10 +5,11 @@ import { PlusIcon } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useNavigate } from 'react-router-dom';
import { useCreateNewModal } from '@/features/KnowledgeBaseModal';
import KnowledgeBaseList from './KnowledgeBaseList';
import KnowledgeBaseList from '../../../components/KnowledgeBaseList';
const useStyles = createStyles(({ css, token }) => ({
header: css`
@@ -19,11 +20,20 @@ const useStyles = createStyles(({ css, token }) => ({
const KnowledgeBase = () => {
const { t } = useTranslation('file');
const { styles } = useStyles();
const navigate = useNavigate();
const [showList, setShowList] = useState(true);
const { open } = useCreateNewModal();
const handleCreate = () => {
open({
onSuccess: (id) => {
navigate(`/bases/${id}`);
},
});
};
return (
<Flexbox flex={1} gap={8}>
<Flexbox
@@ -43,7 +53,12 @@ const KnowledgeBase = () => {
/>
<div style={{ lineHeight: '14px' }}>{t('knowledgeBase.title')}</div>
</Flexbox>
<ActionIcon icon={PlusIcon} onClick={open} size={'small'} title={t('knowledgeBase.new')} />
<ActionIcon
icon={PlusIcon}
onClick={handleCreate}
size={'small'}
title={t('knowledgeBase.new')}
/>
</Flexbox>
{showList && <KnowledgeBaseList />}
@@ -0,0 +1,99 @@
'use client';
import { Modal } from '@lobehub/ui';
import { ConfigProvider } from 'antd';
import { createStyles } from 'antd-style';
import { memo, useCallback, useMemo } from 'react';
import { fileManagerSelectors, useFileStore } from '@/store/file';
import { DETAIL_PANEL_WIDTH } from '../components/FileDetail';
import FileDetail from '../components/modal/FileDetail';
import FilePreview from '../components/modal/FilePreview';
import { useFileModalId, useSetFileModalId } from './useFileQueryParam';
const useStyles = createStyles(({ css, token }, showDetail: boolean) => {
return {
body: css`
height: 100%;
max-height: calc(100dvh - 56px) !important;
`,
content: css`
height: 100%;
border: none !important;
background: transparent !important;
`,
extra: css`
position: fixed;
z-index: ${token.zIndexPopupBase + 10};
inset-block: 0 0;
inset-inline-end: 0;
width: ${DETAIL_PANEL_WIDTH}px;
border-inline-start: 1px solid ${token.colorSplit};
background: ${token.colorBgLayout};
`,
header: css`
background: transparent !important;
`,
modal: css`
position: relative;
inset-block-start: 0;
width: ${showDetail ? `calc(100vw - ${DETAIL_PANEL_WIDTH}px) ` : '100vw'} !important;
max-width: none;
height: 100%;
margin: 0;
padding-block-end: 0;
> div {
height: 100%;
}
`,
};
});
/**
* FileModalQueryRoute component for Knowledge routes
* Renders the file preview modal when a 'file' query parameter is present
* Example: ?file=file_123
* File data is fetched from the Zustand store by FilePreview and FileDetail components
*/
const FileModalQueryRoute = memo(() => {
const fileId = useFileModalId();
const setFileModalId = useSetFileModalId();
const file = useFileStore(fileManagerSelectors.getFileById(fileId));
const showDetail = useMemo(() => Boolean(fileId && file), [fileId, file]);
const { styles } = useStyles(showDetail);
const handleClose = useCallback(() => {
setFileModalId(undefined);
}, [setFileModalId]);
if (!showDetail || !fileId) return null;
return (
<>
<ConfigProvider theme={{ token: { motion: false } }}>
<Modal
className={styles.modal}
classNames={{ body: styles.body, content: styles.content, header: styles.header }}
footer={false}
onCancel={handleClose}
open={showDetail}
width={'auto'}
>
<FilePreview id={fileId} />
</Modal>
</ConfigProvider>
<div className={styles.extra}>
<FileDetail id={fileId} />
</div>
</>
);
});
FileModalQueryRoute.displayName = 'FileModalQueryRoute';
export default FileModalQueryRoute;
@@ -0,0 +1,66 @@
import { useSearchParams } from 'react-router-dom';
/**
* Query parameter key for file modal
* Changed from 'files' to 'file' for better semantics
*/
export const FILE_MODAL_QUERY_KEY = 'file';
/**
* Hook to get and set the file modal ID from URL query parameters
* Uses react-router-dom's useSearchParams for MemoryRouter compatibility
* Supports both ?file=[id] and legacy ?files=[id]
*/
export const useFileModalId = (): string | undefined => {
const [searchParams] = useSearchParams();
// Support both 'file' and legacy 'files' for backwards compatibility
return searchParams.get(FILE_MODAL_QUERY_KEY) ?? searchParams.get('files') ?? undefined;
};
/**
* Hook to set the file modal ID in the URL query parameters
* Uses ?file=[id] format for the new knowledge routes
*/
export const useSetFileModalId = () => {
const [searchParams, setSearchParams] = useSearchParams();
return (id?: string) => {
const newParams = new URLSearchParams(searchParams);
// Remove both new and legacy query params
newParams.delete(FILE_MODAL_QUERY_KEY);
newParams.delete('files');
if (id) {
newParams.set(FILE_MODAL_QUERY_KEY, id);
}
setSearchParams(newParams, { replace: true });
};
};
/**
* Standalone function to set file modal ID (for use outside hooks)
* This creates a callback that can be passed to components
*/
export const createSetFileModalId = (setSearchParams: ReturnType<typeof useSearchParams>[1]) => {
return (id?: string) => {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
// Remove both new and legacy query params
newParams.delete(FILE_MODAL_QUERY_KEY);
newParams.delete('files');
if (id) {
newParams.set(FILE_MODAL_QUERY_KEY, id);
}
return newParams;
},
{ replace: true },
);
};
};
@@ -1,29 +0,0 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import FileManager from '@/features/FileManager';
import FilePanel from '@/features/FileSidePanel';
import { knowledgeBaseSelectors, useKnowledgeBaseStore } from '@/store/knowledgeBase';
import Menu from './features/Menu';
import { useKnowledgeBaseItem } from './hooks/useKnowledgeItem';
const RepoClientPage = memo<{ id: string }>(({ id }) => {
useKnowledgeBaseItem(id);
const name = useKnowledgeBaseStore(knowledgeBaseSelectors.getKnowledgeBaseNameById(id));
return (
<>
<FilePanel>
<Menu id={id} />
</FilePanel>
<Flexbox flex={1} style={{ overflow: 'hidden', position: 'relative' }}>
<FileManager knowledgeBaseId={id} title={name} />
</Flexbox>
</>
);
});
export default RepoClientPage;
@@ -1,20 +0,0 @@
import { Flexbox } from 'react-layout-kit';
import { LayoutProps } from '../type';
const Layout = ({ children }: LayoutProps) => {
return (
<Flexbox
height={'100%'}
horizontal
style={{ maxWidth: 'calc(100vw - 64px)', overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
{children}
</Flexbox>
);
};
Layout.displayName = 'DesktopRepoLayout';
export default Layout;
@@ -1,37 +0,0 @@
'use client';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useShowMobileWorkspace } from '@/hooks/useShowMobileWorkspace';
import { LayoutProps } from './type';
const useStyles = createStyles(({ css, token }) => ({
main: css`
position: relative;
overflow: hidden;
background: ${token.colorBgLayout};
`,
}));
const Layout = memo<LayoutProps>(({ children }) => {
const showMobileWorkspace = useShowMobileWorkspace();
const { styles } = useStyles();
return (
<Flexbox
className={styles.main}
height="100%"
style={showMobileWorkspace ? { display: 'none' } : undefined}
width="100%"
>
{children}
</Flexbox>
);
});
Layout.displayName = 'MobileChatLayout';
export default Layout;
@@ -1,5 +0,0 @@
import { ReactNode } from 'react';
export interface LayoutProps {
children: ReactNode;
}
@@ -1,25 +0,0 @@
'use client';
import { createStyles } from 'antd-style';
import { PropsWithChildren } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, token }) => ({
container: css`
padding: 16px;
border-radius: 8px;
background: ${token.colorBgContainer};
`,
}));
const Container = ({ children }: PropsWithChildren) => {
const { styles } = useStyles();
return (
<Flexbox className={styles.container} height={'100%'}>
{children}
</Flexbox>
);
};
export default Container;
@@ -1,35 +0,0 @@
'use client';
import { Tabs as TabsNav } from '@lobehub/ui';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export const Tabs = ({ knowledgeBaseId }: { knowledgeBaseId: string }) => {
const pathname = usePathname();
const key = pathname.split('/').pop();
return (
<TabsNav
activeKey={key}
items={[
{
key: 'dataset',
label: (
<Link href={`/repos/${knowledgeBaseId}/evals/dataset`} style={{ color: 'initial' }}>
</Link>
),
},
{
key: 'evaluation',
label: (
<Link href={`/repos/${knowledgeBaseId}/evals/evaluation`} style={{ color: 'initial' }}>
</Link>
),
},
]}
/>
);
};
@@ -1,59 +0,0 @@
import { CreateNewEvalDatasets } from '@lobechat/types';
import { Button, Form, Input } from '@lobehub/ui';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
interface CreateFormProps {
knowledgeBaseId: string;
onClose?: () => void;
}
const CreateForm = memo<CreateFormProps>(({ onClose, knowledgeBaseId }) => {
const { t } = useTranslation('ragEval');
const [loading, setLoading] = useState(false);
const createNewDataset = useKnowledgeBaseStore((s) => s.createNewDataset);
const onFinish = async (values: CreateNewEvalDatasets) => {
setLoading(true);
try {
await createNewDataset({ ...values, knowledgeBaseId });
setLoading(false);
onClose?.();
} catch (e) {
console.error(e);
setLoading(false);
}
};
return (
<Form
footer={
<Button block htmlType={'submit'} loading={loading} type={'primary'}>
{t('addDataset.confirm')}
</Button>
}
gap={16}
items={[
{
children: <Input autoFocus placeholder={t('addDataset.name.placeholder')} />,
label: t('addDataset.name.placeholder'),
name: 'name',
rules: [{ message: t('addDataset.name.required'), required: true }],
},
{
children: <Input placeholder={t('addDataset.description.placeholder')} />,
label: t('addDataset.description.placeholder'),
name: 'description',
},
]}
itemsType={'flat'}
layout={'vertical'}
onFinish={onFinish}
/>
);
});
export default CreateForm;
@@ -1,37 +0,0 @@
import { Icon } from '@lobehub/ui';
import { SheetIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { createModal } from '@/components/FunctionModal';
import CreateForm from './CreateForm';
const Title = () => {
const { t } = useTranslation('ragEval');
return (
<Flexbox gap={8} horizontal>
<Icon icon={SheetIcon} />
{t('addDataset.title')}
</Flexbox>
);
};
interface CreateDatasetModalProps {
knowledgeBaseId: string;
}
export const useCreateDatasetModal = createModal<CreateDatasetModalProps>((instance, params) => ({
content: (
<Flexbox paddingInline={16} style={{ paddingBottom: 16 }}>
<CreateForm
knowledgeBaseId={params!.knowledgeBaseId}
onClose={() => {
instance.current?.destroy();
}}
/>
</Flexbox>
),
title: <Title />,
}));
@@ -1,126 +0,0 @@
'use client';
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { EvalDatasetRecordRefFile } from '@lobechat/types';
import { ActionIcon, Button, Text } from '@lobehub/ui';
import { Upload } from 'antd';
import { createStyles } from 'antd-style';
import { Edit2Icon, Trash2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import FileIcon from '@/components/FileIcon';
import { ragEvalService } from '@/services/ragEval';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
const createRequest = (activeDatasetId: number) => async () => {
const records = await ragEvalService.getDatasetRecords(activeDatasetId);
return {
data: records,
success: true,
total: records.length,
};
};
const useStyles = createStyles(({ css }) => ({
container: css`
padding-block: 0;
padding-inline: 12px;
`,
icon: css`
min-width: 24px;
border-radius: 4px;
`,
title: css`
font-size: 16px;
`,
}));
const DatasetDetail = () => {
const { t } = useTranslation(['ragEval', 'common']);
const { styles } = useStyles();
const [importDataset] = useKnowledgeBaseStore((s) => [s.importDataset]);
const [activeDatasetId] = useQueryState('id', parseAsInteger);
const columns: ProColumns[] = [
{
dataIndex: 'question',
ellipsis: true,
title: t('dataset.list.table.columns.question.title'),
width: '40%',
},
{ dataIndex: 'ideal', ellipsis: true, title: t('dataset.list.table.columns.ideal.title') },
{
dataIndex: 'referenceFiles',
render: (dom, entity) => {
const referenceFiles = entity.referenceFiles as EvalDatasetRecordRefFile[];
return (
!!referenceFiles && (
<Flexbox>
{referenceFiles?.map((file) => (
<Flexbox gap={4} horizontal key={file.id}>
<FileIcon fileName={file.name} fileType={file.fileType} size={20} />
<Text ellipsis={{ tooltip: true }}>{file.name}</Text>
</Flexbox>
))}
</Flexbox>
)
);
},
title: t('dataset.list.table.columns.referenceFiles.title'),
width: 200,
},
{
dataIndex: 'actions',
render: () => (
<Flexbox gap={4} horizontal>
<ActionIcon icon={Edit2Icon} size={'small'} title={t('edit', { ns: 'common' })} />
<ActionIcon icon={Trash2Icon} size={'small'} title={t('delete', { ns: 'common' })} />
</Flexbox>
),
title: t('dataset.list.table.columns.actions'),
width: 80,
},
];
const request = !!activeDatasetId ? createRequest(activeDatasetId) : undefined;
return !activeDatasetId ? (
<Center height={'100%'} width={'100%'}>
{t('dataset.list.table.notSelected')}
</Center>
) : (
<Flexbox className={styles.container} gap={24}>
<ProTable
columns={columns}
request={request}
search={false}
size={'small'}
toolbar={{
actions: [
<Upload
beforeUpload={async (file) => {
await importDataset(file, activeDatasetId);
return false;
}}
key={'upload'}
multiple={false}
showUploadList={false}
>
<Button type={'primary'}>{t('dataset.list.table.actions.importData')}</Button>
</Upload>,
],
title: <div className={styles.title}>{t('dataset.list.table.title')}</div>,
}}
/>
</Flexbox>
);
};
export default DatasetDetail;
@@ -1,57 +0,0 @@
import { RAGEvalDataSetItem } from '@lobechat/types';
import { createStyles } from 'antd-style';
import { parseAsInteger, useQueryState } from 'nuqs';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, token }) => ({
active: css`
background: ${token.colorFillTertiary};
&:hover {
background-color: ${token.colorFillSecondary};
}
`,
container: css`
cursor: pointer;
margin-block-end: 2px;
padding-block: 12px;
padding-inline: 8px;
border-radius: 8px;
&:hover {
background-color: ${token.colorFillTertiary};
}
`,
icon: css`
min-width: 24px;
border-radius: 4px;
`,
title: css`
text-align: start;
`,
}));
const Item = memo<RAGEvalDataSetItem>(({ name, description, id }) => {
const { styles, cx } = useStyles();
const [activeDatasetId, activateDataset] = useQueryState('id', parseAsInteger);
const isActive = activeDatasetId === id;
return (
<Flexbox
className={cx(styles.container, isActive && styles.active)}
onClick={() => {
if (!isActive) {
activateDataset(id);
}
}}
>
<div className={styles.title}>{name}</div>
{description && <div>{description}</div>}
</Flexbox>
);
});
export default Item;
@@ -1,31 +0,0 @@
'use client';
import { RAGEvalDataSetItem } from '@lobechat/types';
import { ActionIcon } from '@lobehub/ui';
import { PlusIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Virtuoso } from 'react-virtuoso';
import Item from './Item';
interface DatasetListProps {
dataSource: RAGEvalDataSetItem[];
}
const DatasetList = memo<DatasetListProps>(({ dataSource }) => {
const { t } = useTranslation('ragEval');
return (
<Flexbox gap={24} height={'100%'}>
<Flexbox align={'center'} horizontal justify={'space-between'}>
<span>{t('dataset.list.title')}</span>
<ActionIcon icon={PlusIcon} size={'small'} />
</Flexbox>
<Virtuoso data={dataSource} itemContent={(index, data) => <Item {...data} key={data.id} />} />
</Flexbox>
);
});
export default DatasetList;
@@ -1,33 +0,0 @@
'use client';
import { Button } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useCreateDatasetModal } from '../CreateDataset';
interface EmptyGuideProps {
knowledgeBaseId: string;
}
const EmptyGuide = memo<EmptyGuideProps>(({ knowledgeBaseId }) => {
const { t } = useTranslation('ragEval');
const modal = useCreateDatasetModal();
return (
<Center gap={24} height={'100%'} width={'100%'}>
<div>{t('dataset.emptyGuide')}</div>
<Flexbox gap={8} horizontal>
<Button
onClick={() => {
modal.open({ knowledgeBaseId });
}}
type={'primary'}
>
{t('dataset.addNewButton')}
</Button>
</Flexbox>
</Center>
);
});
export default EmptyGuide;
@@ -1,52 +0,0 @@
'use client';
import { createStyles } from 'antd-style';
import { Flexbox } from 'react-layout-kit';
import Loading from '@/components/Loading/BrandTextLoading';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
import DatasetDetail from './DatasetDetail';
import DatasetList from './DatasetList';
import EmptyGuide from './EmptyGuide';
const useStyles = createStyles(({ css, token }) => ({
sider: css`
padding-inline-end: 12px;
border-inline-end: 1px solid ${token.colorSplit};
`,
}));
interface Params {
id: string;
}
type Props = { params: Params & Promise<Params> };
const Dataset = ({ params }: Props) => {
const { styles } = useStyles();
const knowledgeBaseId = params.id;
const useFetchDatasets = useKnowledgeBaseStore((s) => s.useFetchDatasets);
const { data, isLoading } = useFetchDatasets(knowledgeBaseId);
const isEmpty = data?.length === 0;
return isLoading ? (
<Loading />
) : isEmpty ? (
<EmptyGuide knowledgeBaseId={knowledgeBaseId} />
) : (
<Flexbox height={'100%'} horizontal>
<Flexbox className={styles.sider} width={200}>
<DatasetList dataSource={data!} />
</Flexbox>
<Flexbox width={'100%'}>
<DatasetDetail />
</Flexbox>
</Flexbox>
);
};
export default Dataset;
@@ -1,89 +0,0 @@
import { CreateNewEvalEvaluation } from '@lobechat/types';
import { Button, Form, Input, Select, TextArea } from '@lobehub/ui';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
interface CreateFormProps {
knowledgeBaseId: string;
onClose?: () => void;
onCreate?: () => void;
}
const CreateForm = memo<CreateFormProps>(({ onClose, onCreate, knowledgeBaseId }) => {
const { t } = useTranslation('ragEval');
const [loading, setLoading] = useState(false);
const [useFetchDatasets, createNewEvaluation] = useKnowledgeBaseStore((s) => [
s.useFetchDatasets,
s.createNewEvaluation,
]);
const { data, isLoading } = useFetchDatasets(knowledgeBaseId);
const onFinish = async (values: CreateNewEvalEvaluation) => {
setLoading(true);
try {
await createNewEvaluation({ ...values, knowledgeBaseId });
setLoading(false);
onCreate?.();
onClose?.();
} catch (e) {
console.error(e);
setLoading(false);
}
};
return (
<Form
footer={
<Button block htmlType={'submit'} loading={loading} type={'primary'}>
{t('evaluation.addEvaluation.confirm')}
</Button>
}
gap={16}
items={[
{
children: (
<Input autoFocus placeholder={t('evaluation.addEvaluation.name.placeholder')} />
),
label: t('evaluation.addEvaluation.name.placeholder'),
name: 'name',
rules: [{ message: t('evaluation.addEvaluation.name.required'), required: true }],
},
{
children: (
<TextArea
placeholder={t('evaluation.addEvaluation.description.placeholder')}
style={{ minHeight: 120 }}
/>
),
label: t('evaluation.addEvaluation.description.placeholder'),
name: 'description',
},
{
children: (
<Select
loading={isLoading}
options={data?.map((item) => ({
label: item.name,
value: item.id,
}))}
placeholder={t('evaluation.addEvaluation.datasetId.placeholder')}
/>
),
label: t('evaluation.addEvaluation.datasetId.placeholder'),
name: 'datasetId',
rules: [{ message: t('evaluation.addEvaluation.datasetId.required'), required: true }],
},
]}
itemsType={'flat'}
layout={'vertical'}
onFinish={onFinish}
/>
);
});
export default CreateForm;
@@ -1,28 +0,0 @@
'use client';
import { Button } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateDatasetModal } from './useModal';
interface CreateEvaluationProps {
knowledgeBaseId: string;
onCreate?: () => void;
}
const CreateEvaluation = memo<CreateEvaluationProps>(({ knowledgeBaseId, onCreate }) => {
const { t } = useTranslation('ragEval');
const modal = useCreateDatasetModal();
return (
<Button
onClick={() => {
modal.open({ knowledgeBaseId, onCreate });
}}
type={'primary'}
>
{t('evaluation.addNewButton')}
</Button>
);
});
export default CreateEvaluation;
@@ -1,39 +0,0 @@
import { Icon } from '@lobehub/ui';
import { SheetIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { createModal } from '@/components/FunctionModal';
import CreateForm from './CreateForm';
const Title = () => {
const { t } = useTranslation('ragEval');
return (
<Flexbox gap={8} horizontal>
<Icon icon={SheetIcon} />
{t('addDataset.title')}
</Flexbox>
);
};
interface CreateDatasetModalProps {
knowledgeBaseId: string;
onCreate?: () => void;
}
export const useCreateDatasetModal = createModal<CreateDatasetModalProps>((instance, params) => ({
content: (
<Flexbox paddingInline={16} style={{ paddingBottom: 24 }}>
<CreateForm
knowledgeBaseId={params!.knowledgeBaseId}
onClose={() => {
instance.current?.destroy();
}}
onCreate={params?.onCreate}
/>
</Flexbox>
),
title: <Title />,
}));
@@ -1,25 +0,0 @@
'use client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import CreateEvaluationButton from '../CreateEvaluation';
interface EmptyGuideProps {
knowledgeBaseId: string;
}
const EmptyGuide = memo<EmptyGuideProps>(({ knowledgeBaseId }) => {
const { t } = useTranslation('ragEval');
return (
<Center gap={24} height={'100%'} width={'100%'}>
<div>{t('evaluation.emptyGuide')}</div>
<Flexbox gap={8} horizontal>
<CreateEvaluationButton knowledgeBaseId={knowledgeBaseId} />
</Flexbox>
</Center>
);
});
export default EmptyGuide;
@@ -1,210 +0,0 @@
'use client';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { EvalEvaluationStatus, RAGEvalEvaluationItem } from '@lobechat/types';
import { ActionIcon, Button, ButtonProps, Icon } from '@lobehub/ui';
import { App } from 'antd';
import { createStyles } from 'antd-style';
import { DownloadIcon, PlayIcon, RotateCcwIcon, Trash2Icon } from 'lucide-react';
import Link from 'next/link';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { ragEvalService } from '@/services/ragEval';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
import CreateEvaluationButton from '../CreateEvaluation';
const createRequest = (knowledgeBaseId: string) => async () => {
const records = await ragEvalService.getEvaluationList(knowledgeBaseId);
return {
data: records,
success: true,
total: records.length,
};
};
const useStyles = createStyles(({ css }) => ({
icon: css`
min-width: 24px;
border-radius: 4px;
`,
title: css`
font-size: 16px;
`,
}));
const EvaluationList = ({ knowledgeBaseId }: { knowledgeBaseId: string }) => {
const { t } = useTranslation(['ragEval', 'common']);
const { styles } = useStyles();
const [removeEvaluation, runEvaluation, checkEvaluationStatus] = useKnowledgeBaseStore((s) => [
s.removeEvaluation,
s.runEvaluation,
s.checkEvaluationStatus,
]);
const [isCheckingStatus, setCheckingStatus] = useState(false);
const { modal } = App.useApp();
const actionRef = useRef<ActionType>(null);
const columns: ProColumns<RAGEvalEvaluationItem>[] = [
{
dataIndex: 'name',
ellipsis: true,
title: t('evaluation.table.columns.name.title'),
},
{
dataIndex: ['dataset', 'id'],
render: (dom, entity) => {
return (
<Link
href={`/repos/${knowledgeBaseId}/evals/dataset?id=${entity.dataset.id}`}
style={{ color: 'initial' }}
target={'_blank'}
>
{entity.dataset.name}
</Link>
);
},
title: t('evaluation.table.columns.datasetId.title'),
width: 200,
},
{
dataIndex: 'status',
title: t('evaluation.table.columns.status.title'),
valueEnum: {
[EvalEvaluationStatus.Error]: {
status: 'error',
text: t('evaluation.table.columns.status.error'),
},
[EvalEvaluationStatus.Processing]: {
status: 'processing',
text: t('evaluation.table.columns.status.processing'),
},
[EvalEvaluationStatus.Pending]: {
status: 'default',
text: t('evaluation.table.columns.status.pending'),
},
[EvalEvaluationStatus.Success]: {
status: 'success',
text: t('evaluation.table.columns.status.success'),
},
},
},
{
dataIndex: ['recordsStats', 'total'],
render: (dom, entity) => {
return entity.status === 'Pending'
? entity.recordsStats.total
: `${entity.recordsStats.success}/${entity.recordsStats.total}`;
},
title: t('evaluation.table.columns.records.title'),
},
{
dataIndex: 'actions',
render: (_, entity) => {
const actionsMap: Record<EvalEvaluationStatus, ButtonProps> = {
[EvalEvaluationStatus.Pending]: {
children: t('evaluation.table.columns.actions.run'),
icon: <Icon icon={PlayIcon} />,
onClick: () => {
modal.confirm({
content: t('evaluation.table.columns.actions.confirmRun'),
onOk: async () => {
await runEvaluation(entity.id);
await actionRef.current?.reload();
},
});
},
},
[EvalEvaluationStatus.Error]: {
children: t('evaluation.table.columns.actions.retry'),
icon: <Icon icon={RotateCcwIcon} />,
onClick: () => {
modal.confirm({
content: t('evaluation.table.columns.actions.confirmRun'),
onOk: async () => {
await runEvaluation(entity.id);
await actionRef.current?.reload();
},
});
},
},
[EvalEvaluationStatus.Processing]: {
children: t('evaluation.table.columns.actions.checkStatus'),
icon: null,
loading: isCheckingStatus,
onClick: async () => {
setCheckingStatus(true);
await checkEvaluationStatus(entity.id);
setCheckingStatus(false);
await actionRef.current?.reload();
},
},
[EvalEvaluationStatus.Success]: {
children: t('evaluation.table.columns.actions.downloadRecords'),
icon: <Icon icon={DownloadIcon} />,
onClick: async () => {
window.open(entity.evalRecordsUrl);
},
},
};
const actionProps = actionsMap[entity.status];
return (
<Flexbox gap={4} horizontal>
{!actionProps ? null : <Button {...actionProps} size={'small'} />}
<ActionIcon
icon={Trash2Icon}
onClick={async () => {
modal.confirm({
content: t('evaluation.table.columns.actions.confirmDelete'),
okButtonProps: {
danger: true,
},
onOk: async () => {
await removeEvaluation(entity.id);
await actionRef.current?.reload();
},
});
}}
size={'small'}
title={t('delete', { ns: 'common' })}
/>
</Flexbox>
);
},
title: t('evaluation.table.columns.actions.title'),
width: 120,
},
];
const request = knowledgeBaseId ? createRequest(knowledgeBaseId) : undefined;
return (
<Flexbox gap={24}>
<ProTable
actionRef={actionRef}
columns={columns}
request={request}
search={false}
toolbar={{
actions: [
<CreateEvaluationButton
key={'new'}
knowledgeBaseId={knowledgeBaseId}
onCreate={() => {
actionRef.current?.reload();
}}
/>,
],
title: <div className={styles.title}>{t('evaluation.table.title')}</div>,
}}
/>
</Flexbox>
);
};
export default EvaluationList;
@@ -1,37 +0,0 @@
'use client';
import { Flexbox } from 'react-layout-kit';
import Loading from '@/components/Loading/BrandTextLoading';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
import EmptyGuide from './EmptyGuide';
import EvaluationList from './EvaluationList';
interface Params {
id: string;
}
type Props = { params: Params & Promise<Params> };
const Evaluation = ({ params }: Props) => {
const knowledgeBaseId = params.id;
const useFetchEvaluation = useKnowledgeBaseStore((s) => s.useFetchEvaluationList);
const { data, isLoading } = useFetchEvaluation(knowledgeBaseId);
const isEmpty = data?.length === 0;
return isLoading ? (
<Loading />
) : isEmpty ? (
<EmptyGuide knowledgeBaseId={knowledgeBaseId} />
) : (
<Flexbox height={'100%'}>
<EvaluationList knowledgeBaseId={knowledgeBaseId} />
</Flexbox>
);
};
export default Evaluation;

Some files were not shown because too many files have changed in this diff Show More