feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
build
*.log
.env.local
.env.development
.env.test
coverage
.nyc_output
+14
View File
@@ -0,0 +1,14 @@
# API base URL (relative path to backend)
VITE_API_BASE_URL=/api
# Allowed hosts for API connection (comma-separated)
# Example: VITE_ALLOWED_HOSTS=localhost,127.0.0.1,example.com
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Vite server configuration
VITE_PORT=13001
VITE_HOST=0.0.0.0
VITE_BACKEND_URL=http://localhost:3001
# Interface language (zh, en, ja)
VITE_DEFAULT_LANGUAGE=zh
+14
View File
@@ -0,0 +1,14 @@
# API base URL (relative path to backend)
VITE_API_BASE_URL=/api
# Allowed hosts for API connection (comma-separated)
# Example: VITE_ALLOWED_HOSTS=localhost,127.0.0.1,example.com
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Vite server configuration
VITE_PORT=13001
VITE_HOST=0.0.0.0
VITE_BACKEND_URL=http://localhost:3001
# Interface language (zh, en, ja)
VITE_DEFAULT_LANGUAGE=en
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"deviceId": "ed82e87006e058c331c9aa377c98736db271e2f339d3613c715eb07cf69358fc"
}
+11
View File
@@ -0,0 +1,11 @@
/**
* @deprecated This file is the LEGACY entry point for the original single-page App.
* It has been SUPERSEDED by `index.tsx` + the new react-router-dom routing structure.
*
* The new entry point is: `index.html` → `index.tsx` → `AuthProvider` → `BrowserRouter`
*
* DO NOT import from this file. It is preserved only as a reference/archive.
* Safe to delete once the V2 migration is fully validated.
*/
export { };
+37
View File
@@ -0,0 +1,37 @@
FROM node:22-alpine as builder
WORKDIR /app
# 设置阿里云源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 设置 yarn 阿里云源并安装依赖
COPY web/package*.json web/yarn.lock* ./
RUN yarn config set registry https://registry.npmmirror.com && \
yarn install
# 复制源代码
COPY web/ .
# 设置构建时环境变量
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
# 构建应用
RUN yarn build
# 使用nginx提供静态文件
FROM nginx:alpine
# 删除默认配置
RUN rm /etc/nginx/conf.d/default.conf
# 复制自定义 Nginx 配置
COPY nginx/conf.d/kb.conf /etc/nginx/conf.d/kb.conf
# 复制构建产物到nginx目录
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
+118
View File
@@ -0,0 +1,118 @@
# AuraK V2 — Frontend (web)
A modern multi-tenant knowledge base and AI assistant platform built with React 19, Vite, and Tailwind CSS.
## 🏗️ Architecture
The frontend uses **React Router** for navigation with a clean role-based routing structure:
| Route | Access | Description |
|-------|--------|-------------|
| `/login` | Public | Password or API Key sign-in |
| `/` | All users | Workspace overview |
| `/chat` | All users | AI Chat with scope selection |
| `/knowledge` | All users | Document (Knowledge Base) management |
| `/notebooks` | All users | Notebooks and notes |
| `/settings` | All users | Model and app settings |
| `/admin` | TENANT_ADMIN+ | Team management, share approvals |
| `/admin/tenants` | SUPER_ADMIN | Global tenant management |
| `/admin/models` | TENANT_ADMIN+ | Model configuration |
## 🔐 Authentication
The app supports **two login methods**:
1. **Password login** — POST to `/api/v1/auth/login`, receives an `apiKey` back
2. **Direct API Key** — paste the API key directly
All API calls include `x-api-key: <key>` in the request headers automatically via `services/apiClient.ts`.
## 🛠️ Tech Stack
- **React 19 + TypeScript**
- **Vite** (dev server + build)
- **TailwindCSS v3** (styling)
- **React Router v6** (routing)
- **framer-motion** (animations)
- **Lucide React** (icons)
- **clsx + tailwind-merge** (`cn()` utility)
## 🚀 Quick Start
### 1. Install dependencies
```bash
cd web
yarn install
```
### 2. Configure environment
Copy `.env.example` to `.env` and set the backend URL if needed:
```
VITE_API_BASE_URL=http://localhost:13000
```
The Vite dev server proxies `/api/*` to `http://localhost:13000` by default (see `vite.config.ts`).
### 3. Start the dev server
```bash
yarn dev
# → http://localhost:13001
```
### 4. Build for production
```bash
yarn build
# Output: dist/
```
## 📁 Project Structure
```
web/
├── index.tsx # App root — BrowserRouter + all routes
├── index.html
├── src/
│ ├── contexts/
│ │ └── AuthContext.tsx # User auth state (apiKey, user, role)
│ ├── components/
│ │ └── layouts/
│ │ ├── WorkspaceLayout.tsx # Sidebar + header for main workspace
│ │ └── AdminLayout.tsx # Admin console layout
│ ├── pages/
│ │ ├── auth/
│ │ │ └── Login.tsx # Dual-mode login (password + API key)
│ │ ├── workspace/
│ │ │ ├── ChatPage.tsx
│ │ │ ├── KnowledgePage.tsx
│ │ │ ├── NotebooksPage.tsx
│ │ │ └── SettingsPage.tsx
│ │ └── admin/
│ │ ├── SuperAdminPage.tsx # Tenant management (SUPER_ADMIN)
│ │ └── TenantAdminPage.tsx # Team management (TENANT_ADMIN)
│ └── utils/
│ └── cn.ts # clsx + twMerge helper
├── components/ # Legacy rich view components (still used)
│ └── views/
│ ├── ChatView.tsx
│ ├── KnowledgeBaseView.tsx
│ ├── NotebooksView.tsx
│ └── SettingsView.tsx
└── services/ # API service layer
├── apiClient.ts # Base HTTP client (auto-injects x-api-key)
├── chatService.ts # Streaming chat
├── knowledgeBaseService.ts
└── ...
```
## 🔑 Default Credentials (Development)
After running the backend migration, a default super admin is seeded:
- **Email**: `[email protected]`
- **Password**: `admin123`
The `/api/v1/auth/api-key` endpoint returns your API key after login.
## ⚠️ Troubleshooting
- **401 errors**: Your session expired. You will be redirected to `/login` automatically.
- **Upload fails**: Ensure the backend is running and the `uploads/` directory is writable.
- **No AI response**: Configure at least one LLM model in `/settings` or `/admin/models`.
+202
View File
@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, X, RefreshCw, Check, Eraser } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { chatService } from '../services/chatService'
import { useLanguage } from '../contexts/LanguageContext'
interface AICommandDrawerProps {
isOpen: boolean
onClose: () => void
context: string
onApply: (content: string) => void
authToken: string
}
const PRESET_COMMANDS = [
{ label: 'polishContent', valueKey: 'aiCommandInstructPolish' },
{ label: 'expandContent', valueKey: 'aiCommandInstructExpand' },
{ label: 'summarizeContent', valueKey: 'aiCommandInstructSummarize' },
{ label: 'translateToEnglish', valueKey: 'aiCommandInstructTranslateToEn' },
{ label: 'fixGrammar', valueKey: 'aiCommandInstructFixGrammar' },
]
export const AICommandDrawer: React.FC<AICommandDrawerProps> = ({ isOpen, onClose, context, onApply, authToken }) => {
const { t } = useLanguage()
const [instruction, setInstruction] = useState('')
const [result, setResult] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [mode, setMode] = useState<'input' | 'preview'>('input')
useEffect(() => {
if (isOpen) {
setInstruction('')
setResult('')
setMode('input')
setIsGenerating(false)
}
}, [isOpen])
const handleGenerate = async () => {
if (!instruction) return
setMode('preview')
setIsGenerating(true)
setResult('')
try {
const stream = chatService.streamAssist(instruction, context, authToken)
for await (const chunk of stream) {
if (chunk.type === 'content') {
setResult(prev => prev + chunk.data)
} else if (chunk.type === 'error') {
setResult(prev => prev + `\n\n[Error: ${chunk.data}]`)
}
}
} catch (error) {
console.error(error)
setResult(prev => prev + '\n\n[' + t('aiCommandsError') + ']')
} finally {
setIsGenerating(false)
}
}
// Drawer Styles
const drawerClasses = `fixed inset-y-0 right-0 z-50 w-96 bg-white shadow-2xl transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`
// Overlay for closing on click outside (optional, but good UX)
// Using a transparent overlay that allows interaction with the editor might be desired?
// Usually drawers have a backdrop. User said "Show AI assistant page in right drawer", likely implies a standard drawer behavior.
return (
<>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-black/20 z-40 backdrop-blur-none transition-opacity"
onClick={onClose}
/>
)}
<div className={drawerClasses}>
<div className="flex flex-col h-full">
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-blue-600 p-4 shrink-0 flex justify-between items-center text-white shadow-md">
<div className="flex items-center gap-2">
<Sparkles size={20} className="animate-pulse" />
<h3 className="font-bold text-lg">{t('aiAssistant')}</h3>
</div>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{mode === 'input' ? (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsPreset')}</label>
<div className="flex flex-wrap gap-2">
{PRESET_COMMANDS.map(cmd => (
<button
key={cmd.label}
onClick={() => setInstruction(t(cmd.valueKey as any))}
className={`px-3 py-1.5 text-xs rounded-full border transition-all ${instruction === t(cmd.valueKey as any)
? 'bg-purple-100 border-purple-300 text-purple-700'
: 'bg-white border-slate-200 text-slate-600 hover:border-purple-300 hover:text-purple-600'
}`}
>
{t(cmd.label as keyof typeof t)}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsCustom')}</label>
<textarea
className="w-full h-32 p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none resize-none text-sm"
placeholder={t('aiCommandsCustomPlaceholder')}
value={instruction}
onChange={e => setInstruction(e.target.value)}
autoFocus
/>
</div>
{context && (
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<p className="text-xs text-slate-400 mb-1">{t('aiCommandsReferenceContext')}</p>
<p className="text-xs text-slate-600 line-clamp-3 font-mono">{context.slice(0, 200)}</p>
</div>
)}
</div>
) : (
<div className="h-full flex flex-col">
<div className="flex justify-between items-center mb-2">
<h4 className="font-bold text-slate-700 text-sm">{t('aiCommandsResult')}</h4>
{isGenerating && (
<span className="text-xs text-purple-600 flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" /> {t('aiCommandsGenerating')}
</span>
)}
</div>
<div className="flex-1 bg-slate-50 border border-slate-200 rounded-lg p-3 overflow-y-auto markdown-body text-sm relative">
{result ? <ReactMarkdown>{result}</ReactMarkdown> : <span className="text-slate-400 italic text-xs">Thinking...</span>}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 bg-slate-50 shrink-0 flex flex-col gap-3">
{mode === 'input' ? (
<button
onClick={handleGenerate}
disabled={!instruction}
className="w-full flex justify-center items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:shadow-lg disabled:opacity-50 disabled:shadow-none transition-all font-medium text-sm"
>
<Sparkles size={16} />
{t('aiCommandsStartGeneration')}
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => setMode('input')}
className="flex-1 px-3 py-2 text-slate-600 bg-white border border-slate-200 hover:bg-slate-50 rounded-lg text-sm"
disabled={isGenerating}
>
{t('aiCommandsGoBack')}
</button>
<button
onClick={() => {
onApply(result)
// Optional: Don't close drawer immediately to allow multiple edits?
// Usually "Apply" implies "Done". User can reopen if needed.
onClose()
}}
disabled={isGenerating || !result}
className="flex-1 flex justify-center items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 shadow-sm text-sm"
>
<Check size={16} />
{t('aiCommandsApplyResult')}
</button>
</div>
)}
<div className="text-center">
<button onClick={() => {
setInstruction('')
setResult('')
setMode('input')
}} className="text-xs text-slate-400 hover:text-slate-600 flex items-center justify-center gap-1 mx-auto">
<Eraser size={12} /> {t('aiCommandsReset')}
</button>
</div>
</div>
</div>
</div>
</>
)
}
+176
View File
@@ -0,0 +1,176 @@
import React, { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, X, RefreshCw, Check } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { chatService } from '../services/chatService'
import { useLanguage } from '../contexts/LanguageContext'
interface AICommandModalProps {
isOpen: boolean
onClose: () => void
context: string
onApply: (content: string) => void
authToken: string
}
const PRESET_COMMANDS = [
{ label: 'polishContent', valueKey: 'aiCommandInstructPolish' },
{ label: 'expandContent', valueKey: 'aiCommandInstructExpand' },
{ label: 'summarizeContent', valueKey: 'aiCommandInstructSummarize' },
{ label: 'translateToEnglish', valueKey: 'aiCommandInstructTranslateToEn' },
{ label: 'fixGrammar', valueKey: 'aiCommandInstructFixGrammar' },
]
export const AICommandModal: React.FC<AICommandModalProps> = ({ isOpen, onClose, context, onApply, authToken }) => {
const { t } = useLanguage()
const [instruction, setInstruction] = useState('')
const [result, setResult] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [mode, setMode] = useState<'input' | 'preview'>('input')
useEffect(() => {
if (isOpen) {
setInstruction('')
setResult('')
setMode('input')
setIsGenerating(false)
}
}, [isOpen])
const handleGenerate = async () => {
if (!instruction) return
setMode('preview')
setIsGenerating(true)
setResult('')
try {
const stream = chatService.streamAssist(instruction, context, authToken)
for await (const chunk of stream) {
if (chunk.type === 'content') {
setResult(prev => prev + chunk.data)
} else if (chunk.type === 'error') {
setResult(prev => prev + `\n\n[Error: ${chunk.data}]`)
}
}
} catch (error) {
console.error(error)
setResult(prev => prev + `\n\n[${t('aiCommandsError')}]`)
} finally {
setIsGenerating(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col max-h-[85vh] animate-in fade-in zoom-in duration-200">
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-blue-600 p-4 shrink-0 flex justify-between items-center text-white">
<div className="flex items-center gap-2">
<Sparkles size={20} className="animate-pulse" />
<h3 className="font-bold text-lg">{t('aiAssistant')}</h3>
</div>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto flex-1">
{mode === 'input' ? (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsModalPreset')}</label>
<div className="flex flex-wrap gap-2">
{PRESET_COMMANDS.map(cmd => (
<button
key={cmd.label}
onClick={() => setInstruction(t(cmd.valueKey as any))}
className={`px-3 py-1.5 text-sm rounded-full border transition-all ${instruction === t(cmd.valueKey as any)
? 'bg-purple-100 border-purple-300 text-purple-700'
: 'bg-white border-slate-200 text-slate-600 hover:border-purple-300 hover:text-purple-600'
}`}
>
{t(cmd.label as keyof typeof t)}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsModalCustom')}</label>
<textarea
className="w-full h-32 p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none resize-none"
placeholder={t('aiCommandsModalCustomPlaceholder')}
value={instruction}
onChange={e => setInstruction(e.target.value)}
autoFocus
/>
</div>
{context && (
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
<p className="text-xs text-slate-400 mb-1">{t('aiCommandsModalBasedOnSelection')}</p>
<p className="text-sm text-slate-600 line-clamp-3 font-mono">{context}</p>
</div>
)}
</div>
) : (
<div className="h-full flex flex-col">
<div className="flex justify-between items-center mb-2">
<h4 className="font-bold text-slate-700">{t('aiCommandsModalResult')}</h4>
{isGenerating && (
<span className="text-xs text-purple-600 flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" /> {t('aiCommandsGenerating')}
</span>
)}
</div>
<div className="flex-1 bg-slate-50 border border-slate-200 rounded-lg p-4 overflow-y-auto markdown-body text-sm">
{result ? <ReactMarkdown>{result}</ReactMarkdown> : <span className="text-slate-400 italic">{t('aiCommandsGenerating')}</span>}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 bg-slate-50 shrink-0 flex justify-end gap-3">
{mode === 'input' ? (
<button
onClick={handleGenerate}
disabled={!instruction}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:shadow-lg disabled:opacity-50 disabled:shadow-none transition-all font-medium"
>
<Sparkles size={16} />
{t('aiCommandsStartGeneration')}
</button>
) : (
<>
<button
onClick={() => setMode('input')}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg"
disabled={isGenerating}
>
{t('aiCommandsGoBack')}
</button>
<button
onClick={() => {
onApply(result)
onClose()
}}
disabled={isGenerating || !result}
className="flex items-center gap-2 px-5 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 shadow-sm"
>
<Check size={16} />
{t('aiCommandsModalApply')}
</button>
</>
)}
</div>
</div>
</div>
)
}
+417
View File
@@ -0,0 +1,417 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Loader2, Paperclip, X, Search, Database } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import {
AppSettings,
KnowledgeFile,
ModelConfig,
Message,
Role,
KnowledgeGroup
} from '../types';
import ChatMessage from './ChatMessage'; // Assuming ChatMessage is a default export based on original code
import SearchResultsPanel from './SearchResultsPanel';
import { chatService, ChatMessage as ChatMsg, ChatSource } from '../services/chatService';
import { generateUUID } from '../utils/uuid';
interface ChatInterfaceProps {
files: KnowledgeFile[];
settings: AppSettings;
models: ModelConfig[];
groups: KnowledgeGroup[];
selectedGroups: string[];
onGroupSelectionChange?: (groupIds: string[]) => void;
onOpenGroupSelection?: () => void; // New prop
selectedFiles?: string[];
onClearFileSelection?: () => void;
onMobileUploadClick: () => void;
currentHistoryId?: string;
historyMessages?: any[] | null;
onHistoryMessagesLoaded?: () => void;
onPreviewSource?: (source: ChatSource) => void;
onOpenFile?: (source: ChatSource) => void;
onHistoryIdCreated?: (historyId: string) => void;
authToken?: string; // Add optional auth token prop
}
const ChatInterface: React.FC<ChatInterfaceProps> = ({
files,
settings,
models,
groups,
selectedGroups,
onGroupSelectionChange,
onOpenGroupSelection,
selectedFiles,
onClearFileSelection,
onMobileUploadClick,
currentHistoryId,
historyMessages,
onHistoryMessagesLoaded,
onPreviewSource,
onOpenFile,
onHistoryIdCreated,
authToken
}) => {
const { t, language } = useLanguage();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [sources, setSources] = useState<ChatSource[]>([]);
const [showSources, setShowSources] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const lastSubmitTime = useRef<number>(0);
// Debug logging
// console.log('ChatInterface Render:', {
// selectedFilesCount: selectedFiles?.length,
// totalFilesCount: files.length,
// selectedFiles: selectedFiles,
// matchedFiles: files.filter(f => selectedFiles?.includes(f.id)).map(f => f.name)
// });
// Handle loading of history messages
// Handle loading of history messages
useEffect(() => {
if (historyMessages && historyMessages.length > 0) {
const convertedMessages: Message[] = historyMessages.map(msg => ({
id: msg.id,
role: msg.role === 'user' ? Role.USER : Role.MODEL,
text: msg.content,
timestamp: new Date(msg.createdAt).getTime(),
sources: msg.sources // Attach sources to message
}));
setMessages(convertedMessages);
// Notify parent component that history messages have been loaded
onHistoryMessagesLoaded?.();
}
}, [historyMessages, onHistoryMessagesLoaded]);
useEffect(() => {
const welcomeText = t('welcomeMessage');
setMessages((prevMessages) => {
if (prevMessages.length === 0) {
return [
{
id: 'welcome',
role: Role.MODEL,
text: welcomeText,
timestamp: Date.now(),
},
];
}
const hasWelcome = prevMessages.some(m => m.id === 'welcome');
if (hasWelcome) {
return prevMessages.map(m =>
m.id === 'welcome'
? { ...m, text: welcomeText }
: m
);
}
return prevMessages;
});
}, [t]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = async () => {
if (!input.trim() || isLoading) return;
// Debounce mechanism: prevent duplicate submissions within 500ms
const now = Date.now();
if (now - lastSubmitTime.current < 500) {
console.log('Preventing duplicate submission');
return;
}
lastSubmitTime.current = now;
const userText = input.trim();
// Instantly clear input field and reset height to prevent duplicate submission
setInput('');
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.blur(); // Remove focus
}
// Resolve Model Config
const selectedModel = models.find(m => m.id === settings.selectedLLMId && m.type === 'llm');
if (!selectedModel) {
const errorMsg: Message = {
id: generateUUID(),
role: Role.MODEL,
text: `${t('errorNoModel')} - LLM ID: ${settings.selectedLLMId}, Models: ${models.length} `,
timestamp: Date.now(),
isError: true,
};
setMessages(prev => [...prev, errorMsg]);
return;
}
const newMessage: Message = {
id: generateUUID(),
role: Role.USER,
text: userText,
timestamp: Date.now(),
};
setIsLoading(true);
setMessages((prev) => [...prev, newMessage]);
const effectiveToken = authToken || localStorage.getItem('kb_api_key') || localStorage.getItem('authToken');
if (!effectiveToken) {
const errorMsg: Message = {
id: generateUUID(),
role: Role.MODEL,
text: t('needLogin'),
timestamp: Date.now(),
isError: true,
};
setMessages(prev => [...prev, errorMsg]);
setIsLoading(false);
return;
}
try {
const history: ChatMsg[] = messages
.filter(m => m.id !== 'welcome')
.map(m => ({
role: m.role === Role.USER ? 'user' : 'assistant',
content: m.text,
}));
const botMessageId = generateUUID();
let botContent = '';
// Add initial bot message
const botMessage: Message = {
id: botMessageId,
role: Role.MODEL,
text: '',
timestamp: Date.now(),
};
setMessages(prev => [...prev, botMessage]);
const stream = chatService.streamChat(
userText,
history,
effectiveToken,
language,
settings.selectedEmbeddingId,
settings.selectedLLMId, // Pass selected LLM ID
selectedGroups.length > 0 ? selectedGroups : undefined, // Pass group filter
selectedFiles?.length > 0 ? selectedFiles : undefined, // Pass file filter
currentHistoryId, // Pass history ID
settings.enableRerank, // Pass Rerank switch
settings.selectedRerankId, // Pass Rerank model ID
settings.temperature, // Pass temperature parameter
settings.maxTokens, // Pass max tokens
settings.topK, // Pass Top-K parameter
settings.similarityThreshold, // Pass similarity threshold
settings.rerankSimilarityThreshold, // Pass Rerank threshold
settings.enableQueryExpansion, // Pass query expansion
settings.enableHyDE // Pass HyDE
);
for await (const chunk of stream) {
if (chunk.type === 'content') {
botContent += chunk.data;
setMessages(prev =>
prev.map(msg =>
msg.id === botMessageId
? { ...msg, text: botContent }
: msg
)
);
} else if (chunk.type === 'sources') {
// Attach sources to the current bot message
setMessages(prev =>
prev.map(msg =>
msg.id === botMessageId
? { ...msg, sources: chunk.data }
: msg
)
);
} else if (chunk.type === 'historyId') {
onHistoryIdCreated?.(chunk.data);
} else if (chunk.type === 'error') {
setMessages(prev =>
prev.map(msg =>
msg.id === botMessageId
? { ...msg, text: t('errorMessage').replace('$1', chunk.data), isError: true }
: msg
)
);
break;
}
}
} catch (error: any) {
console.error('Chat error:', error);
let errorText = t('errorGeneric');
if (error.message === "API_KEY_MISSING") errorText = t('apiError');
else if (error.message.includes("OpenAI API Error")) errorText = `OpenAI Error: ${error.message} `;
else if (error.message === "GEMINI_API_ERROR") errorText = t('geminiError');
else if (error.message) errorText = `Error: ${error.message} `;
const errorMessage: Message = {
id: generateUUID(),
role: Role.MODEL,
text: errorText,
timestamp: Date.now(),
isError: true,
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
lastSubmitTime.current = 0;
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!isLoading && input.trim()) {
handleSend();
}
}
};
const handleInputResize = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)} px`;
};
return (
<div className="flex flex-col h-full bg-transparent relative overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 md:px-8 pt-6 pb-32 space-y-8 scrollbar-hide">
{messages.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
onPreviewSource={onPreviewSource}
onOpenFile={onOpenFile}
/>
))}
{isLoading && (
<div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
<div className="flex flex-row gap-4 items-start translate-x-1">
<div className="w-9 h-9 rounded-xl bg-white/80 backdrop-blur-sm border border-slate-200/50 flex items-center justify-center shadow-sm">
<Loader2 className="w-4 h-4 text-indigo-600 animate-spin" />
</div>
<div className="bg-white/80 backdrop-blur-md border border-white/40 px-5 py-3.5 rounded-2xl rounded-tl-none shadow-sm flex items-center">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
</div>
<span className="text-sm font-medium text-slate-500 ml-2 tracking-wide uppercase text-[10px]">{t('analyzing')}</span>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="absolute bottom-6 left-0 right-0 px-4 md:px-8 pointer-events-none">
<div className="max-w-4xl mx-auto pointer-events-auto">
{((selectedFiles && selectedFiles.length > 0) || true) && (
<div className="mb-3 flex flex-wrap gap-2 animate-in slide-in-from-bottom-2 duration-300">
{/* Group Selection Button */}
<button
type="button"
onClick={onOpenGroupSelection}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-semibold transition-all border shadow-sm ${selectedGroups.length > 0
? 'bg-blue-600 text-white border-blue-500 hover:bg-blue-700'
: 'bg-white/90 backdrop-blur-md text-slate-600 border-slate-200/60 hover:bg-white'
}`}
title={t('selectKnowledgeGroup')}
>
<Database size={13} className={selectedGroups.length > 0 ? "text-blue-100" : "text-blue-500"} />
<span className="truncate max-w-[150px]">
{selectedGroups.length === 0
? t('allKnowledgeGroups')
: selectedGroups.length <= 1
? (groups.find(g => g.id === selectedGroups[0])?.name || t('unknownGroup'))
: t('selectedGroupsCount').replace('$1', selectedGroups.length.toString())}
</span>
</button>
{files.filter(f => selectedFiles?.includes(f.id)).map(file => (
<div key={file.id} className="flex items-center gap-1.5 bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-full text-xs font-semibold border border-indigo-100 shadow-sm animate-in zoom-in-95">
<span className="truncate max-w-[150px]">{file.title || file.name}</span>
<button
onClick={onClearFileSelection}
className="hover:bg-indigo-200/50 rounded-full p-0.5 transition-colors"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm flex items-end p-2.5 transition-all duration-300 focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-400 group/input">
<button
onClick={onMobileUploadClick}
className="md:hidden p-3 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-colors"
>
<Paperclip className="w-5 h-5" />
</button>
<textarea
ref={inputRef}
value={input}
onChange={handleInputResize}
onKeyDown={handleKeyDown}
placeholder={files.length > 0 ? t('placeholderWithFiles') : t('placeholderEmpty')}
className="flex-1 max-h-[250px] min-h-[48px] bg-transparent border-none focus:ring-0 text-slate-800 placeholder:text-slate-400/80 resize-none py-3 px-4 text-[15px] leading-relaxed"
rows={1}
disabled={files.length === 0 && messages.length < 2 && false}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className={`p-3 rounded-xl mb-0.5 ml-2 transition-all duration-300 ${input.trim() && !isLoading
? 'bg-gradient-to-br from-blue-600 to-indigo-600 text-white hover:shadow-lg hover:shadow-blue-500/30 transform hover:-translate-y-0.5 active:translate-y-0 active:scale-95'
: 'bg-slate-100 text-slate-300 cursor-not-allowed'
} `}
type="button"
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
<p className="text-center text-[10px] text-slate-400/80 mt-3 font-medium tracking-tight uppercase">
{t('aiDisclaimer')}
</p>
</div>
</div>
</div>
);
};
export default ChatInterface;
+250
View File
@@ -0,0 +1,250 @@
import React, { useState } from 'react';
import { copyToClipboard } from '../utils/clipboard';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Message, Role } from '../types';
import { Bot, User, AlertCircle, Copy, Check, Search, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '../contexts/LanguageContext';
import { ChatSource } from '../types';
interface ChatMessageProps {
message: Message;
onPreviewSource?: (source: ChatSource) => void;
onOpenFile?: (source: ChatSource) => void;
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, onPreviewSource, onOpenFile }) => {
const { t } = useLanguage();
const isUser = message.role === Role.USER;
const [copied, setCopied] = useState(false);
const [sourcesExpanded, setSourcesExpanded] = useState(false);
const handleCopy = async () => {
const success = await copyToClipboard(message.text);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const renderContent = (content: string) => {
return (
<div className="markdown-body text-[15px] leading-[1.6] tracking-tight">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (language === 'mermaid') {
return (
<div className="my-6 p-5 bg-slate-900/5 border border-slate-200 rounded-2xl overflow-x-auto shadow-inner">
<pre className="text-xs text-slate-600 font-mono whitespace-pre-wrap">
{String(children).replace(/\n$/, '')}
</pre>
<div className="flex items-center gap-2 text-[10px] text-slate-400 mt-4 font-bold uppercase tracking-wider">
<div className="w-4 h-4 bg-slate-200 rounded flex items-center justify-center text-[8px]">M</div>
Mermaid diagram rendering
</div>
</div>
);
}
if (!inline && match) {
return (
<div className="my-5 group/code">
<div className="flex items-center justify-between bg-slate-800 text-slate-300 px-4 py-2 rounded-t-xl text-[10px] font-bold uppercase tracking-widest border-b border-white/5">
<span className="font-mono">{language}</span>
<span className="opacity-0 group-hover/code:opacity-100 transition-opacity text-[10px]">Code Block</span>
</div>
<pre className="bg-[#1E293B] text-slate-100 p-4 rounded-b-xl overflow-x-auto border border-t-0 border-slate-800 shadow-lg">
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}
return (
<code className="bg-blue-50 text-blue-700 rounded-md px-1.5 py-0.5 text-xs font-mono font-bold" {...props}>
{children}
</code>
);
},
h1: ({ children }) => (
<h1 className="text-2xl font-bold mt-8 mb-4 text-slate-900 border-b pb-2 border-slate-100">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-bold mt-6 mb-3 text-slate-800 flex items-start gap-2">
<div className="w-1 h-5 bg-blue-500 rounded-full mt-1 shrink-0" />
<span className="break-words min-w-0">{children}</span>
</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-bold mt-5 mb-2 text-slate-800 tracking-tight">{children}</h3>
),
ul: ({ children }) => (
<ul className="list-disc list-outside ml-5 space-y-2 my-4 text-slate-700">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside ml-5 space-y-2 my-4 text-slate-700">{children}</ol>
),
li: ({ children }) => (
<li className="pl-1">{children}</li>
),
p: ({ children }) => (
<p className="my-3 leading-[1.7] last:mb-0">{children}</p>
),
table: ({ children }) => (
<div className="overflow-x-auto my-6 border border-slate-200 rounded-xl shadow-sm">
<table className="min-w-full divide-y divide-slate-200">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-slate-200 bg-slate-50/80 px-4 py-3 text-left text-xs font-bold uppercase tracking-wider text-slate-500">{children}</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-sm text-slate-600 border-b border-slate-50">{children}</td>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-blue-200 bg-blue-50/30 px-5 py-3 my-5 rounded-r-xl italic text-slate-600">
{children}
</blockquote>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'} mb-2`}
>
<div className={`flex max-w-[90%] md:max-w-[85%] gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
{/* Avatar */}
<div className={`shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all ${isUser ? 'bg-gradient-to-br from-blue-600 to-indigo-600 text-white shadow-blue-200 shadow-md' : 'bg-white border border-slate-200 text-indigo-600 shadow-sm'
}`}>
{isUser ? <User className="w-5 h-5" /> : <Sparkles className="w-5 h-5" />}
</div>
{/* Message Bubble + Sources */}
<div className={`flex flex-col ${isUser ? 'items-end' : 'items-start'} group max-w-full`}>
<div className={`relative px-6 py-4 rounded-[24px] shadow-sm transition-all duration-300 ${isUser
? 'bg-gradient-to-br from-blue-600 to-indigo-700 text-white rounded-tr-none shadow-blue-200/50 hover:shadow-lg hover:shadow-blue-200/50'
: message.isError
? 'bg-red-50 border border-red-200 text-red-700 rounded-tl-none'
: 'bg-white/80 backdrop-blur-md border border-slate-200/60 text-slate-800 rounded-tl-none hover:shadow-md hover:border-slate-300/50'
}`}>
{message.isError && (
<div className="flex items-center gap-2 mb-3 text-red-600 font-bold uppercase text-[10px] tracking-widest">
<AlertCircle className="w-4 h-4" />
<span>{t('errorLabel')}</span>
</div>
)}
<div className={`${isUser ? 'text-blue-50' : 'text-slate-800'}`}>
{renderContent(message.text)}
</div>
{/* Copy Button (Always visible, icon only) */}
<div className={`flex justify-end mt-3 pt-3 border-t ${isUser ? 'border-white/10' : 'border-slate-100/50'}`}>
<button
onClick={handleCopy}
className={`flex items-center justify-center p-2 rounded-lg transition-all ${isUser
? 'text-blue-200 hover:bg-white/10 hover:text-white'
: 'text-slate-400 hover:bg-slate-50 hover:text-indigo-600'
}`}
title={copied ? t('copied') : t('copy')}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
{/* Timestamp */}
<span className="text-[10px] font-bold text-slate-400/80 mt-1.5 px-2 uppercase tracking-tight">
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{!isUser && message.sources && message.sources.length > 0 && (
<div className="mt-4 w-full pt-2">
<button
onClick={() => setSourcesExpanded(!sourcesExpanded)}
className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-slate-500 hover:text-indigo-600 transition-all mb-3 group/btn"
>
<div className={`flex items-center justify-center w-5 h-5 rounded-full border border-slate-200 transition-transform ${sourcesExpanded ? 'rotate-90' : 'rotate-0'}`}>
<ChevronRight className="w-3 h-3" />
</div>
<div className="flex items-center gap-1.5">
<Search className="w-3 h-3" />
<span>{t('citationSources')} ({message.sources.length})</span>
</div>
</button>
<AnimatePresence>
{sourcesExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="grid gap-3 w-full overflow-hidden"
>
{message.sources.map((source, index) => (
<div
key={`${source.fileName}-${source.chunkIndex}-${index}`}
className="bg-white/60 hover:bg-white border border-slate-200/60 rounded-xl p-4 transition-all cursor-pointer hover:shadow-lg hover:shadow-indigo-500/5 hover:border-indigo-200 group/source relative overflow-hidden"
onClick={() => onPreviewSource?.(source)}
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-indigo-500 opacity-0 group-hover/source:opacity-100 transition-opacity" />
<div className="flex justify-between items-start mb-2.5">
{source.fileId ? (
<button
onClick={(e) => {
e.stopPropagation();
onOpenFile?.(source);
}}
className="font-bold text-slate-900 text-[13px] truncate pr-3 hover:text-indigo-600 transition-colors text-left"
title={source.fileName}
>
{source.fileName}
</button>
) : (
<div className="font-bold text-slate-900 text-[13px] truncate pr-3" title={source.fileName}>{source.fileName}</div>
)}
<div className="text-[10px] font-black bg-indigo-50 text-indigo-700 px-2.5 py-1 rounded-full shrink-0 border border-indigo-100 shadow-sm uppercase tracking-tighter">
{(source.score * 100).toFixed(1)}%
</div>
</div>
<div className="text-slate-600 text-sm leading-relaxed line-clamp-2 italic font-medium">
&ldquo;{source.content}&rdquo;
</div>
<div className="text-[10px] font-bold text-slate-400 mt-3 flex justify-between items-center uppercase tracking-widest">
<span>{t('chunkNumber')} #{source.chunkIndex + 1}</span>
<span className="text-indigo-600 opacity-0 group-hover/source:opacity-100 transition-all transform translate-x-2 group-hover/source:translate-x-0 flex items-center gap-1">
{t('sourcePreview')} &rarr;
</span>
</div>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</div>
</motion.div>
);
};
export default ChatMessage;
+149
View File
@@ -0,0 +1,149 @@
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { knowledgeBaseService } from '../services/knowledgeBaseService';
import { useLanguage } from '../contexts/LanguageContext';
interface ChunkInfo {
fileId: string;
fileName: string;
totalChunks: number;
chunkSize: number;
chunkOverlap: number;
chunks: Array<{
index: number;
content: string;
contentLength: number;
startPosition: number;
endPosition: number;
}>;
}
interface ChunkInfoDrawerProps {
isOpen: boolean;
onClose: () => void;
fileId: string;
fileName: string;
authToken: string;
}
export const ChunkInfoDrawer: React.FC<ChunkInfoDrawerProps> = ({
isOpen,
onClose,
fileId,
fileName,
authToken,
}) => {
const { t } = useLanguage();
const [chunkInfo, setChunkInfo] = useState<ChunkInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && fileId) {
loadChunks();
}
}, [isOpen, fileId]);
const loadChunks = async () => {
setLoading(true);
setError(null);
try {
const data = await knowledgeBaseService.getFileChunks(fileId, authToken);
setChunkInfo(data);
} catch (err) {
console.error('Failed to load chunks:', err);
setError(t('errorLoadData'));
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-[9998]"
onClick={onClose}
/>
{/* Drawer */}
<div className="fixed right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2 bg-white shadow-2xl z-[9999] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200 shrink-0">
<div>
<h2 className="text-xl font-semibold text-slate-800">
{t('chunkInfo')}
</h2>
<p className="text-sm text-slate-500 mt-1">{fileName}</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-slate-500">{t('loading')}</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-red-500">{error}</div>
</div>
) : chunkInfo ? (
<div>
{/* Summary */}
<div className="bg-slate-50 rounded-lg p-4 mb-6 space-y-2">
<div className="flex justify-between">
<span className="text-slate-600">{t('totalChunks')}:</span>
<span className="font-semibold">{chunkInfo.totalChunks}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">{t('chunkSize')}:</span>
<span className="font-semibold">{chunkInfo.chunkSize} tokens</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">{t('chunkOverlap')}:</span>
<span className="font-semibold">{chunkInfo.chunkOverlap} tokens</span>
</div>
</div>
{/* Chunks List */}
<div className="space-y-4">
{chunkInfo.chunks.map((chunk) => (
<div
key={chunk.index}
className="border border-slate-200 rounded-lg p-4 hover:border-blue-300 transition-colors"
>
<div className="flex justify-between items-center mb-3">
<span className="font-semibold text-slate-800">
{t('chunkIndex')} #{chunk.index}
</span>
<span className="text-sm text-slate-500">
{chunk.contentLength} {t('contentLength')}
</span>
</div>
<div className="bg-white border border-slate-200 rounded-lg p-4 max-h-96 overflow-y-auto">
<div className="text-slate-700 text-sm leading-7 whitespace-pre-wrap break-words" style={{ fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif' }}>
{chunk.content}
</div>
</div>
<div className="text-xs text-slate-400 mt-2">
{t('position')}: {chunk.startPosition} - {chunk.endPosition}
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
</>
);
};
+294
View File
@@ -0,0 +1,294 @@
import React from 'react';
import { AppSettings, ModelConfig, ModelType } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { Settings, Database, Sliders, Layers, Cpu, ChevronRight } from 'lucide-react';
import VisionModelSelector from './VisionModelSelector';
interface ConfigPanelProps {
settings: AppSettings;
models: ModelConfig[];
onSettingsChange: (newSettings: AppSettings) => void;
onOpenSettings: () => void;
mode?: 'chat' | 'kb' | 'all';
isAdmin?: boolean;
}
const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsChange, onOpenSettings, mode = 'all', isAdmin = false }) => {
const { t } = useLanguage();
const { confirm } = useConfirm();
const handleChange = (key: keyof AppSettings, value: any) => {
onSettingsChange({
...settings,
[key]: value,
});
};
const llmModels = models.filter(m => m.type === ModelType.LLM && m.isEnabled !== false && !m.supportsVision);
const embeddingModels = models.filter(m => m.type === ModelType.EMBEDDING && m.isEnabled !== false);
const rerankModels = models.filter(m => m.type === ModelType.RERANK && m.isEnabled !== false);
const showChatSettings = mode === 'chat' || mode === 'all';
const showKbSettings = mode === 'kb' || mode === 'all';
return (
<div className="flex-1 overflow-y-auto p-4 space-y-6 bg-slate-50">
{!isAdmin && (
<div className="bg-orange-50 border border-orange-200 p-3 rounded-lg mb-4">
<p className="text-xs text-orange-700 flex items-center gap-2">
<Sliders className="w-3 h-3" />
{t('onlyAdminCanModify') || "Only administrators can modify system settings."}
</p>
</div>
)}
{/* Model Selection (LLM) - Chat Mode Only */}
{showChatSettings && (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2">
<div className="flex items-center gap-2 text-slate-800 font-semibold">
<Cpu className="w-4 h-4 text-blue-600" />
{t('headerModelSelection')}
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{t('selectLLMModel')}</label>
<select
value={settings.selectedLLMId}
onChange={(e) => handleChange('selectedLLMId', e.target.value)}
disabled={!isAdmin}
className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">--- {t('selectLLMModel')} ---</option>
{llmModels.map(m => (
<option key={m.id} value={m.id}>
{m.name} ({m.modelId})
</option>
))}
</select>
</div>
</div>
</div>
)}
{/* Embedding Model Selection - KB Mode Only */}
{showKbSettings && (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2">
<div className="flex items-center gap-2 text-slate-800 font-semibold">
<Cpu className="w-4 h-4 text-blue-600" />
{t('lblEmbedding')}
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblEmbedding')}</label>
<select
value={settings.selectedEmbeddingId}
onChange={async (e) => {
const newId = e.target.value;
if (newId !== settings.selectedEmbeddingId && settings.selectedEmbeddingId) {
if (await confirm(t('confirmChangeEmbeddingModel') || "WARNING: Changing the embedding model will require re-indexing all existing files. Are you sure?")) {
handleChange('selectedEmbeddingId', newId);
}
} else {
handleChange('selectedEmbeddingId', newId);
}
}}
disabled={!isAdmin}
className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">--- {t('selectEmbeddingModel')} ---</option>
{embeddingModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<p className="text-[10px] text-slate-400 mt-1">{t('defaultForUploads')}</p>
<p className="text-[10px] text-orange-500 mt-1">{t('embeddingModelWarning') || "Changing this setting may require clearing and re-importing your knowledge base."}</p>
</div>
</div>
</div>
)}
{/* Hyperparameters - Chat Mode Only */}
{showChatSettings && (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-slate-800 font-semibold border-b border-slate-100 pb-2">
<Sliders className="w-4 h-4 text-pink-500" />
{t('headerHyperparams')}
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1.5">
<label className="text-xs font-medium text-slate-500">{t('lblTemperature')}</label>
<span className="text-xs text-blue-600 font-bold">{settings.temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.1"
value={settings.temperature}
onChange={(e) => handleChange('temperature', parseFloat(e.target.value))}
disabled={!isAdmin}
className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblMaxTokens')}</label>
<input
type="number"
value={settings.maxTokens}
onChange={(e) => handleChange('maxTokens', parseInt(e.target.value))}
disabled={!isAdmin}
className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
)}
{/* Vision Model Settings - Chat Mode Only? Or both? Assuming Chat */}
{/* Vision Model Settings - KB Only */}
{showKbSettings && <VisionModelSelector isAdmin={isAdmin} />}
{/* Retrieval Settings - KB Mode Only */}
{showKbSettings && (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-slate-800 font-semibold border-b border-slate-100 pb-2">
<Database className="w-4 h-4 text-green-600" />
{t('headerRetrieval')}
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1.5">
<label className="text-xs font-medium text-slate-500">{t('lblTopK')}</label>
<span className="text-xs text-blue-600 font-bold">{settings.topK}</span>
</div>
<input
type="range"
min="1"
max="20"
step="1"
value={settings.topK}
onChange={(e) => handleChange('topK', parseInt(e.target.value))}
disabled={!isAdmin}
className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblRerankRef')}</label>
<select
value={settings.selectedRerankId}
onChange={(e) => handleChange('selectedRerankId', e.target.value)}
disabled={!settings.enableRerank || !isAdmin}
className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">--- {t('selectRerankModel')} ---</option>
{rerankModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div>
<div className="flex justify-between mb-1.5">
<label className="text-xs font-medium text-slate-500">{t('vectorSimilarityThreshold')}</label>
<span className="text-xs text-blue-600 font-bold">{settings.similarityThreshold}</span>
</div>
<input
type="range"
min="0.0"
max="1.0"
step="0.05"
value={settings.similarityThreshold}
onChange={(e) => handleChange('similarityThreshold', parseFloat(e.target.value))}
disabled={!isAdmin}
className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
/>
<p className="text-[10px] text-slate-400 mt-1">{t('filterLowResults')}</p>
</div>
{settings.enableRerank && (
<div>
<div className="flex justify-between mb-1.5">
<label className="text-xs font-medium text-slate-500">{t('rerankSimilarityThreshold')}</label>
<span className="text-xs text-blue-600 font-bold">{settings.rerankSimilarityThreshold}</span>
</div>
<input
type="range"
min="0.0"
max="1.0"
step="0.05"
value={settings.rerankSimilarityThreshold}
onChange={(e) => handleChange('rerankSimilarityThreshold', parseFloat(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-pink-600"
/>
</div>
)}
<div className="flex items-center justify-between pt-2">
<label className="text-sm text-slate-700">{t('lblRerank')}</label>
<button
onClick={() => isAdmin && handleChange('enableRerank', !settings.enableRerank)}
disabled={!isAdmin}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableRerank ? 'bg-blue-600' : 'bg-slate-300'
} ${!isAdmin ? 'cursor-not-allowed opacity-50' : ''}`}
>
<span
className={`${settings.enableRerank ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
/>
</button>
</div>
<div className="flex items-center justify-between pt-2">
<label className="text-sm text-slate-700">{t('fullTextSearch')}</label>
<button
onClick={() => isAdmin && handleChange('enableFullTextSearch', !settings.enableFullTextSearch)}
disabled={!isAdmin}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableFullTextSearch ? 'bg-blue-600' : 'bg-slate-300'
} ${!isAdmin ? 'cursor-not-allowed opacity-50' : ''}`}
>
<span
className={`${settings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
/>
</button>
</div>
{settings.enableFullTextSearch && (
<div className="pt-2 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex justify-between mb-1.5">
<label className="text-xs font-medium text-slate-500">{t('hybridVectorWeight')}</label>
<span className="text-xs text-blue-600 font-bold">{settings.hybridVectorWeight}</span>
</div>
<input
type="range"
min="0.0"
max="1.0"
step="0.05"
value={settings.hybridVectorWeight}
onChange={(e) => handleChange('hybridVectorWeight', parseFloat(e.target.value))}
disabled={!isAdmin}
className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
/>
<p className="text-[10px] text-slate-400 mt-1">{t('hybridVectorWeightDesc')}</p>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default ConfigPanel;
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { AlertCircle, X } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface ConfirmDialogProps {
isOpen: boolean;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
}) => {
const { t } = useLanguage();
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/10 backdrop-blur-[2px] animate-in fade-in duration-200">
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-sm overflow-hidden animate-in zoom-in duration-300 pointer-events-auto border border-white/40 ring-1 ring-black/5">
<div className="flex justify-between items-center px-10 pt-10 pb-4">
<h3 className="text-base font-black text-slate-900 flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-amber-50 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
{title || t('confirmTitle') || 'Confirm'}
</h3>
<button
onClick={onCancel}
className="w-8 h-8 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-all"
>
<X size={18} />
</button>
</div>
<div className="px-10 py-6">
<p className="text-sm font-bold text-slate-500 leading-relaxed whitespace-pre-wrap">{message}</p>
</div>
<div className="px-10 pb-10 pt-2 flex justify-end gap-3">
<button
onClick={onCancel}
className="flex-1 h-12 text-sm text-slate-600 hover:bg-slate-100 rounded-2xl transition-all font-black uppercase tracking-widest"
>
{cancelLabel || t('cancel') || 'Cancel'}
</button>
<button
onClick={onConfirm}
className="flex-1 h-12 text-sm bg-indigo-600 text-white rounded-2xl hover:bg-indigo-700 transition-all font-black shadow-lg shadow-indigo-600/20 active:scale-95 uppercase tracking-widest"
>
{confirmLabel || t('confirm') || 'Confirm'}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;
+197
View File
@@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import { X, Loader, Image as ImageIcon, Box } from 'lucide-react';
import { ocrService } from '../services/ocrService';
import { noteCategoryService } from '../services/noteCategoryService';
import { NoteCategory } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { useToast } from '../contexts/ToastContext';
interface CreateNoteFromPDFDialogProps {
screenshot: Blob;
extractedText: string;
onSave: (title: string, content: string, categoryId?: string) => Promise<void>;
onCancel: () => void;
authToken: string;
initialCategoryId?: string;
initialPageNumber?: number;
}
export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = ({
screenshot,
extractedText,
onSave,
onCancel,
authToken,
initialCategoryId,
initialPageNumber,
}) => {
const { t } = useLanguage();
const { showToast } = useToast();
const defaultTitle = initialPageNumber
? `${t('createPDFNote')} - ${t('page')} ${initialPageNumber} - ${new Date().toLocaleString()}`
: `${t('createPDFNote')} - ${new Date().toLocaleString()}`;
const [title, setTitle] = useState(defaultTitle);
const [content, setContent] = useState(extractedText);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | undefined>(initialCategoryId);
const [categories, setCategories] = useState<NoteCategory[]>([]);
const [saving, setSaving] = useState(false);
const [ocrLoading, setOcrLoading] = useState(false);
const [screenshotUrl, setScreenshotUrl] = useState<string>('');
useEffect(() => {
if (authToken) {
noteCategoryService.getAll(authToken).then(setCategories).catch(console.error);
}
}, [authToken]);
useEffect(() => {
const url = URL.createObjectURL(screenshot);
setScreenshotUrl(url);
// Trigger OCR if initial text is empty
if (!extractedText && authToken) {
setOcrLoading(true);
ocrService.recognizeText(authToken, screenshot)
.then(text => setContent(text))
.catch(err => console.error('OCR failed:', err))
.finally(() => setOcrLoading(false));
}
return () => URL.revokeObjectURL(url);
}, [screenshot, extractedText, authToken]);
const handleSave = async () => {
setSaving(true);
try {
await onSave(title, content, selectedCategoryId);
} catch (error) {
console.error('Failed to save note:', error);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg w-full max-w-3xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">{t('createPDFNote')}</h2>
<button
onClick={onCancel}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
disabled={saving}
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Screenshot Preview */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<ImageIcon size={16} className="inline mr-1" />
{t('screenshotPreview')}
</label>
<div className="border rounded-lg p-2 bg-gray-50">
{screenshotUrl && (
<img
src={screenshotUrl}
alt="PDF Selection"
className="max-w-full h-auto rounded"
/>
)}
</div>
</div>
{/* Note Category selector */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<Box size={16} className="inline mr-1" />
{t('personalNotebook') || t('directoryLabel')}
</label>
<select
value={selectedCategoryId || ''}
onChange={(e) => setSelectedCategoryId(e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
disabled={saving}
>
<option value="">{t('uncategorized')}</option>
{categories.map(c => {
const parent = categories.find(p => p.id === c.parentId)
const grandparent = parent ? categories.find(gp => gp.id === parent.parentId) : null
const path = [grandparent, parent, c].filter(Boolean).map(cat => cat?.name).join(' > ')
return (
<option key={c.id} value={c.id}>
{'\u00A0'.repeat((c.level - 1) * 2)}{c.name}
</option>
)
})}
</select>
</div>
{/* Title Input */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t('title')}
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('enterNoteTitle')}
disabled={saving}
/>
</div>
{/* Content Textarea */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t('contentOCR')}
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
rows={10}
placeholder={ocrLoading ? t('extractingText') : t('placeholderText')}
disabled={saving || ocrLoading}
/>
{ocrLoading && (
<div className="flex items-center gap-2 mt-2 p-2 bg-blue-50/50 rounded-md border border-blue-100/50">
<Loader size={12} className="animate-spin" />
{t('analyzingImage')}
</div>
)}
{!ocrLoading && !content && (
<p className="text-sm text-gray-500">
{t('noTextExtracted')}
</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t bg-gray-50">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
disabled={saving}
>
{t('cancel')}
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
disabled={saving || !title.trim()}
>
{saving && <Loader size={16} className="animate-spin" />}
{saving ? t('saving') : t('saveNote')}
</button>
</div>
</div>
</div>
);
};
+114
View File
@@ -0,0 +1,114 @@
import React, { useState } from 'react'
import { X, Plus } from 'lucide-react'
import { CreateGroupData } from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { useToast } from '../contexts/ToastContext'
interface CreateNotebookDialogProps {
isOpen: boolean
onClose: () => void
onCreate: (data: CreateGroupData) => Promise<void>
}
export const CreateNotebookDialog: React.FC<CreateNotebookDialogProps> = ({
isOpen,
onClose,
onCreate,
}) => {
const { t } = useLanguage();
const { showError } = useToast();
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
try {
setIsSubmitting(true)
await onCreate({
name: name.trim(),
description: description.trim(),
color: '#3b82f6', // Default color
})
// Reset form
setName('')
setDescription('')
onClose()
} catch (error) {
console.error('Failed to create notebook:', error)
showError(t('createFailedRetry'))
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-slate-800">{t('createNotebookTitle')}</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('namePlaceholder')}
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('shortDescription')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('descPlaceholder')}
/>
</div>
<div className="flex justify-end pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg mr-2 transition-colors"
>
{t('cancel')}
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={18} />
{isSubmitting ? t('creating') : t('createNow')}
</button>
</div>
</form>
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import React, { useState } from 'react'
import { Plus, ChevronRight } from 'lucide-react'
import { CreateGroupData } from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { useToast } from '../contexts/ToastContext'
interface CreateNotebookDrawerProps {
isOpen: boolean
onClose: () => void
onCreate: (data: CreateGroupData) => Promise<void>
}
export const CreateNotebookDrawer: React.FC<CreateNotebookDrawerProps> = ({
isOpen,
onClose,
onCreate,
}) => {
const { t } = useLanguage()
const { showError } = useToast()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
try {
setIsSubmitting(true)
await onCreate({
name: name.trim(),
description: description.trim(),
color: '#3b82f6', // Default color
})
// Reset form
setName('')
setDescription('')
onClose()
} catch (error) {
console.error('Failed to create notebook:', error)
showError(t('createFailedRetry'))
} finally {
setIsSubmitting(false)
}
}
return (
<>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
onClick={onClose}
/>
)}
{/* Drawer */}
<div
className={`fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2">
<Plus className="w-6 h-6 text-blue-600" />
{t('createNotebookTitle')}
</h2>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
>
<ChevronRight size={24} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<form id="create-notebook-form" onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder={t('namePlaceholder')}
required
autoFocus
/>
<p className="mt-1 text-xs text-slate-500">{t('nameHelp')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('shortDescription')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder={t('descPlaceholder')}
/>
</div>
</form>
</div>
{/* Footer */}
<div className="p-6 border-t bg-slate-50">
<button
type="submit"
form="create-notebook-form"
disabled={isSubmitting || !name.trim()}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-600/20"
>
<Plus size={20} />
{isSubmitting ? t('creating') : t('createNow')}
</button>
</div>
</div>
</div>
</>
)
}
+136
View File
@@ -0,0 +1,136 @@
import React, { useCallback, useState } from 'react';
import { Upload as UploadIcon, FileText, Image as ImageIcon, Folder, FileUp, ShieldCheck } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { motion, AnimatePresence } from 'framer-motion';
interface DragDropUploadProps {
onFilesSelected: (files: FileList) => void;
isAdmin: boolean;
globalMode?: boolean;
}
export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected, isAdmin, globalMode = false }) => {
const { t } = useLanguage();
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
onFilesSelected(e.dataTransfer.files);
e.dataTransfer.clearData();
}
}, [onFilesSelected]);
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
onFilesSelected(e.target.files);
e.target.value = '';
}
};
if (!isAdmin) return null;
return (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className={`relative w-full overflow-hidden rounded-2xl border-2 border-dashed transition-all duration-300 ${isDragging
? 'border-blue-500 bg-blue-50/50 shadow-[0_0_25px_rgba(59,130,246,0.1)]'
: 'border-slate-200 bg-slate-50/50 hover:border-slate-300 hover:bg-slate-50'
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('file-upload-input')?.click()}
>
<div className="flex flex-col items-center justify-center py-12 px-6 text-center cursor-pointer">
<div className={`p-4 rounded-2xl mb-6 transition-all duration-300 ${isDragging ? 'bg-blue-600 text-white scale-110' : 'bg-white text-blue-600 shadow-sm border border-slate-100'}`}>
<FileUp size={32} />
</div>
<div className="space-y-1 mb-8">
<h3 className="text-lg font-bold text-slate-900 tracking-tight">
{t('dragDropUploadTitle')}
</h3>
<p className="text-sm text-slate-500 font-medium">
{t('dragDropUploadDesc')}
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-4 mb-8">
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
<ShieldCheck size={14} className="text-emerald-500" />
<span>{t('secureIngestion')}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
<FileText size={14} className="text-blue-500" />
<span>{t('documentsAndText')}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
<ImageIcon size={14} className="text-purple-500" />
<span>{t('imagesAndVision')}</span>
</div>
</div>
<button
type="button"
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl shadow-md shadow-blue-100 transition-all font-semibold text-sm active:scale-95 flex items-center gap-2"
>
<Folder size={18} />
{t('browseFiles')}
</button>
<input
type="file"
multiple
onChange={handleFileInput}
className="hidden"
id="file-upload-input"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.html,.csv,.rtf,.odt,.ods,.odp,.json,.js,.jsx,.ts,.tsx,.css,.xml,image/*"
/>
</div>
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-blue-600/5 backdrop-blur-[2px] pointer-events-none flex items-center justify-center border-2 border-blue-500 rounded-2xl"
>
<div className="bg-white p-6 rounded-3xl shadow-2xl flex flex-col items-center gap-3">
<div className="w-12 h-12 bg-blue-600 text-white rounded-2xl flex items-center justify-center animate-bounce">
<FileUp size={24} />
</div>
<span className="text-blue-600 font-bold">{t('dropToIngest')}</span>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useState } from 'react'
import { X, Save } from 'lucide-react'
import { KnowledgeGroup, UpdateGroupData } from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { useToast } from '../contexts/ToastContext'
interface EditNotebookDialogProps {
isOpen: boolean
onClose: () => void
notebook: KnowledgeGroup
onUpdate: (id: string, data: UpdateGroupData) => Promise<void>
}
export const EditNotebookDialog: React.FC<EditNotebookDialogProps> = ({
isOpen,
onClose,
notebook,
onUpdate,
}) => {
const [name, setName] = useState(notebook.name)
const [description, setDescription] = useState(notebook.description || '')
const [isSubmitting, setIsSubmitting] = useState(false)
const { t } = useLanguage()
const { showError } = useToast()
// Reset form when notebook changes or dialog opens
useEffect(() => {
if (isOpen) {
setName(notebook.name)
setDescription(notebook.description || '')
}
}, [isOpen, notebook])
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
try {
setIsSubmitting(true)
await onUpdate(notebook.id, {
name: name.trim(),
description: description.trim(),
})
onClose()
} catch (error) {
console.error('Failed to update notebook:', error)
showError(t('updateFailedRetry'))
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-slate-800">{t('editNotebookTitle')}</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('shortDescription')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('descPlaceholder')}
/>
</div>
<div className="flex justify-end pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg mr-2 transition-colors"
>
{t('cancel')}
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={18} />
{isSubmitting ? t('saving') : t('save')}
</button>
</div>
</form>
</div>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react'
import { Plus, ChevronRight, Save } from 'lucide-react'
import { KnowledgeGroup, UpdateGroupData } from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { useToast } from '../contexts/ToastContext'
interface EditNotebookDrawerProps {
isOpen: boolean
onClose: () => void
notebook: KnowledgeGroup
onUpdate: (id: string, data: UpdateGroupData) => Promise<void>
}
export const EditNotebookDrawer: React.FC<EditNotebookDrawerProps> = ({
isOpen,
onClose,
notebook,
onUpdate,
}) => {
const { t } = useLanguage()
const { showError } = useToast()
const [name, setName] = useState(notebook.name)
const [description, setDescription] = useState(notebook.description || '')
const [isSubmitting, setIsSubmitting] = useState(false)
// Reset form when notebook changes or drawer opens
useEffect(() => {
if (isOpen) {
setName(notebook.name)
setDescription(notebook.description || '')
}
}, [isOpen, notebook])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
try {
setIsSubmitting(true)
await onUpdate(notebook.id, {
name: name.trim(),
description: description.trim(),
})
onClose()
} catch (error) {
console.error('Failed to update notebook:', error)
showError(t('updateFailedRetry'))
} finally {
setIsSubmitting(false)
}
}
return (
<>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
onClick={onClose}
/>
)}
{/* Drawer */}
<div
className={`fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2">
<Save className="w-5 h-5 text-blue-600" />
{t('editNotebookTitle')}
</h2>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
>
<ChevronRight size={24} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<form id="edit-notebook-form" onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder={t('namePlaceholder')}
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('shortDescription')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
placeholder={t('descPlaceholder')}
/>
</div>
</form>
</div>
{/* Footer */}
<div className="p-6 border-t bg-slate-50">
<button
type="submit"
form="edit-notebook-form"
disabled={isSubmitting || !name.trim()}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-600/20"
>
<Save size={20} />
{isSubmitting ? t('saving') : t('save')}
</button>
</div>
</div>
</div>
</>
)
}
+135
View File
@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { KnowledgeGroup } from '../types';
import { knowledgeGroupService } from '../services/knowledgeGroupService';
import { useLanguage } from '../contexts/LanguageContext';
import { useToast } from '../contexts/ToastContext';
import { Tag, Plus, X, FolderPlus } from 'lucide-react';
interface FileGroupTagsProps {
fileId: string;
groups: KnowledgeGroup[];
assignedGroups: string[];
onGroupsChange: (groupIds: string[]) => void;
isAdmin?: boolean;
}
export const FileGroupTags: React.FC<FileGroupTagsProps> = ({
fileId,
groups,
assignedGroups,
onGroupsChange,
isAdmin = false
}) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { showToast } = useToast();
// Monitor custom events to open group selector
React.useEffect(() => {
const handleOpenGroupSelector = (event: CustomEvent) => {
if (event.detail.fileId === fileId) {
setIsOpen(true);
}
};
document.addEventListener('openGroupSelector', handleOpenGroupSelector as EventListener);
return () => {
document.removeEventListener('openGroupSelector', handleOpenGroupSelector as EventListener);
};
}, [fileId]);
const handleAddToGroup = async (groupId: string) => {
if (assignedGroups.includes(groupId)) return;
setLoading(true);
try {
// Correct method: pass all group IDs (existing + new)
const newGroupIds = [...assignedGroups, groupId];
await knowledgeGroupService.addFileToGroups(fileId, newGroupIds);
onGroupsChange(newGroupIds);
showToast('success', t('fileAddedToGroup'));
setIsOpen(false);
} catch (error) {
showToast('error', t('failedToAddToGroup'));
} finally {
setLoading(false);
}
};
const handleRemoveFromGroup = async (groupId: string) => {
setLoading(true);
try {
await knowledgeGroupService.removeFileFromGroup(fileId, groupId);
onGroupsChange(assignedGroups.filter(id => id !== groupId));
showToast('success', t('fileRemovedFromGroup'));
} catch (error) {
showToast('error', t('failedToRemoveFromGroup'));
} finally {
setLoading(false);
}
};
const assignedGroupsData = assignedGroups.map(id => groups.find(g => g.id === id)).filter(Boolean);
const availableGroups = groups.filter(g => !assignedGroups.includes(g.id));
return (
<div className="relative">
<div className="flex items-center flex-wrap gap-1">
{assignedGroupsData.map((group) => (
<div
key={group.id}
className="flex items-center space-x-1 px-2 py-1 rounded-full text-xs"
style={{ backgroundColor: group.color + '20', color: group.color }}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: group.color }}
/>
<span>{group.name}</span>
{isAdmin && (
<button
onClick={() => handleRemoveFromGroup(group.id)}
disabled={loading}
className="hover:bg-black/10 rounded-full p-0.5"
>
<X size={10} />
</button>
)}
</div>
))}
{availableGroups.length > 0 && (
<span></span>
)}
</div>
{isOpen && availableGroups.length > 0 && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-20 min-w-48">
{availableGroups.map((group) => (
<button
key={group.id}
onClick={() => {
handleAddToGroup(group.id);
}}
disabled={loading}
className="w-full flex items-center space-x-2 px-3 py-2 text-sm text-left hover:bg-gray-50 disabled:opacity-50"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<span>{group.name}</span>
</button>
))}
</div>
</>
)}
</div>
);
};
+162
View File
@@ -0,0 +1,162 @@
import { useLayoutEffect, useRef, useState, useCallback } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { motion, AnimatePresence } from 'framer-motion';
import { FileUp, ShieldCheck, FileText, Image as ImageIcon } from 'lucide-react';
interface GlobalDragDropProps {
onFilesSelected: (files: FileList) => void;
isAdmin: boolean;
}
let isDragDropEnabled = true;
let forceHideCallback: (() => void) | null = null;
export const setDragDropEnabled = (enabled: boolean) => {
isDragDropEnabled = enabled;
if (!enabled && forceHideCallback) {
forceHideCallback();
}
};
export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSelected, isAdmin }) => {
const { t } = useLanguage();
const overlayRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const dragCounterRef = useRef(0);
const isDragActiveRef = useRef(false);
const hasFiles = useCallback((dt: DataTransfer | null) => {
if (!dt) return false;
const hasFileType = dt.types && dt.types.includes('Files');
if (!hasFileType) return false;
if (dt.items && dt.items.length > 0) {
for (let i = 0; i < dt.items.length; i++) {
if (dt.items[i].kind === 'file') return true;
}
return false;
}
return hasFileType;
}, []);
const handleDragEnter = useCallback((e: DragEvent) => {
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (dragCounterRef.current === 1) {
setIsVisible(true);
isDragActiveRef.current = true;
}
}, [hasFiles]);
const handleDragOver = useCallback((e: DragEvent) => {
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer!.dropEffect = 'copy';
}, [hasFiles]);
const handleDragLeave = useCallback((e: DragEvent) => {
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0 && isDragActiveRef.current) {
setIsVisible(false);
isDragActiveRef.current = false;
}
}, [hasFiles]);
const handleDrop = useCallback((e: DragEvent) => {
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsVisible(false);
isDragActiveRef.current = false;
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
onFilesSelected(e.dataTransfer.files);
}
}, [hasFiles, onFilesSelected]);
useLayoutEffect(() => {
if (!isAdmin) return;
dragCounterRef.current = 0;
isDragActiveRef.current = false;
forceHideCallback = () => {
dragCounterRef.current = 0;
isDragActiveRef.current = false;
setIsVisible(false);
};
document.addEventListener('dragenter', handleDragEnter);
document.addEventListener('dragover', handleDragOver);
document.addEventListener('dragleave', handleDragLeave);
document.addEventListener('drop', handleDrop);
return () => {
document.removeEventListener('dragenter', handleDragEnter);
document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('dragleave', handleDragLeave);
document.removeEventListener('drop', handleDrop);
forceHideCallback = null;
dragCounterRef.current = 0;
isDragActiveRef.current = false;
setIsVisible(false);
};
}, [isAdmin, handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
if (!isAdmin || typeof window === 'undefined') return null;
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-blue-600/10 backdrop-blur-md items-center justify-center z-[9999] pointer-events-none flex p-8"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="w-full max-w-2xl bg-white rounded-[2.5rem] p-12 text-center shadow-[0_32px_64px_-12px_rgba(0,0,0,0.14)] border border-white pointer-events-auto"
>
<div className="flex flex-col items-center justify-center gap-8">
<div className="w-24 h-24 bg-blue-600 text-white rounded-3xl flex items-center justify-center shadow-xl shadow-blue-200 animate-bounce">
<FileUp size={48} />
</div>
<div className="space-y-3">
<h3 className="text-3xl font-black text-slate-900 tracking-tight">
{t('dragDropUploadTitle')}
</h3>
<p className="text-lg text-slate-500 font-medium">
{t('dropAnywhere')}
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-6 py-8 border-y border-slate-100 w-full">
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
<ShieldCheck size={20} className="text-emerald-500" />
<span>{t('secureProcessing')}</span>
</div>
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
<FileText size={20} className="text-blue-500" />
<span>{t('allFormats')}</span>
</div>
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
<ImageIcon size={20} className="text-purple-500" />
<span>{t('visualVision')}</span>
</div>
</div>
<div className="text-slate-400 font-bold text-xs uppercase tracking-[0.3em]">
{t('releaseToIngest')}
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
+240
View File
@@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import { KnowledgeGroup, CreateGroupData, UpdateGroupData } from '../types';
import { knowledgeGroupService } from '../services/knowledgeGroupService';
import { useToast } from '../contexts/ToastContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Folder, Plus, Edit2, Trash2, X } from 'lucide-react';
interface GroupManagerProps {
groups: KnowledgeGroup[];
onGroupsChange: (groups: KnowledgeGroup[]) => void;
}
const DEFAULT_COLORS = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444',
'#8B5CF6', '#06B6D4', '#84CC16', '#F97316'
];
export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChange }) => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<KnowledgeGroup | null>(null);
const [formData, setFormData] = useState<CreateGroupData>({
name: '',
description: '',
color: DEFAULT_COLORS[0],
});
const [loading, setLoading] = useState(false);
const { showSuccess, showError } = useToast();
const { confirm } = useConfirm();
const { t } = useLanguage();
const resetForm = () => {
setFormData({
name: '',
description: '',
color: DEFAULT_COLORS[0],
});
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) return;
setLoading(true);
try {
const newGroup = await knowledgeGroupService.createGroup(formData);
onGroupsChange([...groups, newGroup]);
setIsCreateModalOpen(false);
resetForm();
showSuccess(t('successNoteCreated')); // Note: Should probably have a more specific translation for group
} catch (error) {
showError(t('createFailed'));
} finally {
setLoading(false);
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingGroup || !formData.name.trim()) return;
setLoading(true);
try {
const updatedGroup = await knowledgeGroupService.updateGroup(editingGroup.id, formData);
onGroupsChange(groups.map(g => g.id === editingGroup.id ? updatedGroup : g));
setEditingGroup(null);
resetForm();
showSuccess(t('successNoteUpdated'));
} catch (error) {
showError(t('updateFailedRetry'));
} finally {
setLoading(false);
}
};
const handleDelete = async (group: KnowledgeGroup) => {
if (!(await confirm(t('confirmDeleteGroup').replace('$1', group.name)))) return;
try {
await knowledgeGroupService.deleteGroup(group.id);
onGroupsChange(groups.filter(g => g.id !== group.id));
showSuccess(t('successNoteDeleted'));
} catch (error) {
showError(t('deleteFailed'));
}
};
const openEditModal = (group: KnowledgeGroup) => {
setEditingGroup(group);
setFormData({
name: group.name,
description: group.description || '',
color: group.color,
});
};
const closeModal = () => {
setIsCreateModalOpen(false);
setEditingGroup(null);
resetForm();
};
const isModalOpen = isCreateModalOpen || editingGroup !== null;
return (
<div className="space-y-4">
{/* Group list */}
<div className="space-y-2">
{groups.map((group) => (
<div
key={group.id}
className="flex items-center justify-between p-3 bg-white rounded-lg border hover:shadow-sm transition-shadow"
>
<div className="flex items-center space-x-3">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<div className="font-medium text-gray-900">{group.name}</div>
{group.description && (
<div className="text-sm text-gray-500">{group.description}</div>
)}
<div className="text-xs text-gray-400">
{group.fileCount} files
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => openEditModal(group)}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(group)}
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
{/* Create button */}
<button
onClick={() => setIsCreateModalOpen(true)}
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
title={t('createNotebook')}
>
<Plus size={18} />
</button>
{/* Create/Edit modal */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
{editingGroup ? t('editNotebookTitle') : t('createNotebookTitle')}
</h3>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
<form onSubmit={editingGroup ? handleUpdate : handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('shortDescription')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('descPlaceholder')}
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Color indicator
</label>
<div className="flex space-x-2">
{DEFAULT_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData({ ...formData, color })}
className={`w-8 h-8 rounded-full border-2 ${formData.color === color ? 'border-gray-400' : 'border-gray-200'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
<div className="flex space-x-3 pt-4">
<button
type="button"
onClick={closeModal}
className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
{t('cancel')}
</button>
<button
type="submit"
disabled={loading || !formData.name.trim()}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? t('saving') : (editingGroup ? t('save') : t('create'))}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+142
View File
@@ -0,0 +1,142 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { KnowledgeGroup } from '../types';
import { Check, X, Search, Database } from 'lucide-react';
interface GroupSelectionDrawerProps {
isOpen: boolean;
onClose: () => void;
groups: KnowledgeGroup[];
selectedGroups: string[];
onSelectionChange: (groupIds: string[]) => void;
}
export const GroupSelectionDrawer: React.FC<GroupSelectionDrawerProps> = ({
isOpen,
onClose,
groups,
selectedGroups,
onSelectionChange
}) => {
const { t } = useLanguage();
const [searchTerm, setSearchTerm] = useState('');
if (!isOpen) return null;
const filteredGroups = groups.filter(g =>
g.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const isAllSelected = selectedGroups.length === 0;
const handleToggleGroup = (groupId: string) => {
if (selectedGroups.includes(groupId)) {
onSelectionChange(selectedGroups.filter(id => id !== groupId));
} else {
onSelectionChange([...selectedGroups, groupId]);
}
};
const handleSelectAll = () => {
onSelectionChange([]);
};
return createPortal(
<div className="fixed inset-0 z-50 overflow-hidden">
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onClick={onClose} />
<div className="absolute inset-y-0 right-0 max-w-md w-full flex">
<div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900 flex items-center gap-2">
<Database size={20} />
{t('selectKnowledgeGroups')}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X size={24} />
</button>
</div>
{/* Search Box */}
<div className="p-4 border-b border-gray-100">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder={t('searchGroupsPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-gray-50"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{!searchTerm && (
<div
onClick={handleSelectAll}
className={`flex items-center px-4 py-3.5 cursor-pointer hover:bg-slate-50 rounded-xl transition-all ${isAllSelected ? 'bg-blue-50/50 text-blue-700 outline outline-1 outline-blue-200' : 'text-slate-700 border border-transparent'
}`}
>
<div className={`w-5 h-5 mr-3 border rounded-md flex items-center justify-center transition-colors ${isAllSelected ? 'bg-blue-600 border-blue-600' : 'border-slate-300'
}`}>
{isAllSelected && <Check size={14} className="text-white" />}
</div>
<span className="font-semibold text-sm">{t('all')}</span>
</div>
)}
{filteredGroups.map((group) => {
const isSelected = selectedGroups.includes(group.id);
return (
<div
key={group.id}
onClick={() => handleToggleGroup(group.id)}
className={`flex items-center px-4 py-3 cursor-pointer hover:bg-slate-50 rounded-xl transition-all ${isSelected ? 'bg-blue-50/50 outline outline-1 outline-blue-200' : 'border border-transparent'
}`}
>
<div className={`w-5 h-5 mr-3 border rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${isSelected ? 'bg-blue-600 border-blue-600' : 'border-slate-300'
}`}>
{isSelected && <Check size={14} className="text-white" />}
</div>
<div
className="w-3 h-3 rounded-full mr-3 flex-shrink-0 shadow-sm"
style={{ backgroundColor: group.color }}
/>
<div className="flex-1 min-w-0">
<div className={`text-sm truncate transition-colors ${isSelected ? 'text-blue-700 font-semibold' : 'text-slate-700 font-medium'}`}>
{group.name}
</div>
<div className={`text-xs mt-0.5 transition-colors ${isSelected ? 'text-blue-500/80' : 'text-slate-400'}`}>
{group.fileCount}
</div>
</div>
</div>
);
})}
{filteredGroups.length === 0 && (
<div className="py-12 text-center text-slate-400 text-sm flex flex-col items-center justify-center gap-2">
<Database size={32} className="text-slate-200" />
<span>{searchTerm ? t('noGroupsFound') : t('noGroups')}</span>
</div>
)}
</div>
<div className="p-4 border-t border-gray-200 bg-gray-50">
<button
onClick={onClose}
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
{t('done')} ({isAllSelected ? t('all') : selectedGroups.length})
</button>
</div>
</div>
</div>
</div>,
document.body
);
};
+184
View File
@@ -0,0 +1,184 @@
import React from 'react';
import { KnowledgeGroup } from '../types';
import { Check, ChevronDown, Search } from 'lucide-react';
interface GroupSelectorProps {
groups: KnowledgeGroup[];
selectedGroups: string[];
onSelectionChange: (groupIds: string[]) => void;
showSelectAll?: boolean;
placeholder?: string;
minimal?: boolean;
direction?: 'up' | 'bottom'; // Added direction prop
}
export const GroupSelector: React.FC<GroupSelectorProps> = ({
groups,
selectedGroups,
onSelectionChange,
showSelectAll = true,
placeholder = 'Select group scope',
minimal = false,
direction = 'bottom'
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const [dropdownStyle, setDropdownStyle] = React.useState<React.CSSProperties>({});
const [searchTerm, setSearchTerm] = React.useState('');
const containerRef = React.useRef<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen && containerRef.current) {
const button = containerRef.current.querySelector('button') as HTMLElement;
if (button) {
const rect = button.getBoundingClientRect();
// Calculate style based on direction
const style: React.CSSProperties = {
left: rect.left,
width: Math.max(rect.width, 240), // Min width for readability
};
if (direction === 'up') {
style.bottom = window.innerHeight - rect.top + 4;
style.maxHeight = '320px'; // Increased height for search + list
} else {
style.top = rect.bottom + 4;
style.maxHeight = '320px';
}
setDropdownStyle(style);
// Auto-focus search input when opening
setTimeout(() => searchInputRef.current?.focus(), 50);
}
} else {
setSearchTerm(''); // Reset search on close
}
}, [isOpen, direction]);
const filteredGroups = groups.filter(g =>
g.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const isAllSelected = selectedGroups.length === 0;
// Optimized display logic
let selectedGroupNames = '';
if (selectedGroups.length === 0) {
selectedGroupNames = 'All groups';
} else if (selectedGroups.length <= 2) {
selectedGroupNames = selectedGroups.map(id => groups.find(g => g.id === id)?.name).filter(Boolean).join(', ');
} else {
selectedGroupNames = `Selected ${selectedGroups.length} groups`;
}
const handleToggleGroup = (groupId: string) => {
if (selectedGroups.includes(groupId)) {
onSelectionChange(selectedGroups.filter(id => id !== groupId));
} else {
onSelectionChange([...selectedGroups, groupId]);
}
};
const handleSelectAll = () => {
onSelectionChange([]);
};
return (
<div className={`relative ${minimal ? '' : 'w-full'} `} ref={containerRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items - center justify - between px - 3 py - 2 bg - white border border - gray - 300 rounded - md text - left focus: outline - none focus: ring - 2 focus: ring - blue - 500 ${minimal ? 'h-9 text-sm min-w-[120px]' : 'w-full'} `}
title={selectedGroups.length > 2 ? selectedGroups.map(id => groups.find(g => g.id === id)?.name).join(', ') : undefined}
>
<span className={`truncate mr - 2 ${selectedGroups.length === 0 ? 'text-gray-500' : 'text-gray-900'} `} style={{ maxWidth: minimal ? '120px' : 'none' }}>
{selectedGroupNames || placeholder}
</span>
<ChevronDown size={14} className={`text - gray - 400 text - xs transition - transform flex - shrink - 0 ${isOpen ? 'rotate-180' : ''} `} />
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-[9998]"
onClick={() => setIsOpen(false)}
/>
<div className="fixed bg-white border border-gray-300 rounded-lg shadow-xl z-[9999] flex flex-col animate-in fade-in zoom-in-95 duration-100"
style={dropdownStyle}>
{/* Search Box */}
<div className="p-2 border-b border-gray-100 shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
ref={searchInputRef}
type="text"
placeholder="Search groups..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-gray-50"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<div className="overflow-y-auto flex-1 p-0">
{!searchTerm && showSelectAll && (
<div
onClick={handleSelectAll}
className={`flex items - center px - 3 py - 2 cursor - pointer hover: bg - gray - 50 border - b border - gray - 100 ${isAllSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
} `}
>
<div className={`w - 4 h - 4 mr - 3 border rounded flex items - center justify - center ${isAllSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
} `}>
{isAllSelected && <Check size={12} className="text-white" />}
</div>
<span className="font-medium text-sm">All groups</span>
</div>
)}
<div className="py-1">
{filteredGroups.map((group) => {
const isSelected = selectedGroups.includes(group.id);
return (
<div
key={group.id}
onClick={() => handleToggleGroup(group.id)}
className={`flex items - center px - 3 py - 2 cursor - pointer hover: bg - gray - 50 transition - colors ${isSelected ? 'bg-blue-50' : ''
} `}
>
<div className={`w - 4 h - 4 mr - 3 border rounded flex items - center justify - center flex - shrink - 0 ${isSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
} `}>
{isSelected && <Check size={12} className="text-white" />}
</div>
<div
className="w-2.5 h-2.5 rounded-full mr-2 flex-shrink-0"
style={{ backgroundColor: group.color }}
/>
<div className="flex-1 min-w-0">
<div className={`text - sm truncate ${isSelected ? 'text-blue-700 font-medium' : 'text-gray-700'} `}>
{group.name}
</div>
<div className="text-[10px] text-gray-400">
{group.fileCount} files
</div>
</div>
</div>
);
})}
{filteredGroups.length === 0 && (
<div className="px-3 py-6 text-center text-gray-400 text-xs">
{searchTerm ? 'No related groups found' : 'No groups'}
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
);
};
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { KnowledgeGroup } from '../types';
import { SearchHistoryList } from './SearchHistoryList';
import { X, History } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface HistoryDrawerProps {
isOpen: boolean;
onClose: () => void;
groups: KnowledgeGroup[];
onSelectHistory: (historyId: string) => void;
}
export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
isOpen,
onClose,
groups,
onSelectHistory
}) => {
const { t } = useLanguage();
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 overflow-hidden">
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onClick={onClose} />
<div className="absolute inset-y-0 right-0 max-w-md w-full flex">
<div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900 flex items-center gap-2">
<History size={20} />
{t('historyTitle')}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<SearchHistoryList
groups={groups}
onSelectHistory={onSelectHistory}
/>
</div>
</div>
</div>
</div>,
document.body
);
};
+554
View File
@@ -0,0 +1,554 @@
import React, { useState, useEffect } from 'react';
import { X, FolderInput, ArrowRight, Info, Layers, Clock, Upload, Calendar } from 'lucide-react';
import { isExtensionAllowed } from '../constants/fileSupport';
import { useLanguage } from '../contexts/LanguageContext';
import { ModelConfig, ModelType, IndexingConfig, KnowledgeGroup } from '../types';
import { modelConfigService } from '../services/modelConfigService';
import { knowledgeGroupService } from '../services/knowledgeGroupService';
import { apiClient } from '../services/apiClient';
import { useToast } from '../contexts/ToastContext';
import IndexingModalWithMode from './IndexingModalWithMode';
interface ImportFolderDrawerProps {
isOpen: boolean;
onClose: () => void;
authToken: string;
initialGroupId?: string;
initialGroupName?: string;
onImportSuccess?: () => void;
}
interface FileWithPath {
file: File;
relativePath: string;
}
type ImportMode = 'immediate' | 'scheduled';
export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
isOpen,
onClose,
authToken,
initialGroupId,
initialGroupName,
onImportSuccess,
}) => {
const { t } = useLanguage();
const { showError, showSuccess } = useToast();
// Tab
const [importMode, setImportMode] = useState<ImportMode>('immediate');
// Immediate mode state
const [localFiles, setLocalFiles] = useState<FileWithPath[]>([]);
const [folderName, setFolderName] = useState('');
const [targetName, setTargetName] = useState('');
const [useHierarchy, setUseHierarchy] = useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [isIndexingConfigOpen, setIsIndexingConfigOpen] = useState(false);
const [models, setModels] = useState<ModelConfig[]>([]);
// Scheduled mode state
const [serverPath, setServerPath] = useState('');
const [scheduledTime, setScheduledTime] = useState(() => {
// Default to 30 min from now
const d = new Date();
d.setMinutes(d.getMinutes() + 30);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
});
const [schedTargetName, setSchedTargetName] = useState('');
const [schedUseHierarchy, setSchedUseHierarchy] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [allGroups, setAllGroups] = useState<KnowledgeGroup[]>([]);
const [parentGroupId, setParentGroupId] = useState<string>('');
const [schedParentGroupId, setSchedParentGroupId] = useState<string>('');
useEffect(() => {
if (isOpen) {
setLocalFiles([]);
setFolderName('');
setTargetName(initialGroupName || '');
setUseHierarchy(false);
setIsIndexingConfigOpen(false);
setImportMode('immediate');
setServerPath('');
setSchedTargetName(initialGroupName || '');
setSchedUseHierarchy(false);
setParentGroupId('');
setSchedParentGroupId('');
// Default scheduled time = 30min from now
const d = new Date();
d.setMinutes(d.getMinutes() + 30);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
setScheduledTime(d.toISOString().slice(0, 16));
modelConfigService.getAll(authToken).then(res => {
setModels(res.filter(m => m.type === ModelType.EMBEDDING));
});
knowledgeGroupService.getGroups().then(groups => {
const flat: any[] = [];
function walk(items: any[], depth = 0) {
for (const g of items) {
flat.push({ ...g, d: depth });
if (g.children?.length) walk(g.children, depth + 1);
}
}
walk(groups);
setAllGroups(flat);
});
}
}, [isOpen, authToken, initialGroupName]);
// ---- Immediate mode handlers ----
const handleLocalFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const allFiles = Array.from(e.target.files);
const files: FileWithPath[] = allFiles
.filter(file => {
const ext = file.name.split('.').pop() || '';
return isExtensionAllowed(ext, 'group');
})
.map(file => ({
file,
relativePath: file.webkitRelativePath || file.name,
}));
if (files.length === 0 && allFiles.length > 0) {
showError(t('noFilesFound'));
return;
}
setLocalFiles(files);
const firstPath = allFiles[0].webkitRelativePath;
if (firstPath) {
const parts = firstPath.split('/');
if (parts.length > 0) {
const name = parts[0];
setFolderName(name);
if (!initialGroupId && !targetName) setTargetName(name);
}
}
}
};
const handleImmediateNext = () => {
if (localFiles.length === 0) {
showError(t('clickToSelectFolder'));
return;
}
if (!initialGroupId && !targetName) {
showError(t('fillTargetName'));
return;
}
setIsIndexingConfigOpen(true);
};
const handleConfirmConfig = async (config: IndexingConfig) => {
setIsLoading(true);
try {
const { uploadService } = await import('../services/uploadService');
if (useHierarchy) {
// Step 1: Determine root group
let rootGroupId = initialGroupId ?? null;
if (!rootGroupId) {
const newGroup = await knowledgeGroupService.createGroup({
name: targetName,
description: t('importedFromLocalFolder').replace('$1', folderName),
parentId: parentGroupId || null,
});
rootGroupId = newGroup.id;
}
// Step 2: Collect all unique directory paths
const dirSet = new Set<string>();
for (const { relativePath } of localFiles) {
const parts = relativePath.split('/');
const dirParts = initialGroupId
? parts.slice(1, parts.length - 1)
: parts.slice(0, parts.length - 1);
for (let i = 1; i <= dirParts.length; i++) {
dirSet.add(dirParts.slice(0, i).join('/'));
}
}
// Step 3: Sort by depth, create groups sequentially
const sortedDirs = Array.from(dirSet).sort((a, b) =>
a.split('/').length - b.split('/').length
);
const dirToGroupId = new Map<string, string>();
dirToGroupId.set(initialGroupId ? '' : folderName, rootGroupId);
for (const dirPath of sortedDirs) {
if (!dirPath || dirToGroupId.has(dirPath)) continue;
const segments = dirPath.split('/');
const segName = segments[segments.length - 1];
const parentPath = segments.slice(0, segments.length - 1).join('/');
const parentId = dirToGroupId.get(parentPath) ?? rootGroupId;
const newGroup = await knowledgeGroupService.createGroup({ name: segName, parentId });
dirToGroupId.set(dirPath, newGroup.id);
}
// Step 4: Upload files in parallel batches
const BATCH_SIZE = 3;
for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
const batch = localFiles.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async ({ file, relativePath }) => {
try {
const parts = relativePath.split('/');
const dirParts = initialGroupId
? parts.slice(1, parts.length - 1)
: parts.slice(0, parts.length - 1);
const fileDirPath = dirParts.join('/');
const targetGroupId = dirToGroupId.get(fileDirPath) ?? rootGroupId!;
const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
await knowledgeGroupService.addFileToGroups(uploadedKb.id, [targetGroupId]);
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err);
}
}));
}
} else {
// Single-group mode
let groupId = initialGroupId ?? null;
if (!groupId) {
const newGroup = await knowledgeGroupService.createGroup({
name: targetName,
description: t('importedFromLocalFolder').replace('$1', folderName),
});
groupId = newGroup.id;
}
const BATCH_SIZE = 3;
for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
const batch = localFiles.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async ({ file }) => {
try {
const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
if (groupId) await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]);
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err);
}
}));
}
}
showSuccess(t('importComplete'));
onImportSuccess?.();
onClose();
} catch (error: any) {
showError(t('submitFailed', error.message));
} finally {
setIsLoading(false);
setIsIndexingConfigOpen(false);
}
};
// ---- Scheduled mode handler ----
const handleScheduledSubmit = async () => {
if (!serverPath.trim()) {
showError(t('fillServerPath'));
return;
}
if (!initialGroupId && !schedTargetName.trim()) {
showError(t('fillTargetName'));
return;
}
const scheduledAt = new Date(scheduledTime);
if (isNaN(scheduledAt.getTime())) {
showError(t('invalidDateTime' as any));
return;
}
setIsLoading(true);
try {
let finalGroupId = initialGroupId || undefined;
if (!finalGroupId && schedTargetName.trim()) {
const newGroup = await knowledgeGroupService.createGroup({
name: schedTargetName.trim(),
description: t('importedFromLocalFolder').replace('$1', schedTargetName.trim()),
parentId: schedParentGroupId || null,
});
finalGroupId = newGroup.id;
}
const defaultModel = models[0];
await apiClient.post('/import-tasks', {
sourcePath: serverPath.trim(),
targetGroupId: finalGroupId,
targetGroupName: undefined,
embeddingModelId: defaultModel?.id,
scheduledAt: scheduledAt.toISOString(),
chunkSize: 500,
chunkOverlap: 50,
mode: 'fast',
useHierarchy: schedUseHierarchy,
});
showSuccess(t('scheduleTaskCreated'));
onImportSuccess?.();
onClose();
} catch (error: any) {
showError(t('submitFailed', error.message));
} finally {
setIsLoading(false);
}
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 z-50 flex justify-end">
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onClick={onClose} />
<div className="relative w-full max-w-md bg-white shadow-2xl flex flex-col h-full animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<FolderInput className="w-5 h-5 text-blue-600" />
{t('importFolderTitle')}
</h2>
<button onClick={onClose} className="p-2 -mr-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors">
<X size={20} />
</button>
</div>
{/* Mode Tabs */}
<div className="flex border-b border-slate-100 shrink-0">
<button
onClick={() => setImportMode('immediate')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'immediate'
? 'border-blue-600 text-blue-600 bg-blue-50/40'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<Upload size={15} />
{t('importImmediate')}
</button>
<button
onClick={() => setImportMode('scheduled')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'scheduled'
? 'border-blue-600 text-blue-600 bg-blue-50/40'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<Clock size={15} />
{t('importScheduled')}
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{importMode === 'immediate' ? (
<>
{/* Immediate: folder picker */}
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-slate-200 rounded-xl p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all group"
>
<div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
<FolderInput size={24} />
</div>
<div className="text-center">
<p className="text-sm font-medium text-slate-700">
{localFiles.length > 0
? t('selectedFilesCount').replace('$1', localFiles.length.toString())
: t('clickToSelectFolder')}
</p>
<p className="text-xs text-slate-400 mt-1">
{localFiles.length > 0 ? folderName : t('selectFolderTip')}
</p>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleLocalFolderChange}
className="hidden"
multiple
// @ts-ignore
webkitdirectory=""
directory=""
/>
</div>
{/* Target group */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
<input
type="text"
value={targetName}
onChange={e => setTargetName(e.target.value)}
disabled={!!initialGroupId}
placeholder={t('placeholderNewGroup')}
className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
/>
{initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
</div>
{!initialGroupId && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
<select
value={parentGroupId}
onChange={e => setParentGroupId(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
>
<option value="">{t('allGroups' as any) || '-- Root --'}</option>
{allGroups.map((g: any) => (
<option key={g.id} value={g.id}>
{'\u00A0'.repeat(g.d * 4)}{g.name}
</option>
))}
</select>
</div>
)}
{/* Hierarchy toggle */}
<HierarchyToggle value={useHierarchy} onChange={setUseHierarchy} t={t} />
</>
) : (
<>
{/* Scheduled: server path */}
<div className="bg-amber-50 border border-amber-100 rounded-lg p-3 text-sm text-amber-800 flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<p className="text-xs">{t('scheduledImportTip')}</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">{t('lblServerPath')}</label>
<input
type="text"
value={serverPath}
onChange={e => setServerPath(e.target.value)}
placeholder={t('placeholderServerPath')}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none font-mono"
/>
</div>
{/* Target group */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
<input
type="text"
value={schedTargetName}
onChange={e => setSchedTargetName(e.target.value)}
disabled={!!initialGroupId}
placeholder={t('placeholderNewGroup')}
className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
/>
{initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
</div>
{!initialGroupId && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
<select
value={schedParentGroupId}
onChange={e => setSchedParentGroupId(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
>
<option value="">{t('allGroups' as any) || '-- Root --'}</option>
{allGroups.map((g: any) => (
<option key={g.id} value={g.id}>
{'\u00A0'.repeat(g.d * 4)}{g.name}
</option>
))}
</select>
</div>
)}
{/* Scheduled datetime */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
<Calendar size={14} className="text-blue-500" />
{t('lblScheduledTime')}
</label>
<input
type="datetime-local"
value={scheduledTime}
onChange={e => setScheduledTime(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
<p className="text-xs text-slate-400">{t('scheduledTimeHint')}</p>
</div>
{/* Hierarchy toggle */}
<HierarchyToggle value={schedUseHierarchy} onChange={setSchedUseHierarchy} t={t} />
</>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-100 shrink-0 flex gap-3 bg-slate-50">
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
{t('cancel')}
</button>
{importMode === 'immediate' ? (
<button
onClick={handleImmediateNext}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
disabled={isLoading}
>
<span>{isLoading ? t('uploading') : t('nextStep')}</span>
<ArrowRight size={16} />
</button>
) : (
<button
onClick={handleScheduledSubmit}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
disabled={isLoading}
>
<Clock size={16} />
<span>{isLoading ? t('uploading') : t('scheduleImport')}</span>
</button>
)}
</div>
</div>
</div>
{/* Indexing Config Modal (immediate mode only) */}
<IndexingModalWithMode
isOpen={isIndexingConfigOpen}
onClose={() => setIsIndexingConfigOpen(false)}
files={[]}
embeddingModels={models}
defaultEmbeddingId={models.length > 0 ? models[0].id : ''}
onConfirm={handleConfirmConfig}
isReconfiguring={false}
/>
</>
);
};
/** Reusable hierarchy toggle */
const HierarchyToggle: React.FC<{
value: boolean;
onChange: (v: boolean) => void;
t: (key: string) => string;
}> = ({ value, onChange, t }) => (
<label className="flex items-center gap-3 cursor-pointer select-none">
<div className="relative">
<input
type="checkbox"
checked={value}
onChange={e => onChange(e.target.checked)}
className="sr-only"
/>
<div className={`w-10 h-5 rounded-full transition-colors ${value ? 'bg-blue-600' : 'bg-slate-200'}`} />
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-5' : ''}`} />
</div>
<div>
<p className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
<Layers size={14} className="text-blue-500" />
{t('useHierarchyImport')}
</p>
<p className="text-xs text-slate-400 mt-0.5">{t('useHierarchyImportDesc')}</p>
</div>
</label>
);
+343
View File
@@ -0,0 +1,343 @@
import React, { useState, useEffect } from 'react';
import { ModelConfig, RawFile, IndexingConfig } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { useToast } from '../contexts/ToastContext';
import { Layers, FileText, Database, X, ArrowRight, Files, Info } from 'lucide-react';
import { formatBytes } from '../utils/fileUtils';
import { chunkConfigService } from '../services/chunkConfigService';
interface IndexingModalProps {
isOpen: boolean;
onClose: () => void;
files: RawFile[];
embeddingModels: ModelConfig[];
defaultEmbeddingId: string;
onConfirm: (config: IndexingConfig) => void;
isReconfiguring?: boolean;
}
const IndexingModal: React.FC<IndexingModalProps> = ({
isOpen,
onClose,
files,
embeddingModels,
defaultEmbeddingId,
onConfirm,
isReconfiguring = false
}) => {
const { t } = useLanguage();
const { showWarning } = useToast();
// Configuration state
const [chunkSize, setChunkSize] = useState(200);
const [chunkOverlap, setChunkOverlap] = useState(40);
const [selectedEmbedding, setSelectedEmbedding] = useState('');
// Limit info state
const [limits, setLimits] = useState<{
maxChunkSize: number;
maxOverlapSize: number;
defaultChunkSize: number;
defaultOverlapSize: number;
modelInfo: {
name: string;
maxInputTokens: number;
maxBatchSize: number;
expectedDimensions: number;
};
} | null>(null);
const [isLoadingLimits, setIsLoadingLimits] = useState(false);
// Get auth token
const getAuthToken = () => {
return localStorage.getItem('authToken') || '';
};
// Load config limits when selected model changes
useEffect(() => {
if (!isOpen || !selectedEmbedding) {
setLimits(null);
return;
}
const loadLimits = async () => {
setIsLoadingLimits(true);
try {
const token = getAuthToken();
if (!token) return;
const limitData = await chunkConfigService.getLimits(selectedEmbedding, token);
setLimits(limitData);
// Auto-adjust if current values exceed new limits
if (chunkSize > limitData.maxChunkSize) {
setChunkSize(limitData.maxChunkSize);
showWarning(t('autoAdjustChunk', limitData.maxChunkSize));
}
if (chunkOverlap > limitData.maxOverlapSize) {
setChunkOverlap(limitData.maxOverlapSize);
showWarning(t('autoAdjustOverlap', limitData.maxOverlapSize));
}
} catch (error) {
console.error('Failed to read configuration limits:', error);
showWarning(t('loadLimitsFailed'));
} finally {
setIsLoadingLimits(false);
}
};
loadLimits();
}, [isOpen, selectedEmbedding]);
// Initialize modal
useEffect(() => {
if (isOpen) {
// Set default embedding model
const validDefault = embeddingModels.find(m => m.id === defaultEmbeddingId);
if (validDefault) {
setSelectedEmbedding(defaultEmbeddingId);
} else if (embeddingModels.length > 0) {
setSelectedEmbedding(embeddingModels[0].id);
} else {
setSelectedEmbedding('');
}
// Reset to defaults
setChunkSize(200);
setChunkOverlap(40);
}
}, [isOpen, defaultEmbeddingId, embeddingModels]);
// Handle chunk size change
const handleChunkSizeChange = (value: number) => {
if (limits && value > limits.maxChunkSize) {
showWarning(t('maxValueMsg', limits.maxChunkSize));
setChunkSize(limits.maxChunkSize);
return;
}
setChunkSize(value);
// Auto-adjust overlap if it exceeds 50% of new chunk size
if (chunkOverlap > value * 0.5) {
setChunkOverlap(Math.floor(value * 0.5));
}
};
// Handle overlap size change
const handleChunkOverlapChange = (value: number) => {
if (limits && value > limits.maxOverlapSize) {
showWarning(t('maxValueMsg', limits.maxOverlapSize));
setChunkOverlap(limits.maxOverlapSize);
return;
}
// Check if it exceeds 50% of chunk size
const maxOverlapByRatio = Math.floor(chunkSize * 0.5);
if (value > maxOverlapByRatio) {
showWarning(t('overlapRatioLimit', maxOverlapByRatio));
setChunkOverlap(maxOverlapByRatio);
return;
}
setChunkOverlap(value);
};
// Render limits info
const renderLimitsInfo = () => {
if (!limits || isLoadingLimits) {
return null;
}
return (
<div className="bg-blue-50/50 backdrop-blur-sm border border-blue-100 rounded-xl p-4 text-xs">
<div className="flex items-center gap-2 mb-2 font-bold text-blue-900">
<Info className="w-4 h-4 text-blue-600" />
{t('modelLimitsInfo')}
</div>
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-slate-600">
<div>{t('model')}: <span className="font-semibold text-slate-900">{limits.modelInfo.name}</span></div>
<div>{t('maxChunkSize')}: <span className="font-semibold text-slate-900">{limits.maxChunkSize} tokens</span></div>
<div>{t('maxOverlapSize')}: <span className="font-semibold text-slate-900">{limits.maxOverlapSize} tokens</span></div>
<div>{t('maxBatchSize')}: <span className="font-semibold text-slate-900">{limits.modelInfo.maxBatchSize}</span></div>
</div>
{limits.modelInfo.maxInputTokens > limits.maxChunkSize && (
<div className="mt-2 text-blue-600/80 text-[10px] flex items-center gap-1">
<Info size={10} />
{t('envLimitWeaker')}: {limits.maxChunkSize} &lt; {limits.modelInfo.maxInputTokens}
</div>
)}
</div>
);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-md p-4 animate-in fade-in duration-300">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh] border border-white/20">
{/* Header */}
<div className="p-6 border-b border-slate-50 bg-white">
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2.5">
<div className="p-2 bg-blue-50 rounded-xl">
<Database className="w-5 h-5 text-blue-600" />
</div>
{isReconfiguring ? t('reconfigureFile') : t('idxModalTitle')}
</h2>
<p className="text-[13px] text-slate-500 mt-1 ml-12">
{isReconfiguring ? t('modifySettings') : t('idxDesc')}
</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl transition-all active:scale-95">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-6">
{/* Pending Files */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<Files className="w-4 h-4 text-slate-500" />
{t('idxFiles')}
</h3>
<div className="space-y-1 max-h-32 overflow-y-auto bg-slate-50/50 rounded-xl p-3 border border-slate-100">
{files.map((file, index) => (
<div key={index} className="text-xs text-slate-600 flex items-center justify-between py-1.5 px-2 hover:bg-white/80 rounded-lg transition-colors">
<span className="truncate flex-1">{file.name}</span>
<span className="text-slate-400 ml-2 font-medium">{formatBytes(file.size)}</span>
</div>
))}
</div>
</div>
{/* Embedding Model Selection */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<Layers className="w-4 h-4 text-slate-500" />
{t('idxEmbeddingModel')}
</h3>
<select
className="w-full text-sm border border-slate-100 bg-slate-50/50 rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none transition-all cursor-pointer"
value={selectedEmbedding}
onChange={(e) => setSelectedEmbedding(e.target.value)}
>
<option value="">{t('pleaseSelect')}</option>
{embeddingModels.map(model => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>
{/* Chunk Configuration */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-500" />
{t('idxMethod')}
</h3>
<div className="space-y-3">
{/* Chunk Size */}
<div>
<div className="flex justify-between mb-1 text-xs">
<span className="text-slate-600">{t('chunkSize')}</span>
<span className="font-mono font-semibold text-blue-600">{chunkSize}</span>
</div>
<input
type="range"
min="50"
max={limits?.maxChunkSize || 8191}
value={chunkSize}
onChange={(e) => handleChunkSizeChange(Number(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
disabled={!selectedEmbedding || isLoadingLimits}
/>
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
<span>{t('min')}: 50</span>
<span>{t('max')}: {limits?.maxChunkSize || '—'}</span>
</div>
</div>
{/* Overlap Size */}
<div>
<div className="flex justify-between mb-1 text-xs">
<span className="text-slate-600">{t('chunkOverlap')}</span>
<span className="font-mono font-semibold text-blue-600">{chunkOverlap}</span>
</div>
<input
type="range"
min="0"
max={limits?.maxOverlapSize || 200}
value={chunkOverlap}
onChange={(e) => handleChunkOverlapChange(Number(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
disabled={!selectedEmbedding || isLoadingLimits}
/>
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
<span>{t('min')}: 0</span>
<span>{t('max')}: {limits?.maxOverlapSize || '—'}</span>
</div>
</div>
</div>
</div>
{/* Model Limits Info */}
{renderLimitsInfo()}
{/* Optimization Tips */}
{limits && (
<div className="bg-amber-50/50 backdrop-blur-sm border border-amber-100 rounded-xl p-4 text-xs text-amber-900">
<p className="font-bold mb-2 flex items-center gap-1.5">
<span className="text-amber-500">💡</span>
{t('optimizationTips')}
</p>
<ul className="list-disc list-inside space-y-1.5 text-[11px] text-amber-800/80">
{chunkSize > 800 && <li>{t('tipChunkTooLarge')}</li>}
{chunkOverlap < chunkSize * 0.1 && <li>{t('tipOverlapSmall').replace('$1', String(Math.floor(chunkSize * 0.1)))}</li>}
{chunkSize === limits.maxChunkSize && <li>{t('tipMaxValues')}</li>}
</ul>
</div>
)}
</div>
{/* Footer Buttons */}
<div className="p-6 border-t border-slate-50 bg-white flex justify-end gap-3">
<button
onClick={onClose}
className="px-6 py-2.5 text-sm font-semibold text-slate-600 hover:bg-slate-50 rounded-xl transition-all active:scale-95"
>
{t('idxCancel')}
</button>
<button
onClick={() => {
if (!selectedEmbedding) {
showWarning(t('selectEmbeddingFirst'));
return;
}
onConfirm({
chunkSize,
chunkOverlap,
embeddingModelId: selectedEmbedding
});
}}
disabled={!selectedEmbedding || isLoadingLimits}
className="px-8 py-2.5 text-sm font-bold bg-blue-600 text-white hover:bg-blue-700 rounded-xl shadow-lg shadow-blue-200 flex items-center gap-2 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowRight className="w-4 h-4" />
{t('idxStart')}
</button>
</div>
</div>
</div>
);
};
export default IndexingModal;
+576
View File
@@ -0,0 +1,576 @@
/**
* Processing mode selection (Fast/Precise) support
*/
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ModelConfig, RawFile, IndexingConfig } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { useToast } from '../contexts/ToastContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { Layers, FileText, Database, X, ArrowRight, Files, Info, Zap, Target, AlertTriangle, Clock, DollarSign } from 'lucide-react';
import { formatBytes } from '../utils/fileUtils';
import { chunkConfigService } from '../services/chunkConfigService';
import { uploadService } from '../services/uploadService';
interface IndexingModalWithModeProps {
isOpen: boolean;
onClose: () => void;
files?: RawFile[];
embeddingModels: ModelConfig[];
defaultEmbeddingId: string;
onConfirm: (config: IndexingConfig) => void;
authToken?: string;
isReconfiguring?: boolean;
}
const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
isOpen,
onClose,
files = [],
embeddingModels,
defaultEmbeddingId,
onConfirm,
authToken,
isReconfiguring = false
}) => {
const { t } = useLanguage();
const { showWarning, showInfo } = useToast();
const { confirm } = useConfirm();
// Configuration state
const [chunkSize, setChunkSize] = useState(200);
const [chunkOverlap, setChunkOverlap] = useState(40);
const [selectedEmbedding, setSelectedEmbedding] = useState('');
const [mode, setMode] = useState<'fast' | 'precise'>('fast');
const [userSelectedMode, setUserSelectedMode] = useState(false); // Track if user manually selected mode
// Mode recommendation info
const [modeRecommendation, setModeRecommendation] = useState<any>(null);
const [isLoadingRecommendation, setIsLoadingRecommendation] = useState(false);
// Limit info state
const [limits, setLimits] = useState<{
maxChunkSize: number;
maxOverlapSize: number;
minOverlapSize: number;
defaultChunkSize: number;
defaultOverlapSize: number;
modelInfo: {
name: string;
maxInputTokens: number;
maxBatchSize: number;
expectedDimensions: number;
};
} | null>(null);
const [isLoadingLimits, setIsLoadingLimits] = useState(false);
// Get auth token
const getAuthToken = () => {
return authToken || localStorage.getItem('kb_api_key') || '';
};
// Load mode recommendation when files change
useEffect(() => {
if (!isOpen || !files || files.length === 0) return;
const loadRecommendation = async () => {
setIsLoadingRecommendation(true);
try {
// Use first file for recommendation (assume similar types)
const file = files[0];
const rec = await uploadService.recommendMode(file.file);
setModeRecommendation(rec);
// Auto-select recommended mode if user hasn't manually selected one
if (!isReconfiguring && !userSelectedMode) {
setMode(rec.recommendedMode);
showInfo(t('recommendationMsg', rec.recommendedMode === 'precise' ? t('preciseMode') : t('fastMode'), t(rec.reason, ...(rec.reasonArgs || []))));
}
} catch (error) {
console.error('Failed to get mode recommendation:', error);
} finally {
setIsLoadingRecommendation(false);
}
};
loadRecommendation();
}, [isOpen, files, isReconfiguring]);
// Load config limits when selected model changes
useEffect(() => {
if (!isOpen || !selectedEmbedding) {
setLimits(null);
return;
}
const loadLimits = async () => {
setIsLoadingLimits(true);
try {
const token = getAuthToken();
if (!token) return;
const limitData = await chunkConfigService.getLimits(selectedEmbedding, token);
setLimits(limitData);
// Auto-adjust if current values exceed new limits
if (chunkSize > limitData.maxChunkSize) {
setChunkSize(limitData.maxChunkSize);
showWarning(t('autoAdjustChunk', limitData.maxChunkSize));
}
if (chunkOverlap > limitData.maxOverlapSize) {
setChunkOverlap(limitData.maxOverlapSize);
showWarning(t('autoAdjustOverlap', limitData.maxOverlapSize));
}
if (chunkOverlap < limitData.minOverlapSize) {
setChunkOverlap(limitData.minOverlapSize);
// Only show warning if it was manually set below the new minimum
if (chunkOverlap < limitData.minOverlapSize) {
showWarning(t('autoAdjustOverlapMin', limitData.minOverlapSize));
}
}
} catch (error) {
console.error('Failed to read configuration limits:', error);
showWarning(t('loadLimitsFailed'));
} finally {
setIsLoadingLimits(false);
}
};
loadLimits();
}, [isOpen, selectedEmbedding]);
// Track isOpen state change, reset only on open
const [prevOpen, setPrevOpen] = useState(false);
// Initialize modal
useEffect(() => {
if (isOpen && !prevOpen) {
// Execute initialization only when going from closed to open
console.log('DEBUG: IndexingModalWithMode opening, files:', files);
// Set default embedding model
const enabledModels = embeddingModels.filter(m => m.isEnabled !== false);
const validDefault = enabledModels.find(m => m.id === defaultEmbeddingId);
if (validDefault) {
setSelectedEmbedding(defaultEmbeddingId);
} else if (enabledModels.length > 0) {
setSelectedEmbedding(enabledModels[0].id);
} else {
setSelectedEmbedding('');
}
// Reset to defaults
setChunkSize(200);
setChunkOverlap(40);
if (!isReconfiguring) {
setMode('fast');
setUserSelectedMode(false); // Reset user selection status
}
setModeRecommendation(null);
}
setPrevOpen(isOpen);
}, [isOpen, prevOpen, defaultEmbeddingId, embeddingModels, isReconfiguring]);
// Handle chunk size change
const handleChunkSizeChange = (value: number) => {
if (limits && value > limits.maxChunkSize) {
showWarning(t('maxValueMsg', limits.maxChunkSize));
setChunkSize(limits.maxChunkSize);
return;
}
setChunkSize(value);
// Auto-adjust overlap if it exceeds 50% of new chunk size
if (chunkOverlap > value * 0.5) {
setChunkOverlap(Math.floor(value * 0.5));
}
};
// Handle overlap size change
const handleChunkOverlapChange = (value: number) => {
if (limits && value > limits.maxOverlapSize) {
showWarning(t('maxValueMsg', limits.maxOverlapSize));
setChunkOverlap(limits.maxOverlapSize);
return;
}
if (limits && value < limits.minOverlapSize) {
// Don't show warning here, just set to min if they slide too low
setChunkOverlap(limits.minOverlapSize);
return;
}
// Check if it exceeds 50% of chunk size
const maxOverlapByRatio = Math.floor(chunkSize * 0.5);
if (value > maxOverlapByRatio) {
showWarning(t('overlapRatioLimit', maxOverlapByRatio));
setChunkOverlap(maxOverlapByRatio);
return;
}
setChunkOverlap(value);
};
// Render limits info
const renderLimitsInfo = () => {
if (!limits || isLoadingLimits) {
return null;
}
return (
<div className="bg-blue-50/50 backdrop-blur-sm border border-blue-100 rounded-xl p-4 text-xs mt-2">
<div className="flex items-center gap-2 mb-2 font-bold text-blue-900">
<Info className="w-4 h-4 text-blue-600" />
{t('modelLimitsInfo')}
</div>
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-slate-600">
<div>{t('model')}: <span className="font-semibold text-slate-900">{limits.modelInfo.name}</span></div>
<div>{t('maxChunkSize')}: <span className="font-semibold text-slate-900">{limits.maxChunkSize} tokens</span></div>
<div>{t('maxOverlapSize')}: <span className="font-semibold text-slate-900">{limits.maxOverlapSize} tokens</span></div>
<div>{t('maxBatchSize')}: <span className="font-semibold text-slate-900">{limits.modelInfo.maxBatchSize}</span></div>
</div>
{limits.modelInfo.maxInputTokens > limits.maxChunkSize && (
<div className="mt-2 text-blue-600/80 text-[10px] flex items-center gap-1">
<Info size={10} />
{t('envLimitWeaker')}: {limits.maxChunkSize} &lt; {limits.modelInfo.maxInputTokens}
</div>
)}
</div>
);
};
// Render mode recommendation info
const renderModeRecommendation = () => {
if (!modeRecommendation || isLoadingRecommendation) {
return null;
}
return (
<div className="space-y-2 p-4 bg-purple-50/50 backdrop-blur-sm border border-purple-100 rounded-xl text-xs">
<div className="font-bold text-purple-900 flex items-center gap-2">
<Target className="w-4 h-4 text-purple-600" />
{t('processingMode')}
</div>
<div className="text-slate-600">
<strong className="text-purple-900/70">{t('recommendationReason')}:</strong> {t(modeRecommendation.reason, ...(modeRecommendation.reasonArgs || []))}
</div>
{modeRecommendation.warnings && modeRecommendation.warnings.length > 0 && (
<div className="mt-2 space-y-1.5 border-t border-purple-100 pt-2">
{modeRecommendation.warnings.map((warning: string, idx: number) => (
<div key={idx} className="text-purple-800/80 flex items-start gap-1.5 leading-relaxed">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-purple-500" />
<span>{t(warning as any)}</span>
</div>
))}
</div>
)}
</div>
);
};
// Render current mode description
const renderModeDescription = () => {
if (mode === 'fast') {
return (
<div className="text-xs text-slate-600 bg-slate-50/50 p-3 rounded-xl border border-slate-100">
<div className="font-bold text-slate-900 mb-2 flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-500" />
{t('fastModeFeatures')}
</div>
<ul className="grid grid-cols-1 gap-1.5 text-[11px] text-slate-500">
{['fastFeature1', 'fastFeature2', 'fastFeature3', 'fastFeature4', 'fastFeature5'].map((feature) => (
<li key={feature} className="flex items-center gap-2 before:content-[''] before:w-1 before:h-1 before:bg-slate-300 before:rounded-full">
{t(feature as any)}
</li>
))}
</ul>
</div>
);
}
return (
<div className="text-xs text-slate-600 bg-slate-50/50 p-3 rounded-xl border border-slate-100">
<div className="font-bold text-slate-900 mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
{t('preciseModeFeatures')}
</div>
<ul className="grid grid-cols-1 gap-1.5 text-[11px] text-slate-500">
{['preciseFeature1', 'preciseFeature2', 'preciseFeature3', 'preciseFeature4', 'preciseFeature5', 'preciseFeature6'].map((feature) => (
<li key={feature} className="flex items-center gap-2 before:content-[''] before:w-1 before:h-1 before:bg-slate-300 before:rounded-full">
{t(feature as any)}
</li>
))}
</ul>
</div>
);
};
if (!isOpen) return null;
return createPortal(
<>
<div
className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-md transition-opacity"
onClick={onClose}
/>
<div className="fixed right-0 top-0 h-full w-full max-w-lg bg-white shadow-2xl z-[101] transform transition-transform duration-500 ease-out animate-in slide-in-from-right flex flex-col border-l border-slate-50">
{/* Header */}
<div className="p-6 border-b border-slate-50 bg-white shrink-0">
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2.5">
<div className="p-2 bg-blue-50 rounded-xl">
<Database className="w-5 h-5 text-blue-600" />
</div>
{isReconfiguring ? t('reconfigureTitle') : t('indexingConfigTitle')}
</h2>
<p className="text-[13px] text-slate-500 mt-1 ml-12">
{isReconfiguring ? t('reconfigureDesc') : t('indexingConfigDesc')}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="p-2 hover:bg-slate-100 rounded-xl transition-all active:scale-95"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-6">
{/* Pending files - only show when there are files */}
{files && files.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<Files className="w-4 h-4 text-slate-500" />
{t('pendingFiles')}
</h3>
<div className="space-y-1 max-h-32 overflow-y-auto bg-slate-50/50 rounded-xl p-3 border border-slate-100">
{files.map((file, index) => (
<div key={index} className="text-xs text-slate-600 flex items-center justify-between py-1.5 px-2 hover:bg-white/80 rounded-lg transition-colors">
<span className="truncate flex-1">{file.name}</span>
<span className="text-slate-400 ml-2 font-medium">{formatBytes(file.size)}</span>
</div>
))}
</div>
</div>
)}
{/* Processing mode selection */}
{!isReconfiguring && (
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-slate-500" />
{t('processingMode')}
{isLoadingRecommendation && <span className="text-xs text-blue-600 ml-2">{t('analyzingFile')}</span>}
</h3>
{/* Mode recommendation info */}
{renderModeRecommendation()}
{/* Mode selection */}
<div className="grid grid-cols-2 gap-3 mt-3">
{/* Fast Mode */}
<button
onClick={() => {
setMode('fast');
setUserSelectedMode(true); // Mark manual selection by user
}}
className={`relative p-3 rounded-lg border-2 text-left transition-all ${mode === 'fast'
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 text-yellow-600" />
<span className="font-semibold text-sm">{t('fastMode')}</span>
</div>
<div className="text-xs text-slate-600 leading-relaxed">
{t('fastModeDesc')}
</div>
{mode === 'fast' && (
<div className="absolute top-2 right-2 text-blue-600">
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
</div>
)}
</button>
{/* Precise Mode */}
<button
onClick={() => {
setMode('precise');
setUserSelectedMode(true); // Mark manual selection by user
}}
className={`relative p-3 rounded-lg border-2 text-left transition-all ${mode === 'precise'
? 'border-purple-500 bg-purple-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-2 mb-2">
<Target className="w-4 h-4 text-purple-600" />
<span className="font-semibold text-sm">{t('preciseMode')}</span>
</div>
<div className="text-xs text-slate-600 leading-relaxed">
{t('preciseModeDesc')}
</div>
{mode === 'precise' && (
<div className="absolute top-2 right-2 text-purple-600">
<div className="w-2 h-2 bg-purple-600 rounded-full"></div>
</div>
)}
</button>
</div>
{/* Mode description */}
<div className="mt-3">
{renderModeDescription()}
</div>
</div>
)}
{/* Embedding model selection */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<Layers className="w-4 h-4 text-slate-500" />
{t('embeddingModel')}
</h3>
<select
className="w-full text-sm border border-slate-100 bg-slate-50/50 rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none transition-all cursor-pointer"
value={selectedEmbedding}
onChange={(e) => setSelectedEmbedding(e.target.value)}
>
<option value="">{t('pleaseSelect')}</option>
{embeddingModels.filter(m => m.isEnabled !== false).map(model => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>
{/* Chunk config */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-500" />
{t('chunkConfig')}
</h3>
<div className="space-y-3">
{/* Chunk size */}
<div>
<div className="flex justify-between mb-1 text-xs">
<span className="text-slate-600">{t('chunkSize')}</span>
<span className="font-mono font-semibold text-blue-600">{chunkSize}</span>
</div>
<input
type="range"
min="50"
max={limits?.maxChunkSize || 8191}
value={chunkSize}
onChange={(e) => handleChunkSizeChange(Number(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
disabled={!selectedEmbedding || isLoadingLimits}
/>
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
<span>{t('min')}: 50</span>
<span>{t('max')}: {limits?.maxChunkSize || '-'}</span>
</div>
</div>
{/* Overlap size */}
<div>
<div className="flex justify-between mb-1 text-xs">
<span className="text-slate-600">{t('chunkOverlap')}</span>
<span className="font-mono font-semibold text-blue-600">{chunkOverlap}</span>
</div>
<input
type="range"
min={limits?.minOverlapSize || 25}
max={limits?.maxOverlapSize || 200}
value={chunkOverlap}
onChange={(e) => handleChunkOverlapChange(Number(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
disabled={!selectedEmbedding || isLoadingLimits}
/>
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
<span>{t('min')}: {limits?.minOverlapSize || 25}</span>
<span>{t('max')}: {limits?.maxOverlapSize || '-'}</span>
</div>
</div>
</div>
</div>
{isReconfiguring && renderLimitsInfo()}
{/* Optimization tips */}
{limits && (
<div className="bg-amber-50/50 backdrop-blur-sm border border-amber-100 rounded-xl p-4 text-xs text-amber-900">
<p className="font-bold mb-2 flex items-center gap-1.5">
<span className="text-amber-500">💡</span>
{t('optimizationTips')}
</p>
<ul className="list-disc list-inside space-y-1.5 text-[11px] text-amber-800/80">
{chunkSize > 800 && <li>{t('tipChunkTooLarge')}</li>}
{chunkOverlap < chunkSize * 0.1 && <li>{t('tipOverlapSmall').replace('$1', `${Math.floor(chunkSize * 0.1)}`)}</li>}
{chunkSize === limits.maxChunkSize && <li>{t('tipMaxValues')}</li>}
{mode === 'precise' && <li>{t('tipPreciseCost')}</li>}
</ul>
</div>
)}
</div>
{/* Footer buttons */}
<div className="p-6 border-t border-slate-50 bg-white flex justify-end gap-3 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="px-6 py-2.5 text-sm font-semibold text-slate-600 hover:bg-slate-50 rounded-xl transition-all active:scale-95"
>
{t('cancel')}
</button>
<button
onClick={async () => {
if (!selectedEmbedding) {
showWarning(t('selectEmbeddingFirst'));
return;
}
if (!isReconfiguring && mode === 'precise') {
// Precise mode confirmation
if (!(await confirm(t('confirmPreciseCost')))) {
return;
}
}
onConfirm({
chunkSize,
chunkOverlap,
embeddingModelId: selectedEmbedding,
mode,
});
}}
disabled={isLoadingLimits}
className="px-8 py-2.5 text-sm font-bold bg-blue-600 text-white hover:bg-blue-700 rounded-xl shadow-lg shadow-blue-200 flex items-center gap-2 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowRight className="w-4 h-4" />
{t('startProcessing')}
</button>
</div>
</div>
</>,
document.body
);
};
export default IndexingModalWithMode;
+96
View File
@@ -0,0 +1,96 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X, Check } from 'lucide-react';
interface InputDrawerProps {
isOpen: boolean;
onClose: () => void;
title: string;
onSubmit: (value: string) => void;
placeholder?: string;
defaultValue?: string;
submitLabel?: string;
}
export const InputDrawer: React.FC<InputDrawerProps> = ({
isOpen,
onClose,
title,
onSubmit,
placeholder = '',
defaultValue = '',
submitLabel = 'Confirm'
}) => {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setValue(defaultValue);
setTimeout(() => {
inputRef.current?.focus();
}, 50);
}
}, [isOpen, defaultValue]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (value.trim()) {
onSubmit(value.trim());
onClose();
}
};
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 overflow-hidden">
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onClick={onClose} />
<div className="absolute inset-y-0 right-0 max-w-sm w-full flex pointer-events-none">
{/* pointer-events-none on wrapper, auto on content to allow closing by clicking left side */}
<div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300 pointer-events-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{title}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 flex-1 flex flex-col">
<label className="block text-sm font-medium text-gray-700 mb-2">
{title}
</label>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
/>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={!value.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{submitLabel}
</button>
</div>
</form>
</div>
</div>
</div>,
document.body
);
};
+96
View File
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { ShieldCheck, ArrowRight } from 'lucide-react';
import { authService } from '../services/authService';
interface LoginPageProps {
onLoginSuccess: (token: string) => void;
}
const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
const { t } = useLanguage();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) {
setError(t('loginError'));
return;
}
setError('');
try {
const response = await authService.login(username, password);
onLoginSuccess(response.access_token);
} catch (err) {
setError(err.message || 'Login failed.');
}
};
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-slate-100">
<div className="flex justify-center mb-6">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center text-blue-600">
<ShieldCheck className="w-8 h-8" />
</div>
</div>
<h1 className="text-2xl font-bold text-slate-900 text-center mb-2">
{t('loginTitle')}
</h1>
<p className="text-slate-500 text-center mb-8">
{t('loginDesc')}
</p>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setError('');
}}
className="w-full px-4 py-3 rounded-lg border border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
placeholder={t('usernamePlaceholder') || 'Username'}
/>
</div>
<div>
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
className="w-full px-4 py-3 rounded-lg border border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
placeholder={t('passwordPlaceholder') || 'Password'}
/>
{error && <p className="text-red-500 text-sm mt-1 ml-1">{error}</p>}
</div>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg flex items-center justify-center gap-2 transition-transform active:scale-95"
>
{t('loginButton')}
<ArrowRight className="w-4 h-4" />
</button>
</form>
<div className="mt-8 text-center">
<p className="text-xs text-slate-400">
Authorized Personnel Only
</p>
</div>
</div>
</div>
);
};
export default LoginPage;
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
interface LogoProps {
className?: string;
size?: number;
withText?: boolean;
textClassName?: string;
}
export const Logo: React.FC<LogoProps> = ({ className = '', size = 32, withText = false, textClassName = '' }) => {
return (
<div className={`flex items-center gap-3 ${className}`}>
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
{/* Glow effect */}
<div className="absolute inset-0 bg-blue-500/30 blur-xl rounded-full" />
<svg
width={size}
height={size}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="relative z-10"
>
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)" />
<path
d="M20 10C20 10 24 18 28 20C24 22 20 30 20 30C20 30 16 22 12 20C16 18 20 10 20 10Z"
fill="white"
className="drop-shadow-sm"
/>
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#2563EB" /> {/* Blue 600 */}
<stop offset="50%" stopColor="#4F46E5" /> {/* Indigo 600 */}
<stop offset="100%" stopColor="#7C3AED" /> {/* Violet 600 */}
</linearGradient>
</defs>
</svg>
</div>
{withText && (
<span className={`font-bold text-xl tracking-tight bg-gradient-to-r from-blue-400 to-indigo-300 bg-clip-text text-transparent ${textClassName}`}>
AuraK
</span>
)}
</div>
);
};
+249
View File
@@ -0,0 +1,249 @@
/**
* Processing mode selection component
* Used to select fast or precise mode when uploading files
*/
import React, { useState, useEffect } from 'react';
import { uploadService } from '../services/uploadService';
import { ModeRecommendation } from '../types';
interface ModeSelectorProps {
file: File | null;
onModeChange: (mode: 'fast' | 'precise') => void;
className?: string;
}
export const ModeSelector: React.FC<ModeSelectorProps> = ({
file,
onModeChange,
className = '',
}) => {
const [selectedMode, setSelectedMode] = useState<'fast' | 'precise'>('fast');
const [recommendation, setRecommendation] = useState<ModeRecommendation | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (file) {
loadRecommendation();
} else {
setRecommendation(null);
}
}, [file]);
const loadRecommendation = async () => {
if (!file) return;
setLoading(true);
try {
const rec = await uploadService.recommendMode(file);
setRecommendation(rec);
// Automatically select recommended mode
setSelectedMode(rec.recommendedMode);
onModeChange(rec.recommendedMode);
} catch (error) {
console.error('Failed to get mode recommendation:', error);
} finally {
setLoading(false);
}
};
const handleModeChange = (mode: 'fast' | 'precise') => {
setSelectedMode(mode);
onModeChange(mode);
};
if (!file) {
return null;
}
return (
<div className={`mode-selector ${className}`}>
<div className="mode-selector-header">
<h4>Select processing mode</h4>
{loading && <span className="loading">Analyzing...</span>}
</div>
{/* Mode recommendation info */}
{recommendation && (
<div className="recommendation-info">
<div className="reason">
<strong>Recommended:</strong> {recommendation.reason}
</div>
{recommendation.warnings && recommendation.warnings.length > 0 && (
<div className="warnings">
{recommendation.warnings.map((warning, idx) => (
<div key={idx} className="warning-item">
{warning}
</div>
))}
</div>
)}
</div>
)}
{/* Mode selection */}
<div className="mode-options">
<label className={`mode-option ${selectedMode === 'fast' ? 'selected' : ''}`}>
<input
type="radio"
name="processing-mode"
value="fast"
checked={selectedMode === 'fast'}
onChange={() => handleModeChange('fast')}
/>
<div className="mode-content">
<div className="mode-title"> Fast Mode</div>
<div className="mode-desc">
Simple text extraction, fast, ideal for plain text documents
</div>
<div className="mode-benefits">
Fast<br />
No additional cost<br />
Processes text information only
</div>
</div>
</label>
<label className={`mode-option ${selectedMode === 'precise' ? 'selected' : ''}`}>
<input
type="radio"
name="processing-mode"
value="precise"
checked={selectedMode === 'precise'}
onChange={() => handleModeChange('precise')}
/>
<div className="mode-content">
<div className="mode-title">🎯 Precise Mode</div>
<div className="mode-desc">
Accurately recognizes content and retains full information
</div>
<div className="mode-benefits">
Recognizes images/tables<br />
Retains layout information<br />
Mixed image and text content<br />
API cost required<br />
Long processing time
</div>
</div>
</label>
</div>
<style jsx>{`
.mode-selector {
margin: 16px 0;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fafafa;
}
.mode-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.mode-selector-header h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.loading {
color: #1890ff;
font-size: 12px;
}
.recommendation-info {
margin-bottom: 16px;
padding: 12px;
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 6px;
font-size: 13px;
}
.recommendation-info .reason {
margin-bottom: 8px;
color: #0050b3;
}
.warnings {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #91d5ff;
}
.warning-item {
color: #d4380d;
margin: 4px 0;
}
.mode-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.mode-option {
display: flex;
gap: 8px;
padding: 12px;
border: 2px solid #d9d9d9;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.mode-option:hover {
border-color: #1890ff;
background: #f0f7ff;
}
.mode-option.selected {
border-color: #1890ff;
background: #e6f7ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.mode-option input[type="radio"] {
margin-top: 4px;
cursor: pointer;
}
.mode-content {
flex: 1;
}
.mode-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.mode-desc {
font-size: 12px;
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
.mode-benefits {
font-size: 11px;
line-height: 1.6;
color: #555;
}
@media (max-width: 768px) {
.mode-options {
grid-template-columns: 1fr;
}
}
`}</style>
</div>
);
};
+105
View File
@@ -0,0 +1,105 @@
import React, { useCallback, useState } from 'react';
import { Upload as UploadIcon, FileText, Image as ImageIcon, Folder, FileUp, ShieldCheck } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { GROUP_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES } from '../constants/fileSupport';
import { motion, AnimatePresence } from 'framer-motion';
interface NotebookDragDropUploadProps {
onFilesSelected: (files: FileList) => void;
isAdmin: boolean;
globalMode?: boolean;
children?: React.ReactNode;
}
export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({ onFilesSelected, isAdmin, globalMode = false, children }) => {
const { t } = useLanguage();
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
onFilesSelected(e.dataTransfer.files);
e.dataTransfer.clearData();
}
}, [onFilesSelected]);
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
onFilesSelected(e.target.files);
e.target.value = '';
}
};
if (!isAdmin) return <>{children}</>;
return (
<div className="relative h-full flex flex-col">
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-[100] bg-blue-600/10 backdrop-blur-sm flex items-center justify-center p-8 pointer-events-none"
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="bg-white rounded-[2rem] p-10 text-center shadow-2xl border border-blue-100 flex flex-col items-center gap-6"
>
<div className="w-16 h-16 bg-blue-600 text-white rounded-2xl flex items-center justify-center animate-bounce">
<FileUp size={32} />
</div>
<div className="space-y-1">
<h3 className="text-xl font-bold text-slate-900">Ingest into Group</h3>
<p className="text-slate-500 font-medium text-sm">Release to start processing</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<div
className="flex-1 flex flex-col"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
</div>
<input
type="file"
multiple
onChange={handleFileInput}
className="hidden"
id="notebook-file-upload-input"
accept={GROUP_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
/>
</div>
);
};
@@ -0,0 +1,159 @@
import { useLayoutEffect, useRef, useState, useCallback } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { GROUP_ALLOWED_EXTENSIONS } from '../constants/fileSupport';
import { motion, AnimatePresence } from 'framer-motion';
import { FileUp, ShieldCheck, FileText, Image as ImageIcon } from 'lucide-react';
interface NotebookGlobalDragDropProps {
onFilesSelected: (files: FileList) => void;
isAdmin: boolean;
}
let isNotebookDragDropEnabled = true;
let notebookForceHideCallback: (() => void) | null = null;
export const setNotebookDragDropEnabled = (enabled: boolean) => {
isNotebookDragDropEnabled = enabled;
if (!enabled && notebookForceHideCallback) {
notebookForceHideCallback();
}
};
export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps> = ({ onFilesSelected, isAdmin }) => {
const { t } = useLanguage();
const overlayRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const dragCounterRef = useRef(0);
const isDragActiveRef = useRef(false);
const hasFiles = useCallback((dt: DataTransfer | null) => {
if (!dt) return false;
const hasFileType = dt.types && dt.types.includes('Files');
if (!hasFileType) return false;
if (dt.items && dt.items.length > 0) {
for (let i = 0; i < dt.items.length; i++) {
if (dt.items[i].kind === 'file') return true;
}
return false;
}
return hasFileType;
}, []);
const handleDragEnter = useCallback((e: DragEvent) => {
if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (dragCounterRef.current === 1) {
setIsVisible(true);
isDragActiveRef.current = true;
}
}, [hasFiles]);
const handleDragOver = useCallback((e: DragEvent) => {
if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer!.dropEffect = 'copy';
}, [hasFiles]);
const handleDragLeave = useCallback((e: DragEvent) => {
if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0 && isDragActiveRef.current) {
setIsVisible(false);
isDragActiveRef.current = false;
}
}, [hasFiles]);
const handleDrop = useCallback((e: DragEvent) => {
if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsVisible(false);
isDragActiveRef.current = false;
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
onFilesSelected(e.dataTransfer.files);
}
}, [hasFiles, onFilesSelected]);
useLayoutEffect(() => {
if (!isAdmin) return;
dragCounterRef.current = 0;
isDragActiveRef.current = false;
notebookForceHideCallback = () => {
dragCounterRef.current = 0;
isDragActiveRef.current = false;
setIsVisible(false);
};
document.addEventListener('dragenter', handleDragEnter);
document.addEventListener('dragover', handleDragOver);
document.addEventListener('dragleave', handleDragLeave);
document.addEventListener('drop', handleDrop);
return () => {
document.removeEventListener('dragenter', handleDragEnter);
document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('dragleave', handleDragLeave);
document.removeEventListener('drop', handleDrop);
notebookForceHideCallback = null;
dragCounterRef.current = 0;
isDragActiveRef.current = false;
setIsVisible(false);
};
}, [isAdmin, handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
if (!isAdmin || typeof window === 'undefined') return null;
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-blue-600/10 backdrop-blur-md items-center justify-center z-[9999] pointer-events-none flex p-8"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="w-full max-w-2xl bg-white rounded-[2.5rem] p-12 text-center shadow-[0_32px_64px_-12px_rgba(0,0,0,0.14)] border border-white pointer-events-auto"
>
<div className="flex flex-col items-center justify-center gap-8">
<div className="w-24 h-24 bg-blue-600 text-white rounded-3xl flex items-center justify-center shadow-xl shadow-blue-200 animate-bounce">
<FileUp size={48} />
</div>
<div className="space-y-3">
<h3 className="text-3xl font-black text-slate-900 tracking-tight">
{t('dragDropUploadTitle')}
</h3>
<p className="text-lg text-slate-500 font-medium">
Release to ingest files into this Group
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-6 py-8 border-y border-slate-100 w-full">
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
<ShieldCheck size={20} className="text-emerald-500" />
<span>Group Specific</span>
</div>
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
<FileText size={20} className="text-blue-500" />
<span>{GROUP_ALLOWED_EXTENSIONS.slice(0, 3).join(', ').toUpperCase()}...</span>
</div>
</div>
<div className="text-slate-400 font-bold text-xs uppercase tracking-[0.3em]">
Drop anywhere to begin
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
+713
View File
@@ -0,0 +1,713 @@
import React, { useState, useEffect, useRef } from 'react';
import { isFormatSupportedForPreview } from '../constants/fileSupport';
import { PDFStatus } from '../types';
import { pdfPreviewService } from '../services/pdfPreviewService';
import { useToast } from '../contexts/ToastContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { X, FileText, Loader, AlertCircle, Maximize2, Eye, Download, ExternalLink, RefreshCw, Scissors, ChevronLeft, ChevronRight } from 'lucide-react';
import { PDFSelectionTool } from './PDFSelectionTool';
import { CreateNoteFromPDFDialog } from './CreateNoteFromPDFDialog';
import { noteService } from '../services/noteService';
import { useLanguage } from '../contexts/LanguageContext';
import { knowledgeBaseService } from '../services/knowledgeBaseService';
import * as pdfjs from 'pdfjs-dist';
// Set worker path for PDF.js
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
interface PDFPreviewProps {
fileId: string;
fileName: string;
authToken: string;
groupId?: string;
onClose: () => void;
}
export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, onClose }) => {
const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
const [loading, setLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string>('');
const [iframeError, setIframeError] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [selectionData, setSelectionData] = useState<{ screenshot: Blob; text: string } | null>(null);
const [numPages, setNumPages] = useState<number>(0);
const [pdfDoc, setPdfDoc] = useState<pdfjs.PDFDocumentProxy | null>(null);
const [zoomLevel, setZoomLevel] = useState<number>(1.0); // Add zoom level state
const currentRenderTask = useRef<pdfjs.RenderTask | null>(null); // Save current rendering task
const scrollContainerRef = useRef<HTMLDivElement>(null);
const flipDirection = useRef<'next' | 'prev' | null>(null);
const lastFlipTime = useRef<number>(0);
const { showToast } = useToast();
const { confirm } = useConfirm();
const { t, language } = useLanguage();
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (status.status === 'ready') {
pdfPreviewService.getPDFUrl(fileId)
.then(result => {
setPdfUrl(result.url); // Set pdfUrl for download
// Fetch PDF data and create blob URL
fetch(result.url)
.then(async response => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to fetch PDF data');
}
return response.blob();
})
.then(blob => {
setPdfBlob(blob);
// Start fetching and rendering PDF document
loadAndRenderPDF(blob);
})
.catch((err) => {
console.error('PDF fetch error:', err);
setIframeError(true);
setStatus({ status: 'failed', error: err.message });
});
})
.catch((err) => {
console.error('getPDFUrl error:', err);
setIframeError(true);
setStatus({ status: 'failed', error: err.message });
});
}
}, [status.status, fileId]);
useEffect(() => {
if (pdfDoc && currentPage) {
// Re-render on page change or zoom level change
renderCurrentPage(pdfDoc, currentPage);
}
}, [currentPage, pdfDoc, zoomLevel]);
const isSupported = isFormatSupportedForPreview(fileName);
useEffect(() => {
if (isSupported) {
checkPDFStatus();
const interval = setInterval(checkPDFStatus, 3000);
return () => clearInterval(interval);
} else {
setLoading(false);
setStatus({ status: 'failed', error: t('previewNotSupported') });
}
}, [fileId, isSupported]);
const checkPDFStatus = async () => {
try {
const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
setStatus(pdfStatus);
// Actively trigger conversion if status is pending
if (pdfStatus.status === 'pending') {
setStatus({ status: 'converting' });
try {
// Access PDF URL to trigger conversion
await pdfPreviewService.preloadPDF(fileId);
} catch (error) {
console.log('Preload triggered, conversion should start');
}
}
if (pdfStatus.status === 'ready' || pdfStatus.status === 'failed') {
setLoading(false);
}
} catch (error: any) {
setLoading(false);
const errorMessage = error.message || t('checkPDFStatusFailed');
setStatus({ status: 'failed', error: errorMessage });
showToast(errorMessage, 'error');
}
};
const loadAndRenderPDF = async (blob: Blob) => {
try {
const pdfData = await blob.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
setPdfDoc(pdf);
setNumPages(pdf.numPages);
if (currentPage > pdf.numPages) {
setCurrentPage(pdf.numPages);
}
renderCurrentPage(pdf, currentPage);
} catch (error) {
console.error('Failed to load PDF:', error);
setIframeError(true);
}
};
const handleZoomIn = () => {
setZoomLevel(prev => Math.min(3.0, prev + 0.1));
};
const handleZoomOut = () => {
setZoomLevel(prev => Math.max(0.5, prev - 0.1));
};
const handleResetZoom = () => {
setZoomLevel(1.0);
};
const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => {
if (!canvasRef.current) return;
try {
// Cancel rendering task if one is in progress
if (currentRenderTask.current) {
currentRenderTask.current.cancel();
}
const page = await pdf.getPage(pageNum);
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
// Get container dimensions
if (!containerRef.current) return;
const container = containerRef.current;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
// Handle high DPI displays
const devicePixelRatio = window.devicePixelRatio || 1;
// Calculate scale to fit page in container width while maintaining aspect ratio
const viewport = page.getViewport({ scale: 1 });
const baseScale = (containerWidth - 48) / viewport.width; // Add padding for width
// Apply zoom level to base scale
const scale = baseScale * zoomLevel;
const finalScale = scale * devicePixelRatio;
const scaledViewport = page.getViewport({ scale: finalScale });
const cssViewport = page.getViewport({ scale: scale });
// Set canvas dimensions with device pixel ratio
canvas.width = scaledViewport.width;
canvas.height = scaledViewport.height;
// Set CSS dimensions explicitly for high-DPI
canvas.style.width = `${cssViewport.width}px`;
canvas.style.height = `${cssViewport.height}px`;
// Reset any previous transforms
context.setTransform(1, 0, 0, 1, 0, 0);
// Clear canvas
context.clearRect(0, 0, canvas.width, canvas.height);
// Fill with white background
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
// Set proper transform for high DPI
context.scale(devicePixelRatio, devicePixelRatio);
// Create and save the new render task
const renderContext = {
canvasContext: context,
viewport: page.getViewport({ scale: scale }),
};
// Save the render task to allow for cancellation
currentRenderTask.current = page.render(renderContext);
// Wait for rendering to complete
await currentRenderTask.current.promise;
// Clear the current render task
currentRenderTask.current = null;
// Adjust scroll position after page turn
if (flipDirection.current && scrollContainerRef.current) {
const container = scrollContainerRef.current;
if (flipDirection.current === 'next') {
container.scrollTop = 0;
} else if (flipDirection.current === 'prev') {
container.scrollTop = container.scrollHeight;
}
flipDirection.current = null;
}
} catch (error) {
if (error instanceof Error && error.name !== 'RenderingCancelledException') {
console.error('Failed to render PDF page:', error);
}
// Clear the current render task even if there's an error
currentRenderTask.current = null;
}
};
const handleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
const handleDownload = () => {
if (pdfUrl) {
// Directly download if pdfUrl already exists
const link = document.createElement('a');
link.href = pdfUrl;
link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
// Try fetching and downloading if pdfUrl does not exist
pdfPreviewService.getPDFUrl(fileId)
.then(result => {
const link = document.createElement('a');
link.href = result.url;
link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.catch(error => {
console.error('Failed to download PDF:', error);
showToast('error', t('downloadPDFFailed'));
});
}
};
const handleOpenInNewTab = () => {
if (pdfUrl) {
window.open(pdfUrl, '_blank');
} else {
// Try fetching and opening if pdfUrl does not exist
pdfPreviewService.getPDFUrl(fileId)
.then(result => {
window.open(result.url, '_blank');
})
.catch(error => {
console.error('Failed to open PDF in new tab:', error);
showToast('error', t('openPDFInNewTabFailed'));
});
}
};
const handleRegenerate = async () => {
if (await confirm(t('confirmRegeneratePDF'))) {
setStatus({ status: 'converting' });
setLoading(true);
try {
await pdfPreviewService.preloadPDF(fileId, true);
// Reset state and trigger reload
setPdfUrl('');
setIframeError(false);
setPdfDoc(null);
setPdfBlob(null);
setNumPages(0);
} catch (error) {
showToast('error', t('requestRegenerationFailed'));
setStatus({ status: 'failed', error: t('requestRegenerationFailed') });
}
}
};
const handleIframeError = () => {
setIframeError(true);
};
const handleWheel = (e: React.WheelEvent) => {
if (!scrollContainerRef.current || isSelectionMode) return;
const container = scrollContainerRef.current;
const { scrollTop, scrollHeight, clientHeight } = container;
const now = Date.now();
const throttleMs = 600; // Prevent rapid page turning
// Scroll down for next page
if (e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1) {
if (currentPage < numPages && now - lastFlipTime.current > throttleMs) {
flipDirection.current = 'next';
lastFlipTime.current = now;
setCurrentPage(prev => prev + 1);
}
}
// Scroll up for previous page
else if (e.deltaY < 0 && scrollTop <= 1) {
if (currentPage > 1 && now - lastFlipTime.current > throttleMs) {
flipDirection.current = 'prev';
lastFlipTime.current = now;
setCurrentPage(prev => prev - 1);
}
}
};
const handleSelectionComplete = (screenshot: Blob, text: string) => {
// Set preliminary data and open dialog
setSelectionData({ screenshot, text });
setIsSelectionMode(false);
};
const handleSaveNote = async (title: string, content: string, selectedCategoryId?: string) => {
if (!authToken || !selectionData) return;
try {
await noteService.createFromPDFSelection(
authToken,
fileId,
selectionData.screenshot,
undefined, // groupId is no longer used for notes from PDF
selectedCategoryId,
currentPage
);
showToast('success', t('noteCreatedSuccess'));
setSelectionData(null);
} catch (error) {
console.error('Failed to create note:', error);
showToast('error', t('noteCreatedFailed'));
}
};
const renderContent = () => {
switch (status.status) {
case 'pending':
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Loader size={48} className="animate-spin mb-4" />
<div className="text-lg font-medium mb-2">{t('preparingPDFConversion')}</div>
<div className="text-sm">{t('pleaseWait')}</div>
</div>
);
case 'converting':
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Loader size={48} className="animate-spin mb-4" />
<div className="text-lg font-medium mb-2">{t('convertingPDF')}</div>
<div className="text-sm">{t('pleaseWait')}</div>
</div>
);
case 'failed':
return (
<div className="flex flex-col items-center justify-center h-full text-red-500">
<AlertCircle size={48} className="mb-4" />
<div className="text-lg font-medium mb-2">{t('pdfConversionFailed')}</div>
<div className="text-sm text-gray-500 text-center max-w-md">
{status.error || t('pdfConversionError')}
</div>
<button
onClick={checkPDFStatus}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{t('retry')}
</button>
</div>
);
case 'ready':
if (iframeError) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<AlertCircle size={48} className="mb-4" />
<div className="text-lg font-medium mb-2">{t('pdfLoadFailed')}</div>
<div className="text-sm text-gray-500 mb-4">{t('pdfLoadError')}</div>
<div className="flex gap-2">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
<Download size={16} />
{t('downloadPDF')}
</button>
<button
onClick={handleOpenInNewTab}
className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
>
<ExternalLink size={16} />
{t('openInNewWindow')}
</button>
</div>
</div>
);
}
if (!pdfDoc) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Loader size={48} className="animate-spin mb-4" />
<div className="text-lg font-medium mb-2">{t('loadingPDF')}</div>
</div>
);
}
return (
<div className="relative w-full h-full flex flex-col" ref={containerRef}>
<div
ref={scrollContainerRef}
onWheel={handleWheel}
className="flex-grow overflow-auto pdf-canvas-container bg-gray-100"
>
<div className="flex flex-col items-center py-12 pb-32 min-h-full">
<canvas
ref={canvasRef}
className="bg-white shadow-xl max-w-full"
/>
</div>
</div>
{isSelectionMode && (
<PDFSelectionTool
containerRef={containerRef}
canvasRef={canvasRef}
pdfBlob={pdfBlob}
pageNumber={currentPage}
authToken={authToken}
zoomLevel={zoomLevel}
onSelectionComplete={handleSelectionComplete}
onCancel={() => setIsSelectionMode(false)}
/>
)}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 bg-white/90 border border-slate-200 px-3 py-1.5 rounded-full shadow-lg z-40">
<div className="flex items-center border-r border-slate-200 pr-2 mr-2">
<button
onClick={handleZoomOut}
className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
title={t('zoomOut')}
>
<span className="text-lg"></span>
</button>
<span className="mx-1 text-sm text-slate-600 min-w-[40px] text-center">{Math.round(zoomLevel * 100)}%</span>
<button
onClick={handleZoomIn}
className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
title={t('zoomIn')}
>
<span className="text-lg">+</span>
</button>
<button
onClick={handleResetZoom}
className="ml-1 p-1 px-2 hover:bg-slate-100 rounded text-slate-600 text-xs"
title={t('resetZoom')}
>
100%
</button>
</div>
<button
onClick={() => {
const newPage = Math.max(1, currentPage - 1);
setCurrentPage(newPage);
}}
className="p-1 hover:bg-slate-100 rounded text-slate-600"
>
<ChevronLeft size={16} />
</button>
<div className="flex items-center gap-1">
<input
type="number"
value={currentPage}
onChange={(e) => {
const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
setCurrentPage(val);
}}
className="w-12 text-center text-sm border-none focus:ring-0 bg-transparent font-medium"
/>
<span className="text-sm text-slate-500">/ {numPages}</span>
</div>
<button
onClick={() => {
const newPage = Math.min(numPages, currentPage + 1);
setCurrentPage(newPage);
}}
className="p-1 hover:bg-slate-100 rounded text-slate-600"
>
<ChevronRight size={16} />
</button>
</div>
{selectionData && (
<CreateNoteFromPDFDialog
screenshot={selectionData.screenshot}
extractedText={selectionData.text}
authToken={authToken}
initialCategoryId={undefined} // Notes don't inherit KB group
initialPageNumber={currentPage}
onSave={handleSaveNote}
onCancel={() => setSelectionData(null)}
/>
)}
</div>
);
default:
return null;
}
};
return (
<div className={`fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[999] ${isFullscreen ? 'p-0' : 'p-4'
}`}>
<div className={`bg-white rounded-lg overflow-hidden flex flex-col ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="flex items-center space-x-3">
<FileText size={20} className="text-gray-600" />
<div>
<div className="font-medium text-gray-900">{fileName}</div>
<div className="text-sm text-gray-500">{t('pdfPreview')}</div>
</div>
</div>
<div className="flex items-center space-x-2">
{status.status === 'ready' && !iframeError && (
<>
<div className="flex items-center gap-2 mr-2 border-r pr-2">
<span className="text-sm text-gray-500">{t('selectPageNumber')}</span>
<input
type="number"
min={1}
max={numPages}
value={currentPage}
onChange={(e) => {
const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
setCurrentPage(val);
}}
className="w-16 px-2 py-1 border rounded text-sm"
title={t('enterPageNumber')}
/>
</div>
<button
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={`p-2 transition-colors ${isSelectionMode ? 'bg-blue-100 text-blue-600 rounded' : 'text-gray-400 hover:text-blue-600'}`}
title={isSelectionMode ? t('exitSelectionMode') : t('clickToSelectAndNote')}
>
<Scissors size={18} />
</button>
<button
onClick={handleRegenerate}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title={t('regeneratePDF')}
>
<RefreshCw size={18} />
</button>
<button
onClick={handleDownload}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title={t('downloadPDF')}
>
<Download size={18} />
</button>
<button
onClick={handleOpenInNewTab}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title={t('openInNewWindow')}
>
<ExternalLink size={18} />
</button>
<button
onClick={handleFullscreen}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title={isFullscreen ? t('exitFullscreen') : t('fullscreenDisplay')}
>
<Maximize2 size={18} />
</button>
</>
)}
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 h-full">
{renderContent()}
</div>
</div>
</div>
);
};
interface PDFPreviewButtonProps {
fileId: string;
fileName: string;
onPreview: () => void;
}
export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
fileId,
fileName,
onPreview
}) => {
const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
const [loading, setLoading] = useState(true);
const { t } = useLanguage();
const isSupported = isFormatSupportedForPreview(fileName);
useEffect(() => {
if (isSupported) {
checkStatus();
} else {
setLoading(false);
}
}, [fileId, isSupported]);
const checkStatus = async () => {
try {
const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
setStatus(pdfStatus);
} catch (error) {
// Ignore error and use default state
} finally {
setLoading(false);
}
};
const getIcon = () => {
if (!isSupported) {
return <Eye className="w-3 h-3 text-slate-200" />;
}
if (loading || status.status === 'converting') {
return <Loader className="w-3 h-3 animate-spin" />;
}
if (status.status === 'failed') {
return <AlertCircle className="w-3 h-3" />;
}
return <Eye className="w-3 h-3" />;
};
const getTitle = () => {
if (!isSupported) return t('previewNotSupported');
switch (status.status) {
case 'ready': return t('pdfPreviewReady');
case 'converting': return t('convertingInProgress');
case 'failed': return t('conversionFailed');
default: return t('generatePDFPreviewButton');
}
};
return (
<button
onClick={onPreview}
disabled={loading || status.status === 'converting' || !isSupported}
className={`p-1 rounded transition-colors ${!isSupported
? 'text-slate-200 cursor-not-allowed'
: status.status === 'failed'
? 'text-red-400 hover:text-red-500 hover:bg-red-50'
: 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={getTitle()}
>
{getIcon()}
</button>
);
};
+437
View File
@@ -0,0 +1,437 @@
import React, { useState, useRef, useEffect } from 'react';
import * as pdfjs from 'pdfjs-dist';
import { useLanguage } from '../contexts/LanguageContext';
// Set worker path for PDF.js
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
// Helper function to convert coordinates between different scales
const convertCoordinates = (
x: number,
y: number,
containerWidth: number,
containerHeight: number,
pdfWidth: number,
pdfHeight: number,
zoomLevel: number = 1.0
) => {
// Calculate the scale factors to fit the PDF page in the container while maintaining aspect ratio
const scaleX = containerWidth / pdfWidth;
const scaleY = containerHeight / pdfHeight;
let scale = Math.min(scaleX, scaleY);
// Apply zoom level to the scale
scale *= zoomLevel;
// Calculate padding offsets to center the PDF page in the container
const paddedWidth = pdfWidth * scale;
const paddedHeight = pdfHeight * scale;
const offsetX = (containerWidth - paddedWidth) / 2;
const offsetY = (containerHeight - paddedHeight) / 2;
// Convert from container coordinates to PDF page coordinates
const pdfX = (x - offsetX) / scale;
const pdfY = (y - offsetY) / scale;
return { x: pdfX, y: pdfY, scale, offsetX, offsetY };
};
// Function to calculate how PDF page is laid out in container space
const calculatePDFLayout = (
containerWidth: number,
containerHeight: number,
pdfWidth: number,
pdfHeight: number,
zoomLevel: number = 1.0
) => {
// Calculate the scale factors to fit the PDF page in the container while maintaining aspect ratio
const scaleX = containerWidth / pdfWidth;
const scaleY = containerHeight / pdfHeight;
let pageScale = Math.min(scaleX, scaleY);
// Apply zoom level to the page scale
pageScale *= zoomLevel;
// Calculate padding offsets to center the PDF page in the container
const paddedWidth = pdfWidth * pageScale;
const paddedHeight = pdfHeight * pageScale;
const offsetX = (containerWidth - paddedWidth) / 2;
const offsetY = (containerHeight - paddedHeight) / 2;
return {
pageScale,
offsetX: Math.round(offsetX),
offsetY: Math.round(offsetY),
paddedWidth,
paddedHeight
};
};
// Enhanced function to calculate precise PDF page layout with improved accuracy
const calculatePrecisePDFLayout = (
containerWidth: number,
containerHeight: number,
pdfWidth: number,
pdfHeight: number,
zoomLevel: number = 1.0
) => {
// Calculate scale to fit the PDF page in the container while maintaining aspect ratio
const scaleX = containerWidth / pdfWidth;
const scaleY = containerHeight / pdfHeight;
let pageScale = Math.min(scaleX, scaleY);
// Apply zoom level to the page scale
pageScale *= zoomLevel;
// Calculate exact page dimensions after scaling
const scaledPageWidth = pdfWidth * pageScale;
const scaledPageHeight = pdfHeight * pageScale;
// Calculate padding to center the page in the container
const offsetX = (containerWidth - scaledPageWidth) / 2;
const offsetY = (containerHeight - scaledPageHeight) / 2;
return {
pageScale,
offsetX: offsetX,
offsetY: offsetY,
scaledPageWidth,
scaledPageHeight,
containerWidth,
containerHeight
};
};
export interface SelectionCoordinates {
x: number;
y: number;
width: number;
height: number;
}
interface PDFSelectionToolProps {
containerRef: React.RefObject<HTMLDivElement>;
canvasRef: React.RefObject<HTMLCanvasElement>;
onSelectionComplete: (screenshot: Blob, text: string) => void;
onCancel: () => void;
pdfBlob: Blob | null;
pageNumber: number;
authToken: string;
zoomLevel?: number; // Optional zoom level parameter
}
export const PDFSelectionTool: React.FC<PDFSelectionToolProps> = ({
containerRef,
canvasRef,
onSelectionComplete,
onCancel,
pdfBlob,
pageNumber,
authToken,
zoomLevel = 1.0, // Default zoom level is 1.0
}) => {
const { t } = useLanguage();
const [isSelecting, setIsSelecting] = useState(false);
const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null);
const [currentPoint, setCurrentPoint] = useState<{ x: number; y: number } | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCancel]);
const handleMouseDown = (e: React.MouseEvent) => {
if (!containerRef.current || !pdfBlob) return;
const rect = containerRef.current.getBoundingClientRect();
// Use actual coordinates relative to container
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setStartPoint({ x, y });
setCurrentPoint({ x, y });
setIsSelecting(true);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isSelecting || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setCurrentPoint({ x, y });
};
const handleMouseUp = async () => {
if (!isSelecting || !startPoint || !currentPoint || !containerRef.current || !canvasRef.current) return;
setIsSelecting(false);
// Calculate selection rectangle based on mouse events (in container coordinates)
const startX = Math.min(startPoint.x, currentPoint.x);
const startY = Math.min(startPoint.y, currentPoint.y);
const endX = Math.max(startPoint.x, currentPoint.x);
const endY = Math.max(startPoint.y, currentPoint.y);
const width = endX - startX;
const height = endY - startY;
// Minimum selection size
if (width < 10 || height < 10) {
onCancel();
return;
}
try {
// Get the actual canvas element from PDFPreview
const sourceCanvas = canvasRef.current;
const containerRect = containerRef.current.getBoundingClientRect();
console.log('=== Direct Canvas Capture ===');
console.log('Container dimensions:', { width: containerRect.width, height: containerRect.height });
console.log('Canvas dimensions:', { width: sourceCanvas.width, height: sourceCanvas.height });
console.log('Selection in container space:', { startX, startY, endX, endY, width, height });
// Get the canvas bounding rect to find where it's positioned within the container
const canvasRect = sourceCanvas.getBoundingClientRect();
const canvasOffsetX = canvasRect.left - containerRect.left;
const canvasOffsetY = canvasRect.top - containerRect.top;
console.log('Canvas position in container:', { offsetX: canvasOffsetX, offsetY: canvasOffsetY });
console.log('Canvas display size:', { width: canvasRect.width, height: canvasRect.height });
// Calculate selection relative to the canvas element
const selectionRelativeToCanvas = {
x: startX - canvasOffsetX,
y: startY - canvasOffsetY,
width: width,
height: height
};
console.log('Selection relative to canvas:', selectionRelativeToCanvas);
// Calculate the scale factor between canvas display size and actual pixel size
// The canvas may be rendered at higher resolution (devicePixelRatio)
const scaleX = sourceCanvas.width / canvasRect.width;
const scaleY = sourceCanvas.height / canvasRect.height;
console.log('Canvas scale factors:', { scaleX, scaleY });
// Convert selection coordinates to canvas pixel coordinates
const canvasX = Math.round(selectionRelativeToCanvas.x * scaleX);
const canvasY = Math.round(selectionRelativeToCanvas.y * scaleY);
const canvasWidth = Math.round(selectionRelativeToCanvas.width * scaleX);
const canvasHeight = Math.round(selectionRelativeToCanvas.height * scaleY);
console.log('Selection in canvas pixel space:', { canvasX, canvasY, canvasWidth, canvasHeight });
// Ensure coordinates are within canvas bounds
const safeX = Math.max(0, Math.min(canvasX, sourceCanvas.width));
const safeY = Math.max(0, Math.min(canvasY, sourceCanvas.height));
const safeWidth = Math.max(0, Math.min(canvasWidth, sourceCanvas.width - safeX));
const safeHeight = Math.max(0, Math.min(canvasHeight, sourceCanvas.height - safeY));
console.log('Safe coordinates:', { safeX, safeY, safeWidth, safeHeight });
if (safeWidth === 0 || safeHeight === 0) {
console.warn('Selection is outside canvas bounds');
onCancel();
return;
}
// Extract the selected region from the source canvas
const ctx = sourceCanvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}
const imageData = ctx.getImageData(safeX, safeY, safeWidth, safeHeight);
// Create a new canvas for the selected region
const selectedCanvas = document.createElement('canvas');
selectedCanvas.width = safeWidth;
selectedCanvas.height = safeHeight;
const selectedCtx = selectedCanvas.getContext('2d');
if (!selectedCtx) {
throw new Error('Could not create selection canvas context');
}
selectedCtx.putImageData(imageData, 0, 0);
// Convert selected canvas to blob
const screenshot = await new Promise<Blob>((resolve, reject) => {
selectedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create blob from canvas'));
}
}, 'image/jpeg', 0.98);
});
console.log('Screenshot created successfully');
// Extract text from the selected area using OCR
let extractedText = '';
try {
extractedText = await performOCR(screenshot, authToken);
} catch (ocrError) {
console.error('OCR extraction failed:', ocrError);
}
onSelectionComplete(screenshot, extractedText);
} catch (error) {
console.error('Failed to process selection:', error);
onCancel();
}
};
// Render PDF to canvas at specified scale
const renderPDFToCanvas = async (
pdfBlob: Blob,
pageNumber: number,
canvas: HTMLCanvasElement,
containerWidth: number,
containerHeight: number,
renderScale: number,
zoomLevel: number = 1.0
): Promise<{ offsetX: number; offsetY: number; pageScale: number; viewport: any }> => {
const pdfData = await pdfBlob.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
if (pageNumber < 1 || pageNumber > pdf.numPages) {
throw new Error(`Invalid page number: ${pageNumber}`);
}
const page = await pdf.getPage(pageNumber);
// Calculate the scale needed to render the PDF page to match the layout in container
// We want the same aspect ratio and positioning as in the PDF viewer
const originalViewport = page.getViewport({ scale: 1 });
// Calculate scale factors to fit page within container while preserving aspect ratio
const scaleX = containerWidth / originalViewport.width;
const scaleY = containerHeight / originalViewport.height;
let pageScale = Math.min(scaleX, scaleY);
// Apply zoom level to the page scale
pageScale *= zoomLevel;
// Apply the render scale factor for high resolution
const finalScale = pageScale * renderScale;
// Create the viewport at this scale
const viewport = page.getViewport({ scale: finalScale });
const context = canvas.getContext('2d');
if (!context) {
// Return default values if context not available
return { offsetX: 0, offsetY: 0, pageScale: 1, viewport: originalViewport };
}
// Calculate offset to center the page in the canvas
const offsetX = Math.round((canvas.width - viewport.width) / 2);
const offsetY = Math.round((canvas.height - viewport.height) / 2);
// Render the page with anti-aliasing and smooth rendering for quality
const renderContext = {
canvasContext: context,
viewport: viewport,
transform: [1, 0, 0, 1, offsetX, offsetY],
intent: 'display' as const
};
// Render the page with improved rendering quality
await page.render(renderContext).promise;
return { offsetX, offsetY, pageScale, viewport };
};
// Perform OCR on the captured image
const performOCR = async (image: Blob, token: string): Promise<string> => {
const formData = new FormData();
formData.append('image', image);
const response = await fetch('/api/ocr/recognize', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData,
});
if (!response.ok) {
throw new Error('Failed to recognize text via OCR');
}
const data = await response.json();
return data.text;
};
useEffect(() => {
if (!overlayCanvasRef.current || !startPoint || !currentPoint || !containerRef.current) return;
const canvas = overlayCanvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Match the canvas dimensions to the container
const containerRect = containerRef.current.getBoundingClientRect();
canvas.width = containerRect.width;
canvas.height = containerRect.height;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw selection rectangle
const x = Math.min(startPoint.x, currentPoint.x);
const y = Math.min(startPoint.y, currentPoint.y);
const width = Math.abs(currentPoint.x - startPoint.x);
const height = Math.abs(currentPoint.y - startPoint.y);
// Draw semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear selection area
ctx.clearRect(x, y, width, height);
// Draw selection border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
// Draw corner handles
const handleSize = 8;
ctx.fillStyle = '#3b82f6';
ctx.fillRect(x - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
ctx.fillRect(x + width - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
ctx.fillRect(x - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
ctx.fillRect(x + width - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
}, [startPoint, currentPoint, containerRef]);
return (
<div
className="absolute inset-0 z-50 cursor-crosshair bg-white/20"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<canvas
ref={overlayCanvasRef}
className="absolute inset-0 pointer-events-none"
/>
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-black/75 text-white px-4 py-2 rounded-lg text-sm z-[60]">
{t('dragToSelect')}
</div>
</div>
);
};
+178
View File
@@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { SearchHistoryItem, KnowledgeGroup } from '../types';
import { searchHistoryService } from '../services/searchHistoryService';
import { useToast } from '../contexts/ToastContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { MessageCircle, Trash2, Clock, Users } from 'lucide-react';
interface SearchHistoryListProps {
groups: KnowledgeGroup[];
onSelectHistory: (historyId: string) => void;
onDeleteHistory?: (historyId: string) => void;
}
export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
groups,
onSelectHistory,
onDeleteHistory
}) => {
const [histories, setHistories] = useState<SearchHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const { showError, showSuccess } = useToast();
const { confirm } = useConfirm();
const { t, language } = useLanguage();
const loadHistories = async (pageNum: number = 1, append: boolean = false) => {
try {
setLoading(true);
const response = await searchHistoryService.getHistories(pageNum, 20);
if (append) {
setHistories(prev => [...prev, ...response.histories]);
} else {
setHistories(response.histories);
}
setHasMore(response.histories.length === 20);
setPage(pageNum);
} catch (error) {
showError(t('loadingHistoriesFailed'));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadHistories();
}, []);
const handleDelete = async (historyId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!(await confirm(t('confirmDeleteHistory')))) return;
try {
await searchHistoryService.deleteHistory(historyId);
setHistories(prev => prev.filter(h => h.id !== historyId));
onDeleteHistory?.(historyId);
showSuccess(t('deleteHistorySuccess'));
} catch (error) {
showError(t('deleteHistoryFailed'));
}
};
const loadMore = () => {
if (!loading && hasMore) {
loadHistories(page + 1, true);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// Determine locale for standard date functions
const localeMap: Record<string, string> = {
'zh': 'zh-CN',
'en': 'en-US',
'ja': 'ja-JP'
};
const locale = localeMap[language] || 'ja-JP';
if (diffDays === 0) {
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) {
return t('yesterday');
} else if (diffDays < 7) {
return t('daysAgo', diffDays);
} else {
return date.toLocaleDateString(locale);
}
};
const getGroupNames = (selectedGroups: string[] | null) => {
if (!selectedGroups || selectedGroups.length === 0) {
return t('allKnowledgeGroups');
}
return selectedGroups
.map(id => groups.find(g => g.id === id)?.name)
.filter(Boolean)
.join(', ');
};
if (loading && histories.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500">{t('loading')}</div>
</div>
);
}
if (histories.length === 0) {
return (
<div className="text-center py-8">
<MessageCircle size={48} className="mx-auto text-gray-300 mb-4" />
<div className="text-gray-500">{t('noHistory')}</div>
<div className="text-sm text-gray-400 mt-1">{t('noHistoryDesc')}</div>
</div>
);
}
return (
<div className="space-y-2">
{histories.map((history) => (
<div
key={history.id}
onClick={() => onSelectHistory(history.id)}
className="p-4 bg-white rounded-lg border hover:shadow-sm cursor-pointer transition-all group"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate mb-1">
{history.title}
</div>
<div className="flex items-center space-x-4 text-sm text-gray-500 mb-2">
<div className="flex items-center space-x-1">
<MessageCircle size={14} />
<span>{t('historyMessages', history.messageCount)}</span>
</div>
<div className="flex items-center space-x-1">
<Clock size={14} />
<span>{formatDate(history.lastMessageAt)}</span>
</div>
</div>
<div className="flex items-center space-x-1 text-xs text-gray-400">
<Users size={12} />
<span>{getGroupNames(history.selectedGroups)}</span>
</div>
</div>
<button
onClick={(e) => handleDelete(history.id, e)}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-600 transition-all"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
{hasMore && (
<button
onClick={loadMore}
disabled={loading}
className="w-full py-3 text-center text-blue-600 hover:text-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? t('loading') : t('loadMore')}
</button>
)}
</div>
);
};
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { RagSearchResult } from '../services/ragService';
import { FileText, Star } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface SearchResultsPanelProps {
results: RagSearchResult[];
isVisible: boolean;
onClose: () => void;
}
const SearchResultsPanel: React.FC<SearchResultsPanelProps> = ({ results, isVisible, onClose }) => {
const { t } = useLanguage();
if (!isVisible || results.length === 0) return null;
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
<div className="p-4 border-b border-slate-200 flex justify-between items-center">
<h3 className="text-lg font-semibold text-slate-800">{t('searchResults')}</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 text-xl"
>
×
</button>
</div>
<div className="overflow-y-auto max-h-[60vh] p-4 space-y-4">
{results.map((result, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4 hover:bg-slate-50">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-blue-600" />
<span className="font-medium text-slate-700">{result.fileName}</span>
<div className="flex items-center gap-1 ml-auto">
<Star className="w-3 h-3 text-yellow-500" />
<span className="text-xs text-slate-500">{result.score.toFixed(3)}</span>
</div>
</div>
<p className="text-sm text-slate-600 line-clamp-4">{result.content}</p>
<div className="mt-2 text-xs text-slate-400">
{t('chunkNumber')} #{result.chunkIndex + 1}
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default SearchResultsPanel;
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { createPortal } from 'react-dom';
import ConfigPanel from './ConfigPanel';
import { AppSettings, ModelConfig } from '../types';
import { X } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface SettingsDrawerProps {
isOpen: boolean;
onClose: () => void;
settings: AppSettings;
models: ModelConfig[];
onSettingsChange: (newSettings: AppSettings) => void;
onOpenSettings: () => void; // Keeps the "Full Settings" link working if needed, or we might redirect
mode?: 'chat' | 'kb' | 'all';
isAdmin?: boolean;
}
export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
isOpen,
onClose,
settings,
models,
onSettingsChange,
onOpenSettings,
mode = 'all',
isAdmin = false
}) => {
const { t } = useLanguage();
const [localSettings, setLocalSettings] = React.useState<AppSettings>(settings);
// Initial sync
React.useEffect(() => {
if (isOpen) {
setLocalSettings(settings);
}
}, [isOpen, settings]);
if (!isOpen) return null;
const handleLocalChange = (newSettings: AppSettings) => {
setLocalSettings(newSettings);
};
const handleConfirm = () => {
onSettingsChange(localSettings);
onClose();
};
return createPortal(
<div className="fixed inset-0 z-50 overflow-hidden">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity" onClick={onClose} />
<div className="absolute inset-y-0 right-0 max-w-md w-full flex">
<div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{t('systemConfiguration')}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<ConfigPanel
settings={localSettings}
models={models}
onSettingsChange={handleLocalChange}
onOpenSettings={onOpenSettings}
mode={mode}
isAdmin={isAdmin}
/>
</div>
<div className="p-4 border-t border-gray-200 bg-gray-50">
<button
onClick={handleConfirm}
className="w-full py-2.5 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 transition-colors shadow-sm"
>
{t('confirm')}
</button>
</div>
</div>
</div>
</div>,
document.body
);
};
+562
View File
@@ -0,0 +1,562 @@
import React, { useState, useEffect } from 'react';
import { ModelConfig, ModelType } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { X, Plus, Trash2, Edit2, Save, Cpu, Box, Loader2, User, Shield, Key, LogOut, Globe, Settings as SettingsIcon, Star } from 'lucide-react';
import { userService } from '../services/userService';
import { settingsService } from '../services/settingsService';
import { userSettingService } from '../services/userSettingService';
import { knowledgeGroupService } from '../services/knowledgeGroupService';
import { modelConfigService } from '../services/modelConfigService';
import { useConfirm } from '../contexts/ConfirmContext';
import { AppSettings, KnowledgeGroup } from '../types';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
// Model Props
models: ModelConfig[];
authToken: string | null;
onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
onLogout: () => void;
}
type TabType = 'general' | 'user' | 'model';
export const SettingsModal: React.FC<SettingsModalProps> = ({
isOpen,
onClose,
models,
authToken,
onUpdateModels,
onLogout
}) => {
const { t, language, setLanguage } = useLanguage();
const { confirm } = useConfirm();
const [activeTab, setActiveTab] = useState<TabType>('general');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// --- Model Manager State ---
const [editingId, setEditingId] = useState<string | null>(null);
const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
type: ModelType.LLM,
baseUrl: 'http://localhost:11434/v1',
modelId: 'llama3',
name: '',
dimensions: 1536,
maxInputTokens: 8191,
maxBatchSize: 2048
});
// --- User Management State ---
interface UserType {
id: string;
username: string;
isAdmin: boolean;
role?: string;
createdAt: string;
}
const [users, setUsers] = useState<UserType[]>([]);
const [isUserLoading, setIsUserLoading] = useState(false);
const [showAddUser, setShowAddUser] = useState(false);
const [newUser, setNewUser] = useState({ username: '', password: '' });
const [userSuccess, setUserSuccess] = useState('');
// --- Change Password State ---
const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
const [passwordSuccess, setPasswordSuccess] = useState('');
// --- App Settings State ---
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
const [currentUser, setCurrentUser] = useState<UserType | null>(null);
// Reset state on open
useEffect(() => {
if (isOpen) {
setActiveTab('general');
setError(null);
setEditingId(null);
}
}, [isOpen]);
// Fetch Users when User tab is active
useEffect(() => {
if (isOpen) {
if (activeTab === 'user') {
fetchUsers();
} else if (activeTab === 'general') {
fetchSettingsAndGroups();
}
}
}, [isOpen, activeTab]);
const fetchSettingsAndGroups = async () => {
if (!authToken) return;
setIsSettingsLoading(true);
try {
const [settings, groups, users, personal] = await Promise.all([
userSettingService.get(authToken),
knowledgeGroupService.getGroups(),
userService.getUsers().catch(() => []), // Regular users might fail this
userSettingService.getPersonal(authToken).catch(() => null)
]);
setAppSettings(settings);
setKnowledgeGroups(groups);
if (personal?.language && personal.language !== language) {
setLanguage(personal.language as any);
}
// Temporary way to get current user details since we lack a /me endpoint hook here
const tokenPayload = JSON.parse(atob(authToken.split('.')[1]));
const me = users.find(u => u.id === tokenPayload.sub) || { isAdmin: tokenPayload.role === 'SUPER_ADMIN' || tokenPayload.role === 'TENANT_ADMIN', role: tokenPayload.role };
setCurrentUser(me as any);
} catch (error) {
console.error('Failed to fetch settings or groups:', error);
} finally {
setIsSettingsLoading(false);
}
};
if (!isOpen) return null;
// --- General Tab Handlers ---
const handleLanguageChange = async (newLanguage: string) => {
setIsLoading(true);
try {
await settingsService.updateLanguage(newLanguage);
setLanguage(newLanguage as any);
} catch (error) {
console.error('Failed to update language:', error);
} finally {
setIsLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setPasswordSuccess('');
if (passwordForm.new !== passwordForm.confirm) {
setError(t('passwordMismatch'));
return;
}
if (passwordForm.new.length < 6) {
setError(t('newPasswordMinLength'));
return;
}
setIsLoading(true);
try {
await userService.changePassword(passwordForm.current, passwordForm.new);
setPasswordSuccess(t('passwordChangeSuccess'));
setPasswordForm({ current: '', new: '', confirm: '' });
} catch (err: any) {
setError(err.message || t('passwordChangeFailed'));
} finally {
setIsLoading(false);
}
};
// --- User Tab Handlers ---
const fetchUsers = async () => {
setIsUserLoading(true);
try {
const userList = await userService.getUsers();
setUsers(userList);
} catch (error: any) {
setError(error.message || t('getUserListFailed'));
} finally {
setIsUserLoading(false);
}
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setUserSuccess('');
if (newUser.password.length < 6) {
setError(t('passwordMinLength'));
return;
}
try {
await userService.createUser(newUser.username, newUser.password);
setUserSuccess(t('userCreatedSuccess'));
setNewUser({ username: '', password: '' });
setShowAddUser(false);
fetchUsers();
} catch (error: any) {
setError(error.message || t('createUserFailed'));
}
};
// --- Model Tab Handlers ---
const handleSaveModel = async () => {
if (!authToken) {
setError(t('mmErrorNotAuthenticated'));
return;
}
setError(null);
if (!modelFormData.name?.trim()) { setError(t('mmErrorNameRequired')); return; }
if (!modelFormData.modelId?.trim()) { setError(t('mmErrorModelIdRequired')); return; }
if (!modelFormData.baseUrl?.trim()) { setError(t('mmErrorBaseUrlRequired')); return; }
setIsLoading(true);
try {
const saveData = { ...modelFormData } as ModelConfig;
await onUpdateModels(editingId === 'new' ? 'create' : 'update', saveData);
setEditingId(null);
} catch (err: any) {
setError(err.message || t('errorGeneric'));
} finally {
setIsLoading(false);
}
};
const handleDeleteModel = async (id: string) => {
if (await confirm(t('confirmClear'))) {
await onUpdateModels('delete', { id } as ModelConfig);
}
};
const handleSetDefault = async (id: string) => {
if (!authToken) {
setError(t('mmErrorNotAuthenticated'));
return;
}
setIsLoading(true);
try {
await modelConfigService.setDefault(authToken, id);
// Reload page to fetch model list again
window.location.reload();
} catch (err: any) {
setError(err.message || t('defaultSettingFailed'));
} finally {
setIsLoading(false);
}
};
const getTypeLabel = (type: ModelType) => {
switch (type) {
case ModelType.LLM: return t('typeLLM');
case ModelType.EMBEDDING: return t('typeEmbedding');
case ModelType.RERANK: return t('typeRerank');
case ModelType.VISION: return t('typeVision');
}
};
// --- Render Functions ---
const renderGeneralTab = () => (
<div className="space-y-8 animate-in slide-in-from-right duration-300">
{/* Language section */}
<section>
<h3 className="text-sm font-medium text-slate-500 mb-3 flex items-center gap-2">
<Globe className="w-4 h-4" />
{t('languageSettings')}
</h3>
<div className="flex gap-2">
{(['zh', 'en', 'ja'] as const).map((lang) => (
<button
key={lang}
onClick={() => handleLanguageChange(lang)}
disabled={isLoading}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors border ${language === lang
? 'bg-blue-50 border-blue-200 text-blue-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{lang === 'zh' ? 'Chinese' : lang === 'en' ? 'English' : 'Japanese'}
</button>
))}
</div>
</section>
{/* Change Password Section */}
<section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
<h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
<Key className="w-4 h-4 text-blue-500" />
{t('changePassword')}
</h3>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
<input type="hidden" name="username" value="current-user" autoComplete="username" />
<div>
<input
type="password"
name="currentPassword"
placeholder={t('currentPassword')}
value={passwordForm.current}
onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="current-password"
/>
</div>
<div>
<input
type="password"
name="newPassword"
placeholder={t('newPassword')}
value={passwordForm.new}
onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="new-password"
/>
</div>
<div>
<input
type="password"
name="confirmPassword"
placeholder={t('confirmPassword')}
value={passwordForm.confirm}
onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="new-password"
/>
</div>
{passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
</button>
</form>
</section>
{/* Logout Section */}
<section className="pt-4 border-t border-slate-200">
<button
onClick={onLogout}
className="flex items-center gap-2 text-red-600 hover:bg-red-50 px-4 py-2 rounded-lg transition-colors text-sm font-medium"
>
<LogOut className="w-4 h-4" />
{t('logout')}
</button>
</section>
</div>
);
const renderUserTab = () => (
<div className="space-y-4 animate-in slide-in-from-right duration-300">
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium text-slate-700">{t('userList')}</h3>
{currentUser?.role === 'SUPER_ADMIN' && (
<button
onClick={() => setShowAddUser(!showAddUser)}
className="flex items-center gap-1 text-sm bg-blue-600 text-white px-3 py-1.5 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
{t('addUser')}
</button>
)}
</div>
{showAddUser && (
<form onSubmit={handleCreateUser} className="bg-slate-50 p-4 rounded-lg border border-slate-200 mb-4 space-y-3">
<input
type="text"
placeholder={t('username')}
value={newUser.username}
onChange={e => setNewUser({ ...newUser, username: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
required
autoComplete="username"
/>
<input
type="password"
placeholder={t('password')}
value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
required
autoComplete="new-password"
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowAddUser(false)} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-1">{t('cancel')}</button>
<button type="submit" className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700">{t('create')}</button>
</div>
</form>
)}
{userSuccess && <p className="text-xs text-green-600 mb-2">{userSuccess}</p>}
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{users.map(user => (
<div key={user.id} className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${user.isAdmin ? 'bg-orange-100 text-orange-600' : 'bg-slate-100 text-slate-600'}`}>
{user.isAdmin ? <Shield className="w-4 h-4" /> : <User className="w-4 h-4" />}
</div>
<div>
<p className="text-sm font-medium text-slate-800">{user.username}</p>
<p className="text-xs text-slate-400">{new Date(user.createdAt).toLocaleDateString()}</p>
</div>
</div>
<span className="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded">
{user.isAdmin ? t('admin') : t('user')}
</span>
</div>
))}
</div>
</div>
);
const renderModelTab = () => (
<div className="animate-in slide-in-from-right duration-300">
{editingId ? (
<div className="bg-white p-6 rounded-lg border border-blue-100 shadow-sm space-y-4">
<h3 className="font-semibold text-slate-700 mb-4">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormName')} *</label>
<input className="w-full text-sm border rounded-md px-3 py-2" value={modelFormData.name} onChange={e => setModelFormData({ ...modelFormData, name: e.target.value })} disabled={isLoading} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormModelId')} *</label>
<input className="w-full text-sm border rounded-md px-3 py-2 font-mono" value={modelFormData.modelId} onChange={e => setModelFormData({ ...modelFormData, modelId: e.target.value })} disabled={isLoading} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormType')} *</label>
<select className="w-full text-sm border rounded-md px-3 py-2" value={modelFormData.type} onChange={e => setModelFormData({ ...modelFormData, type: e.target.value as ModelType })} disabled={isLoading}>
<option value={ModelType.LLM}>{t('typeLLM')}</option>
<option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
<option value={ModelType.RERANK}>{t('typeRerank')}</option>
<option value={ModelType.VISION}>{t('typeVision')}</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormBaseUrl')} *</label>
<input className="w-full text-sm border rounded-md px-3 py-2 font-mono" value={modelFormData.baseUrl} onChange={e => setModelFormData({ ...modelFormData, baseUrl: e.target.value })} disabled={isLoading} autoComplete="off" />
</div>
{modelFormData.type === ModelType.EMBEDDING && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Max Input</label>
<input type="number" className="w-full text-sm border rounded px-3 py-2" value={modelFormData.maxInputTokens || 8191} onChange={e => setModelFormData({ ...modelFormData, maxInputTokens: parseInt(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Dimensions</label>
<input type="number" className="w-full text-sm border rounded px-3 py-2" value={modelFormData.dimensions || 1536} onChange={e => setModelFormData({ ...modelFormData, dimensions: parseInt(e.target.value) })} />
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => { setEditingId(null); setError(null); }} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">{t('mmCancel')}</button>
<button onClick={handleSaveModel} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg flex items-center gap-2" disabled={isLoading}>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{t('mmSave')}
</button>
</div>
</div>
) : (
<div className="space-y-3">
{models.map(model => (
<div key={model.id} className="bg-white border border-slate-200 rounded-lg p-3 flex justify-between items-start">
<div className="flex gap-3 flex-1">
<div className="w-10 h-10 rounded-lg bg-emerald-50 text-emerald-600 flex items-center justify-center"><Cpu className="w-5 h-5" /></div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-sm text-slate-800">{model.name}</h4>
{model.isDefault && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-amber-50 text-amber-700 text-xs font-medium rounded-full border border-amber-200">
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
{t('defaultBadge')}
</span>
)}
</div>
<div className="flex gap-2 text-xs text-slate-500">
<span className="bg-slate-100 px-1 rounded">{getTypeLabel(model.type)}</span>
<span className="font-mono">{model.modelId}</span>
</div>
</div>
</div>
<div className="flex gap-1">
<button onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }} className="p-2 text-slate-400 hover:text-blue-600"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDeleteModel(model.id)} className="p-2 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
))}
<button onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }} className="w-full py-3 border-2 border-dashed border-slate-200 rounded-lg text-slate-500 hover:border-blue-400 hover:text-blue-600 flex justify-center gap-2 items-center">
<Plus className="w-5 h-5" /> {t('mmAddBtn')}
</button>
</div>
)}
</div>
);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl h-[80vh] flex overflow-hidden">
{/* Sidebar */}
<div className="w-64 bg-slate-50 border-r border-slate-200 flex flex-col">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
<SettingsIcon className="w-6 h-6 text-blue-600" />
{t('settings')}
</h2>
</div>
<nav className="flex-1 px-4 space-y-1">
<button onClick={() => setActiveTab('general')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'general' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
<Globe className="w-5 h-5" /> {t('generalSettings')}
</button>
{currentUser?.role === 'SUPER_ADMIN' && (
<button onClick={() => setActiveTab('user')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'user' ? 'bg-white text-purple-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
<User className="w-5 h-5" /> {t('userManagement')}
</button>
)}
{currentUser?.role !== 'USER' && (
<button onClick={() => setActiveTab('model')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'model' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
<Cpu className="w-5 h-5" /> {t('modelManagement')}
</button>
)}
</nav>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between p-4 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">
{activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : t('modelManagement')}
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors"><X className="w-5 h-5 text-slate-500" /></button>
</div>
<div className="flex-1 overflow-y-auto p-6 bg-white">
{error && (
<div className="mb-4 p-4 bg-red-50 text-red-700 rounded-lg flex gap-2 items-start text-sm">
<span className="font-bold">Error:</span> {error}
</div>
)}
{activeTab === 'general' && renderGeneralTab()}
{activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
{activeTab === 'model' && currentUser?.role !== 'USER' && renderModelTab()}
</div>
</div>
</div>
</div>
);
};
+114
View File
@@ -0,0 +1,114 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { X, FileText, Copy, Check, Star, Hash } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { ChatSource } from '../services/chatService';
import { useToast } from '../contexts/ToastContext';
import { copyToClipboard } from '../utils/clipboard';
interface SourcePreviewDrawerProps {
isOpen: boolean;
onClose: () => void;
source: ChatSource | null;
onOpenFile?: (source: ChatSource) => void;
}
export const SourcePreviewDrawer: React.FC<SourcePreviewDrawerProps> = ({
isOpen,
onClose,
source,
onOpenFile
}) => {
const { t } = useLanguage();
const { showSuccess } = useToast();
const [isCopied, setIsCopied] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setIsCopied(false);
}
}, [isOpen, source]);
if (!isOpen || !source) return null;
const handleCopy = async () => {
const success = await copyToClipboard(source.content);
if (success) {
setIsCopied(true);
showSuccess(t('copySuccess'));
setTimeout(() => setIsCopied(false), 2000);
}
};
return createPortal(
<>
<div
className="fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
<div className="fixed right-0 top-0 h-full w-full max-w-lg bg-white shadow-2xl z-[101] transform transition-transform duration-300 ease-in-out animate-in slide-in-from-right flex flex-col">
{/* Header */}
<div className="p-5 border-b border-slate-100 bg-slate-50 shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-5 h-5 text-blue-600 shrink-0" />
<div className="flex flex-col overflow-hidden">
{source.fileId ? (
<button
onClick={() => onOpenFile?.(source)}
className="text-lg font-bold text-slate-800 truncate hover:text-blue-600 hover:underline text-left transition-colors"
title={source.fileName}
>
{source.fileName}
</button>
) : (
<h2 className="text-lg font-bold text-slate-800 truncate" title={source.fileName}>
{source.fileName}
</h2>
)}
<span className="text-xs text-slate-500">{t('sourcePreview')}</span>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-200 rounded-full transition-colors active:scale-90"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-6">
{/* Meta Info */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg border border-blue-100">
<Star className="w-4 h-4" />
<span className="font-medium">{(source.score * 100).toFixed(1)}% {t('matchScore')}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-600 rounded-lg border border-slate-200">
<Hash className="w-4 h-4" />
<span className="font-medium">#{source.chunkIndex + 1}</span>
</div>
</div>
{/* Main Content */}
<div className="bg-slate-50 rounded-xl border border-slate-200 p-1 relative group">
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
className="p-2 bg-white/80 backdrop-blur hover:bg-white text-slate-500 hover:text-blue-600 rounded-lg border border-slate-200 shadow-sm transition-all"
title={t('copyContent')}
>
{isCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="p-4 overflow-x-auto whitespace-pre-wrap text-slate-700 text-sm leading-relaxed font-mono">
{source.content}
</div>
</div>
</div>
</div>
</>,
document.body
);
};
+86
View File
@@ -0,0 +1,86 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle, AlertCircle, XCircle, Info, X } from 'lucide-react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastProps {
type: ToastType;
title?: string;
message: string;
duration?: number;
onClose: () => void;
}
const Toast: React.FC<ToastProps> = ({ type, title, message, duration = 5000, onClose }) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for animation to complete
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'error':
return <XCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
case 'info':
return <Info className="w-5 h-5 text-blue-500" />;
}
};
const getStyles = () => {
switch (type) {
case 'success':
return 'bg-green-50 border-green-200 text-green-800';
case 'error':
return 'bg-red-50 border-red-200 text-red-800';
case 'warning':
return 'bg-yellow-50 border-yellow-200 text-yellow-800';
case 'info':
return 'bg-blue-50 border-blue-200 text-blue-800';
}
};
return (
<div
className={`relative w-full transition-all duration-300 ease-in-out ${isVisible ? 'translate-x-0 opacity-100 max-h-40 mb-3' : 'translate-x-full opacity-0 max-h-0 mb-0 overflow-hidden'
}`}
role="alert"
aria-live="polite"
>
<div className={`rounded-lg border shadow-lg p-4 ${getStyles()}`}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
{title && (
<p className="text-sm font-semibold mb-1">{title}</p>
)}
<p className="text-sm break-words">{message}</p>
</div>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
className="flex-shrink-0 ml-2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
};
export default Toast;
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { User, Shield, UserRound } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface UserInfoDisplayProps {
currentUser: any;
}
export const UserInfoDisplay: React.FC<UserInfoDisplayProps> = ({ currentUser }) => {
const { t } = useLanguage();
if (!currentUser) {
return null;
}
return (
<div className="px-4 py-3 bg-slate-900 border-b border-slate-800">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white">
<UserRound size={18} />
</div>
{currentUser.isAdmin && (
<div className="absolute -bottom-1 -right-1 bg-orange-500 rounded-full p-1 border-2 border-slate-900">
<Shield size={10} className="text-white" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white truncate">{currentUser.displayName || currentUser.username}</p>
{currentUser.isAdmin && (
<span className="text-xs px-2 py-0.5 bg-orange-500/20 text-orange-300 rounded-full border border-orange-500/30">
{t('admin')}
</span>
)}
</div>
<p className="text-xs text-slate-400 truncate">
ID: {currentUser.id?.substring(0, 8)}...
</p>
</div>
</div>
</div>
);
};
+94
View File
@@ -0,0 +1,94 @@
import React, { useState, useEffect } from 'react';
import { Eye } from 'lucide-react';
import { settingsService } from '../services/settingsService';
import { useLanguage } from '../contexts/LanguageContext';
interface VisionModel {
id: string;
name: string;
modelId: string;
}
interface VisionModelSelectorProps {
isAdmin?: boolean;
}
const VisionModelSelector: React.FC<VisionModelSelectorProps> = ({ isAdmin = false }) => {
const { t } = useLanguage();
const [visionModels, setVisionModels] = useState<VisionModel[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
setError('');
const [models, current] = await Promise.all([
settingsService.getVisionModels(),
settingsService.getVisionModel()
]);
console.log('Vision models loaded:', models);
console.log('Current vision model:', current);
setVisionModels(models || []);
setSelectedModelId(current?.visionModelId || '');
} catch (error) {
console.error(t('loadVisionModelFailed'), error);
setError(t('loadFailed'));
} finally {
setLoading(false);
}
};
const handleChange = async (modelId: string) => {
try {
setSelectedModelId(modelId);
await settingsService.updateVisionModel(modelId);
} catch (error) {
console.error(t('saveVisionModelFailed'), error);
}
};
return (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-slate-800 font-semibold border-b border-slate-100 pb-2">
<Eye className="w-4 h-4 text-purple-600" />
{t('visionModelSettings')}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">
{t('defaultVisionModel')}
</label>
{error ? (
<div className="text-red-500 text-sm p-2 bg-red-50 rounded">
{error}
</div>
) : (
<select
value={selectedModelId}
onChange={(e) => handleChange(e.target.value)}
disabled={loading || !isAdmin}
className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">--- {t('selectVisionModel')} ---</option>
{visionModels.map(model => (
<option key={model.id} value={model.id}>
{model.name} ({model.modelId})
</option>
))}
</select>
)}
<p className="text-[10px] text-slate-400 mt-1">
{t('visionModelHelp')}
</p>
</div>
</div>
);
};
export default VisionModelSelector;
@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import { X, Box, Loader2, Trash2 } from 'lucide-react';
import { importService, ImportTask } from '../../services/importService';
import { useLanguage } from '../../contexts/LanguageContext';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { KnowledgeGroup } from '../../types';
interface ImportTasksDrawerProps {
isOpen: boolean;
onClose: () => void;
authToken: string;
}
export const ImportTasksDrawer: React.FC<ImportTasksDrawerProps> = ({
isOpen,
onClose,
authToken,
}) => {
const { t } = useLanguage();
const [importTasks, setImportTasks] = useState<ImportTask[]>([]);
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchData = async () => {
if (!authToken) return;
try {
setIsLoading(true);
const [tasks, groupsData] = await Promise.all([
importService.getAll(authToken),
knowledgeGroupService.getGroups()
]);
setImportTasks(tasks);
// Flatten the groups tree so we can easily find names by ID
const flat: KnowledgeGroup[] = [];
const walk = (items: KnowledgeGroup[]) => {
for (const g of items) {
flat.push(g);
if (g.children?.length) walk(g.children);
}
};
if (groupsData) walk(groupsData);
setGroups(flat);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setIsLoading(false);
}
};
const handleDelete = async (taskId: string) => {
if (!window.confirm(t('confirmDeleteTask'))) return;
try {
await importService.delete(authToken, taskId);
fetchData();
} catch (error) {
console.error('Failed to delete task:', error);
alert(t('deleteTaskFailed'));
}
};
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen, authToken]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onClick={onClose} />
<div className="relative w-full max-w-4xl bg-white shadow-2xl flex flex-col h-full animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
<Box size={16} />
</div>
<h2 className="text-lg font-bold text-slate-800">{t('importTasksTitle')}</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchData}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-slate-50 rounded-lg transition-all"
title={t('refresh')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
</button>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 bg-slate-50/30">
{isLoading ? (
<div className="p-12 flex justify-center">
<Loader2 className="animate-spin text-slate-300 w-8 h-8" />
</div>
) : importTasks.length === 0 ? (
<div className="p-12 text-center text-slate-400 flex flex-col items-center">
<Box size={48} className="mb-4 opacity-20" />
<span className="text-sm font-bold uppercase tracking-widest">{t('noTasksFound')}</span>
</div>
) : (
<div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-200/60 bg-slate-50/50 text-[10px] font-black uppercase tracking-widest text-slate-500">
<th className="px-6 py-4">{t('sourcePath')}</th>
<th className="px-6 py-4">{t('targetGroup')}</th>
<th className="px-6 py-4">{t('status')}</th>
<th className="px-6 py-4">{t('scheduledAt')}</th>
<th className="px-6 py-4">{t('createdAt')}</th>
<th className="px-6 py-4 text-right">{t('actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100/80 text-sm">
{importTasks.map(task => (
<tr key={task.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4 text-slate-900 font-medium">
{task.sourcePath}
</td>
<td className="px-6 py-4 text-slate-500">
{groups.find((g: any) => g.id === task.targetGroupId)?.name || task.targetGroupName || task.targetGroupId || '-'}
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 rounded-md text-xs font-bold ${task.status === 'COMPLETED' ? 'bg-emerald-100 text-emerald-700' :
task.status === 'FAILED' ? 'bg-red-100 text-red-700' :
task.status === 'PROCESSING' ? 'bg-blue-100 text-blue-700' :
'bg-amber-100 text-amber-700'
}`}>
{task.status}
</span>
{task.status === 'FAILED' && task.logs && (
<div className="text-xs text-red-500 mt-1 max-w-xs truncate" title={task.logs}>
{task.logs}
</div>
)}
</td>
<td className="px-6 py-4 text-slate-500">
{task.scheduledAt ? new Date(task.scheduledAt).toLocaleString() : '-'}
</td>
<td className="px-6 py-4 text-slate-400 text-xs">
{new Date(task.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleDelete(task.id)}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('delete')}
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</div>
);
};
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { SidebarRail, NavItem } from './SidebarRail';
import { Database } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
interface AdminLayoutProps {
children: React.ReactNode;
currentView: string;
onViewChange: (view: string) => void;
onLogout: () => void;
currentUser: any;
appMode?: 'workspace' | 'admin';
onSwitchMode?: () => void;
}
export const AdminLayout: React.FC<AdminLayoutProps> = ({
children, currentView, onViewChange, onLogout, currentUser, appMode, onSwitchMode
}) => {
const { t } = useLanguage();
const navItems: NavItem[] = [
{ id: 'knowledge', icon: Database, label: t('navKnowledge') }
];
return (
<div className='flex h-screen w-full bg-slate-50 overflow-hidden relative'>
<SidebarRail
currentView={currentView}
onViewChange={onViewChange}
onLogout={onLogout}
currentUser={currentUser}
navItems={navItems}
appMode={appMode}
onSwitchMode={onSwitchMode}
/>
<div className="flex-1 overflow-hidden relative">
{children}
</div>
</div>
);
};
+252
View File
@@ -0,0 +1,252 @@
import React, { useState } from 'react'
import { MessageSquare, Book, Mic, Settings, LogOut, Database, ChevronRight, ChevronLeft, Menu, Globe, DownloadCloud, User } from 'lucide-react'
import { Logo } from '../Logo'
import { useLanguage } from '../../contexts/LanguageContext'
import { UserInfoDisplay } from '../UserInfoDisplay'
export type ViewType = 'chat' | 'knowledge' | 'notebooks' | 'settings' | string;
export interface NavItem {
id: ViewType;
icon: React.ElementType;
label: string;
}
interface SidebarRailProps {
currentView: ViewType;
onViewChange: (view: ViewType) => void;
onLogout?: () => void;
onOpenSettings?: () => void;
currentUser?: any;
navItems: NavItem[];
appMode?: 'workspace' | 'admin';
onSwitchMode?: () => void;
}
export const SidebarRail: React.FC<SidebarRailProps> = ({ currentView, onViewChange, onLogout, currentUser, navItems, appMode, onSwitchMode }) => {
const [isExpanded, setIsExpanded] = useState(false)
const { language, setLanguage, t } = useLanguage()
const handleLanguageCycle = () => {
const langs = ['zh', 'en', 'ja'] as const
const currentIndex = langs.indexOf(language as any)
const nextIndex = (currentIndex + 1) % langs.length
setLanguage(langs[nextIndex])
}
return (
<div className={`
${isExpanded ? 'w-64' : 'w-20'}
bg-slate-950 flex flex-col py-6 shrink-0 z-50 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border-r border-slate-800
`}>
{/* Header / Logo */}
<div className={`mb-8 flex items-center ${isExpanded ? 'px-6 justify-between' : 'justify-center'}`}>
<div className="flex items-center justify-center shrink-0 cursor-default">
<Logo size={isExpanded ? 32 : 40} withText={isExpanded} />
</div>
{isExpanded && appMode && (
<span className={`ml-2 px-2 py-0.5 text-xs font-semibold rounded-md ${appMode === 'admin' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'}`}>
{appMode === 'admin' ? 'Admin' : 'Workspace'}
</span>
)}
{isExpanded && (
<button
onClick={() => setIsExpanded(false)}
className="text-slate-500 hover:text-slate-300 transition-colors p-1 rounded-full hover:bg-slate-800/50 ml-auto"
>
<ChevronLeft size={18} />
</button>
)}
</div>
{/* Current User Display - only when expanded */}
{isExpanded && currentUser && (
<div className="px-2 mb-4">
<UserInfoDisplay currentUser={currentUser} />
</div>
)}
{/* Navigation items */}
<div className="flex-1 flex flex-col space-y-2 w-full px-3">
{navItems.map((item) => {
// Determine active tab based on current route
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onViewChange(item.id)}
draggable="false"
className={`
py-3 px-3 rounded-xl transition-all duration-300 group relative flex items-center outline-none
${isExpanded ? 'justify-start' : 'justify-center'}
${isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25'
: 'text-slate-400 hover:bg-white/5 hover:text-slate-200'
}
`}
title={!isExpanded ? item.label : undefined}
>
<item.icon size={20} strokeWidth={isActive ? 2.5 : 2} className={`shrink-0 transition-transform duration-300 ${isActive ? 'scale-110' : 'group-hover:scale-110'}`} />
<span className={`
ml-3 font-medium whitespace-nowrap overflow-hidden transition-all duration-300
${isExpanded ? 'opacity-100 w-auto translate-x-0' : 'opacity-0 w-0 -translate-x-4 absolute'}
`}>
{item.label}
</span>
{/* Tooltip (only when collapsed) */}
{!isExpanded && (
<span className="
absolute left-full ml-4 bg-slate-900 text-slate-200 text-xs px-2.5 py-1.5 rounded-md
opacity-0 group-hover:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50
top-1/2 -translate-y-1/2 shadow-xl border border-slate-800 translate-x-[-8px] group-hover:translate-x-0
">
{item.label}
</span>
)}
</button>
)
})}
</div>
{/* Footer / Settings / Toggle */}
<div className="flex flex-col space-y-2 w-full px-3 mt-auto">
{/* Toggle Button (When collapsed, show here to expand) */}
{!isExpanded && (
<button
onClick={() => setIsExpanded(true)}
className="p-3 rounded-xl text-slate-500 hover:bg-white/5 hover:text-slate-300 transition-all flex justify-center mb-2"
title={t('expandMenu')}
>
<ChevronRight size={20} />
</button>
)}
{/* Settings Button */}
<button
onClick={() => onViewChange('settings')}
className={`
py-3 px-3 rounded-xl transition-all duration-300 flex items-center group relative outline-none
${isExpanded ? 'justify-start' : 'justify-center'}
${currentView === 'settings'
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25'
: 'text-slate-400 hover:bg-white/5 hover:text-slate-200'
}
`}
title={!isExpanded ? t('settings') : undefined}
>
<Settings size={20} className={`shrink-0 transition-transform duration-300 ${currentView === 'settings' ? 'rotate-90' : 'group-hover:rotate-45'}`} />
<span className={`
ml-3 font-medium whitespace-nowrap overflow-hidden transition-all duration-300
${isExpanded ? 'opacity-100 w-auto' : 'opacity-0 w-0 absolute'}
`}>
{t('settings')}
</span>
{!isExpanded && (
<span className="
absolute left-full ml-4 bg-slate-900 text-slate-200 text-xs px-2.5 py-1.5 rounded-md
opacity-0 group-hover:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50
top-1/2 -translate-y-1/2 shadow-xl border border-slate-800 translate-x-[-8px] group-hover:translate-x-0
">
{t('settings')}
</span>
)}
</button>
{/* App Switcher for Admins */}
{currentUser?.isAdmin && onSwitchMode && (
<button
onClick={onSwitchMode}
className={`
py-3 px-3 rounded-xl transition-all duration-300 flex items-center group relative outline-none
${isExpanded ? 'justify-start' : 'justify-center'}
text-slate-400 hover:bg-white/5 hover:text-slate-200
`}
title={!isExpanded ? (appMode === 'admin' ? t('backToWorkspace') || 'Workspace' : t('goToAdmin') || 'Admin Dashboard') : undefined}
>
{appMode === 'admin' ? (
<User size={20} className="shrink-0 group-hover:scale-110 transition-transform duration-300" />
) : (
<Database size={20} className="shrink-0 group-hover:scale-110 transition-transform duration-300" />
)}
<span className={`
ml-3 font-medium whitespace-nowrap overflow-hidden transition-all duration-300
${isExpanded ? 'opacity-100 w-auto' : 'opacity-0 w-0 absolute'}
`}>
{appMode === 'admin' ? t('backToWorkspace') || 'Workspace' : t('goToAdmin') || 'Admin'}
</span>
{!isExpanded && (
<span className="
absolute left-full ml-4 bg-slate-900 text-slate-200 text-xs px-2.5 py-1.5 rounded-md
opacity-0 group-hover:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50
top-1/2 -translate-y-1/2 shadow-xl border border-slate-800 translate-x-[-8px] group-hover:translate-x-0
">
{appMode === 'admin' ? t('backToWorkspace') || 'Workspace' : t('goToAdmin') || 'Admin'}
</span>
)}
</button>
)}
{/* Language Switcher */}
<button
onClick={handleLanguageCycle}
className={`
py-3 px-3 rounded-xl transition-all duration-300 flex items-center group relative outline-none
${isExpanded ? 'justify-start' : 'justify-center'}
text-slate-400 hover:bg-white/5 hover:text-slate-200
`}
title={!isExpanded ? t('switchLanguage') : undefined}
>
<Globe size={20} className="shrink-0 group-hover:scale-110 transition-transform duration-300" />
<span className={`
ml-3 font-medium whitespace-nowrap overflow-hidden transition-all duration-300
${isExpanded ? 'opacity-100 w-auto' : 'opacity-0 w-0 absolute'}
`}>
{language === 'ja' ? t('langJa') : language === 'en' ? t('langEn') : t('langZh')}
</span>
{!isExpanded && (
<span className="
absolute left-full ml-4 bg-slate-900 text-slate-200 text-xs px-2.5 py-1.5 rounded-md
opacity-0 group-hover:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50
top-1/2 -translate-y-1/2 shadow-xl border border-slate-800 translate-x-[-8px] group-hover:translate-x-0
">
{t('switchLanguage')}
</span>
)}
</button>
{/* Logout Button */}
<button
onClick={onLogout}
className={`
py-3 px-3 rounded-xl transition-all duration-300 flex items-center group relative outline-none
${isExpanded ? 'justify-start' : 'justify-center'}
text-slate-400 hover:bg-red-500/10 hover:text-red-400
`}
title={!isExpanded ? t('logout') : undefined}
>
<LogOut size={20} className="shrink-0 group-hover:translate-x-1 transition-transform duration-300" />
<span className={`
ml-3 font-medium whitespace-nowrap overflow-hidden transition-all duration-300
${isExpanded ? 'opacity-100 w-auto' : 'opacity-0 w-0 absolute'}
`}>
{t('logout')}
</span>
{!isExpanded && (
<span className="
absolute left-full ml-4 bg-slate-900 text-slate-200 text-xs px-2.5 py-1.5 rounded-md
opacity-0 group-hover:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50
top-1/2 -translate-y-1/2 shadow-xl border border-slate-800 translate-x-[-8px] group-hover:translate-x-0
">
{t('logout')}
</span>
)}
</button>
</div>
</div>
)
}
@@ -0,0 +1,42 @@
import React from 'react';
import { SidebarRail, NavItem } from './SidebarRail';
import { MessageSquare, Book } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
interface WorkspaceLayoutProps {
children: React.ReactNode;
currentView: string;
onViewChange: (view: string) => void;
onLogout: () => void;
currentUser: any;
appMode?: 'workspace' | 'admin';
onSwitchMode?: () => void;
}
export const WorkspaceLayout: React.FC<WorkspaceLayoutProps> = ({
children, currentView, onViewChange, onLogout, currentUser, appMode, onSwitchMode
}) => {
const { t } = useLanguage();
const navItems: NavItem[] = [
{ id: 'chat', icon: MessageSquare, label: t('navChat') },
{ id: 'notebooks', icon: Book, label: t('navKnowledgeGroups') },
];
return (
<div className='flex h-screen w-full bg-slate-50 overflow-hidden relative'>
<SidebarRail
currentView={currentView}
onViewChange={onViewChange}
onLogout={onLogout}
currentUser={currentUser}
navItems={navItems}
appMode={appMode}
onSwitchMode={onSwitchMode}
/>
<div className="flex-1 overflow-hidden relative">
{children}
</div>
</div>
);
};
@@ -0,0 +1,328 @@
import React, { useState, useEffect } from 'react';
import { Users, FileText, Award, TrendingUp, Calendar, Filter, Download, ChevronDown } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../src/contexts/AuthContext';
import { assessmentStatsService, AssessmentStats, StatsQueryParams } from '../../src/services/assessmentStatsService';
import { templateService } from '../../services/templateService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { AssessmentTemplate } from '../../types';
import { KnowledgeGroup } from '../../types';
import { cn } from '../../src/utils/cn';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ElementType;
color: string;
}
const StatCard: React.FC<StatCardProps> = ({ title, value, subtitle, icon: Icon, color }) => (
<div className="bg-white rounded-2xl p-6 border border-slate-100 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-500">{title}</p>
<p className="text-3xl font-bold mt-1" style={{ color }}>{value}</p>
{subtitle && <p className="text-xs text-slate-400 mt-1">{subtitle}</p>}
</div>
<div className={cn("w-12 h-12 rounded-xl flex items-center justify-center", `bg-${color}/10`)}>
<Icon className={cn("w-6 h-6", `text-${color}`)} style={{ color }} />
</div>
</div>
</div>
);
export const AssessmentStatsView: React.FC = () => {
const { language, t } = useLanguage();
const { user } = useAuth();
const [stats, setStats] = useState<AssessmentStats | null>(null);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<StatsQueryParams>({});
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [showFilters, setShowFilters] = useState(false);
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
useEffect(() => {
const fetchData = async () => {
try {
const [templatesData, groupsData] = await Promise.all([
templateService.getAll(),
knowledgeGroupService.getGroups(),
]);
setTemplates(templatesData);
setGroups(groupsData);
} catch (err) {
console.error('Failed to fetch options:', err);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchStats = async () => {
setLoading(true);
try {
const data = await assessmentStatsService.getStats(filters);
setStats(data);
} catch (err) {
console.error('Failed to fetch stats:', err);
} finally {
setLoading(false);
}
};
fetchStats();
}, [filters]);
const handleFilterChange = (key: keyof StatsQueryParams, value: string) => {
setFilters(prev => ({ ...prev, [key]: value || undefined }));
};
const handleExport = () => {
if (!stats?.recentRecords) return;
const csv = [
['ID', 'Knowledge Base/Group', 'Template', 'Score', 'Status', 'Created At'].join(','),
...stats.recentRecords.map(r => [
r.id,
r.knowledgeBase,
r.template,
r.score ?? '',
r.status,
r.createdAt,
].join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `assessment-stats-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(language === 'zh' ? 'zh-CN' : language === 'ja' ? 'ja-JP' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const isZh = language === 'zh';
if (!isAdmin) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<p className="text-slate-500">{isZh ? '仅管理员可查看统计' : 'Statistics only available for admins'}</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full p-6 space-y-6 overflow-hidden">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">
{isZh ? '评估统计' : 'Assessment Statistics'}
</h1>
<p className="text-sm text-slate-500 mt-1">
{isZh ? '查看所有用户评估数据' : 'View assessment data for all users'}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl border transition-colors",
showFilters ? "bg-blue-50 border-blue-200 text-blue-700" : "bg-white border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
<Filter size={16} />
{isZh ? '筛选' : 'Filters'}
</button>
{stats && (
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
<Download size={16} />
{isZh ? '导出' : 'Export'}
</button>
)}
</div>
</div>
{showFilters && (
<div className="bg-white rounded-2xl p-4 border border-slate-100 shadow-sm">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '开始日期' : 'Start Date'}
</label>
<input
type="date"
value={filters.startDate || ''}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '结束日期' : 'End Date'}
</label>
<input
type="date"
value={filters.endDate || ''}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '模板' : 'Template'}
</label>
<select
value={filters.templateId || ''}
onChange={(e) => handleFilterChange('templateId', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{isZh ? '全部' : 'All'}</option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{isZh ? '知识库' : 'Knowledge Group'}
</label>
<select
value={filters.knowledgeGroupId || ''}
onChange={(e) => handleFilterChange('knowledgeGroupId', e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{isZh ? '全部' : 'All'}</option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title={isZh ? '总评估次数' : 'Total Attempts'}
value={stats?.totalAttempts ?? 0}
icon={FileText}
color="#6366f1"
/>
<StatCard
title={isZh ? '最高分' : 'Highest Score'}
value={stats?.highestScore ?? 0}
subtitle={isZh ? '分' : 'points'}
icon={Award}
color="#f59e0b"
/>
<StatCard
title={isZh ? '平均分' : 'Average Score'}
value={stats?.averageScore ?? 0}
subtitle={isZh ? '分' : 'points'}
icon={TrendingUp}
color="#10b981"
/>
<StatCard
title={isZh ? '完成率' : 'Completion Rate'}
value={`${stats?.completionRate ?? 0}%`}
icon={Users}
color="#8b5cf6"
/>
</div>
<div className="flex-1 bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">
{isZh ? '历史记录' : 'Recent Records'}
</h2>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
) : stats?.recentRecords?.length === 0 ? (
<div className="flex items-center justify-center h-full text-slate-400">
{isZh ? '暂无记录' : 'No records found'}
</div>
) : (
<table className="w-full">
<thead className="bg-slate-50 sticky top-0">
<tr>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '知识库' : 'Knowledge Base'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '模板' : 'Template'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '分数' : 'Score'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '状态' : 'Status'}
</th>
<th className="text-left text-xs font-medium text-slate-500 px-4 py-3">
{isZh ? '时间' : 'Created At'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{stats?.recentRecords?.map((record) => (
<tr key={record.id} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm text-slate-900">
{record.knowledgeBase}
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{record.template}
</td>
<td className="px-4 py-3 text-sm font-medium">
{record.score !== null ? (
<span className={record.score >= 90 ? 'text-green-600' : record.score >= 60 ? 'text-yellow-600' : 'text-red-600'}>
{record.score}
</span>
) : (
<span className="text-slate-400">-</span>
)}
</td>
<td className="px-4 py-3">
<span className={cn(
"inline-flex px-2 py-0.5 text-xs font-medium rounded-full",
record.status === 'COMPLETED'
? "bg-green-50 text-green-700"
: "bg-yellow-50 text-yellow-700"
)}>
{record.status === 'COMPLETED' ? (isZh ? '已完成' : 'Completed') : (isZh ? '进行中' : 'In Progress')}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-500">
{formatDate(record.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
export default AssessmentStatsView;
@@ -0,0 +1,414 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Plus, Edit2, Trash2, FileText, Loader2, X, Sparkles, Sliders, Hash, Type, Brain, Copy, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '../../contexts/LanguageContext';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { templateService } from '../../services/templateService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types';
export const AssessmentTemplateManager: React.FC = () => {
const { t } = useLanguage();
const { showSuccess, showError } = useToast();
const { confirm } = useConfirm();
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<AssessmentTemplate | null>(null);
// UI state uses strings for easy input
const [formData, setFormData] = useState({
name: '',
description: '',
keywords: '',
questionCount: 5,
difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
style: 'Professional',
knowledgeGroupId: '',
});
const [copiedId, setCopiedId] = useState<string | null>(null);
const fetchTemplates = async () => {
setIsLoading(true);
try {
const data = await templateService.getAll();
setTemplates(data);
} catch (error) {
console.error('Failed to fetch templates:', error);
showError(t('actionFailed'));
} finally {
setIsLoading(false);
}
};
const fetchGroups = async () => {
try {
const data = await knowledgeGroupService.getGroups();
setGroups(data);
} catch (error) {
console.error('Failed to fetch groups:', error);
}
};
useEffect(() => {
fetchTemplates();
fetchGroups();
}, []);
const handleOpenModal = (template?: AssessmentTemplate) => {
if (template) {
setEditingTemplate(template);
setFormData({
name: template.name,
description: template.description || '',
keywords: Array.isArray(template.keywords) ? template.keywords.join(', ') : '',
questionCount: template.questionCount,
difficultyDistribution: typeof template.difficultyDistribution === 'object'
? JSON.stringify(template.difficultyDistribution)
: (template.difficultyDistribution || ''),
style: template.style || 'Professional',
knowledgeGroupId: template.knowledgeGroupId || '',
});
} else {
setEditingTemplate(null);
setFormData({
name: '',
description: '',
keywords: '',
questionCount: 5,
difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
style: 'Professional',
knowledgeGroupId: '',
});
}
setShowModal(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
// Convert UI strings back to required types
const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
let diffDist: any = formData.difficultyDistribution;
try {
if (formData.difficultyDistribution.startsWith('{')) {
diffDist = JSON.parse(formData.difficultyDistribution);
}
} catch (e) {
// Keep as string if parsing fails
}
const payload: CreateTemplateData = {
name: formData.name,
description: formData.description,
keywords: keywordsArray,
questionCount: formData.questionCount,
difficultyDistribution: diffDist,
style: formData.style,
knowledgeGroupId: formData.knowledgeGroupId || undefined,
};
if (editingTemplate) {
await templateService.update(editingTemplate.id, payload as UpdateTemplateData);
showSuccess(t('featureUpdated'));
} else {
await templateService.create(payload);
showSuccess(t('confirm'));
}
setShowModal(false);
fetchTemplates();
} catch (error) {
console.error('Save failed:', error);
showError(t('actionFailed'));
} finally {
setIsSaving(false);
}
};
const handleCopyId = async (id: string) => {
try {
await navigator.clipboard.writeText(id);
setCopiedId(id);
showSuccess(t('copySuccess') || 'ID copied to clipboard');
setTimeout(() => setCopiedId(null), 2000);
} catch (err) {
showError(t('actionFailed'));
}
};
const handleDelete = async (id: string) => {
if (!(await confirm(t('confirmTitle')))) return;
try {
await templateService.delete(id);
showSuccess(t('confirm'));
fetchTemplates();
} catch (error) {
showError(t('actionFailed'));
}
};
const renderDifficulty = (dist: any) => {
if (typeof dist === 'string') return dist;
if (typeof dist === 'object' && dist !== null) {
return Object.entries(dist).map(([k, v]) => `${k}: ${v}`).join(', ');
}
return '';
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center">
<FileText size={22} />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">{t('assessmentTemplates')}</h3>
<p className="text-xs text-slate-500">{t('assessmentTemplatesSubtitle')}</p>
</div>
</div>
<button
onClick={() => handleOpenModal()}
className="px-4 py-2.5 bg-indigo-600 text-white rounded-xl text-sm font-black flex items-center gap-2 shadow-lg shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95"
>
<Plus size={18} />
{t('createTemplate')}
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 opacity-20" />
</div>
) : templates.length === 0 ? (
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
<FileText className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => (
<motion.div
key={template.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-24 h-24 bg-indigo-500/5 rounded-full blur-3xl -mr-12 -mt-12" />
<div className="flex justify-between items-start mb-4 relative z-10">
<h4 className="text-base font-black text-slate-900 truncate pr-8">{template.name}</h4>
<div className="flex gap-1">
<button
onClick={() => handleOpenModal(template)}
className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDelete(template.id)}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
>
<Trash2 size={14} />
</button>
</div>
</div>
<p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{template.description || t('noDescription')}</p>
<div className="grid grid-cols-2 gap-2 mb-2">
<div className="bg-slate-50 rounded-xl p-2 border border-slate-100">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('questionCount')}</span>
<span className="text-xs font-bold text-slate-700">{template.questionCount}</span>
</div>
<div className="bg-slate-50 rounded-xl p-2 border border-slate-100 overflow-hidden">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('difficultyDistribution')}</span>
<span className="text-xs font-bold text-slate-700 truncate block">
{renderDifficulty(template.difficultyDistribution)}
</span>
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2 border border-slate-100 mb-2 flex items-center justify-between group/id">
<div className="flex flex-col min-w-0">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Template ID</span>
<span className="text-[10px] font-mono font-medium text-slate-500 truncate">{template.id}</span>
</div>
<button
onClick={() => handleCopyId(template.id)}
className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-white rounded-lg transition-all opacity-0 group-hover/id:opacity-100"
title="Copy ID"
>
{copiedId === template.id ? <Check size={12} className="text-emerald-500" /> : <Copy size={12} />}
</button>
</div>
<div className="bg-indigo-50/30 rounded-xl p-2 border border-indigo-100/50 mb-4 flex items-center gap-2">
<Brain size={12} className="text-indigo-500" />
<span className="text-[10px] font-bold text-indigo-700 truncate">
{template.knowledgeGroup?.name || t('selectKnowledgeGroup')}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
<span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
{kw}
</span>
))}
{(!template.keywords || template.keywords.length === 0) && <span className="text-[10px] text-slate-400 italic">No keywords</span>}
</div>
</motion.div>
))}
</div>
)}
{createPortal(
<AnimatePresence>
{showModal && (
<div key="assessment-template-modal" className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowModal(false)}
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden"
>
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center">
{editingTemplate ? <Edit2 size={24} /> : <Plus size={24} />}
</div>
<h3 className="text-xl font-black text-slate-900">
{editingTemplate ? t('editTemplate') : t('createTemplate')}
</h3>
</div>
<button onClick={() => setShowModal(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all">
<X size={20} />
</button>
</div>
<form onSubmit={handleSave} className="p-8 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Type size={12} className="text-indigo-500" />
{t('templateName')} *
</label>
<input
required
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. Senior Frontend Engineer Technical Interview"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Sparkles size={12} className="text-indigo-500" />
{t('keywords')} ({t('keywordsHint')})
</label>
<input
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
value={formData.keywords}
onChange={e => setFormData({ ...formData, keywords: e.target.value })}
placeholder={t('keywordsHint')}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Hash size={12} className="text-indigo-500" />
{t('questionCount')}
</label>
<input
type="number"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.questionCount}
onChange={e => setFormData({ ...formData, questionCount: parseInt(e.target.value) })}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Sliders size={12} className="text-indigo-500" />
{t('difficultyDistribution')}
</label>
<input
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.difficultyDistribution}
onChange={e => setFormData({ ...formData, difficultyDistribution: e.target.value })}
placeholder='{"Basic": 3, "Inter": 4, "Adv": 3}'
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Sliders size={12} className="text-indigo-500" />
{t('selectKnowledgeGroup')} *
</label>
<select
required
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all appearance-none cursor-pointer"
value={formData.knowledgeGroupId}
onChange={e => setFormData({ ...formData, knowledgeGroupId: e.target.value })}
>
<option value="" disabled>{t('selectKnowledgeGroup')}</option>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<Sliders size={12} className="text-indigo-500" />
{t('style')}
</label>
<input
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={formData.style}
onChange={e => setFormData({ ...formData, style: e.target.value })}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors"
>
{t('mmCancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="px-10 py-4 bg-indigo-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95 flex items-center gap-2"
>
{isSaving && <Loader2 size={16} className="animate-spin" />}
{t('save')}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
};
+791
View File
@@ -0,0 +1,791 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Brain,
Send,
Loader2,
CheckCircle,
AlertCircle,
ChevronRight,
History,
ClipboardCheck,
RefreshCcw,
FileText,
Star,
Award,
Trophy,
Trash2
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '../../contexts/LanguageContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { assessmentService, AssessmentSession, AssessmentState } from '../../services/assessmentService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { templateService } from '../../services/templateService';
import { KnowledgeGroup, AssessmentTemplate } from '../../types';
import { cn } from '../../src/utils/cn';
interface AssessmentViewProps {
onLogout: () => void;
onNavigate: (path: string) => void;
isAdmin: boolean;
}
export const AssessmentView: React.FC<AssessmentViewProps> = ({
onLogout,
onNavigate,
isAdmin
}) => {
const { language, t } = useLanguage();
const { confirm } = useConfirm();
const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [session, setSession] = useState<AssessmentSession | null>(null);
const [state, setState] = useState<AssessmentState | null>(null);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [processStep, setProcessStep] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<AssessmentSession[]>([]);
const [loadingHistoryId, setLoadingHistoryId] = useState<string | null>(null);
const [showBasis, setShowBasis] = useState(false);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchGroups = async () => {
try {
const data = await knowledgeGroupService.getGroups();
setGroups(data);
} catch (err) {
console.error('Failed to fetch groups:', err);
}
};
const fetchTemplates = async () => {
try {
const data = await templateService.getAll();
setTemplates(data);
} catch (err) {
console.error('Failed to fetch templates:', err);
}
};
fetchGroups();
fetchTemplates();
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [state?.messages, isLoading]);
const fetchHistory = async () => {
try {
const data = await assessmentService.getHistory();
setHistory(data);
} catch (err) {
console.error('Failed to fetch history:', err);
}
};
useEffect(() => {
fetchHistory();
}, []);
const isZh = language === 'zh';
const isJa = language === 'ja';
const getStatusText = (node: string) => {
const mapping: Record<string, any> = {
generator: 'statusGeneratingQuestions',
grader: 'statusEvaluatingAnswer',
interviewer: 'statusPreparingQuestion',
analyzer: 'statusGeneratingReport',
};
return t(mapping[node]) || t('statusProcessing');
};
const handleSelectHistory = async (histSession: AssessmentSession) => {
if (isLoading) return;
setLoadingHistoryId(histSession.id);
setIsLoading(true);
setError(null);
try {
const histState = await assessmentService.getSessionState(histSession.id);
setState(histState);
setSession(histSession);
} catch (err: any) {
setError(err.message || 'Failed to load historical assessment');
} finally {
setIsLoading(false);
setLoadingHistoryId(null);
}
};
const handleDeleteHistory = async (e: React.MouseEvent, histId: string) => {
e.stopPropagation();
const confirmed = await confirm(t('confirmDeleteAssessment'));
if (!confirmed) return;
try {
await assessmentService.deleteSession(histId);
setHistory(prev => prev.filter(h => h.id !== histId));
if (session?.id === histId) {
setSession(null);
setState(null);
}
} catch (err: any) {
console.error('Failed to delete history:', err);
setError(t('deleteAssessmentFailed'));
}
};
const handleStartAssessment = async () => {
if (!selectedTemplate) return;
setIsLoading(true);
setError(null);
setProcessStep(isZh ? '正在初始化...' : isJa ? '初期化中...' : 'Initializing...');
try {
const newSession = await assessmentService.startSession(selectedGroup || undefined, language, selectedTemplate || undefined);
setSession(newSession);
for await (const event of assessmentService.startSessionStream(newSession.id)) {
if (event.type === 'node') {
setProcessStep(getStatusText(event.node));
if (event.data) {
setState(prev => {
if (!prev) return event.data;
const prevMessages = prev.messages || [];
return {
...prev,
...event.data,
messages: event.data.messages
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
: prevMessages,
feedbackHistory: event.data.feedbackHistory
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
: (prev.feedbackHistory || []),
scores: { ...(prev.scores || {}), ...(event.data.scores || {}) }
} as any;
});
}
} else if (event.type === 'final') {
setState(event.data);
}
}
} catch (err: any) {
setError(err.message || 'Failed to start assessment');
} finally {
setIsLoading(false);
setProcessStep('');
}
};
const handleRetry = async () => {
if (!session) return;
setIsLoading(true);
setError(null);
setProcessStep(isZh ? '正在重新尝试生成...' : isJa ? '再生成中...' : 'Retrying generation...');
try {
for await (const event of assessmentService.startSessionStream(session.id)) {
if (event.type === 'node') {
setProcessStep(getStatusText(event.node));
} else if (event.type === 'final') {
setState(event.data);
}
}
} catch (err: any) {
setError(err.message || 'Retry failed');
} finally {
setIsLoading(false);
setProcessStep('');
}
};
const handleSubmitAnswer = async () => {
if (!session || !inputValue.trim() || isLoading) return;
const answer = inputValue.trim();
setInputValue('');
setIsLoading(true);
setError(null);
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
try {
setState(prev => ({
...prev!,
messages: [
...(prev?.messages || []),
{ role: 'user' as const, content: answer, timestamp: Date.now() }
]
}));
for await (const event of assessmentService.submitAnswerStream(session.id, answer, language)) {
if (event.type === 'node') {
setProcessStep(getStatusText(event.node));
if (event.data) {
setState(prev => {
if (!prev) return event.data;
const prevMessages = prev.messages || [];
const mergedMessages = event.data.messages
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
: prevMessages;
return {
...prev,
...event.data,
messages: mergedMessages,
feedbackHistory: event.data.feedbackHistory
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
: (prev.feedbackHistory || []),
scores: { ...(prev.scores || {}), ...(event.data.scores || {}) }
} as any;
});
}
} else if (event.type === 'final') {
setState(event.data);
if (event.data.status === 'COMPLETED') {
setSession(prev => prev ? { ...prev, status: 'COMPLETED' } : null);
fetchHistory();
}
}
}
} catch (err: any) {
setError(err.message || 'Failed to submit answer');
} finally {
setIsLoading(false);
setProcessStep('');
}
};
const renderHeader = () => (
<div className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-200">
<Brain size={22} className="text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 leading-tight">{t('assessmentTitle')}</h2>
<p className="text-xs text-slate-500 font-medium">{t('assessmentDesc')}</p>
</div>
</div>
<div className="flex items-center gap-3">
{session && (
<div className="px-3 py-1.5 bg-slate-100 rounded-full flex items-center gap-2">
<div className={cn(
"w-2 h-2 rounded-full animate-pulse",
session.status === 'IN_PROGRESS' ? "bg-green-500" : "bg-blue-500"
)} />
<span className="text-xs font-bold text-slate-600 uppercase tracking-wider">
{session.status === 'IN_PROGRESS' ? t('inProgress') : t('statusReadyFragment')}
</span>
</div>
)}
<button
onClick={() => {
setSession(null);
setState(null);
setSelectedGroup(null);
}}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all"
title={t('newChat')}
>
<RefreshCcw size={18} />
</button>
</div>
</div>
);
const renderSetup = () => (
<div className="flex-1 flex bg-[#F8FAFC] overflow-hidden">
{/* Main Setup Content */}
<div className="flex-1 overflow-y-auto p-8 flex flex-col items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-xl w-full"
>
<div className="bg-white rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 p-8">
<div className="text-center mb-8">
<div className="w-20 h-20 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-inner">
<Brain size={40} />
</div>
<h3 className="text-2xl font-black text-slate-900 mb-2">{t('readyForAssessment')}</h3>
<p className="text-slate-500 font-medium">{t('readyForAssessmentDesc')}</p>
</div>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 ml-1">
{t('assessmentTemplates')}
</label>
<div className="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
{templates.map(template => (
<button
key={template.id}
onClick={() => setSelectedTemplate(template.id)}
className={cn(
"w-full text-left px-4 py-3 rounded-xl border-2 transition-all flex items-center justify-between",
selectedTemplate === template.id
? "border-indigo-600 bg-indigo-50/50 text-indigo-700 shadow-sm"
: "border-slate-100 hover:border-slate-200 text-slate-500 hover:bg-slate-50"
)}
>
<div className="flex flex-col">
<span className="text-sm font-bold truncate max-w-[240px]">{template.name}</span>
<span className="text-[10px] opacity-40 font-mono mt-0.5 mb-1">{template.id}</span>
<span className="text-[10px] opacity-60 font-medium">
{template.questionCount} {t('questionsCountLabel')} {
template.difficultyDistribution
? (typeof template.difficultyDistribution === 'object'
? Object.entries(template.difficultyDistribution).map(([k, v]) => `${k}:${v}`).join(', ')
: String(template.difficultyDistribution))
: ''
}
</span>
</div>
{selectedTemplate === template.id && <div className="w-1.5 h-1.5 bg-indigo-600 rounded-full" />}
</button>
))}
</div>
</div>
<button
onClick={handleStartAssessment}
disabled={!selectedTemplate || isLoading}
className={cn(
"w-full py-4 rounded-2xl font-black text-white transition-all transform hover:scale-[1.02] active:scale-[0.98] shadow-lg flex items-center justify-center gap-3",
!selectedTemplate || isLoading
? "bg-slate-300 shadow-none cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-700 shadow-indigo-200"
)}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
<>
<ClipboardCheck size={20} />
<span>{t('startProfessionalEvaluation')}</span>
</>
)}
</button>
{error && (
<div className="mt-4 p-4 bg-rose-50 border border-rose-100 rounded-2xl flex items-start gap-3 animate-shake">
<AlertCircle size={20} className="text-rose-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-bold text-rose-700">{error}</p>
<button
onClick={handleRetry}
className="mt-1 text-xs font-bold text-rose-600 hover:text-rose-800 underline flex items-center gap-1"
>
<RefreshCcw size={12} />
{t('retry')}
</button>
</div>
</div>
)}
</div>
</div>
<div className="mt-8 flex gap-4 justify-center">
<div className="flex items-center gap-2 text-[13px] font-bold text-slate-400 uppercase tracking-widest">
<CheckCircle size={14} className="text-emerald-500" />
{t('aiPoweredAnalysis')}
</div>
<div className="flex items-center gap-2 text-[13px] font-bold text-slate-400 uppercase tracking-widest">
<CheckCircle size={14} className="text-emerald-500" />
{t('masteryScoring')}
</div>
</div>
</motion.div>
</div>
{/* Assessment History Sidebar */}
{history.length > 0 && (
<div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-200/60 shadow-[4px_0_24px_rgba(0,0,0,0.02)]">
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
<History size={18} className="text-indigo-600" />
{t('recentAssessments')}
</h3>
<div className="space-y-3 custom-scrollbar">
{history.map(hist => (
<div
key={hist.id}
className="w-full text-left p-4 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-between group"
>
<div className="flex flex-col">
<span className="text-sm font-bold text-slate-800 truncate max-w-[180px]">
{hist.knowledgeBase?.name || hist.knowledgeGroup?.name || t('assessmentTitle')}
</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-black text-indigo-400 px-1.5 py-0.5 bg-indigo-50 rounded">
{hist.finalScore !== null && hist.finalScore !== undefined ? `${Math.round(hist.finalScore * 10) / 10}/10` : t('inProgress')}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
{new Date(hist.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => handleDeleteHistory(e, hist.id)}
className="w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center text-slate-400 hover:text-rose-600 hover:border-rose-100 transition-all opacity-0 group-hover:opacity-100"
title={t('delete')}
>
<Trash2 size={14} />
</button>
<button
type="button"
onClick={() => !isLoading && handleSelectHistory(hist)}
disabled={isLoading}
className={cn(
"w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center transition-all shrink-0",
isLoading ? "opacity-50 cursor-not-allowed" : "hover:bg-indigo-600 hover:text-white"
)}
title={t('view')}
>
{loadingHistoryId === hist.id ? (
<Loader2 size={14} className="animate-spin text-indigo-600 group-hover:text-white" />
) : (
<FileText size={14} />
)}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
const renderAssessment = () => {
const currentIndex = state?.currentQuestionIndex || 0;
const totalQuestions = state?.questions?.length || 0;
// 如果currentIndex已达到或超过题目数量,说明已完成
const displayNo = currentIndex >= totalQuestions ? totalQuestions : currentIndex + 1;
console.log('[AssessmentView] Counter:', { displayNo, totalQuestions, currentIndex });
const progressLabel = totalQuestions > 0
? t('questionProgress', displayNo, totalQuestions)
: t('initializingQuestion', displayNo);
const messages = state?.messages || [];
const filteredMessages = messages.filter(m =>
m.role !== 'system' &&
!(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:')))
);
const feedbackHistory = state?.feedbackHistory || [];
const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
const feedbackMatch = lastFeedbackMessage?.content?.toString().match(/(?:Score|得分): (\d+)\/10\n\n(?:Feedback|反馈): ([\s\S]*)/i);
const latestScore = feedbackMatch ? feedbackMatch[1] : null;
const latestFeedback = feedbackMatch ? feedbackMatch[2] : (lastFeedbackMessage?.content || null);
return (
<div className="flex-1 flex bg-[#F8FAFC] overflow-hidden">
{/* Left: Chat Area */}
<div className="flex-1 flex flex-col border-r border-slate-200/60 transition-all duration-500">
<div className="flex-none px-6 py-3 bg-white/50 border-b border-slate-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full uppercase tracking-wider">
{progressLabel}
</span>
{isLoading && (
<span className="text-[10px] font-bold text-slate-400 animate-pulse flex items-center gap-1.5 uppercase tracking-widest">
<div className="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" />
{processStep || t('aiIsProcessing')}
</span>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-8 custom-scrollbar">
<div className="max-w-3xl mx-auto space-y-8">
{filteredMessages.map((msg, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, x: msg.role === 'user' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
className={cn(
"flex flex-col max-w-[85%]",
msg.role === 'user' ? "ml-auto items-end" : "mr-auto items-start"
)}
>
<div className={cn(
"px-5 py-4 rounded-2xl shadow-sm text-[15px] leading-relaxed",
msg.role === 'user'
? "bg-indigo-600 text-white rounded-tr-none"
: "bg-white text-slate-800 border border-slate-100 rounded-tl-none"
)}>
{msg.content}
</div>
<span className="mt-1.5 text-[10px] items-center uppercase tracking-widest font-bold text-slate-400">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</motion.div>
))}
{isLoading && (
<div className="flex items-start mr-auto max-w-[85%]">
<div className="px-5 py-4 bg-white border border-slate-100 rounded-2xl rounded-tl-none shadow-sm">
<div className="flex gap-1.5">
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
<div className="p-6 bg-white border-t border-slate-200/60 shadow-[0_-4px_20px_-10px_rgba(0,0,0,0.05)]">
<div className="max-w-3xl mx-auto flex items-end gap-3">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmitAnswer();
}
}}
placeholder={t('typeAnswerPlaceholder')}
className="flex-1 max-h-32 p-4 bg-slate-50 border-none rounded-2xl focus:bg-white focus:ring-2 focus:ring-indigo-500/20 text-sm font-medium resize-none transition-all placeholder:text-slate-400 outline-none shadow-inner"
rows={1}
/>
<button
onClick={handleSubmitAnswer}
disabled={!inputValue.trim() || isLoading}
className={cn(
"w-14 h-14 flex items-center justify-center rounded-2xl transition-all shadow-lg",
!inputValue.trim() || isLoading
? "bg-slate-100 text-slate-400 shadow-none"
: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200 active:scale-95"
)}
>
<Send size={22} className={isLoading ? "animate-pulse" : ""} />
</button>
</div>
</div>
</div>
{/* Right: Feedback Panel */}
<div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-100">
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
<ClipboardCheck size={18} className="text-indigo-600" />
{t('liveFeedback')}
</h3>
{latestScore ? (
<div className="space-y-6">
<div className="bg-indigo-50/50 rounded-3xl p-6 text-center border border-indigo-100">
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-[0.2em] mb-2 block">{t('currentScore')}</span>
<div className="text-5xl font-black text-indigo-600">{latestScore}<span className="text-xl opacity-40">/10</span></div>
</div>
<div className="space-y-4">
<h4 className="text-[11px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<History size={14} />
{t('aiExplanation')}
</h4>
<div className="text-sm text-slate-600 leading-relaxed font-medium bg-slate-50 rounded-2xl p-5 border border-slate-100">
{latestFeedback}
</div>
</div>
<div className="bg-emerald-50 rounded-2xl p-4 border border-emerald-100 flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-500 text-white rounded-lg flex items-center justify-center shrink-0">
<Star size={18} />
</div>
<div>
<div className="text-xs font-black text-emerald-800">{t('masteryProgress')}</div>
<div className="text-[10px] text-emerald-600 font-bold opacity-80">{t('trackedInRealTime')}</div>
</div>
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-40 space-y-4">
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center">
<History size={32} className="text-slate-400" />
</div>
<div className="text-xs font-bold text-slate-500">
{t('submitAnswerToSeeFeedback')}
</div>
</div>
)}
{state?.questions && state.questions[state.currentQuestionIndex] && (
<div className="mt-6 pt-6 border-t border-slate-100">
<h4 className="text-[11px] font-black text-slate-400 uppercase tracking-widest flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<ClipboardCheck size={14} />
{t('questionBasis')}
</div>
<button
onClick={() => setShowBasis(!showBasis)}
className="text-indigo-600 hover:text-indigo-800 lowercase tracking-normal font-bold"
>
{showBasis ? t('hideBasis') : t('viewBasis')}
</button>
</h4>
<AnimatePresence>
{showBasis && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="text-sm text-slate-600 leading-relaxed font-medium bg-indigo-50/30 rounded-2xl p-4 border border-indigo-100/50">
{state.questions[state.currentQuestionIndex].basis || "No basis provided."}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
<div className="mt-auto pt-6 border-t border-slate-100">
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-100 flex items-start gap-3">
<AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
<div className="text-[11px] text-amber-800 font-medium leading-relaxed">
<strong>{t('assessmentGuide')}</strong> {t('assessmentGuideDesc')}
</div>
</div>
</div>
</div>
</div>
);
};
const renderCompletion = () => (
<div className="flex-1 overflow-y-auto bg-[#F8FAFC] p-8 custom-scrollbar">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-4xl mx-auto space-y-8"
>
<div className="bg-white rounded-[40px] shadow-2xl shadow-indigo-100/50 border border-slate-100 overflow-hidden">
<div className="bg-indigo-600 p-10 text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -mr-32 -mt-32 blur-3xl" />
<div className="relative z-10 flex flex-col items-center text-center">
<div className="w-20 h-20 bg-white/20 backdrop-blur-md rounded-3xl flex items-center justify-center mb-6 border border-white/30 shadow-2xl">
<Trophy size={40} className="text-yellow-300" />
</div>
<h3 className="text-4xl font-black mb-2 tracking-tight">{t('level')} {state?.report?.match(/LEVEL:\s*(\w+)/i)?.[1] || 'Pending'}</h3>
<p className="text-indigo-100 font-bold uppercase tracking-[0.2em] text-sm opacity-80">{t('assessmentResultsAvailable')}</p>
</div>
</div>
<div className="p-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 -mt-20 relative z-20 mb-12">
<div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
<div className="w-12 h-12 bg-amber-50 text-amber-600 rounded-xl flex items-center justify-center mb-3">
<Star size={24} />
</div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('knowledgeCoverage')}</span>
<span className="text-2xl font-black text-slate-900">
{state?.questions && state.questions.length > 0
? `${Math.round((Object.keys(state.scores || {}).length / state.questions.length) * 100)}%`
: '0%'}
</span>
</div>
<div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
<div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center mb-3">
<Award size={24} />
</div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('precisionScore')}</span>
<span className="text-2xl font-black text-slate-900">
{state?.finalScore !== undefined ? (Math.round(state.finalScore * 10) / 10) : '0'}/10
</span>
</div>
<div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
<div className="w-12 h-12 bg-emerald-50 text-emerald-600 rounded-xl flex items-center justify-center mb-3">
<CheckCircle size={24} />
</div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('status')}</span>
<span className={cn(
"text-2xl font-black uppercase tracking-tighter",
(state?.finalScore || 0) >= 6 ? "text-emerald-600" : "text-rose-600"
)}>
{(state?.finalScore || 0) >= 6 ? t('verified') : t('fail')}
</span>
</div>
</div>
<div className="space-y-8">
<div>
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
<FileText size={20} className="text-indigo-600" />
{t('comprehensiveMasteryReport')}
</h4>
<div className="bg-slate-50 border border-slate-100 rounded-3xl p-8 text-slate-800 leading-relaxed font-medium assessment-report overflow-hidden whitespace-pre-wrap">
{state?.report}
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => {
setSession(null);
setState(null);
}}
className="flex-1 py-4 bg-indigo-600 text-white rounded-2xl font-black shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all active:scale-[0.98]"
>
{t('newAssessmentSession')}
</button>
<button
className="px-8 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
>
{t('downloadPdfReport')}
</button>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
return (
<div className="flex flex-col h-full bg-white animate-in flex-1">
{renderHeader()}
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="absolute top-20 left-1/2 -translate-x-1/2 z-50 min-w-[320px] max-w-lg"
>
<div className="mx-6 p-4 bg-red-50 border border-red-100 rounded-2xl flex items-center gap-3 shadow-xl">
<AlertCircle size={20} className="text-red-500 shrink-0" />
<p className="text-sm font-bold text-red-800 pr-2">{error}</p>
<button
onClick={() => setError(null)}
className="ml-auto p-1.5 text-red-400 hover:text-red-500 rounded-lg transition-colors"
>
<AlertCircle size={16} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{!session && renderSetup()}
{session && session.status === 'IN_PROGRESS' && renderAssessment()}
{session && session.status === 'COMPLETED' && renderCompletion()}
</div>
);
};
+459
View File
@@ -0,0 +1,459 @@
import React, { useCallback, useEffect, useState, useRef } from 'react'
import ChatInterface from '../../components/ChatInterface'
import IndexingModalWithMode from '../../components/IndexingModalWithMode'
import { GroupManager } from '../../components/GroupManager'
import { GroupSelector } from '../../components/GroupSelector'
import { SearchHistoryList } from '../../components/SearchHistoryList'
import { HistoryDrawer } from '../../components/HistoryDrawer'
import { GroupSelectionDrawer } from '../../components/GroupSelectionDrawer'
import { PDFPreview } from '../../components/PDFPreview'
import { SourcePreviewDrawer } from '../../components/SourcePreviewDrawer'
import { ChatSource } from '../../services/chatService'
import {
AppSettings,
DEFAULT_MODELS,
DEFAULT_SETTINGS,
IndexingConfig,
KnowledgeFile,
ModelConfig,
ModelType,
RawFile,
KnowledgeGroup,
} from '../../types'
import { readFile, formatBytes } from '../../utils/fileUtils'
import { isFormatSupportedForPreview } from '../../constants/fileSupport'
import { Key, LogOut, Menu, Users, X, Folder, History, Plus, Sparkles, Settings } from 'lucide-react'
import { useLanguage } from '../../contexts/LanguageContext'
import { useToast } from '../../contexts/ToastContext'
import { modelConfigService } from '../../services/modelConfigService'
import { userSettingService } from '../../services/userSettingService'
import { uploadService } from '../../services/uploadService'
import { knowledgeBaseService } from '../../services/knowledgeBaseService'
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
import { searchHistoryService } from '../../services/searchHistoryService'
import { userService } from '../../services/userService'
interface ChatViewProps {
authToken: string;
onLogout: () => void;
modelConfigs?: ModelConfig[]; // Optional to allow backward compat while refactoring
onNavigate: (view: any) => void;
initialChatContext?: { selectedGroups?: string[], selectedFiles?: string[] } | null;
onClearContext?: () => void;
isAdmin?: boolean;
}
export const ChatView: React.FC<ChatViewProps> = ({
authToken,
onLogout,
modelConfigs = DEFAULT_MODELS,
onNavigate,
initialChatContext,
onClearContext,
isAdmin = false
}) => {
const { showError, showWarning } = useToast()
const [files, setFiles] = useState<KnowledgeFile[]>([])
const [groups, setGroups] = useState<KnowledgeGroup[]>([])
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
const [isLoadingSettings, setIsLoadingSettings] = useState(true)
const [isGroupManagerOpen, setIsGroupManagerOpen] = useState(false)
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isGroupSelectionOpen, setIsGroupSelectionOpen] = useState(false) // New state
const [currentHistoryId, setCurrentHistoryId] = useState<string | undefined>()
const [historyMessages, setHistoryMessages] = useState<any[] | null>(null)
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
const [previewSource, setPreviewSource] = useState<ChatSource | null>(null)
const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
// Modals state removed as they are moved to Settings
const [isLanguageLoading, setIsLanguageLoading] = useState(false)
const { t, language, setLanguage } = useLanguage()
const fileInputRef = useRef<HTMLInputElement>(null)
const handleNewChat = () => {
const currentLanguage = language
localStorage.removeItem('chatHistory')
localStorage.removeItem('chatMessages')
localStorage.removeItem('chatSources')
localStorage.setItem('userLanguage', currentLanguage)
setCurrentHistoryId(undefined)
setHistoryMessages(null)
window.location.reload()
}
// Function to fetch user settings from backend
const fetchAndSetSettings = useCallback(async () => {
if (!authToken) return
try {
const [personalSettings, tenantSettings] = await Promise.all([
userSettingService.getPersonal(authToken).catch(() => null),
userSettingService.get(authToken).catch(() => ({} as Partial<AppSettings>))
]);
const appSettings: AppSettings = {
...DEFAULT_SETTINGS,
...tenantSettings,
language: personalSettings?.language || tenantSettings?.language || DEFAULT_SETTINGS.language,
};
setSettings(appSettings)
} catch (error) {
console.error('Failed to fetch settings:', error)
setSettings(DEFAULT_SETTINGS)
} finally {
setIsLoadingSettings(false)
}
}, [authToken])
const fetchAndSetFiles = useCallback(async () => {
if (!authToken) return
try {
const data = await knowledgeBaseService.getAll(authToken)
setFiles(data.items)
} catch (error) {
console.error('Failed to fetch files:', error)
}
}, [authToken])
// Function to fetch groups from backend
const fetchAndSetGroups = useCallback(async () => {
if (!authToken) return
try {
const remoteGroups = await knowledgeGroupService.getGroups()
setGroups(remoteGroups)
// Filter out selected groups that no longer exist
setSelectedGroups(prev => {
const validGroupIds = new Set(remoteGroups.map(g => g.id))
return prev.filter(id => validGroupIds.has(id))
})
} catch (error) {
console.error('Failed to fetch groups:', error)
}
}, [authToken])
useEffect(() => {
if (authToken) {
fetchAndSetSettings()
fetchAndSetFiles()
fetchAndSetGroups()
}
}, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
// Handle Initial Context
useEffect(() => {
if (initialChatContext) {
if (initialChatContext.selectedGroups) {
setSelectedGroups(initialChatContext.selectedGroups)
}
if (initialChatContext.selectedFiles) {
setSelectedFiles(initialChatContext.selectedFiles)
}
}
}, [initialChatContext])
// Load chat history from localStorage on mount
useEffect(() => {
const savedHistory = localStorage.getItem('chatMessages');
if (savedHistory) {
try {
const parsedHistory = JSON.parse(savedHistory);
if (Array.isArray(parsedHistory) && parsedHistory.length > 0) {
setHistoryMessages(parsedHistory);
}
} catch (error) {
console.error('Failed to parse saved chat history:', error);
}
}
}, []);
const handleFileUpload = async (fileList: FileList) => {
if (!authToken) {
showWarning(t('loginToUpload'))
return
}
const MAX_FILE_SIZE = 104857600
const MAX_SIZE_MB = 100
const rawFiles: RawFile[] = []
const errors: string[] = []
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
if (file.size > MAX_FILE_SIZE) {
errors.push(t('fileSizeLimitExceeded').replace('$1', file.name).replace('$2', formatBytes(file.size)).replace('$3', MAX_SIZE_MB.toString()))
continue
}
const allowedTypes = [
'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.graphics',
'text/plain', 'text/markdown', 'text/html', 'text/csv', 'text/xml', 'application/xml', 'application/json',
'text/x-python', 'text/x-java', 'text/x-c', 'text/x-c++', 'text/javascript', 'text/typescript',
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', 'image/bmp', 'image/svg+xml',
'application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z-compressed',
'application/rtf', 'application/epub+zip', 'application/x-mobipocket-ebook',
]
const ext = file.name.toLowerCase().split('.').pop()
const allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'html', 'csv', 'rtf', 'odt', 'ods', 'odp', 'json', 'xml']
const isAllowed = allowedTypes.includes(file.type) ||
file.type.startsWith('text/') ||
file.type.startsWith('application/vnd.') ||
file.type.startsWith('application/x-') ||
file.type === '' ||
allowedExtensions.includes(ext || '')
if (!isAllowed) {
errors.push(t('unsupportedFileType').replace('$1', file.name).replace('$2', file.type || 'unknown'))
continue
}
try {
const rawFile = await readFile(file)
rawFiles.push(rawFile)
} catch (error) {
console.error(`Error reading file ${file.name}:`, error)
errors.push(t('readFailed').replace('$1', file.name))
}
}
if (errors.length > 0) {
showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
}
if (rawFiles.length === 0) return
if (errors.length > 0 && rawFiles.length > 0) {
showWarning(t('uploadWarning').replace('$1', rawFiles.length.toString()).replace('$2', errors.length.toString()))
}
setPendingFiles(rawFiles);
setIsIndexingModalOpen(true);
}
const handleConfirmIndexing = async (config: IndexingConfig) => {
if (!authToken) return
let hasSuccess = false
for (const rawFile of pendingFiles) {
try {
await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
hasSuccess = true
} catch (error) {
console.error(`Error uploading file ${rawFile.name}:`, error)
showError(t('uploadFailed').replace('$1', rawFile.name).replace('$2', error.message))
}
}
if (hasSuccess) {
await fetchAndSetFiles()
}
setPendingFiles([])
setIsIndexingModalOpen(false)
}
const handleCancelIndexing = () => {
setPendingFiles([])
setIsIndexingModalOpen(false)
}
const handleGroupsChange = (newGroups: KnowledgeGroup[]) => {
setGroups(newGroups)
}
const handleSelectHistory = async (historyId: string) => {
try {
const historyDetail = await searchHistoryService.getHistoryDetail(historyId)
setCurrentHistoryId(historyId)
setIsHistoryOpen(false)
setHistoryMessages(historyDetail.messages)
} catch (error) {
console.error('Failed to load history detail:', error)
showError(t('loadHistoryFailed'))
}
}
const handleShowHistory = () => {
setIsHistoryOpen(true)
}
const handleInputFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileUpload(e.target.files)
}
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
if (isLoadingSettings) {
return (
<div className='flex items-center justify-center min-h-screen bg-slate-50 w-full'>
<div className='text-blue-600 animate-spin rounded-full h-12 w-12 border-4 border-t-4 border-blue-300'></div>
<p className='ml-4 text-slate-700'>{t('loadingUserData')}</p>
</div>
)
}
return (
<div className='flex h-full w-full bg-transparent overflow-hidden relative'>
<input
type="file"
ref={fileInputRef}
onChange={handleInputFileChange}
multiple
className="hidden"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.html,.csv,.rtf,.odt,.ods,.odp,.json,.js,.jsx,.ts,.tsx,.css,.xml,image/*"
/>
{/* Main Content */}
<div className='flex-1 flex flex-col h-full w-full relative overflow-hidden'>
{/* Header */}
<div className="px-8 pt-8 pb-4 flex items-start justify-between shrink-0 z-20">
<div>
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
{t('chatTitle')}
</h1>
<p className="text-[15px] text-slate-500 mt-1">{t('chatDesc')}</p>
</div>
<div className='flex items-center gap-3 flex-shrink-0'>
{/* History button */}
<button
onClick={handleShowHistory}
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 text-slate-700 hover:text-blue-600 hover:bg-slate-50 rounded-lg font-semibold text-sm transition-all shadow-sm"
>
<History size={18} />
{t('viewHistory')}
</button>
{/* New chat button */}
<button
onClick={handleNewChat}
className="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
>
<Plus size={18} />
{t('newChat')}
</button>
</div>
</div>
<div className='flex-1 overflow-hidden'>
<ChatInterface
files={files}
settings={settings}
models={modelConfigs}
groups={groups}
selectedGroups={selectedGroups}
onGroupSelectionChange={setSelectedGroups}
onOpenGroupSelection={() => setIsGroupSelectionOpen(true)} // Pass handler
selectedFiles={selectedFiles}
onClearFileSelection={() => setSelectedFiles([])}
onMobileUploadClick={() => {
fileInputRef.current?.click()
}}
currentHistoryId={currentHistoryId}
historyMessages={historyMessages}
onHistoryMessagesLoaded={() => setHistoryMessages(null)}
onHistoryIdCreated={setCurrentHistoryId}
onPreviewSource={setPreviewSource}
authToken={authToken}
onOpenFile={(source) => {
if (source.fileId) {
if (isFormatSupportedForPreview(source.fileName)) {
setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
} else {
showWarning(t('previewNotSupported'));
}
}
}}
/>
</div>
</div>
{/* Modals */}
<IndexingModalWithMode
isOpen={isIndexingModalOpen}
onClose={handleCancelIndexing}
files={pendingFiles}
embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
defaultEmbeddingId={settings.selectedEmbeddingId}
onConfirm={handleConfirmIndexing}
/>
{/* Group Selection Drawer */}
<GroupSelectionDrawer
isOpen={isGroupSelectionOpen}
onClose={() => setIsGroupSelectionOpen(false)}
groups={groups}
selectedGroups={selectedGroups}
onSelectionChange={setSelectedGroups}
/>
{/* Knowledge base enhancement features modal (Legacy) */}
{isGroupManagerOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">{t('notebooks')}</h2>
<button
onClick={() => setIsGroupManagerOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<X size={24} />
</button>
</div>
<GroupManager
groups={groups}
onGroupsChange={handleGroupsChange}
/>
</div>
</div>
)}
<HistoryDrawer
isOpen={isHistoryOpen}
onClose={() => setIsHistoryOpen(false)}
groups={groups}
onSelectHistory={handleSelectHistory}
/>
{pdfPreview && (
<PDFPreview
fileId={pdfPreview.fileId}
fileName={pdfPreview.fileName}
authToken={authToken}
onClose={() => setPdfPreview(null)}
/>
)}
<SourcePreviewDrawer
isOpen={!!previewSource}
onClose={() => setPreviewSource(null)}
source={previewSource}
onOpenFile={(source) => {
if (source.fileId) {
if (isFormatSupportedForPreview(source.fileName)) {
setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
} else {
showWarning(t('previewNotSupported'));
}
}
}}
/>
</div>
)
}
+952
View File
@@ -0,0 +1,952 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import IndexingModalWithMode from '../../components/IndexingModalWithMode'
import { PDFPreview } from '../../components/PDFPreview'
import { DragDropUpload } from '../../components/DragDropUpload'
import { GlobalDragDropOverlay } from '../../components/GlobalDragDropOverlay'
import {
AppSettings,
DEFAULT_MODELS,
DEFAULT_SETTINGS,
IndexingConfig,
KnowledgeFile,
ModelConfig,
ModelType,
RawFile,
KnowledgeGroup,
} from '../../types'
import { readFile, formatBytes } from '../../utils/fileUtils'
import { useLanguage } from '../../contexts/LanguageContext'
import { useToast } from '../../contexts/ToastContext'
import { userSettingService } from '../../services/userSettingService'
import { uploadService } from '../../services/uploadService'
import { knowledgeBaseService } from '../../services/knowledgeBaseService'
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
import { useConfirm } from '../../contexts/ConfirmContext'
import { ChunkInfoDrawer } from '../../components/ChunkInfoDrawer'
import {
Search,
Plus,
FileText,
Image as ImageIcon,
FileType,
CheckCircle2,
CircleDashed,
RefreshCw,
Eye,
Trash2,
Settings,
Folder,
Hash,
Tag,
Layers,
ChevronRight,
ChevronDown,
FolderInput,
Box,
} from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { KB_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES, isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
import { ImportFolderDrawer } from '../../components/ImportFolderDrawer'
import { ImportTasksDrawer } from '../../components/drawers/ImportTasksDrawer'
interface KnowledgeBaseViewProps {
authToken: string;
onLogout: () => void;
modelConfigs?: ModelConfig[];
onNavigate: (view: any) => void;
isAdmin?: boolean;
}
/** Flatten a tree of groups into a flat list (for file counts, filtering, etc.) */
function flattenGroups(groups: KnowledgeGroup[]): (KnowledgeGroup & { depth?: number })[] {
const result: (KnowledgeGroup & { depth?: number })[] = [];
function walk(items: KnowledgeGroup[], depth = 0) {
for (const g of items) {
result.push({ ...g, depth });
if (g.children?.length) walk(g.children, depth + 1);
}
}
walk(groups);
return result;
}
/** Recursively collect all descendant group IDs (including self) */
function collectGroupIds(group: KnowledgeGroup): string[] {
const ids = [group.id];
if (group.children?.length) {
for (const child of group.children) {
ids.push(...collectGroupIds(child));
}
}
return ids;
}
// ---- Tree node component ----
interface GroupTreeNodeProps {
group: KnowledgeGroup;
selectedGroupId?: string;
onSelect: (groupId: string) => void;
isAdmin: boolean;
onEdit: (group: KnowledgeGroup) => void;
onDelete: (group: KnowledgeGroup) => void;
depth?: number;
}
const GroupTreeNode: React.FC<GroupTreeNodeProps> = ({
group,
selectedGroupId,
onSelect,
isAdmin,
onEdit,
onDelete,
depth = 0,
}) => {
const hasChildren = group.children && group.children.length > 0;
const isSelected = selectedGroupId === group.id ||
(hasChildren && group.children!.some(c => collectGroupIds(c).includes(selectedGroupId || '')));
const [collapsed, setCollapsed] = useState(false);
// Auto-expand if a child is selected
useEffect(() => {
if (selectedGroupId && hasChildren) {
const allIds = collectGroupIds(group);
if (allIds.includes(selectedGroupId)) setCollapsed(false);
}
}, [selectedGroupId]);
return (
<div>
<div
className={`group flex items-center justify-between rounded-lg transition-colors ${selectedGroupId === group.id ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
style={{ paddingLeft: `${depth * 12 + 12}px` }}
>
{/* Expand/collapse toggle */}
<div className="flex items-center flex-1">
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); setCollapsed(c => !c); }}
className="p-0.5 mr-1 shrink-0 text-slate-400 hover:text-slate-700"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
) : (
<span className="w-5 shrink-0" />
)}
<button
onClick={() => onSelect(group.id)}
className="flex-1 flex items-center gap-1.5 py-1.5 text-sm font-medium text-left whitespace-nowrap"
>
<Folder size={14} className={selectedGroupId === group.id ? 'text-blue-500 shrink-0' : 'text-slate-400 shrink-0'} />
<span>{group.name}</span>
</button>
</div>
{isAdmin && (
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 pr-2 shrink-0">
<button
onClick={() => onEdit(group)}
className="p-1 text-slate-400 hover:text-blue-600 rounded"
>
<Settings size={11} />
</button>
<button
onClick={() => onDelete(group)}
className="p-1 text-slate-400 hover:text-red-500 rounded"
>
<Trash2 size={11} />
</button>
</div>
)}
</div>
{/* Children */}
{hasChildren && !collapsed && (
<div>
{group.children!.map(child => (
<GroupTreeNode
key={child.id}
group={child}
selectedGroupId={selectedGroupId}
onSelect={onSelect}
isAdmin={isAdmin}
onEdit={onEdit}
onDelete={onDelete}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
};
// ---- Pagination component ----
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
onPageChange: (page: number) => void;
t: (key: string, ...args: any[]) => string;
}
const Pagination: React.FC<PaginationProps> = ({ currentPage, totalPages, totalItems, pageSize, onPageChange, t }) => {
if (totalItems === 0) return null;
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalItems);
return (
<div className="px-8 py-4 border-t border-slate-200/60 bg-white/50 backdrop-blur-md flex items-center justify-center gap-2 shrink-0">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600 text-sm"
>
{t('previous')}
</button>
<div className="px-3 py-2 text-sm font-semibold text-slate-700">
{t('showingRange', start, end, totalItems)}
</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all font-medium text-slate-600 text-sm"
>
{t('next')}
</button>
</div>
);
};
export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
const { authToken, modelConfigs = DEFAULT_MODELS, isAdmin = false } = props;
const { showError, showWarning, showSuccess } = useToast()
const { confirm } = useConfirm()
const { t } = useLanguage()
// Data State
const [files, setFiles] = useState<KnowledgeFile[]>([])
// groups is now a tree; flatGroups is the flattened version for lookups
const [groups, setGroups] = useState<KnowledgeGroup[]>([])
const flatGroups = useMemo(() => flattenGroups(groups), [groups])
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
const [globalStats, setGlobalStats] = useState<{ total: number, uncategorized: number }>({ total: 0, uncategorized: 0 })
const [isLoadingSettings, setIsLoadingSettings] = useState(true)
const [isLoadingFiles, setIsLoadingFiles] = useState(true)
// UI State
const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
const [fileInputRef] = useState<React.RefObject<HTMLInputElement>>(React.createRef())
const [shouldOpenModal, setShouldOpenModal] = useState(false)
// Filter & Pagination State
const [filterName, setFilterName] = useState('')
const [filterStatus, setFilterStatus] = useState<'all' | 'ready' | 'indexing' | 'failed'>('all')
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 12
const [chunkDrawer, setChunkDrawer] = useState<{ isOpen: boolean; fileId: string; fileName: string } | null>(null)
const [isAutoRefreshEnabled] = useState(false)
const [autoRefreshInterval] = useState<number>(5000)
// Sidebar State
const [selectedSidebarFilter, setSelectedSidebarFilter] = useState<{ type: 'all' | 'uncategorized' | 'group'; groupId?: string }>({ type: 'all' })
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false)
const [isImportDrawerOpen, setIsImportDrawerOpen] = useState(false);
const [isImportTasksDrawerOpen, setIsImportTasksDrawerOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<KnowledgeGroup | null>(null)
const [newGroupName, setNewGroupName] = useState('')
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null)
const fetchAndSetSettings = useCallback(async () => {
if (!authToken) return
try {
const settingsData = await userSettingService.get(authToken);
setSettings({ ...DEFAULT_SETTINGS, ...settingsData })
} catch (error) {
console.error('Failed to fetch settings:', error)
} finally {
setIsLoadingSettings(false)
}
}, [authToken, isAdmin])
const fetchAndSetFiles = useCallback(async () => {
if (!authToken) return
try {
setIsLoadingFiles(true)
const result = await knowledgeBaseService.getAll(authToken, {
page: currentPage,
limit: pageSize,
name: filterName,
status: filterStatus,
groupId: selectedSidebarFilter.type === 'group' ? selectedSidebarFilter.groupId : undefined
})
setFiles(result.items)
} catch (error) {
console.error('Failed to fetch files:', error)
} finally {
setIsLoadingFiles(false)
}
}, [authToken, currentPage, filterName, filterStatus, selectedSidebarFilter])
const fetchAndSetGroups = useCallback(async () => {
if (!authToken) return
try {
const remoteGroups = await knowledgeGroupService.getGroups()
setGroups(remoteGroups)
} catch (error) {
console.error('Failed to fetch groups:', error)
}
}, [authToken])
const fetchAndSetStats = useCallback(async () => {
if (!authToken) return
try {
const stats = await knowledgeBaseService.getStats(authToken)
setGlobalStats(stats)
} catch (error) {
console.error('Failed to fetch stats:', error)
}
}, [authToken])
useEffect(() => {
if (authToken) {
fetchAndSetSettings()
fetchAndSetGroups()
fetchAndSetStats()
}
}, [authToken, fetchAndSetSettings, fetchAndSetGroups, fetchAndSetStats])
useEffect(() => {
if (authToken) {
fetchAndSetFiles()
}
}, [fetchAndSetFiles])
useEffect(() => {
if (shouldOpenModal && pendingFiles.length > 0) {
setIsIndexingModalOpen(true);
setShouldOpenModal(false);
}
}, [shouldOpenModal, pendingFiles.length]);
const handleFileUpload = async (fileList: FileList) => {
if (!authToken) {
showWarning(t('loginRequired'))
return
}
const MAX_FILE_SIZE = 104857600;
const rawFiles: RawFile[] = []
const errors: string[] = []
const filesArray = Array.from(fileList);
for (const file of filesArray) {
const extension = file.name.split('.').pop() || ''
if (!isExtensionAllowed(extension, 'kb')) {
errors.push(t('unsupportedFileType', file.name, extension))
continue
}
if (file.size > MAX_FILE_SIZE) {
errors.push(t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100))
continue;
}
try {
const rawFile = await readFile(file)
rawFiles.push(rawFile)
} catch (error) {
errors.push(`${file.name} - ${t('readingFailed')}`);
}
}
if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`);
if (rawFiles.length === 0) return;
setPendingFiles(rawFiles);
setShouldOpenModal(true);
}
const handleConfirmIndexing = async (config: IndexingConfig) => {
if (!authToken) return
let hasSuccess = false
for (const rawFile of pendingFiles) {
try {
const indexingConfig = {
...config,
groupIds: selectedSidebarFilter.type === 'group' ? [selectedSidebarFilter.groupId!] : []
};
await uploadService.uploadFileWithConfig(rawFile.file, indexingConfig, authToken)
hasSuccess = true
} catch (error: any) {
showError(`${t('uploadFailed')}: ${rawFile.name} - ${error.message}`)
}
}
if (hasSuccess) await fetchAndSetFiles()
setPendingFiles([])
setIsIndexingModalOpen(false)
}
const handleRemoveFile = async (id: string) => {
if (!(await confirm(t('confirmDeleteFile')))) return
if (!authToken) return
try {
await knowledgeBaseService.deleteFile(id, authToken)
setFiles(prev => prev.filter(f => f.id !== id))
showSuccess(t('fileDeleted'))
} catch (error: any) {
showError(`${t('deleteFailed')}: ` + error.message)
}
}
const handleToggleFileCategory = async (file: KnowledgeFile, groupId: string) => {
try {
const currentGroupIds = file.groups?.map(g => g.id) || [];
const isAssigned = currentGroupIds.includes(groupId);
let newGroupIds: string[];
if (isAssigned) {
newGroupIds = currentGroupIds.filter(id => id !== groupId);
} else {
newGroupIds = [...currentGroupIds, groupId];
}
await knowledgeGroupService.addFileToGroups(file.id, newGroupIds);
await fetchAndSetFiles();
} catch (error: any) {
console.error('Failed to toggle category:', error);
showError(t('actionFailed') + ': ' + error.message);
}
}
const handleClearAll = async () => {
if (!(await confirm(t('confirmClearKB')))) return
if (!authToken) return
try {
await knowledgeBaseService.clearAll(authToken)
setFiles([])
fetchAndSetStats()
showSuccess(t('kbCleared'))
} catch (error: any) {
showError(`${t('clearFailed')}: ` + error.message)
}
}
// Filtering: when a group is selected, include files in that group AND all descendant groups
const filteredFiles = useMemo(() => {
return files.filter(file => {
const matchName = file.name.toLowerCase().includes(filterName.toLowerCase());
let matchGroup = true;
if (selectedSidebarFilter.type === 'uncategorized') {
matchGroup = !file.groups || file.groups.length === 0;
} else if (selectedSidebarFilter.type === 'group' && selectedSidebarFilter.groupId) {
// Find the selected group in the tree to collect all descendant IDs
const selectedGroup = flatGroups.find(g => g.id === selectedSidebarFilter.groupId);
const allIds = selectedGroup ? collectGroupIds(selectedGroup) : [selectedSidebarFilter.groupId];
matchGroup = file.groups?.some(g => allIds.includes(g.id)) || false;
}
const matchStatus = filterStatus === 'all' ||
(filterStatus === 'ready' && (file.status === 'ready' || file.status === 'vectorized')) ||
(filterStatus === 'indexing' && (file.status === 'indexing' || file.status === 'pending' || file.status === 'extracted')) ||
(filterStatus === 'failed' && (file.status === 'failed' || file.status === 'error'));
return matchName && matchGroup && matchStatus;
});
}, [files, filterName, selectedSidebarFilter, filterStatus, flatGroups]);
const totalPages = Math.ceil(filteredFiles.length / pageSize);
const paginatedFiles = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return filteredFiles.slice(start, start + pageSize);
}, [filteredFiles, currentPage, pageSize]);
useEffect(() => {
setCurrentPage(1);
}, [filterName, filterStatus, selectedSidebarFilter]);
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;
const hasIndexingFiles = files.some(file => ['pending', 'indexing', 'extracted', 'vectorized'].includes(file.status));
if (isAutoRefreshEnabled && hasIndexingFiles) {
intervalId = setInterval(() => {
fetchAndSetFiles();
}, autoRefreshInterval);
}
return () => { if (intervalId) clearInterval(intervalId); };
}, [isAutoRefreshEnabled, files, autoRefreshInterval, fetchAndSetFiles]);
const getFileIcon = (file: KnowledgeFile) => {
if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
return <FileText size={20} className="text-blue-500" />;
};
const handleCreateOrUpdateGroup = async () => {
if (!newGroupName.trim()) return
try {
if (editingGroup) {
await knowledgeGroupService.updateGroup(editingGroup.id, { name: newGroupName, parentId: newGroupParentId })
showSuccess(t('groupUpdated'))
} else {
await knowledgeGroupService.createGroup({ name: newGroupName, parentId: newGroupParentId })
showSuccess(t('groupCreated'))
}
fetchAndSetGroups()
setIsGroupModalOpen(false)
setEditingGroup(null)
setNewGroupName('')
setNewGroupParentId(null)
} catch (error: any) {
showError(t('actionFailed') + ': ' + error.message)
}
}
const handleDeleteGroup = async (group: KnowledgeGroup) => {
if (!(await confirm(t('confirmDeleteGroup').replace('$1', group.name)))) return
try {
await knowledgeGroupService.deleteGroup(group.id)
showSuccess(t('groupDeleted'))
if (selectedSidebarFilter.groupId === group.id) {
setSelectedSidebarFilter({ type: 'all' })
}
fetchAndSetGroups()
} catch (error: any) {
showError(t('deleteFailed') + ': ' + error.message)
}
}
const openCreateGroup = (parentId?: string | null) => {
setEditingGroup(null);
setNewGroupName('');
setNewGroupParentId(parentId ?? null);
setIsGroupModalOpen(true);
}
const openEditGroup = (group: KnowledgeGroup) => {
setEditingGroup(group);
setNewGroupName(group.name);
setNewGroupParentId(group.parentId ?? null);
setIsGroupModalOpen(true);
}
const selectedGroupObj = selectedSidebarFilter.type === 'group'
? flatGroups.find(g => g.id === selectedSidebarFilter.groupId)
: null;
if (isLoadingSettings) {
return (
<div className='flex items-center justify-center min-h-[400px] w-full'>
<div className='text-blue-600 animate-spin rounded-full h-8 w-8 border-2 border-t-transparent border-blue-600'></div>
</div>
)
}
return (
<div className='flex flex-row h-full w-full bg-slate-50 overflow-hidden'>
{/* Sidebar */}
<div className="w-64 bg-white border-r border-slate-200 flex flex-col shrink-0">
<div className="p-6 flex flex-col min-h-0">
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4">{t('navCatalog')}</h2>
<nav className="space-y-1">
<button
onClick={() => setSelectedSidebarFilter({ type: 'all' })}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${selectedSidebarFilter.type === 'all' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<div className="flex items-center gap-2">
<Layers size={16} />
<span>{t('allDocuments')}</span>
</div>
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">{files.length}</span>
</button>
<button
onClick={() => setSelectedSidebarFilter({ type: 'uncategorized' })}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${selectedSidebarFilter.type === 'uncategorized' ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<div className="flex items-center gap-2">
<FileText size={16} />
<span>{t('uncategorized')}</span>
</div>
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
{files.filter(f => !f.groups || f.groups.length === 0).length}
</span>
</button>
</nav>
<div className="mt-6 flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">{t('categories')}</h2>
{isAdmin && (
<button
onClick={() => openCreateGroup(null)}
className="p-1 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-all"
title={t('createCategory') as string}
>
<Plus size={14} />
</button>
)}
</div>
<div className="space-y-0.5 overflow-y-auto overflow-x-auto flex-1 pr-1 pb-4">
{groups.map(group => (
<GroupTreeNode
key={group.id}
group={group}
selectedGroupId={selectedSidebarFilter.type === 'group' ? selectedSidebarFilter.groupId : undefined}
onSelect={(gId) => setSelectedSidebarFilter({ type: 'group', groupId: gId })}
isAdmin={isAdmin}
onEdit={openEditGroup}
onDelete={handleDeleteGroup}
depth={0}
/>
))}
</div>
</div>
</div>
{/* Main Content Area */}
<div className='flex flex-col flex-1 h-full w-full bg-transparent overflow-hidden'>
<input
type="file"
ref={fileInputRef}
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) handleFileUpload(e.target.files)
}}
multiple
className="hidden"
accept={KB_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
/>
<GlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
{/* Header Section */}
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
{selectedSidebarFilter.type === 'all' ? t('kbManagement') :
selectedSidebarFilter.type === 'uncategorized' ? t('uncategorizedFiles') :
selectedGroupObj?.name || t('category')}
</h1>
<p className="text-[15px] text-slate-500 mt-1">
{selectedSidebarFilter.type === 'group'
? selectedGroupObj?.description || t('kbManagementDesc')
: t('kbManagementDesc')}
</p>
</div>
<div className="flex items-center gap-3">
{isAdmin && (
<>
{selectedSidebarFilter.type === 'group' && (
<button
onClick={() => openCreateGroup(selectedSidebarFilter.groupId)}
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg shadow-sm transition-all font-semibold text-sm active:scale-95 hover:bg-slate-50"
>
<Plus size={16} />
{t('addSubcategory')}
</button>
)}
<button
onClick={() => setIsImportTasksDrawerOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg shadow-sm transition-all font-semibold text-sm active:scale-95 hover:bg-slate-50"
>
<Box size={18} className="text-indigo-600" />
{t('importTasksTitle')}
</button>
<button
onClick={() => setIsImportDrawerOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg shadow-sm transition-all font-semibold text-sm active:scale-95 hover:bg-slate-50"
>
<FolderInput size={18} className="text-blue-600" />
{t('importFolder')}
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
>
<Plus size={18} />
{t('addFile')}
</button>
</>
)}
</div>
</div>
{/* Filter Bar */}
<div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
<div className="flex items-center gap-3 flex-1">
<div className="relative max-w-xs w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => fetchAndSetFiles()}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
title="Refresh"
>
<RefreshCw size={18} />
</button>
</div>
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto px-8 pb-4">
{isLoadingFiles ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : paginatedFiles.length > 0 ? (
<div className="flex flex-col gap-3">
<AnimatePresence mode="popLayout">
{paginatedFiles.map((file) => (
<motion.div
key={file.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-xl border border-slate-200/80 p-4 shadow-sm hover:shadow-md transition-all group relative flex items-center gap-4"
>
{/* Icon */}
<div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
{getFileIcon(file)}
</div>
{/* Name & Desc */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3
onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
className="font-bold text-slate-900 text-[15px] cursor-pointer hover:text-blue-600 transition-colors truncate"
>
{file.name}
</h3>
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="flex items-center gap-2">
<p className="text-[13px] text-slate-500 truncate">
{file.status === 'ready' || file.status === 'vectorized'
? t('statusReadyDesc')
: t('statusIndexingDesc', file.status)
}
</p>
{file.groups && file.groups.length > 0 && (
<div className="flex gap-1 ml-2">
{file.groups.map(g => (
<span key={g.id} className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full border border-blue-100">
{g.name}
</span>
))}
</div>
)}
</div>
</div>
{/* Meta & Actions */}
<div className="flex items-center gap-6 shrink-0">
<div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
<span>{new Date(file.createdAt || Date.now()).toLocaleDateString()}</span>
<span>{formatBytes(file.size)}</span>
</div>
<div className="flex items-center gap-2">
{file.status !== 'ready' && file.status !== 'vectorized' ? (
<CircleDashed size={16} className="text-blue-400 animate-spin mr-2" />
) : null}
</div>
<div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
{isFormatSupportedForPreview(file.name) && (
<button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title={t('preview') as string || 'Preview'}>
<Eye size={16} />
</button>
)}
<div className="relative group/tag">
<button className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title={t('groups') as string || 'Groups'}>
<Tag size={16} />
</button>
<div className="absolute right-0 top-full mt-1 w-52 bg-white border border-slate-200 rounded-lg shadow-lg opacity-0 invisible group-hover/tag:opacity-100 group-hover/tag:visible transition-all z-20 overflow-hidden">
<div className="p-2 border-b border-slate-100 bg-slate-50 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{t('selectCategory')}
</div>
<div className="max-h-48 overflow-y-auto">
<button
onClick={() => knowledgeGroupService.addFileToGroups(file.id, []).then(fetchAndSetFiles)}
className="w-full text-left px-3 py-2 text-xs text-slate-600 hover:bg-slate-50 flex items-center gap-2"
>
<Layers size={12} />
{t('noneUncategorized')}
</button>
{flatGroups.map(g => (
<button
key={g.id}
onClick={() => handleToggleFileCategory(file, g.id)}
style={{ paddingLeft: `${(g.depth || 0) * 12 + 12}px` }}
className="w-full text-left pr-3 py-2 text-xs text-slate-600 hover:bg-slate-50 border-t border-slate-50 flex items-center justify-between"
>
<div className="flex items-center gap-1.5 truncate">
<Hash size={12} className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-500 shrink-0' : 'text-slate-400 shrink-0'} />
<span className={file.groups?.some(fg => fg.id === g.id) ? 'text-blue-600 font-medium truncate' : 'truncate'}>{g.name}</span>
</div>
{file.groups?.some(fg => fg.id === g.id) && <CheckCircle2 size={12} className="text-blue-600 shrink-0 ml-2" />}
</button>
))}
</div>
</div>
</div>
{isAdmin && (
<button onClick={() => handleRemoveFile(file.id)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50" title={t('delete') as string || 'Delete'}>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
) : (
<div className="max-w-4xl mx-auto w-full pt-12">
<DragDropUpload onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
</div>
)}
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filteredFiles.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
t={t}
/>
</div>
<IndexingModalWithMode
isOpen={isIndexingModalOpen}
onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
files={pendingFiles}
embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
defaultEmbeddingId={settings.selectedEmbeddingId}
onConfirm={handleConfirmIndexing}
authToken={authToken}
/>
{/* Group Create/Edit Modal */}
<AnimatePresence>
{isGroupModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden"
>
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-2">
{editingGroup ? t('editCategory') : t('createCategory')}
</h2>
<p className="text-slate-500 text-sm mb-6">
{t('categoryDesc')}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('categoryName')}</label>
<input
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t('exampleResearch')}
className="w-full h-11 px-4 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateOrUpdateGroup()}
/>
</div>
{/* Parent category selector */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('parentCategory')}</label>
<select
value={newGroupParentId ?? ''}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
className="w-full h-11 px-4 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all text-sm"
>
<option value="">{t('noParentTopLevel')}</option>
{flatGroups
.filter(g => g.id !== editingGroup?.id) // don't allow self as parent
.map(g => (
<option key={g.id} value={g.id}>
{'\u00A0'.repeat((g.depth || 0) * 4)}{g.name}
</option>
))}
</select>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
<button
onClick={() => { setIsGroupModalOpen(false); setEditingGroup(null); setNewGroupParentId(null); }}
className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200/50 rounded-lg transition-all"
>
{t('cancel')}
</button>
<button
onClick={handleCreateOrUpdateGroup}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg shadow-sm transition-all active:scale-95"
>
{editingGroup ? t('saveChanges') : t('createCategoryBtn')}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{pdfPreview && (
<PDFPreview
fileId={pdfPreview.fileId}
fileName={pdfPreview.fileName}
authToken={authToken}
onClose={() => setPdfPreview(null)}
/>
)}
{chunkDrawer && (
<ChunkInfoDrawer
isOpen={chunkDrawer.isOpen}
onClose={() => setChunkDrawer(null)}
fileId={chunkDrawer.fileId}
fileName={chunkDrawer.fileName}
authToken={authToken}
/>
)}
<ImportFolderDrawer
isOpen={isImportDrawerOpen}
onClose={() => setIsImportDrawerOpen(false)}
authToken={authToken}
onImportSuccess={() => fetchAndSetFiles()}
/>
<ImportTasksDrawer
isOpen={isImportTasksDrawerOpen}
onClose={() => setIsImportTasksDrawerOpen(false)}
authToken={authToken}
/>
</div>
);
};
+550
View File
@@ -0,0 +1,550 @@
import React, { useEffect, useState, useCallback } from 'react'
import { Book, Plus, Search, Trash2, Edit2, Clock, Eye, EyeOff, X, ArrowLeft, Folder, FolderPlus, MoreVertical, Check, ChevronRight } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { noteService } from '../../services/noteService'
import { noteCategoryService } from '../../services/noteCategoryService'
import { Note, NoteCategory } from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { useToast } from '../../contexts/ToastContext'
import { useConfirm } from '../../contexts/ConfirmContext'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
interface MemosViewProps {
authToken: string
isAdmin?: boolean
}
export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false }) => {
const { t } = useLanguage()
const { showError, showSuccess } = useToast()
const { confirm } = useConfirm()
const [notes, setNotes] = useState<Note[]>([])
const [isLoading, setIsLoading] = useState(true)
const [filterText, setFilterText] = useState('')
// Editor state
const [isEditing, setIsEditing] = useState(false)
const [currentNote, setCurrentNote] = useState<Partial<Note>>({})
const [showPreview, setShowPreview] = useState(true)
// Category state
const [categories, setCategories] = useState<NoteCategory[]>([])
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null) // null = All
const [isCategoryLoading, setIsCategoryLoading] = useState(false)
const [showAddCategory, setShowAddCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
const [editCategoryName, setEditCategoryName] = useState('')
const [addingSubCategoryId, setAddingSubCategoryId] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const fetchNotes = useCallback(async () => {
if (!authToken) return
try {
setIsLoading(true)
const data = await noteService.getAll(authToken, undefined, selectedCategoryId || undefined)
setNotes(data)
} catch (error: any) {
console.error(error)
const errorMsg = error.message || ''
if (!errorMsg.includes('401') && !errorMsg.includes('403')) {
showError(`${t('errorLoadData')}: ${errorMsg}`)
}
} finally {
setIsLoading(false)
}
}, [authToken, selectedCategoryId, showError, t])
const fetchCategories = useCallback(async () => {
if (!authToken) return
try {
setIsCategoryLoading(true)
const data = await noteCategoryService.getAll(authToken)
setCategories(data)
} catch (error: any) {
console.error(error)
} finally {
setIsCategoryLoading(false)
}
}, [authToken])
useEffect(() => {
fetchNotes()
}, [fetchNotes])
useEffect(() => {
fetchCategories()
}, [fetchCategories])
const handleSaveNote = async () => {
if (!currentNote.title || !currentNote.content) {
showError(t('errorTitleContentRequired'))
return
}
try {
if (currentNote.id) {
await noteService.update(authToken, currentNote.id, {
title: currentNote.title,
content: currentNote.content,
categoryId: currentNote.categoryId
})
showSuccess(t('successNoteUpdated'))
} else {
await noteService.create(authToken, {
title: currentNote.title,
content: currentNote.content,
groupId: '',
categoryId: currentNote.categoryId || selectedCategoryId || undefined
})
showSuccess(t('successNoteCreated'))
}
setIsEditing(false)
setCurrentNote({})
fetchNotes()
} catch (error: any) {
showError(t('errorSaveFailed', error.message))
}
}
const handleDeleteNote = async (id: string) => {
if (!(await confirm(t('confirmDeleteNote')))) return
try {
await noteService.delete(authToken, id)
showSuccess(t('successNoteDeleted'))
fetchNotes()
} catch (error) {
showError(t('deleteFailed'))
}
}
const filteredNotes = notes.filter(n =>
n.title.toLowerCase().includes(filterText.toLowerCase()) ||
n.content.toLowerCase().includes(filterText.toLowerCase())
)
const handleCreateCategory = async (e: React.FormEvent, parentId?: string) => {
e.preventDefault()
if (!newCategoryName.trim()) return
try {
await noteCategoryService.create(authToken, newCategoryName.trim(), parentId)
setNewCategoryName('')
setShowAddCategory(false)
setAddingSubCategoryId(null)
fetchCategories()
showSuccess(t('categoryCreated'))
} catch (error: any) {
showError(`${t('failedToCreateCategory')}: ${error.message}`)
}
}
const handleUpdateCategory = async (id: string) => {
if (!editCategoryName.trim()) return
try {
await noteCategoryService.update(authToken, id, editCategoryName.trim())
setEditingCategoryId(null)
fetchCategories()
showSuccess(t('groupUpdated'))
} catch (error) {
showError(t('actionFailed'))
}
}
const handleDeleteCategory = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (!(await confirm(t('confirmDeleteCategory')))) return
try {
await noteCategoryService.delete(authToken, id)
if (selectedCategoryId === id) setSelectedCategoryId(null)
fetchCategories()
showSuccess(t('groupDeleted'))
} catch (error) {
showError(t('failedToDeleteCategory'))
}
}
const toggleCategory = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
const newExpanded = new Set(expandedCategories)
if (newExpanded.has(id)) newExpanded.delete(id)
else newExpanded.add(id)
setExpandedCategories(newExpanded)
}
const renderCategoryTree = (parentId: string | null) => {
const items = categories.filter(c => (c.parentId || null) === (parentId || null))
return items.map(cat => {
const hasChildren = categories.some(c => c.parentId === cat.id)
const isExpanded = expandedCategories.has(cat.id)
return (
<div key={cat.id} className="flex flex-col">
<div className="group relative">
{editingCategoryId === cat.id ? (
<div className="flex items-center gap-2 bg-white border border-blue-200 rounded-lg p-1.5 mx-2">
<input
autoFocus
className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
value={editCategoryName}
onChange={e => setEditCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleUpdateCategory(cat.id)}
/>
<button onClick={() => handleUpdateCategory(cat.id)} className="text-blue-600"><Check size={14} /></button>
<button onClick={() => setEditingCategoryId(null)} className="text-slate-400"><X size={14} /></button>
</div>
) : (
<div
onClick={() => setSelectedCategoryId(cat.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm group cursor-pointer transition-all ${selectedCategoryId === cat.id ? 'bg-blue-50 text-blue-700 font-bold shadow-sm' : 'text-slate-500 hover:bg-slate-100/50'}`}
style={{ paddingLeft: `${(cat.level - 1) * 12 + 12}px` }}
>
<div className="flex items-center gap-2 overflow-hidden">
<div
onClick={(e) => toggleCategory(cat.id, e)}
className={`p-0.5 hover:bg-blue-100 rounded transition-colors ${!hasChildren ? 'invisible' : ''}`}
>
<ChevronRight size={14} className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} />
</div>
<Folder size={16} className={selectedCategoryId === cat.id ? 'text-blue-600' : 'text-slate-400 group-hover:text-blue-500'} />
<span className="truncate">{cat.name}</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{cat.level < 3 && (
<button
onClick={(e) => {
e.stopPropagation()
setAddingSubCategoryId(cat.id)
setShowAddCategory(true)
if (!isExpanded) {
const newExpanded = new Set(expandedCategories)
newExpanded.add(cat.id)
setExpandedCategories(newExpanded)
}
}}
className="p-1 hover:text-blue-600"
title={t('subFolderPlaceholder')}
>
<FolderPlus size={12} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation()
setEditingCategoryId(cat.id)
setEditCategoryName(cat.name)
}}
className="p-1 hover:text-blue-600"
>
<Edit2 size={12} />
</button>
<button
onClick={(e) => handleDeleteCategory(e, cat.id)}
className="p-1 hover:text-red-500"
>
<Trash2 size={12} />
</button>
</div>
</div>
)}
</div>
{isExpanded && (
<div className="flex flex-col">
{renderCategoryTree(cat.id)}
{addingSubCategoryId === cat.id && (
<form
onSubmit={(e) => handleCreateCategory(e, cat.id)}
className="my-1 mx-2"
style={{ paddingLeft: `${cat.level * 12 + 12}px` }}
>
<div className="flex items-center gap-2 bg-white border border-blue-200 rounded-lg p-1.5 focus-within:ring-2 focus-within:ring-blue-100 transition-all">
<input
autoFocus
className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
placeholder={t('subFolderPlaceholder')}
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onBlur={() => !newCategoryName && setAddingSubCategoryId(null)}
/>
<button type="submit" className="text-blue-600 hover:text-blue-800"><Check size={14} /></button>
<button type="button" onClick={() => setAddingSubCategoryId(null)} className="text-slate-400"><X size={14} /></button>
</div>
</form>
)}
</div>
)}
</div>
)
})
}
if (isEditing) {
return (
<div className="flex flex-col h-full bg-white overflow-hidden">
<div className="px-8 pt-8 pb-6 flex items-center justify-between shrink-0 border-b border-slate-100">
<div className="flex items-center gap-4">
<button
onClick={() => setIsEditing(false)}
className="p-2 -ml-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
title={t('back')}
>
<ArrowLeft size={20} />
</button>
<div className="flex flex-col">
<h2 className="text-xl font-bold text-slate-900 leading-tight">
{currentNote.id ? t('editNote') : t('newNote')}
</h2>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('directoryLabel')}:</span>
<select
className="text-[11px] font-bold text-blue-600 bg-blue-50/50 px-2 py-0.5 rounded border-none outline-none focus:ring-0 cursor-pointer max-w-[150px] truncate"
value={currentNote.categoryId || ''}
onChange={(e) => setCurrentNote({ ...currentNote, categoryId: e.target.value || undefined })}
>
<option value="">{t('uncategorized')}</option>
{categories.map(c => {
const parent = categories.find(p => p.id === c.parentId)
const grandparent = parent ? categories.find(gp => gp.id === parent.parentId) : null
const path = [grandparent, parent, c].filter(Boolean).map(cat => cat?.name).join(' > ')
return (
<option key={c.id} value={c.id}>
{'\u00A0'.repeat((c.level - 1) * 2)}{c.name}
</option>
)
})}
</select>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 px-3 py-1.5 text-[13px] font-semibold text-slate-500 hover:bg-slate-50 rounded-lg transition-all"
>
{showPreview ? <EyeOff size={16} /> : <Eye size={16} />}
{showPreview ? t('hidePreview') : t('showPreview')}
</button>
<button
onClick={handleSaveNote}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold text-sm shadow-sm transition-all active:scale-95"
>
{t('save')}
</button>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
<div className={`flex-1 flex flex-col p-8 gap-6 ${showPreview ? 'border-r border-slate-100' : ''}`}>
<input
type="text"
placeholder={t('noteTitlePlaceholder')}
value={currentNote.title || ''}
onChange={(e) => setCurrentNote({ ...currentNote, title: e.target.value })}
className="text-2xl font-bold text-slate-900 bg-transparent border-none focus:ring-0 placeholder:text-slate-200 w-full"
/>
<textarea
placeholder={t('startWritingPlaceholder')}
value={currentNote.content || ''}
onChange={(e) => setCurrentNote({ ...currentNote, content: e.target.value })}
className="flex-1 text-[15px] text-slate-700 bg-transparent border-none focus:ring-0 placeholder:text-slate-200 w-full resize-none leading-relaxed"
/>
</div>
{showPreview && (
<div className="flex-1 p-8 overflow-y-auto bg-slate-50/20">
<div className="prose prose-slate prose-sm max-w-none">
<h1 className="text-2xl font-bold text-slate-900 mb-6">{currentNote.title || t('previewHeader')}</h1>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
>
{currentNote.content || t('noContentToPreview')}
</ReactMarkdown>
</div>
</div>
)}
</div>
</div>
)
}
return (
<div className="flex h-full bg-white overflow-hidden">
{/* Category Sidebar */}
<aside className="w-64 border-r border-slate-100 flex flex-col bg-slate-50/30 shrink-0">
<div className="p-6 pb-2">
<div className="flex items-center justify-between mb-4">
<h2 className="text-[11px] font-black text-slate-400 uppercase tracking-widest">{t('personalNotebook') || t('directoryLabel')}</h2>
<button
onClick={() => setShowAddCategory(true)}
className="p-1 hover:bg-slate-100 rounded-md text-slate-400 transition-colors"
>
<FolderPlus size={16} />
</button>
</div>
<div className="space-y-0.5">
<button
onClick={() => setSelectedCategoryId(null)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${!selectedCategoryId ? 'bg-blue-50 text-blue-700 font-bold shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}
>
<Book size={16} className={!selectedCategoryId ? 'text-blue-600' : 'text-slate-400'} />
{t('allNotes')}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-2">
{showAddCategory && !addingSubCategoryId && (
<form onSubmit={(e) => handleCreateCategory(e)} className="mb-4 px-2">
<div className="flex items-center gap-2 bg-white border border-blue-200 rounded-lg p-1.5 focus-within:ring-2 focus-within:ring-blue-100 transition-all">
<input
autoFocus
className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
placeholder={t('enterNamePlaceholder')}
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onBlur={() => !newCategoryName && setShowAddCategory(false)}
/>
<button type="submit" className="text-blue-600 hover:text-blue-800"><Check size={14} /></button>
<button type="button" onClick={() => setShowAddCategory(false)} className="text-slate-400"><X size={14} /></button>
</div>
</form>
)}
<div className="space-y-1">
{renderCategoryTree(null)}
</div>
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden relative">
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-2xl font-bold text-slate-900 leading-tight">{t('navNotebook')}</h1>
{selectedCategoryId && (
<>
<ChevronRight size={16} className="text-slate-300" />
<span className="text-2xl font-bold text-blue-600 truncate max-w-[200px]">
{categories.find(c => c.id === selectedCategoryId)?.name || t('directoryLabel')}
</span>
</>
)}
</div>
<p className="text-[15px] text-slate-500">{t('notebookDesc') || 'Capture your personal thoughts and research notes.'}</p>
</div>
<button
onClick={() => {
setCurrentNote({ categoryId: selectedCategoryId || undefined })
setIsEditing(true)
}}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
>
<Plus size={18} />
{t('newNote')}
</button>
</div>
<div className="px-8 pb-6 flex items-center shrink-0">
<div className="relative max-w-xs w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
type="text"
placeholder={t('filterNotesPlaceholder')}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
</div>
<div className="px-8 pb-8 flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : filteredNotes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-slate-50/10 text-center">
<Book className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<h3 className="text-slate-900 font-bold">{t('noNotesFound') || 'No Notes Found'}</h3>
<p className="text-slate-500 text-sm mt-1">{t('startByCreatingNote') || 'Start by creating your first personal note.'}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence>
{filteredNotes.map((note) => (
<motion.div
key={note.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-64 cursor-pointer"
onClick={() => {
setCurrentNote(note)
setIsEditing(true)
}}
>
<div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
<button
onClick={(e) => {
e.stopPropagation()
setCurrentNote(note)
setIsEditing(true)
}}
className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50"
>
<Edit2 size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteNote(note.id)
}}
className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50"
>
<Trash2 size={16} />
</button>
</div>
<div className="flex-1">
<div className="w-11 h-11 bg-slate-50 rounded-lg text-slate-400 flex items-center justify-center mb-4 transition-all group-hover:bg-blue-600 group-hover:text-white">
<Book size={20} />
</div>
<h3 className="font-bold text-slate-900 text-[16px] mb-2 leading-tight group-hover:text-blue-600 transition-colors truncate pr-12">
{note.title}
</h3>
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-4 overflow-hidden">
{note.content}
</p>
</div>
<div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock size={12} className="text-slate-300" />
<span className="text-[11px] font-medium text-slate-400">
{new Date(note.updatedAt).toLocaleDateString()}
</span>
</div>
{note.categoryId && (
<span className="text-[10px] bg-slate-100 text-slate-500 px-2 py-0.5 rounded font-medium">
{categories.find(c => c.id === note.categoryId)?.name || t('directoryLabel')}
</span>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
</div>
)
}
+312
View File
@@ -0,0 +1,312 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react'
import { ArrowLeft, Plus, MessageSquare, BookOpen, Trash2, Eye, FileText, FileType, Image as ImageIcon, Search, RefreshCw } from 'lucide-react'
import { KnowledgeGroup, KnowledgeFile } from '../../types'
import { knowledgeBaseService } from '../../services/knowledgeBaseService'
import { modelConfigService } from '../../services/modelConfigService'
import { uploadService } from '../../services/uploadService'
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
import { useToast } from '../../contexts/ToastContext'
import { useConfirm } from '../../contexts/ConfirmContext'
import { RawFile, IndexingConfig, ModelConfig } from '../../types'
import IndexingModalWithMode from '../IndexingModalWithMode'
import { PDFPreview } from '../PDFPreview'
import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
import { useLanguage } from '../../contexts/LanguageContext'
import { readFile, formatBytes } from '../../utils/fileUtils'
import { isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
import { motion, AnimatePresence } from 'framer-motion'
interface NotebookDetailViewProps {
authToken: string;
notebook: KnowledgeGroup;
onBack: () => void;
onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
isAdmin?: boolean;
}
export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
const [files, setFiles] = useState<KnowledgeFile[]>([])
const [isLoading, setIsLoading] = useState(false)
const { showError, showSuccess } = useToast()
const { confirm } = useConfirm()
const { t } = useLanguage()
const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
const [shouldOpenModal, setShouldOpenModal] = useState(false)
const [models, setModels] = useState<ModelConfig[]>([])
const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
const [filterName, setFilterName] = useState('')
const fileInputRef = React.useRef<HTMLInputElement>(null)
useEffect(() => {
const fetchModels = async () => {
try {
const res = await modelConfigService.getAll(authToken)
setModels(res)
} catch (error) {
console.error('Failed to fetch models', error)
}
}
if (authToken) fetchModels()
}, [authToken])
useEffect(() => {
if (shouldOpenModal && pendingFiles.length > 0) {
setIsIndexingModalOpen(true);
setShouldOpenModal(false);
}
}, [shouldOpenModal, pendingFiles.length]);
const loadData = useCallback(async () => {
setIsLoading(true)
try {
const allFiles = await knowledgeBaseService.getAll(authToken)
const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
setFiles(notebookFiles)
} catch (error) {
console.error(error)
showError(t('errorLoadData'))
} finally {
setIsLoading(false)
}
}, [authToken, notebook.id, t, showError])
useEffect(() => {
loadData()
}, [loadData])
const handleFileUpload = async (fileList: FileList | File[]) => {
if (!fileList || fileList.length === 0) return
const errors: string[] = []
const newPendingFiles: RawFile[] = []
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
try {
if (file.size > 104857600) {
errors.push(`${file.name} - ${t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100)}`)
continue
}
const extension = file.name.split('.').pop() || ''
if (!isExtensionAllowed(extension, 'group')) {
if (!(await confirm(t('confirmUnsupportedFile', extension || t('unknown'))))) continue
}
const rawFile = await readFile(file)
newPendingFiles.push(rawFile)
} catch (error: any) {
errors.push(`${file.name} - ${t('readingFailed')}`)
}
}
if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
if (newPendingFiles.length > 0) {
setPendingFiles(prev => [...prev, ...newPendingFiles])
setShouldOpenModal(true)
}
}
const handleConfirmIndexing = async (config: IndexingConfig) => {
setIsIndexingModalOpen(false)
try {
for (const rawFile of pendingFiles) {
const uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
if (uploadRes && uploadRes.id) {
await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
}
}
showSuccess(t('successUploadFile'))
loadData()
} catch (error: any) {
showError(t('errorUploadFile', error.message || t('unknownError')))
} finally {
setPendingFiles([])
}
}
const handleRemoveFile = async (fileId: string, fileName: string) => {
if (!(await confirm(t('confirmRemoveFileFromGroup', fileName)))) return;
try {
await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
setFiles(prev => prev.filter(f => f.id !== fileId));
showSuccess(t('fileDeleted'));
} catch (error) {
showError(t('deleteFailed'));
}
}
const filteredFiles = useMemo(() => {
return files.filter(file => file.name.toLowerCase().includes(filterName.toLowerCase()));
}, [files, filterName]);
const getFileIcon = (file: KnowledgeFile) => {
if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
return <FileText size={20} className="text-blue-500" />;
};
return (
<div className="flex flex-col h-full bg-transparent overflow-hidden">
<NotebookGlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
<input
type="file"
ref={fileInputRef}
onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
multiple
className="hidden"
/>
{/* Header */}
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div className="flex items-start gap-4 min-w-0">
<button
onClick={onBack}
className="mt-1 p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors active:scale-90"
>
<ArrowLeft size={20} />
</button>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-blue-50 rounded-lg text-blue-600 border border-blue-100/30">
<BookOpen size={18} />
</div>
<h1 className="text-2xl font-bold text-slate-900 truncate leading-tight">
{notebook.name}
</h1>
</div>
<p className="text-[15px] text-slate-500 mt-1 truncate max-w-2xl">
{notebook.description || t('browseManageFiles')}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => onChatWithContext?.({ selectedGroups: [notebook.id] })}
className="flex items-center gap-2 px-5 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg font-semibold text-sm hover:bg-slate-50 transition-all shadow-sm"
>
<MessageSquare size={18} className="text-blue-600" />
{t('chatWithGroup')}
</button>
{isAdmin && (
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
>
<Plus size={18} />
{t('addFile')}
</button>
)}
</div>
</div>
{/* Filter Bar */}
<div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
<div className="relative max-w-xs w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
type="text"
placeholder={t('filterGroupFiles')}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
<button
onClick={() => loadData()}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
>
<RefreshCw size={18} />
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto px-8 pb-8">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : filteredFiles.length > 0 ? (
<div className="flex flex-col gap-3">
<AnimatePresence mode="popLayout">
{filteredFiles.map((file) => (
<motion.div
key={file.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-xl border border-slate-200/80 p-4 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex items-center gap-4"
>
{/* Icon */}
<div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
{getFileIcon(file)}
</div>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-bold text-slate-900 text-[15px] truncate">
{file.name}
</h3>
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
{file.status}
</span>
</div>
</div>
{/* Meta info & Actions */}
<div className="flex items-center gap-6 shrink-0">
<div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
<span>{formatBytes(file.size)}</span>
<span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{isFormatSupportedForPreview(file.name) && (
<button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50">
<Eye size={16} />
</button>
)}
{isAdmin && (
<button onClick={() => handleRemoveFile(file.id, file.name)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50">
<Plus size={16} className="rotate-45" />
</button>
)}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-white/50 text-center">
<BookOpen className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<h3 className="text-slate-900 font-bold">{t('noFiles')}</h3>
<p className="text-slate-500 text-sm mt-1">{t('noFilesDesc')}</p>
</div>
)}
</div>
<IndexingModalWithMode
isOpen={isIndexingModalOpen}
onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
files={pendingFiles}
embeddingModels={models.filter(m => m.type === 'embedding')}
defaultEmbeddingId={models.find(m => m.isDefault)?.id || ''}
onConfirm={handleConfirmIndexing}
authToken={authToken}
/>
{pdfPreview && (
<PDFPreview
fileId={pdfPreview.fileId}
fileName={pdfPreview.fileName}
authToken={authToken}
onClose={() => setPdfPreview(null)}
/>
)}
</div>
)
}
+293
View File
@@ -0,0 +1,293 @@
import React, { useMemo } from 'react'
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
import { KnowledgeGroup, UpdateGroupData, CreateGroupData } from '../../types'
import { Plus, Book, Library, MessageSquare, Trash2, Edit2, FolderInput, ChevronLeft, ChevronRight } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { NotebookDetailView } from './NotebookDetailView'
import { CreateNotebookDrawer } from '../CreateNotebookDrawer'
import { EditNotebookDrawer } from '../EditNotebookDrawer'
import { ImportFolderDrawer } from '../ImportFolderDrawer'
import { useLanguage } from '../../contexts/LanguageContext'
import { useToast } from '../../contexts/ToastContext'
import { useConfirm } from '../../contexts/ConfirmContext'
interface NotebooksViewProps {
authToken: string
onChatWithContext: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void
isAdmin?: boolean
}
/** Flatten a tree of groups into a flat list */
function flattenGroups(groups: KnowledgeGroup[]): KnowledgeGroup[] {
const result: KnowledgeGroup[] = [];
function walk(items: KnowledgeGroup[]) {
for (const g of items) {
result.push(g);
if (g.children?.length) walk(g.children);
}
}
walk(groups);
return result;
}
const PAGE_SIZE = 12;
export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatWithContext, isAdmin = false }) => {
const { t } = useLanguage()
const { showError } = useToast()
const { confirm } = useConfirm()
const [notebooks, setNotebooks] = React.useState<KnowledgeGroup[]>([])
const [isLoading, setIsLoading] = React.useState(true)
const [selectedNotebook, setSelectedNotebook] = React.useState<KnowledgeGroup | null>(null)
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false)
const [isImportDrawerOpen, setIsImportDrawerOpen] = React.useState(false)
const [editingNotebook, setEditingNotebook] = React.useState<KnowledgeGroup | null>(null)
const [currentPage, setCurrentPage] = React.useState(1)
// Flatten tree for display in the grid
const flatNotebooks = useMemo(() => flattenGroups(notebooks), [notebooks])
const totalPages = Math.ceil(flatNotebooks.length / PAGE_SIZE)
const paginatedNotebooks = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return flatNotebooks.slice(start, start + PAGE_SIZE);
}, [flatNotebooks, currentPage])
const fetchNotebooks = async () => {
try {
const result = await knowledgeGroupService.getGroups()
// result can be an array (tree/list) or an object (paginated flat list)
if (Array.isArray(result)) {
setNotebooks(result)
} else if (result && result.items) {
setNotebooks(result.items)
} else {
setNotebooks([])
}
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
}
}
React.useEffect(() => {
fetchNotebooks()
}, [authToken, selectedNotebook])
const handleCreateNotebook = async (data: CreateGroupData) => {
try {
setIsLoading(true)
await knowledgeGroupService.createGroup(data)
await fetchNotebooks()
setIsCreateDrawerOpen(false)
} catch (error) {
console.error(error)
showError(t('createFailed'))
} finally {
setIsLoading(false)
}
}
const handleUpdateNotebook = async (id: string, data: UpdateGroupData) => {
await knowledgeGroupService.updateGroup(id, data)
await fetchNotebooks()
}
const handleDeleteNotebook = async (e: React.MouseEvent, id: string, name: string) => {
e.stopPropagation()
if (!(await confirm(t('confirmDeleteNotebook').replace('$1', name)))) return
try {
setIsLoading(true)
await knowledgeGroupService.deleteGroup(id)
setNotebooks(prev => flattenGroups(prev).filter(n => n.id !== id) as any)
await fetchNotebooks()
} catch (error) {
console.error(error)
showError(t('deleteFailed'))
} finally {
setIsLoading(false)
}
}
if (selectedNotebook) {
return (
<NotebookDetailView
authToken={authToken}
notebook={selectedNotebook}
onBack={() => setSelectedNotebook(null)}
onChatWithContext={onChatWithContext}
isAdmin={!!isAdmin}
/>
)
}
return (
<div className="flex flex-col h-full bg-transparent overflow-hidden">
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 leading-tight">{t('navKnowledgeGroups')}</h1>
<p className="text-[15px] text-slate-500 mt-1">{t('notebooksDesc')}</p>
</div>
<div className="flex items-center gap-3">
{isAdmin && (
<button
onClick={() => setIsImportDrawerOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 text-sm font-semibold rounded-lg hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95 shadow-sm"
>
<FolderInput size={18} className="text-blue-600" />
<span>{t('importFolder')}</span>
</button>
)}
{isAdmin && (
<button
onClick={() => setIsCreateDrawerOpen(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
>
<Plus size={18} />
<span>{t('newGroup')}</span>
</button>
)}
</div>
</div>
<div className="px-8 flex-1 overflow-y-auto pb-4">
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : flatNotebooks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-white/50 text-center">
<Library className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<h3 className="text-slate-900 font-bold">{t('noKnowledgeGroups')}</h3>
<p className="text-slate-500 text-sm mt-1">{t('createGroupDesc')}</p>
{isAdmin && (
<button
onClick={() => setIsCreateDrawerOpen(true)}
className="mt-6 px-5 py-2 bg-blue-600 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 transition-all shadow-sm"
>
{t('createNotebook')}
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-7xl mx-auto">
<AnimatePresence>
{paginatedNotebooks.map((notebook) => (
<motion.div
key={notebook.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
onClick={() => setSelectedNotebook(notebook)}
className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-64 cursor-pointer"
>
<div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
<button
onClick={(e) => {
e.stopPropagation()
onChatWithContext({ selectedGroups: [notebook.id] })
}}
className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md"
>
<MessageSquare size={16} />
</button>
{isAdmin && (
<button
onClick={(e) => {
e.stopPropagation()
setEditingNotebook(notebook)
}}
className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md"
>
<Edit2 size={16} />
</button>
)}
{isAdmin && (
<button
onClick={(e) => handleDeleteNotebook(e, notebook.id, notebook.name)}
className="p-1.5 text-slate-400 hover:text-red-500 rounded-md"
>
<Trash2 size={16} />
</button>
)}
</div>
<div className="flex-1">
<div className="w-11 h-11 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 flex items-center justify-center mb-4 transition-transform group-hover:scale-105">
<Book size={20} />
</div>
<h3 className="font-bold text-slate-900 text-[16px] mb-1 leading-tight group-hover:text-blue-600 transition-colors truncate">
{notebook.parentId && <span className="text-slate-300 text-xs mr-1"></span>}
{notebook.name}
</h3>
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-3 italic opacity-85">
{notebook.description || t('noDescriptionProvided')}
</p>
</div>
<div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
<div className="flex items-center gap-2 px-2.5 py-1 bg-slate-50 border border-slate-100 rounded-md">
<span className="text-[11px] font-bold text-slate-700">{notebook.fileCount || 0}</span>
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-tight">{t('files')}</span>
</div>
<span className="text-[11px] font-medium text-slate-300">
{notebook.updatedAt ? new Date(notebook.updatedAt).toLocaleDateString() : ''}
</span>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
{/* Pagination: always show when there are notebooks */}
{flatNotebooks.length > 0 && (
<div className="px-8 py-4 border-t border-slate-200/60 bg-white/50 backdrop-blur-md flex items-center justify-center gap-2 shrink-0">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
>
<ChevronLeft size={16} />
{t('previous')}
</button>
<div className="px-3 py-2 text-sm font-semibold text-slate-700">
{t('showingRange', (currentPage - 1) * PAGE_SIZE + 1, Math.min(currentPage * PAGE_SIZE, flatNotebooks.length), flatNotebooks.length)}
</div>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
>
{t('next')}
<ChevronRight size={16} />
</button>
</div>
)}
{isCreateDrawerOpen && (
<CreateNotebookDrawer
isOpen={isCreateDrawerOpen}
onClose={() => setIsCreateDrawerOpen(false)}
onCreate={handleCreateNotebook}
/>
)}
{editingNotebook && (
<EditNotebookDrawer
isOpen={!!editingNotebook}
onClose={() => setEditingNotebook(null)}
notebook={editingNotebook}
onUpdate={handleUpdateNotebook}
/>
)}
<ImportFolderDrawer
isOpen={isImportDrawerOpen}
onClose={() => setIsImportDrawerOpen(false)}
authToken={authToken}
onImportSuccess={fetchNotebooks}
/>
</div>
)
}
+176
View File
@@ -0,0 +1,176 @@
import React from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Search, Plus, MoreHorizontal, Puzzle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// Mock data for plugins
interface PluginMock {
id: string;
name: string;
description: string;
status: 'installed' | 'not-installed' | 'update-available';
developer: string;
iconEmoji: string;
iconBgClass: string;
}
const mockPlugins: PluginMock[] = [
{
id: '1',
name: 'plugin1Name',
description: 'plugin1Desc',
status: 'installed',
developer: 'Official',
iconEmoji: '🛠️',
iconBgClass: 'bg-blue-50'
},
{
id: '2',
name: 'plugin2Name',
description: 'plugin2Desc',
status: 'update-available',
developer: 'Official',
iconEmoji: '📄',
iconBgClass: 'bg-indigo-50'
},
{
id: '3',
name: 'plugin3Name',
description: 'plugin3Desc',
status: 'installed',
developer: 'Community',
iconEmoji: '🦊',
iconBgClass: 'bg-orange-50'
},
{
id: '4',
name: 'plugin4Name',
description: 'plugin4Desc',
status: 'not-installed',
developer: 'Official',
iconEmoji: '🌐',
iconBgClass: 'bg-emerald-50'
},
{
id: '5',
name: 'plugin5Name',
description: 'plugin5Desc',
status: 'not-installed',
developer: 'Community',
iconEmoji: '🗄️',
iconBgClass: 'bg-slate-100'
},
{
id: '6',
name: 'plugin6Name',
description: 'plugin6Desc',
status: 'installed',
developer: 'Official',
iconEmoji: '💬',
iconBgClass: 'bg-sky-50'
}
];
export const PluginsView: React.FC = () => {
const { t } = useLanguage();
return (
<div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
{/* Header Area */}
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<h1 className="text-[22px] font-bold text-slate-900 leading-tight flex items-center gap-2">
<Puzzle className="text-blue-600" size={24} />
{t('pluginTitle')}
</h1>
<p className="text-[14px] text-slate-500 mt-1">{t('pluginDesc')}</p>
</div>
<div className="flex items-center gap-4">
<div className="relative w-64">
<Search className="absolute text-slate-400 left-3 top-1/2 -translate-y-1/2" size={16} />
<input
type="text"
placeholder={t('searchPlugin')}
className="w-full h-10 pl-10 pr-4 bg-white border border-slate-200 rounded-lg focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all text-sm font-medium"
/>
</div>
</div>
</div>
{/* Content Area */}
<div className="px-8 pb-8 flex-1 overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-[1600px] mx-auto">
<AnimatePresence>
{mockPlugins.map((plugin) => (
<motion.div
key={plugin.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-all group flex flex-col h-[220px]"
>
{/* Top layer */}
<div className="flex items-center justify-between mb-4">
<div className={`w-12 h-12 flex items-center justify-center rounded-xl ${plugin.iconBgClass} text-2xl shadow-sm border border-black/5`}>
{plugin.iconEmoji}
</div>
<div className="flex items-center gap-3">
{/* Status Badge */}
{plugin.status === 'installed' && (
<div className="px-2.5 py-1 text-[12px] font-semibold text-emerald-600 bg-emerald-50 rounded-full border border-emerald-100 flex flex-row items-center justify-center">
{t('installedPlugin')}
</div>
)}
{plugin.status === 'update-available' && (
<div className="px-2.5 py-1 text-[12px] font-semibold text-orange-600 bg-orange-50 rounded-full border border-orange-100 flex flex-row items-center justify-center">
{t('updatePlugin')}
</div>
)}
{/* Options button */}
<button className="text-slate-400 hover:text-slate-600 transition-colors">
<MoreHorizontal size={20} />
</button>
</div>
</div>
{/* Middle layer */}
<div className="flex-1">
<h3 className="font-bold text-slate-800 text-[17px] mb-2 leading-tight flex items-center gap-2">
{t(plugin.name as any)}
{plugin.developer === 'Official' && (
<span className="bg-blue-100 text-blue-700 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-sm">{t('pluginOfficial')}</span>
)}
</h3>
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-2">
{t(plugin.description as any)}
</p>
</div>
{/* Bottom layer */}
<div className="mt-4 pt-4 border-t border-slate-50 flex items-center justify-between">
<span className="text-[12px] font-medium text-slate-400">
{t('pluginBy')}{plugin.developer === 'Official' ? t('pluginOfficial') : t('pluginCommunity')}
</span>
{plugin.status === 'not-installed' ? (
<button className="flex items-center justify-center gap-1.5 px-4 py-1.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shadow-sm">
<Plus size={14} className="text-white" />
<span className="text-[13px] font-bold">{t('installPlugin')}</span>
</button>
) : plugin.status === 'update-available' ? (
<button className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-orange-600 bg-orange-50 hover:bg-orange-100 rounded-lg transition-colors border border-orange-200">
<span className="text-[13px] font-bold">{t('updatePlugin')}</span>
</button>
) : (
<button className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-slate-600 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors border border-slate-200">
<span className="text-[13px] font-bold">{t('pluginConfig')}</span>
</button>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
);
};
+2193
View File
@@ -0,0 +1,2193 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ModelConfig, ModelType, AppSettings, KnowledgeGroup, Tenant, TenantMember, DEFAULT_SETTINGS } from '../../types';
import { useLanguage } from '../../contexts/LanguageContext';
import {
ChevronLeft,
ChevronRight,
Plus,
Search,
KeyRound,
Trash2,
Edit,
UserPlus,
Globe,
PlusCircle,
Clock,
ExternalLink,
Download,
Upload,
Building,
Settings as SettingsIcon,
Shield,
User,
MoreVertical,
Check,
ChevronDown,
ChevronUp,
Filter,
RefreshCcw,
LayoutDashboard,
Users,
Database,
UserCircle,
HardDrive,
LayoutGrid,
X,
Key,
Loader2,
Edit2,
Save,
Cpu,
BookOpen,
Sparkles,
ToggleRight,
ToggleLeft,
FileText,
} from "lucide-react";
import { motion, AnimatePresence } from 'framer-motion';
import { userService } from '../../services/userService';
import { settingsService } from '../../services/settingsService';
import { userSettingService } from '../../services/userSettingService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { apiClient } from '../../services/apiClient';
import { AssessmentTemplateManager } from './AssessmentTemplateManager';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useToast } from '../../contexts/ToastContext';
interface SettingsViewProps {
// Model Props
models: ModelConfig[];
authToken: string | null;
onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
isAdmin?: boolean; // Added isAdmin prop
currentUser?: any; // Added current user prop
initialTab?: TabType;
}
type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates';
const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
const map = new Map<string, Tenant>();
const roots: Tenant[] = [];
tenants.forEach(t => {
map.set(t.id, { ...t, children: [] });
});
tenants.forEach(t => {
const node = map.get(t.id)!;
if (t.parentId && map.has(t.parentId)) {
const parent = map.get(t.parentId)!;
parent.children = parent.children || [];
parent.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
// Moved outside to prevent re-mounting
const Pagination: React.FC<{
current: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
}> = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center gap-2 mt-6">
<button
disabled={current === 1}
onClick={() => onChange(current - 1)}
className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
>
<ChevronDown className="w-4 h-4 rotate-90" />
</button>
<div className="flex items-center gap-1">
{[...Array(totalPages)].map((_, i) => {
const p = i + 1;
if (totalPages > 7) {
if (p !== 1 && p !== totalPages && Math.abs(p - current) > 1) {
if (p === 2 || p === totalPages - 1) return <span key={p} className="px-1 font-bold text-slate-300">...</span>;
return null;
}
}
return (
<button
key={p}
onClick={() => onChange(p)}
className={`w-9 h-9 flex items-center justify-center rounded-xl text-xs font-black transition-all ${current === p ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'bg-white border border-slate-200 text-slate-500 hover:border-indigo-500 hover:text-indigo-600'}`}
>
{p}
</button>
);
})}
</div>
<button
disabled={current === totalPages}
onClick={() => onChange(current + 1)}
className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
>
<ChevronDown className="w-4 h-4 -rotate-90" />
</button>
</div>
);
};
export const SettingsView: React.FC<SettingsViewProps> = ({
models,
authToken,
onUpdateModels,
isAdmin = false,
currentUser,
initialTab = 'general',
}) => {
const { t, language, setLanguage } = useLanguage();
const { confirm } = useConfirm();
const { showError, showSuccess } = useToast();
const [activeTab, setActiveTab] = useState<TabType>('general');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// --- Model Manager State ---
const [editingId, setEditingId] = useState<string | null>(null);
const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
type: ModelType.LLM,
baseUrl: 'http://localhost:11434/v1',
modelId: 'llama3',
name: '',
dimensions: 1536,
apiKey: '',
maxInputTokens: 8191,
maxBatchSize: 2048
});
const [users, setUsers] = useState<any[]>([]);
const [isUserLoading, setIsUserLoading] = useState(false);
const [userPage, setUserPage] = useState(1);
const USER_PAGE_SIZE = 20;
const [showAddUser, setShowAddUser] = useState(false);
const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '' });
const [userSuccess, setUserSuccess] = useState('');
// --- Change Password State ---
const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
const [passwordSuccess, setPasswordSuccess] = useState('');
// --- App Settings State ---
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
// --- Tenant Admin Binding Search State ---
const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
const [userSearchQuery, setUserSearchQuery] = useState('');
// --- Manage Members Modal State ---
const [managingMembersTenantId, setManagingMembersTenantId] = useState<string | null>(null);
const [tenantMembers, setTenantMembers] = useState<any[]>([]);
const [allMemberIds, setAllMemberIds] = useState<Set<string>>(new Set());
const [memberUserSearch, setMemberUserSearch] = useState('');
const [bindingRole, setBindingRole] = useState('USER');
const [currentMemberSearch, setCurrentMemberSearch] = useState('');
const [isMembersLoading, setIsMembersLoading] = useState(false);
const [activeTenantManagementId, setActiveTenantManagementId] = useState<string | null>(null);
const [memberPage, setMemberPage] = useState(1);
const [memberTotal, setMemberTotal] = useState(0);
const MEMBER_PAGE_SIZE = 20;
const [userTotal, setUserTotal] = useState(0);
// --- Tenant Tree & Global Management State ---
const [tenants, setTenants] = useState<Tenant[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
const [stats, setStats] = useState({ users: 0, tenants: 0 });
const [showCreateTenant, setShowCreateTenant] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [newTenant, setNewTenant] = useState<{ name: string; domain: string; parentId: string | null }>({
name: '',
domain: '',
parentId: null
});
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab);
}
}, [initialTab]);
useEffect(() => {
if (activeTab === 'user' || activeTab === 'tenants') {
fetchUsers(userPage);
}
}, [userPage]);
useEffect(() => {
if (selectedTenantId) {
fetchTenantMembers(selectedTenantId, memberPage);
fetchAllMemberIds(selectedTenantId);
} else {
setAllMemberIds(new Set());
}
}, [selectedTenantId, memberPage]);
// Data fetching on tab change
useEffect(() => {
// Reset pages when switching tabs to avoid bleed-over
if (activeTab === 'user' || activeTab === 'tenants') {
setUserPage(1);
}
if (activeTab === 'user') {
fetchUsers(1);
} else if (activeTab === 'general') {
fetchSettingsAndGroups();
} else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
fetchTenantsData();
fetchUsers(1); // Ensure users are loaded for admin binding
}
// Independent check for KB/Model settings to avoid being blocked by the branches above
if ((activeTab === 'knowledge_base' || activeTab === 'model') &&
(currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin)) {
fetchKnowledgeBaseSettings();
}
}, [activeTab, currentUser, authToken, isAdmin]);
const [kbSettings, setKbSettings] = useState<any>(null);
const [localKbSettings, setLocalKbSettings] = useState<any>(null);
const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
const fetchKnowledgeBaseSettings = async () => {
if (!authToken) return;
setIsLoading(true);
try {
const data = await userSettingService.get(authToken);
// If data is null, undefined, or empty object, use DEFAULT_SETTINGS
const finalSettings = (data && Object.keys(data).length > 0) ? { ...DEFAULT_SETTINGS, ...data } : DEFAULT_SETTINGS;
setKbSettings(finalSettings);
setLocalKbSettings(finalSettings);
} catch (error) {
console.error(error);
// Fallback to defaults on error to prevent blank page
setKbSettings(DEFAULT_SETTINGS);
setLocalKbSettings(DEFAULT_SETTINGS);
} finally {
setIsLoading(false);
}
};
const handleUpdateKbSettings = (key: string, value: any) => {
setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
};
const handleSaveKbSettings = async () => {
if (!authToken || !localKbSettings) return;
setIsSavingKbSettings(true);
try {
await userSettingService.update(authToken, localKbSettings);
setKbSettings(localKbSettings);
showSuccess(t('kbSettingsSaved'));
} catch (error) {
console.error(error);
showError(t('actionFailed'));
} finally {
setIsSavingKbSettings(false);
}
};
const handleCancelKbSettings = () => {
setLocalKbSettings(kbSettings);
};
const fetchSettingsAndGroups = async () => {
if (!authToken) return;
setIsSettingsLoading(true);
try {
const [settings, groups, personal] = await Promise.all([
userSettingService.get(authToken),
knowledgeGroupService.getGroups(),
userSettingService.getPersonal(authToken)
]);
setAppSettings(settings);
setKnowledgeGroups(groups);
// Sync local language with user settings if they differ
if (personal?.language && personal.language !== language) {
setLanguage(personal.language as any);
}
// Also update KB settings with the same data if not already set
if (settings && Object.keys(settings).length > 0) {
setKbSettings(settings);
setLocalKbSettings(settings);
}
} catch (error) {
console.error('Failed to fetch settings or groups:', error);
} finally {
setIsSettingsLoading(false);
}
};
// --- General tab handlers ---
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setPasswordSuccess('');
if (passwordForm.new !== passwordForm.confirm) {
setError(t('passwordMismatch'));
return;
}
if (passwordForm.new.length < 6) {
setError(t('newPasswordMinLength'));
return;
}
setIsLoading(true);
try {
await userService.changePassword(passwordForm.current, passwordForm.new);
setPasswordSuccess(t('passwordChangeSuccess'));
setPasswordForm({ current: '', new: '', confirm: '' });
} catch (err: any) {
setError(err.message || t('passwordChangeFailed'));
} finally {
setIsLoading(false);
}
};
// --- ユーザータブのハンドラー ---
const fetchUsers = async (page?: number) => {
setIsUserLoading(true);
const p = page || userPage;
try {
const result = await userService.getUsers(p, USER_PAGE_SIZE);
if (result && result.data) {
setUsers(result.data);
setUserTotal(result.total);
} else if (Array.isArray(result)) {
setUsers(result);
setUserTotal(result.length);
}
} catch (error: any) {
setError(error.message || t('getUserListFailed'));
} finally {
setIsUserLoading(false);
}
};
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setUserSuccess('');
if (newUser.username && newUser.password && newUser.displayName) {
setIsUserLoading(true);
try {
await userService.createUser(
newUser.username,
newUser.password,
false,
undefined,
newUser.displayName
);
showSuccess(t('userCreatedSuccess'));
setNewUser({ username: '', password: '', displayName: '' });
setShowAddUser(false);
fetchUsers();
} catch (error: any) {
setError(error.message || t('createUserFailed'));
} finally {
setIsUserLoading(false);
}
}
};
const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
// --- Edit User State ---
const [editUserData, setEditUserData] = useState<{ userId: string, username: string, displayName: string } | null>(null);
const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
try {
await userService.updateUser(userId, newAdminStatus);
// Re-fetch user list
fetchUsers();
setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
} catch (error: any) {
setError(error.message || t('updateUserFailed'));
}
};
const handleUserPasswordChange = async () => {
if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
try {
// Update user password
await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
setUserSuccess(t('passwordChangeSuccess'));
setPasswordChangeUserData(null);
fetchUsers(); // Refresh the user list
} catch (error: any) {
setError(error.message || t('passwordChangeFailed'));
}
};
const fetchAllMemberIds = async (tenantId: string) => {
try {
const { data } = await apiClient.get<string[]>(`/v1/tenants/${tenantId}/members/ids`);
if (Array.isArray(data)) {
setAllMemberIds(new Set(data));
}
} catch (e) {
console.error('Failed to fetch all member IDs:', e);
}
};
const fetchTenantMembers = async (tenantId: string, page?: number) => {
setIsMembersLoading(true);
const p = page || memberPage;
try {
const { data } = await apiClient.get(`/v1/tenants/${tenantId}/members?page=${p}&limit=${MEMBER_PAGE_SIZE}`);
if (data && data.data) {
setTenantMembers(data.data);
setMemberTotal(data.total);
} else if (Array.isArray(data)) {
setTenantMembers(data);
setMemberTotal(data.length);
}
} catch (e) {
console.error(e);
} finally {
setIsMembersLoading(false);
}
};
const handleAddMember = async (tenantId: string, userId: string, role: string = 'USER') => {
try {
await apiClient.post(`/v1/tenants/${tenantId}/members`, { userId, role });
setAllMemberIds(prev => {
const next = new Set(prev);
next.add(userId);
return next;
});
showSuccess(t('confirm'));
fetchTenantMembers(tenantId);
fetchTenantsData();
} catch (e: any) {
showError(e.message || 'Error adding member');
}
};
const handleRemoveMember = async (tenantId: string, userId: string) => {
try {
await apiClient.delete(`/v1/tenants/${tenantId}/members/${userId}`);
setAllMemberIds(prev => {
const next = new Set(prev);
next.delete(userId);
return next;
});
showSuccess('User removed from organization');
fetchTenantMembers(tenantId);
fetchTenantsData();
} catch (e: any) {
showError(e.message || 'Error removing member');
}
};
const handleUpdateMemberRole = async (tenantId: string, userId: string, role: string) => {
try {
await apiClient.patch(`/v1/tenants/${tenantId}/members/${userId}`, { role });
showSuccess(t('featureUpdated'));
fetchTenantMembers(tenantId);
} catch (e: any) {
showError(e.message || 'Error updating role');
}
};
const fetchTenantsData = async () => {
if (!authToken) return;
setIsLoading(true);
try {
const [tenRes, admRes] = await Promise.all([
apiClient.get('/v1/tenants'),
apiClient.get('/users?page=1&limit=1')
]);
const data: Tenant[] = tenRes.data;
const filteredData = data.filter(t => t.name !== 'Default');
setTenants(filteredData);
setStats(s => ({ ...s, tenants: filteredData.length }));
const result = admRes.data;
setStats(s => ({ ...s, users: result.total ?? result.length ?? 0 }));
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
const handleCreateTenant = async (e: React.FormEvent) => {
e.preventDefault();
try {
const path = editingTenant ? `/v1/tenants/${editingTenant.id}` : '/v1/tenants';
const body = {
name: newTenant.name,
domain: newTenant.domain,
parentId: newTenant.parentId
};
if (editingTenant) {
await apiClient.put(path, body);
} else {
await apiClient.post(path, body);
}
setShowCreateTenant(false);
setEditingTenant(null);
setNewTenant({ name: '', domain: '', parentId: null });
fetchTenantsData();
showSuccess(editingTenant ? 'Tenant updated' : 'Tenant created');
} catch (e: any) {
showError(e.message || 'Action failed');
}
};
const handleRemoveTenant = async (tenantId: string) => {
if (!(await confirm(t('confirmDeleteTenant')))) return;
try {
await apiClient.delete(`/v1/tenants/${tenantId}`);
setSelectedTenantId(null);
fetchTenantsData();
showSuccess('Tenant deleted');
} catch (e: any) {
showError(e.message || 'Delete failed');
}
};
const handleUpdateUser = async () => {
if (!editUserData) return;
try {
await userService.updateUserInfo(editUserData.userId, {
username: editUserData.username,
displayName: editUserData.displayName
});
showSuccess(t('featureUpdated'));
setEditUserData(null);
fetchUsers();
} catch (error: any) {
showError('Failed to update user');
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm(t('confirmDeleteUser') || "Are you sure?")) return;
try {
await userService.deleteUser(userId);
showSuccess(t('userDeletedSuccessfully'));
fetchUsers();
} catch (error: any) {
showError(error.message || t('deleteUserFailed'));
}
};
const handleExportUsers = async () => {
try {
const blob = await userService.exportUsers();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `users_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Export users failed', error);
showError(t('exportFailed'));
}
};
const handleImportUsers = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await userService.importUsers(file);
showSuccess(t('importSuccess').replace('$1', (result.success || 0).toString()).replace('$2', (result.failed || 0).toString()));
fetchUsers();
if (result.errors.length > 0) {
console.warn('Import had errors:', result.errors);
}
} catch (error: any) {
console.error('Import users failed', error);
showError(t('importFailed') + (error.response?.data?.message ? `: ${error.response.data.message}` : ''));
} finally {
// Reset input
e.target.value = '';
}
};
const handleSaveModel = async () => {
if (!authToken) return;
setIsLoading(true);
try {
await onUpdateModels(editingId === 'new' ? 'create' : 'update', modelFormData as ModelConfig);
setEditingId(null);
} catch (err) {
setError('Update failed');
} finally {
setIsLoading(false);
}
};
const handleToggleModel = async (model: ModelConfig) => {
if (currentUser?.role === 'TENANT_ADMIN') {
const newEnabledIds = enabledModelIds.includes(model.id)
? enabledModelIds.filter(id => id !== model.id)
: [...enabledModelIds, model.id];
try {
await apiClient.put('/v1/admin/settings', { enabledModelIds: newEnabledIds });
setEnabledModelIds(newEnabledIds);
setLocalKbSettings((prev: any) => ({ ...prev, enabledModelIds: newEnabledIds }));
showSuccess('Updated');
} catch (e: any) {
showError(e.message || 'Update failed');
}
return;
}
await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
};
const handleDeleteModel = async (id: string) => {
if (await confirm(t('confirmDeleteModel'))) {
await onUpdateModels('delete', { id } as ModelConfig);
}
};
const TenantTreeNode: React.FC<{
tenant: Tenant;
selectedTenantId: string | null;
onSelect: (id: string) => void;
onCreateSubtenant: (parentId: string) => void;
depth?: number;
}> = ({ tenant, selectedTenantId, onSelect, onCreateSubtenant, depth = 0 }) => {
const [collapsed, setCollapsed] = useState(false);
const hasChildren = tenant.children && tenant.children.length > 0;
const isSelected = selectedTenantId === tenant.id;
return (
<div className="select-none">
<div
className={`group flex items-center gap-2 px-3 py-2 rounded-xl cursor-pointer transition-all ${isSelected ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'hover:bg-slate-50 text-slate-600 hover:text-slate-900'}`}
style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
onClick={() => onSelect(tenant.id)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
className={`p-0.5 rounded-md hover:bg-black/10 transition-colors ${isSelected ? 'text-white/70' : 'text-slate-400'}`}
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
) : (
<div className="w-5" />
)}
<Building size={16} className={isSelected ? 'text-white' : 'text-slate-400'} />
<span className="text-sm font-bold truncate">{tenant.name}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onCreateSubtenant(tenant.id);
}}
className={`p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all ${isSelected ? 'hover:bg-white/20 text-white' : 'hover:bg-slate-200 text-slate-400 hover:text-indigo-600'}`}
title={t('createSubOrg')}
>
<Plus size={14} />
</button>
</div>
{hasChildren && !collapsed && (
<div className="mt-1">
{tenant.children?.map(child => (
<TenantTreeNode
key={child.id}
tenant={child}
selectedTenantId={selectedTenantId}
onSelect={onSelect}
onCreateSubtenant={onCreateSubtenant}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
};
const getTypeLabel = (type: ModelType) => {
switch (type) {
case ModelType.LLM: return t('typeLLM');
case ModelType.EMBEDDING: return t('typeEmbedding');
case ModelType.RERANK: return t('typeRerank');
case ModelType.VISION: return t('typeVision');
default: return type;
}
};
// --- Rendering functions ---
const renderGeneralTab = () => (
<div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
{/* Change password section */}
<section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
<h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
<Key className="w-4 h-4 text-blue-500" />
{t('changePassword')}
</h3>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
<input type="hidden" name="username" value="current-user" autoComplete="username" />
<div>
<input
type="password"
name="currentPassword"
placeholder={t('currentPassword')}
value={passwordForm.current}
onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="current-password"
/>
</div>
<div>
<input
type="password"
name="newPassword"
placeholder={t('newPassword')}
value={passwordForm.new}
onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="new-password"
/>
</div>
<div>
<input
type="password"
name="confirmPassword"
placeholder={t('confirmPassword')}
value={passwordForm.confirm}
onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
required
autoComplete="new-password"
/>
</div>
{passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
</button>
</form>
</section>
{/* 语言设置セクション */}
<section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
<h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
<Globe className="w-4 h-4 text-blue-500" />
{t('languageSettings')}
</h3>
<div className="space-y-4 max-w-sm">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">
{t('switchLanguage')}
</label>
<select
value={language}
onChange={async (e) => {
const newLang = e.target.value as any;
setLanguage(newLang);
try {
await settingsService.updateLanguage(newLang);
showSuccess(t('confirm'));
} catch (err) {
console.error('Failed to update backend language preference:', err);
}
}}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
<option value="en">English</option>
<option value="zh"> (Chinese)</option>
<option value="ja"> (Japanese)</option>
</select>
</div>
</div>
</section>
</div >
);
const renderUserTab = () => (
<div className="space-y-6 w-full">
<div className="flex justify-between items-center mb-6">
<div>
<p className="text-xs text-slate-400 font-medium">{''}</p>
</div>
{currentUser?.role === 'SUPER_ADMIN' && (
<div className="flex gap-2">
<button
onClick={handleExportUsers}
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
title={t('exportUsers')}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('exportUsers')}</span>
</button>
<div className="relative">
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleImportUsers}
className="absolute inset-0 opacity-0 cursor-pointer"
title={t('importUsers')}
/>
<button
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('importUsers')}</span>
</button>
</div>
<button
onClick={() => setShowAddUser(!showAddUser)}
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
{t('addUser')}
</button>
</div>
)}
</div>
{showAddUser && (
<motion.form
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
onSubmit={handleCreateUser}
className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<input
type="text"
placeholder={t('usernamePlaceholder')}
value={newUser.username}
onChange={e => setNewUser({ ...newUser, username: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
required
autoComplete="username"
/>
<input
type="text"
placeholder={t('displayNamePlaceholder') || t('name')}
value={newUser.displayName}
onChange={e => setNewUser({ ...newUser, displayName: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
required
autoComplete="name"
/>
<input
type="password"
placeholder={t('passwordPlaceholder')}
value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
required
autoComplete="new-password"
/>
</div>
<div className="flex items-center justify-between">
<div></div>
<div className="flex gap-3">
<button type="button" onClick={() => setShowAddUser(false)} className="px-5 py-2 text-xs font-bold text-slate-500 hover:text-slate-700">{t('cancel')}</button>
<button type="submit" className="px-8 py-2 bg-slate-900 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:bg-indigo-600 transition-all shadow-lg shadow-slate-100">{t('create')}</button>
</div>
</div>
</motion.form>
)}
{createPortal(
<AnimatePresence>
{passwordChangeUserData && (
<div key="password-change-modal" className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[1000] p-6">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
>
<div className="flex items-center justify-between mb-8">
<h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
<button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
<X size={20} className="text-slate-400" />
</button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
{t('newPassword')}
</label>
<input
type="password"
value={passwordChangeUserData.newPassword}
onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
placeholder={t('enterNewPassword')}
required
/>
</div>
<div className="flex gap-4 pt-4">
<button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
<button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('confirmChange')}</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
{/* Edit User Modal */}
{createPortal(
<AnimatePresence>
{editUserData && (
<div key="edit-user-modal" className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
>
<div className="flex items-center justify-between mb-8">
<h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
<button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
<X size={20} className="text-slate-400" />
</button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
{t('username')}
</label>
<input
type="text"
value={editUserData.username}
onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
placeholder={t('usernamePlaceholder')}
required
/>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
{t('displayName') || t('name')}
</label>
<input
type="text"
value={editUserData.displayName}
onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
required
/>
</div>
<div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
<p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
<p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
{t('roleManagedInOrg') || "Roles are managed within organizations."}
</p>
</div>
<div className="flex gap-4 pt-4">
<button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
<button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('saveChanges')}</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
<div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-hidden shadow-sm">
<table className="w-full border-collapse text-left">
<thead>
<tr className="bg-slate-50/50 border-b border-slate-200/50">
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('username')}</th>
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('displayName') || t('name')}</th>
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('organizations')}</th>
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('createdAt')}</th>
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<AnimatePresence>
{users.filter((u: any) => u.username !== 'admin').map((user: any, index: number) => {
let IconComponent = User;
let iconColors = 'bg-slate-50 text-slate-400';
if (user.isAdmin) {
IconComponent = Shield;
iconColors = 'bg-red-50 text-red-600';
}
return (
<motion.tr
key={user.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="group hover:bg-slate-50/50 transition-all"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${iconColors}`}>
<IconComponent size={18} />
</div>
<div className="min-w-0">
<p className="font-bold text-slate-900 truncate">{user.username}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-600 truncate">{user.displayName || '-'}</p>
</td>
<td className="px-6 py-4">
{user.tenantMembers && user.tenantMembers.length > 0 ? (
<div className="flex flex-wrap gap-1">
{user.tenantMembers
.filter((m: any) => m.tenant?.name !== 'Default')
.map((m: any) => (
<span
key={m.tenantId}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-700 text-[9px] font-black rounded-md uppercase tracking-wider border border-emerald-100"
>
<Building size={8} />
{m.tenant?.name || m.tenantId}
</span>
))}
</div>
) : (
<span className="text-[10px] text-slate-400 italic">{t('noOrganization')}</span>
)}
</td>
<td className="px-6 py-4">
<p className="text-[11px] font-medium text-slate-600">
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
{user.username !== 'admin' && (
<>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditUserData({
userId: user.id,
username: user.username,
displayName: user.displayName || ''
});
}}
className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
title={t('edit')}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
title={t('changeUserPassword')}
>
<Key className="w-4 h-4" />
</button>
{user.id !== currentUser?.id && (
<button
onClick={() => handleDeleteUser(user.id)}
className="p-2 rounded-lg text-slate-400 hover:text-red-500 hover:bg-white shadow-sm transition-all"
title={t('deleteUser')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</>
)}
</div>
</td>
</motion.tr>
);
})}
</AnimatePresence>
</tbody>
</table>
</div>
<Pagination
current={userPage}
total={userTotal}
pageSize={USER_PAGE_SIZE}
onChange={setUserPage}
/>
</div>
);
const renderTenantsTab = () => {
const tenantTree = buildTenantTree(tenants);
const activeTenant = selectedTenantId ? tenants.find(t => t.id === selectedTenantId) : null;
return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)] animate-in fade-in duration-500">
{/* Left: Organization Tree */}
<div className="w-full lg:w-80 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-sm overflow-hidden min-h-[400px] lg:min-h-0">
<div className="p-6 border-b border-slate-100 flex items-center justify-between shrink-0">
<div>
<h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{t('globalTenantControl')}</p>
</div>
<button
onClick={() => {
setNewTenant({ name: '', domain: '', parentId: null });
setEditingTenant(null);
setShowCreateTenant(true);
}}
className="p-2 bg-slate-900 text-white rounded-xl hover:bg-slate-700 transition-all"
>
<Plus size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
{tenantTree.length > 0 ? (
tenantTree.map(t => (
<TenantTreeNode
key={t.id}
tenant={t}
selectedTenantId={selectedTenantId}
onSelect={(id) => {
if (id !== selectedTenantId) {
setSelectedTenantId(id);
setMemberPage(1);
setUserPage(1);
}
}}
onCreateSubtenant={(parentId) => {
setNewTenant({ name: '', domain: '', parentId });
setEditingTenant(null);
setShowCreateTenant(true);
}}
/>
))
) : (
<div className="py-20 text-center">
<Building size={32} className="mx-auto text-slate-200 mb-3" />
<p className="text-xs font-black text-slate-300 uppercase tracking-widest">{t('noOrganizations')}</p>
</div>
)}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100 shrink-0">
<div className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-2xl shadow-sm">
<div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
<Building size={20} />
</div>
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('totalTenants')}</p>
<p className="text-xl font-black text-slate-900">{stats.tenants}</p>
</div>
</div>
</div>
</div>
{/* Right: User List & Management */}
<div className="flex-1 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-xl overflow-hidden min-h-[600px] lg:min-h-0">
{activeTenant ? (
<div className="flex flex-col h-full">
{/* Organization Header */}
<div className="p-8 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center text-indigo-600">
<Building size={28} />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-black text-slate-900 tracking-tight">{activeTenant.name}</h2>
<span className="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase tracking-widest border border-indigo-100">{t('activeOrg')}</span>
</div>
<p className="text-xs font-medium text-slate-400">{activeTenant.domain || t('noCustomDomain')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setEditingTenant(activeTenant);
setNewTenant({
name: activeTenant.name,
domain: activeTenant.domain || '',
parentId: activeTenant.parentId || null
});
setShowCreateTenant(true);
}}
className="p-2.5 bg-white border border-slate-200 text-slate-600 rounded-xl hover:border-indigo-500 hover:text-indigo-600 transition-all shadow-sm"
title={t('orgSettings')}
>
<SettingsIcon size={18} />
</button>
<button
onClick={() => handleRemoveTenant(activeTenant.id)}
className="p-2.5 bg-white border border-slate-200 text-slate-400 hover:text-red-500 hover:border-red-500 transition-all shadow-sm"
title={t('deleteOrg')}
>
<Trash2 size={18} />
</button>
</div>
</div>
{/* Main Content Split: Members vs All Users */}
<div className="flex-1 flex overflow-hidden">
{/* Current Members */}
<div className="flex-1 flex flex-col border-r border-slate-100 overflow-hidden">
<div className="p-6 border-b border-slate-50 flex items-center justify-between shrink-0">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('orgMembers')}</h4>
<span className="text-[10px] font-black px-2 py-0.5 bg-slate-100 text-slate-500 rounded-full">
{t('membersCount').replace('$1', (memberTotal || 0).toString())}
</span>
</div>
<div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
<div className="grid grid-cols-1 gap-3">
{tenantMembers?.map((m: any) => (
<div key={m.id} className="p-4 bg-slate-50/50 border border-slate-100 rounded-2xl flex items-center justify-between group hover:bg-white hover:shadow-sm transition-all hover:border-slate-200">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm">
<User size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-black text-slate-900 truncate">
{m.user?.displayName || m.user?.username || m.userId}
</p>
<select
value={m.role}
onChange={(e) => handleUpdateMemberRole(activeTenant.id, m.userId, e.target.value)}
className={`text-[9px] font-black uppercase tracking-widest bg-transparent border-none outline-none cursor-pointer hover:bg-slate-100 rounded px-1 transition-colors ${m.role === 'TENANT_ADMIN' ? 'text-indigo-500' : 'text-slate-400'}`}
>
<option value="USER">{t('roleRegularUser')}</option>
<option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
</select>
</div>
</div>
<button
onClick={() => handleRemoveMember(activeTenant.id, m.userId)}
className="p-2 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div>
))}
{(!tenantMembers || tenantMembers.length === 0) && (
<div className="py-20 text-center">
<Users size={24} className="mx-auto text-slate-200 mb-2" />
<p className="text-[10px] font-bold text-slate-300 uppercase tracking-wider">{t('noMembersAssigned')}</p>
</div>
)}
</div>
<Pagination
current={memberPage}
total={memberTotal}
pageSize={MEMBER_PAGE_SIZE}
onChange={setMemberPage}
/>
</div>
</div>
{/* Add New Users (Right side of specific tenant view) */}
<div className="w-72 flex flex-col bg-slate-50/30 overflow-hidden shrink-0">
<div className="p-6 border-b border-slate-100 shrink-0">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3">{t('addMembers')}</h4>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
className="w-full pl-9 pr-3 py-2 bg-white border border-slate-200 rounded-xl text-xs outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
placeholder={t('searchSystemUsers')}
value={userSearchQuery}
onChange={e => setUserSearchQuery(e.target.value)}
/>
</div>
<div className="mt-3 flex gap-1 p-1 bg-white border border-slate-200 rounded-xl">
<button
onClick={() => setBindingRole('USER')}
className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'USER' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
{t('roleRegularUser')}
</button>
<button
onClick={() => setBindingRole('TENANT_ADMIN')}
className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'TENANT_ADMIN' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
{t('roleTenantAdmin')}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{users
.filter(u =>
u.username !== 'admin' &&
u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
)
.map(u => {
const isAlreadyMember = allMemberIds.has(u.id);
return (
<button
key={u.id}
onClick={() => !isAlreadyMember && handleAddMember(activeTenant.id, u.id, bindingRole)}
disabled={isAlreadyMember}
className={`w-full p-3 border rounded-xl flex items-center justify-between group transition-all ${isAlreadyMember
? 'bg-slate-50 border-slate-100 cursor-not-allowed opacity-60'
: 'bg-white border-slate-100 hover:border-indigo-500 hover:shadow-sm'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isAlreadyMember
? 'bg-slate-100 text-slate-300'
: 'bg-slate-50 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-600'
}`}>
<User size={14} />
</div>
<span className={`text-[13px] font-bold truncate ${isAlreadyMember ? 'text-slate-400' : 'text-slate-700'}`}>
{u.displayName || u.username}
</span>
</div>
{isAlreadyMember ? (
<Check size={14} className="text-emerald-500" />
) : (
<Plus size={14} className="text-slate-300 group-hover:text-indigo-600" />
)}
</button>
);
})
}
<Pagination
current={userPage}
total={userTotal}
pageSize={USER_PAGE_SIZE}
onChange={setUserPage}
/>
</div>
</div>
</div>
{/* Create Tenant Modal (Nested in Tab Content for scope) */}
{showCreateTenant && (
<div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
<h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? t('editOrg') : t('newTenant')}</h3>
<form onSubmit={handleCreateTenant} className="space-y-5">
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
{!editingTenant ? (
<div className="w-full mt-1 px-4 py-3 bg-slate-100 border border-slate-200 rounded-2xl text-sm text-slate-500 font-bold">
{newTenant.parentId ? tenants.find(t => t.id === newTenant.parentId)?.name : t('noneRoot')}
</div>
) : (
<select
className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20"
value={newTenant.parentId || ''}
onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
>
<option value="">{t('noneRoot')}</option>
{tenants.filter(t => t.id !== editingTenant?.id).map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
)}
</div>
<div className="flex gap-4 pt-2">
<button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
<button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{editingTenant ? t('update') : t('create')}</button>
</div>
</form>
</motion.div>
</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-slate-50/30">
<div className="w-24 h-24 rounded-[2rem] bg-indigo-50 flex items-center justify-center text-indigo-400 mb-6 shadow-xl shadow-indigo-100/50">
<Building size={48} />
</div>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">{t('selectOrg')}</h2>
<p className="text-sm text-slate-400 max-w-xs font-medium leading-relaxed">{t('selectOrgDesc')}</p>
<div className="mt-12 grid grid-cols-2 gap-4 w-full max-w-lg">
<div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
<div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600 mb-4">
<Users size={20} />
</div>
<p className="text-xl font-black text-slate-900">{stats.users}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('totalSystemUsers')}</p>
</div>
<div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
<div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 mb-4">
<Shield size={20} />
</div>
<p className="text-xl font-black text-slate-900">{tenants.filter(t => t.parentId === null).length}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('rootOrgs')}</p>
</div>
</div>
{/* Scope Modal for Create even when no selection */}
{showCreateTenant && (
<div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
<h3 className="text-xl font-black text-slate-900 mb-6">{t('newTenant')}</h3>
<form onSubmit={handleCreateTenant} className="space-y-5">
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
<input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
<select
className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20"
value={newTenant.parentId || ''}
onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
>
<option value="">{t('noneRoot')}</option>
{tenants.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div className="flex gap-4 pt-2">
<button
type="button"
onClick={() => {
setShowCreateTenant(false);
setNewTenant({ name: '', domain: '', parentId: null });
}}
className="flex-1 py-3 text-slate-500 font-bold text-sm"
>
{t('cancel')}
</button>
<button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{t('create')}</button>
</div>
</form>
</motion.div>
</div>
)}
</div>
)}
</div>
</div>
);
};
const renderKnowledgeBaseTab = () => (
<div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
{localKbSettings ? (
<>
{/* Save/Cancel Bar */}
<div className="flex justify-end gap-3 sticky top-0 z-20 py-4 bg-white/50 backdrop-blur-sm border-b border-slate-100 mb-6">
<button
onClick={handleCancelKbSettings}
disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
>
{t('cancel')}
</button>
<button
onClick={handleSaveKbSettings}
disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
className="flex items-center gap-2 px-8 py-2 bg-indigo-600 text-white text-sm font-black uppercase tracking-widest rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95 disabled:opacity-50"
>
{isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{t('saveChanges')}
</button>
</div>
{/* Model Configuration */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
<Cpu size={16} />
</div>
{t('modelConfiguration')}
</div>
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
<select
value={localKbSettings.selectedLLMId || ''}
onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all cursor-pointer appearance-none"
>
<option value="">--- {t('selectLLMModel')} ---</option>
{models.filter(m => m.type === ModelType.LLM).map(m => (
<option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
<select
value={localKbSettings.selectedEmbeddingId || ''}
onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
>
<option value="">--- {t('selectEmbeddingModel')} ---</option>
{models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
<select
value={localKbSettings.selectedRerankId || ''}
onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
>
<option value="">--- {t('selectRerankModel')} ---</option>
{models.filter(m => m.type === ModelType.RERANK).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
{t('defaultVisionModel')}
<span className="ml-1 text-[8px] opacity-60">({t('typeVision')})</span>
</label>
<select
value={localKbSettings.selectedVisionId || ''}
onChange={(e) => handleUpdateKbSettings('selectedVisionId', e.target.value)}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
>
<option value="">--- {t('selectVisionModel')} ---</option>
{models.filter(m => m.type === ModelType.VISION || m.supportsVision).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
</div>
</div>
</section>
{/* Indexing & Chunking Configuration */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
<BookOpen size={16} />
</div>
{t('indexingChunkingConfig')}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
</div>
<input
type="range"
min="100"
max="8192"
step="100"
value={localKbSettings.chunkSize || 1000}
onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
</div>
<input
type="range"
min="0"
max="2048"
step="10"
value={localKbSettings.chunkOverlap || 100}
onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
</section>
{/* Chat Hyperparameters */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
<Sparkles size={16} />
</div>
{t('chatHyperparameters')}
</div>
<div className="space-y-8">
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.1"
value={localKbSettings.temperature || 0.7}
onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
<span>{t('precise')}</span>
<span>{t('creative')}</span>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
<input
type="number"
value={localKbSettings.maxTokens || 2000}
onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
/>
</div>
</div>
</section>
{/* Retrieval & Search Settings */}
<section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
<div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
<Database size={16} />
</div>
{t('retrievalSearchSettings')}
</div>
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
</div>
<input
type="range"
min="1"
max="50"
step="1"
value={localKbSettings.topK || 10}
onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between mb-3 px-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
<span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={localKbSettings.similarityThreshold || 0.5}
onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
<div className="space-y-4 pt-4 border-t border-slate-100">
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
<div className="text-[10px] text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableFullTextSearch ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
{localKbSettings.enableFullTextSearch && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
>
<div className="flex justify-between mb-2 px-1">
<label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
<span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={localKbSettings.hybridVectorWeight || 0.5}
onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
<span>{t('pureText')}</span>
<span>{t('pureVector')}</span>
</div>
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
<div className="text-[10px] text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableQueryExpansion ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
<div className="text-[10px] text-slate-400 font-medium">{t('hydeDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableHyDE ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
</div>
<div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
<div>
<div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
<div className="text-[10px] text-slate-400 font-medium">{t('rerankingDesc')}</div>
</div>
<button
onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableRerank ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
>
<span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
</button>
</div>
{localKbSettings.enableRerank && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
>
<div className="flex justify-between mb-2 px-1">
<label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
<span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={localKbSettings.rerankSimilarityThreshold || 0.5}
onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
<span>{t('broad')}</span>
<span>{t('strict')}</span>
</div>
</motion.div>
)}
</div>
</div>
</section>
</>
) : (
<div className="flex flex-col items-center justify-center py-20 space-y-4">
<Loader2 size={40} className="animate-spin text-indigo-600 opacity-20" />
<p className="text-sm font-medium text-slate-400 animate-pulse">{t('loading')}</p>
</div>
)}
</div>
);
const renderModelTab = () => (
<div className="w-full space-y-6">
<div className="flex justify-between items-center mb-6">
<div>
</div>
{!editingId && currentUser?.role === 'SUPER_ADMIN' && (
<button
onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
{t('mmAddBtn')}
</button>
)}
</div>
{editingId ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
>
<div className="flex items-center gap-4 mb-2">
<div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
<Cpu className="w-6 h-6" />
</div>
<h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
<input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.name || ''} onChange={e => setModelFormData({ ...modelFormData, name: e.target.value })} disabled={isLoading} />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
<input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.modelId || ''} onChange={e => setModelFormData({ ...modelFormData, modelId: e.target.value })} disabled={isLoading} />
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
<select className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all appearance-none" value={modelFormData.type} onChange={e => setModelFormData({ ...modelFormData, type: e.target.value as ModelType })} disabled={isLoading}>
<option value={ModelType.LLM}>{t('typeLLM')}</option>
<option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
<option value={ModelType.RERANK}>{t('typeRerank')}</option>
<option value={ModelType.VISION}>{t('typeVision')}</option>
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
<input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.baseUrl || ''} onChange={e => setModelFormData({ ...modelFormData, baseUrl: e.target.value })} disabled={isLoading} />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
<input
type="password"
className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
value={modelFormData.apiKey || ''}
onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
disabled={isLoading}
placeholder={t('mmFormApiKeyPlaceholder')}
/>
</div>
{modelFormData.type === ModelType.EMBEDDING && (
<div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
<input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.maxInputTokens || 8191} onChange={e => setModelFormData({ ...modelFormData, maxInputTokens: parseInt(e.target.value) })} />
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
<input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.dimensions || 1536} onChange={e => setModelFormData({ ...modelFormData, dimensions: parseInt(e.target.value) })} />
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => { setEditingId(null); setError(null); }} className="px-6 py-3 text-sm font-bold text-slate-500 hover:text-slate-700">{t('mmCancel')}</button>
<button onClick={handleSaveModel} className="px-10 py-3 bg-indigo-600 text-white rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-100 transition-all active:scale-95 flex items-center gap-2" disabled={isLoading}>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{t('mmSave')}
</button>
</div>
</motion.div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 overflow-y-auto pr-2 pb-6 scrollbar-hide">
<AnimatePresence>
{models.map((model, index) => (
<motion.div
key={model.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
className="bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-[2rem] p-6 flex flex-col justify-between group hover:shadow-xl hover:shadow-indigo-500/5 hover:border-indigo-500/30 transition-all duration-300 relative overflow-hidden"
>
{/* Subtle background pattern/glow */}
<div className="absolute -top-12 -right-12 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl group-hover:bg-indigo-500/10 transition-all duration-500" />
<div className="relative z-10">
<div className="flex items-start justify-between mb-5">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-500 ${model.isEnabled !== false ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100 rotate-0 group-hover:rotate-6' : 'bg-slate-100 text-slate-400 opacity-60'}`}>
<Cpu size={26} strokeWidth={2.5} />
</div>
<div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
<button
onClick={() => handleToggleModel(model)}
className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
>
{((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
</button>
</div>
</div>
<div className="space-y-1 mb-6">
<div className="flex items-center gap-2.5">
<h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] font-black bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-indigo-100/50">
{getTypeLabel(model.type)}
</span>
{model.isDefault && (
<span className="text-[9px] font-black bg-amber-50 text-amber-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-amber-100/50 flex items-center gap-1">
<Sparkles size={8} /> {t('defaultBadge')}
</span>
)}
</div>
<p className="text-[11px] font-mono text-slate-400 mt-2 truncate bg-slate-50 px-2 py-1 rounded-lg border border-slate-100/50 inline-block max-w-full">
{model.modelId}
</p>
</div>
{/* Additional info grid */}
<div className="grid grid-cols-2 gap-3 mb-6">
{model.type === ModelType.EMBEDDING && (
<>
<div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
<span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
</div>
<div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
<span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
</div>
</>
)}
{model.type === ModelType.LLM && (
<div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
<span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
<span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
<SettingsIcon size={12} />
{t('configured')}
</div>
<div className="flex gap-2">
{currentUser?.role === 'SUPER_ADMIN' && (
<>
<button
onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm transition-all"
>
<Edit2 size={15} />
</button>
<button
onClick={() => handleDeleteModel(model.id)}
className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-red-500 hover:bg-red-50 hover:shadow-sm transition-all"
>
<Trash2 size={15} />
</button>
</>
)}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{models.length === 0 && (
<div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
<Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
</div>
)}
</div>
)}
</div>
);
return (
<div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
{/* Settings Sidebar */}
<div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
<div className="p-6 pb-2">
<h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1">
<button
onClick={() => setActiveTab('general')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'general' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<SettingsIcon size={18} />
{t('generalSettings')}
</button>
{currentUser?.role === 'SUPER_ADMIN' && (
<button
onClick={() => setActiveTab('user')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'user' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<UserCircle size={18} />
{t('userManagement')}
</button>
)}
{isAdmin && (
<>
<button
onClick={() => setActiveTab('model')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'model' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<HardDrive size={18} />
{t('modelManagement')}
</button>
<button
onClick={() => setActiveTab('knowledge_base')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'knowledge_base' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<Database size={18} />
{t('sidebarTitle')}
</button>
</>
)}
{currentUser?.role === 'SUPER_ADMIN' && (
<button
onClick={() => setActiveTab('tenants')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'tenants' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<LayoutGrid size={18} />
{t('navTenants')}
</button>
)}
{isAdmin && (
<button
onClick={() => setActiveTab('assessment_templates')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'assessment_templates' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<FileText size={18} />
{t('assessmentTemplates')}
</button>
)}
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
{activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : t('assessmentTemplates')}
</h1>
<p className="text-[15px] text-slate-500 mt-1">
{activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : t('assessmentTemplatesSubtitle')}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
<div className="w-full">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8 p-4 bg-red-50/80 backdrop-blur-md border border-red-200/50 text-red-700 rounded-2xl flex gap-3 items-center text-sm shadow-sm"
>
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
<X className="w-4 h-4 text-red-600" />
</div>
<div>
<span className="font-black uppercase tracking-widest text-[10px] block mb-0.5">{t('errorLabel')}</span>
{error}
</div>
</motion.div>
)}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'general' && renderGeneralTab()}
{activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
{activeTab === 'model' && isAdmin && renderModelTab()}
{activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
{activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
{activeTab === 'assessment_templates' && isAdmin && (
<div className="bg-white rounded-3xl border border-slate-200/60 p-8 shadow-sm">
<AssessmentTemplateManager />
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
);
};
+25
View File
@@ -0,0 +1,25 @@
export const DOC_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'csv', 'txt', 'md', 'html', 'json', 'xml', 'odt', 'ods', 'odp'];
export const CODE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx', 'css', 'py', 'java', 'sql', 'cpp', 'h', 'go', 'rs', 'php', 'rb'];
export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'];
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', 'image/tiff'];
export const KB_ALLOWED_EXTENSIONS = [...DOC_EXTENSIONS, ...CODE_EXTENSIONS, ...IMAGE_EXTENSIONS];
export const GROUP_ALLOWED_EXTENSIONS = [...DOC_EXTENSIONS, ...CODE_EXTENSIONS, ...IMAGE_EXTENSIONS];
export const isExtensionAllowed = (ext: string, type: 'kb' | 'group'): boolean => {
const normalizedExt = ext.toLowerCase().replace('.', '');
if (type === 'kb') {
return KB_ALLOWED_EXTENSIONS.includes(normalizedExt);
}
return GROUP_ALLOWED_EXTENSIONS.includes(normalizedExt);
};
export const getSupportedFormatsLabel = (type: 'kb' | 'group'): string => {
return 'PDF, Word, Excel, PPT, TXT, MD, Code(JS/TS/PY...), Images...';
};
export const isFormatSupportedForPreview = (filename: string): boolean => {
const ext = filename.toLowerCase().split('.').pop() || '';
return [...DOC_EXTENSIONS, ...IMAGE_EXTENSIONS].includes(ext);
};
+72
View File
@@ -0,0 +1,72 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import ConfirmDialog from '../components/ConfirmDialog';
interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
}
interface ConfirmContextType {
confirm: (options: ConfirmOptions | string) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextType | undefined>(undefined);
export const useConfirm = () => {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return context;
};
interface ConfirmProviderProps {
children: ReactNode;
}
export const ConfirmProvider: React.FC<ConfirmProviderProps> = ({ children }) => {
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null);
const confirm = (opts: ConfirmOptions | string): Promise<boolean> => {
return new Promise((resolve) => {
if (typeof opts === 'string') {
setOptions({ message: opts });
} else {
setOptions(opts);
}
setResolveRef(() => resolve);
});
};
const handleConfirm = () => {
if (resolveRef) resolveRef(true);
setOptions(null);
setResolveRef(null);
};
const handleCancel = () => {
if (resolveRef) resolveRef(false);
setOptions(null);
setResolveRef(null);
};
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
{options && (
<ConfirmDialog
isOpen={!!options}
title={options.title}
message={options.message}
confirmLabel={options.confirmLabel}
cancelLabel={options.cancelLabel}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)}
</ConfirmContext.Provider>
);
};
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import { translations, Language } from '../utils/translations';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: keyof typeof translations['zh'], ...args: any[]) => string;
}
const LanguageContext = React.createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [language, setLanguage] = React.useState<Language>(() => {
const saved = localStorage.getItem('userLanguage');
return (saved as Language) || 'zh';
});
const handleSetLanguage = (lang: Language) => {
setLanguage(lang);
localStorage.setItem('userLanguage', lang);
};
const t = (key: keyof typeof translations['zh'], ...args: any[]) => {
let str = translations[language][key] || key;
if (args.length > 0) {
args.forEach((arg, i) => {
str = str.replace(new RegExp(`\\$${i + 1}`, 'g'), String(arg));
});
}
return str;
};
return React.createElement(
LanguageContext.Provider,
{ value: { language, setLanguage: handleSetLanguage, t } },
children
);
};
export const useLanguage = () => {
const context = React.useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
+83
View File
@@ -0,0 +1,83 @@
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
import Toast, { ToastType } from '../components/Toast';
import { registerToastHandler } from '../src/utils/toast';
interface ToastItem {
id: string;
type: ToastType;
title?: string;
message: string;
duration?: number;
}
interface ToastContextType {
showToast: (type: ToastType, message: string, title?: string, duration?: number) => void;
showSuccess: (message: string, title?: string) => void;
showError: (message: string, title?: string) => void;
showWarning: (message: string, title?: string) => void;
showInfo: (message: string, title?: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
interface ToastProviderProps {
children: ReactNode;
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = (type: ToastType, message: string, title?: string, duration?: number) => {
const id = Date.now().toString();
const newToast: ToastItem = { id, type, message, title, duration };
setToasts(prev => {
// Deduplicate identical messages: discard old one if current type and content are the same
const filtered = prev.filter(t => t.message !== message || t.type !== type);
return [...filtered, newToast];
});
};
useEffect(() => {
registerToastHandler({ showToast });
}, []);
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
const showSuccess = (message: string, title?: string) => showToast('success', message, title);
const showError = (message: string, title?: string) => showToast('error', message, title);
const showWarning = (message: string, title?: string) => showToast('warning', message, title);
const showInfo = (message: string, title?: string) => showToast('info', message, title);
return (
<ToastContext.Provider value={{ showToast, showSuccess, showError, showWarning, showInfo }}>
{children}
<div className="fixed bottom-0 right-0 z-[9999] p-4 space-y-3 pointer-events-none w-full max-w-sm flex flex-col items-end">
{toasts.map((toast) => (
<div
key={toast.id}
className="pointer-events-auto w-full"
>
<Toast
type={toast.type}
title={toast.title}
message={toast.message}
duration={toast.duration}
onClose={() => removeToast(toast.id)}
/>
</div>
))}
</div>
</ToastContext.Provider>
);
};
+8
View File
@@ -0,0 +1,8 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@layer base {
body {
@apply bg-[#FCFDFF] text-slate-800 antialiased;
}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="AuraK - Your Intelligent AI Knowledge Base" />
<title>AuraK</title>
<!-- Katex CSS for math rendering -->
<link rel="stylesheet" href="/katex/katex.min.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
import './index.css';
import React, { lazy, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './src/contexts/AuthContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { ToastProvider } from './contexts/ToastContext';
import { ConfirmProvider } from './contexts/ConfirmContext';
import Login from './src/pages/auth/Login';
import WorkspaceLayout from './src/components/layouts/WorkspaceLayout';
// Lazy-loaded page components
const ChatPage = lazy(() => import('./src/pages/workspace/ChatPage'));
const AgentsPage = lazy(() => import('./src/pages/workspace/AgentsPage'));
const AssessmentPage = lazy(() => import('./src/pages/workspace/AssessmentPage'));
const PluginsPage = lazy(() => import('./src/pages/workspace/PluginsPage'));
const KnowledgePage = lazy(() => import('./src/pages/workspace/KnowledgePage'));
const NotebooksPage = lazy(() => import('./src/pages/workspace/NotebooksPage'));
const MemosPage = lazy(() => import('./src/pages/workspace/MemosPage'));
const SettingsPage = lazy(() => import('./src/pages/workspace/SettingsPage'));
const AssessmentStatsPage = lazy(() => import('./src/pages/workspace/AssessmentStatsPage'));
const PageLoader = () => (
<div className="flex h-full items-center justify-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
function ProtectedRoute({ children, allowedRoles }: { children: React.ReactNode, allowedRoles?: string[] }) {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-slate-50">
<PageLoader />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
if (allowedRoles && !allowedRoles.includes(user.role)) return <Navigate to="/" replace />;
return <>{children}</>;
}
function OverviewPage() {
const { user } = useAuth();
return (
<div className="p-8 bg-white rounded-2xl shadow-sm border border-slate-100">
<h1 className="text-2xl font-bold text-slate-900">Welcome back 👋</h1>
<p className="mt-2 text-slate-500">
Signed in as <span className="font-semibold">{user?.displayName || user?.username}</span>{' '}
· role <span className="font-semibold text-blue-600">{user?.role?.replace(/_/g, ' ')}</span>
</p>
</div>
);
}
function App() {
return (
<LanguageProvider>
<ToastProvider>
<ConfirmProvider>
<AuthProvider>
<BrowserRouter>
<Suspense
fallback={
<div className="flex h-screen items-center justify-center bg-slate-50">
<PageLoader />
</div>
}
>
<Routes>
{/* Public */}
<Route path="/login" element={<Login />} />
{/* Workspace */}
<Route
path="/*"
element={
<ProtectedRoute>
<WorkspaceLayout />
</ProtectedRoute>
}
>
<Route index element={<OverviewPage />} />
<Route path="chat" element={<ChatPage />} />
<Route path="agents" element={<AgentsPage />} />
<Route path="assessment" element={<AssessmentPage />} />
<Route path="assessment-stats" element={<AssessmentStatsPage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="notebook" element={<MemosPage />} />
<Route path="knowledge/*" element={<KnowledgePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="users" element={<SettingsPage initialTab="user" />} />
<Route path="models" element={<SettingsPage initialTab="model" />} />
<Route path="kb-settings" element={<SettingsPage initialTab="knowledge_base" />} />
<Route path="tenants" element={<ProtectedRoute allowedRoles={['SUPER_ADMIN']}><SettingsPage initialTab="tenants" /></ProtectedRoute>} />
</Route>
{/* Remove standalone Admin routes as we integrated Dashboard into Settings */}
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
</ConfirmProvider>
</ToastProvider>
</LanguageProvider>
);
}
// ── Mount ──────────────────────────────────────────────────────────────────────
const container = document.getElementById('root')!;
createRoot(container).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "簡易ナレッジベース",
"description": "大規模モデルの長いコンテキストウィンドウを活用したRAGスタイルのQAシステムです。PDF、ドキュメント、画像、またはテキストファイルをアップロードし、正確な引用付きで質問に回答できます。",
"requestFramePermissions": []
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^1.32.0",
"@tailwindcss/vite": "^4.2.1",
"@types/react-syntax-highlighter": "^15.5.13",
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"html2canvas": "^1.4.1",
"lucide-react": "^0.556.0",
"mermaid": "^11.12.2",
"motion": "^12.34.3",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"react-syntax-highlighter": "^16.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.6",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+315
View File
@@ -0,0 +1,315 @@
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+1
View File
@@ -0,0 +1 @@
@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.27"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .hbox{width:100%}.katex .hbox,.katex .thinbox{display:inline-flex;flex-direction:row}.katex .thinbox{max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{border:0 solid;display:inline-block;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .eqn-num:before{content:"(" counter(katexEqnNo) ")";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:"(" counter(mmlEqnNo) ")";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo}
File diff suppressed because one or more lines are too long
+186
View File
@@ -0,0 +1,186 @@
import { API_BASE_URL } from '../utils/constants';
interface ApiResponse<T = any> {
data: T;
status: number;
}
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private getAuthHeaders(): Record<string, string> {
const apiKey = localStorage.getItem('kb_api_key');
const activeTenantId = localStorage.getItem('kb_active_tenant_id');
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
const language = localStorage.getItem('userLanguage') || 'zh';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-user-language': language,
};
if (apiKey) {
if (apiKey.startsWith('kb_')) {
headers['x-api-key'] = apiKey;
} else {
headers['Authorization'] = `Bearer ${apiKey}`;
}
} else if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null') {
headers['x-tenant-id'] = activeTenantId;
}
return headers;
}
private async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
const text = await response.text();
let data: any;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
data = null;
}
if (!response.ok) {
throw new Error(data?.message || text || 'Request failed');
}
return { data: data as T, status: response.status };
}
// 新しい API 呼び出し方法、{ data, status } を返す
async get<T = any>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return this.handleResponse<T>(response);
}
async post<T = any>(url: string, body?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'POST',
headers: this.getAuthHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async put<T = any>(url: string, body?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'PUT',
headers: this.getAuthHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async patch<T = any>(url: string, body?: any): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'PATCH',
headers: this.getAuthHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async delete<T = any>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'DELETE',
headers: this.getAuthHeaders(),
});
return this.handleResponse<T>(response);
}
// New methods for special formats
async getBlob(url: string): Promise<Blob> {
const response = await fetch(`${this.baseURL}${url}`, {
method: 'GET',
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error('Request failed');
}
return await response.blob();
}
async postMultipart<T = any>(url: string, formData: FormData): Promise<ApiResponse<T>> {
const headers = this.getAuthHeaders();
// Remove Content-Type to let the browser set it with the correct boundary
delete headers['Content-Type'];
const response = await fetch(`${this.baseURL}${url}`, {
method: 'POST',
headers,
body: formData,
});
return this.handleResponse<T>(response);
}
// Legacy compatibility method — returns raw Response for streaming and other special cases
async request(path: string, options: RequestInit = {}): Promise<Response> {
const authHeaders = this.getAuthHeaders();
const headers = new Headers(options.headers);
// Merge auth headers into request headers
Object.entries(authHeaders).forEach(([key, value]) => {
// Don't override if already set
if (headers.has(key)) return;
// Don't set Content-Type if body is FormData (let browser set it with boundary)
if (key === 'Content-Type' && options.body instanceof FormData) return;
headers.set(key, value);
});
let url = path;
if (!path.startsWith('http')) {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
url = `${this.baseURL}${cleanPath}`;
}
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
localStorage.removeItem('kb_api_key');
localStorage.removeItem('authToken');
window.location.href = '/login';
throw new Error('Unauthorized');
}
return response;
}
private handleUnauthorized() {
console.warn('[ApiClient] 401 Unauthorized detected. Cleaning up and redirecting to login...');
localStorage.removeItem('kb_api_key');
localStorage.removeItem('authToken');
localStorage.removeItem('token');
localStorage.removeItem('kb_active_tenant_id');
// Only redirect if we are not already on the login page
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
}
export const apiClient = new ApiClient(API_BASE_URL);
+99
View File
@@ -0,0 +1,99 @@
import { apiClient } from './apiClient';
export interface AssessmentSession {
id: string;
userId: string;
knowledgeBaseId: string;
threadId: string;
status: 'IN_PROGRESS' | 'COMPLETED';
finalScore?: number;
finalReport?: string;
createdAt: string;
updatedAt: string;
knowledgeBase?: { id: string; name: string };
knowledgeGroup?: { id: string; name: string };
}
export interface AssessmentState {
messages: any[];
assessmentSessionId: string;
knowledgeBaseId: string;
questions: any[];
currentQuestionIndex: number;
shouldFollowUp: boolean;
scores: Record<string, number>;
feedbackHistory?: any[];
status?: 'IN_PROGRESS' | 'COMPLETED';
report?: string;
finalScore?: number;
}
export class AssessmentService {
async startSession(knowledgeBaseId: string, language: string, templateId?: string): Promise<AssessmentSession> {
const { data } = await apiClient.post<AssessmentSession>('/assessment/start', { knowledgeBaseId, language, templateId });
return data;
}
async submitAnswer(sessionId: string, answer: string, language: string): Promise<AssessmentState> {
const { data } = await apiClient.post<AssessmentState>(`/assessment/${sessionId}/answer`, { answer, language });
return data;
}
async getSessionState(sessionId: string): Promise<AssessmentState> {
const { data } = await apiClient.get<AssessmentState>(`/assessment/${sessionId}/state`);
return data;
}
async getHistory(): Promise<AssessmentSession[]> {
const { data } = await apiClient.get<AssessmentSession[]>('/assessment');
return data;
}
async deleteSession(sessionId: string): Promise<void> {
await apiClient.delete(`/assessment/${sessionId}`);
}
async *startSessionStream(sessionId: string, templateId?: string): AsyncIterableIterator<any> {
const query = templateId ? `?templateId=${templateId}` : '';
const response = await apiClient.request(`/assessment/${sessionId}/start-stream${query}`, {
method: 'GET',
});
yield* this.parseStream(response);
}
async *submitAnswerStream(sessionId: string, answer: string, language: string, templateId?: string): AsyncIterableIterator<any> {
const query = new URLSearchParams({ answer, language, ...(templateId && { templateId }) }).toString();
const response = await apiClient.request(`/assessment/${sessionId}/answer-stream?${query}`, {
method: 'GET',
});
yield* this.parseStream(response);
}
private async *parseStream(response: Response): AsyncIterableIterator<any> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
yield data;
} catch (e) {
console.error('Failed to parse SSE data:', line);
}
}
}
}
}
}
export const assessmentService = new AssessmentService();
+45
View File
@@ -0,0 +1,45 @@
import { API_BASE_URL } from '../utils/constants';
import { apiClient } from './apiClient';
interface AuthResponse {
access_token: string;
}
export const authService = {
async login(username: string, password: string): Promise<AuthResponse> {
const language = localStorage.getItem('userLanguage') || 'ja';
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-language': language,
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
return response.json();
},
async getProfile(token: string): Promise<any> {
const response = await apiClient.request('/auth/profile', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to fetch profile');
}
return response.json();
},
};
+166
View File
@@ -0,0 +1,166 @@
import { apiClient } from './apiClient';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ChatSource {
fileName: string;
title?: string;
content: string;
score: number;
chunkIndex: number;
fileId?: string;
}
export class ChatService {
async *streamChat(
message: string,
history: ChatMessage[],
authToken: string,
userLanguage: string = 'zh',
selectedEmbeddingId?: string,
selectedLLMId?: string, // Added: Selected LLM ID
selectedGroups?: string[], // Added: Selected groups
selectedFiles?: string[], // Added: Selected files
historyId?: string, // Added: Conversation history ID
enableRerank?: boolean, // Added: Enable Rerank
selectedRerankId?: string, // Added: Rerank model ID
temperature?: number, // Added: temperature parameter
maxTokens?: number, // Added: maxTokens parameter
topK?: number, // Added: topK parameter
similarityThreshold?: number, // Added: similarityThreshold parameter
rerankSimilarityThreshold?: number, // Added: rerankSimilarityThreshold parameter
enableQueryExpansion?: boolean, // Added
enableHyDE?: boolean // Added
): AsyncGenerator<{ type: 'content' | 'sources' | 'error' | 'historyId'; data: any }> {
try {
const response = await apiClient.request('/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-language': userLanguage || localStorage.getItem('userLanguage') || 'zh',
},
body: JSON.stringify({
message,
history,
userLanguage,
selectedEmbeddingId,
selectedLLMId,
selectedGroups,
selectedFiles,
historyId,
enableRerank,
selectedRerankId,
temperature,
maxTokens,
topK,
similarityThreshold,
rerankSimilarityThreshold,
enableQueryExpansion,
enableHyDE
}),
});
if (!response.ok) {
let errorMessage = 'Request failed';
try {
const error = await response.json();
errorMessage = error.error || error.message || 'Request failed';
} catch {
errorMessage = `Server error: ${response.status}`;
}
yield { type: 'error', data: errorMessage };
return;
}
const reader = response.body?.getReader();
if (!reader) {
yield { type: 'error', data: 'Cannot read response stream' };
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
return;
}
try {
const parsed = JSON.parse(data);
yield parsed;
} catch (e) {
console.warn('Failed to parse SSE data:', data);
}
}
}
}
} catch (error: any) {
yield { type: 'error', data: error.message || 'Network error' };
}
}
async *streamAssist(
instruction: string,
context: string,
authToken: string
): AsyncGenerator<{ type: 'content' | 'error'; data: any }> {
try {
const response = await apiClient.request('/chat/assist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-language': localStorage.getItem('userLanguage') || 'zh',
},
body: JSON.stringify({ instruction, context }),
});
if (!response.ok) {
yield { type: 'error', data: 'Request failed' };
return;
}
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
yield JSON.parse(data);
} catch (e) { console.warn(e) }
}
}
}
} catch (error: any) {
yield { type: 'error', data: error.message };
}
}
}
export const chatService = new ChatService();
+37
View File
@@ -0,0 +1,37 @@
import { apiClient } from './apiClient';
export interface ChunkConfigLimits {
maxChunkSize: number; // Max chunk size (tokens)
maxOverlapSize: number; // Max overlap size (tokens)
minOverlapSize: number; // Min overlap size (tokens)
defaultChunkSize: number; // Default chunk size
defaultOverlapSize: number; // Default overlap size
modelInfo: EmbeddingModelLimit; // Model info
}
export interface EmbeddingModelLimit {
name: string; // Model name
maxInputTokens: number; // Model input limit
maxBatchSize: number; // Model batch limit
expectedDimensions: number; // Expected vector dimensions
}
export const chunkConfigService = {
formatLimits(limits: ChunkConfigLimits): string {
return [
`Model: ${limits.modelInfo.name}`,
`Max Chunk: ${limits.maxChunkSize} tokens`,
`Max Overlap: ${limits.maxOverlapSize} tokens`,
`Batch Limit: ${limits.modelInfo.maxBatchSize}`,
`Vector Dimensions: ${limits.modelInfo.expectedDimensions}`,
].join(' | ');
},
async getLimits(embeddingModelId: string, token?: string): Promise<ChunkConfigLimits> {
const response = await apiClient.get<ChunkConfigLimits>(
`/knowledge-bases/chunk-config/limits?embeddingModelId=${embeddingModelId}`
);
return response.data;
},
};
+195
View File
@@ -0,0 +1,195 @@
import { KnowledgeFile, Message, Role, Language, AppSettings, ModelConfig } from "../types";
import { ragService } from './ragService';
const buildSystemInstruction = (files: KnowledgeFile[], settings: AppSettings, langInstruction: string) => {
const fileNames = files.map(f => f.name).join(", ");
return `
You are an intelligent knowledge base assistant operating as a RAG system.
System Configuration:
- Retrieval: Top ${settings.topK} chunks, Rerank: ${settings.enableRerank ? 'Enabled' : 'Disabled'}
- Knowledge Base Files: ${fileNames}
You have access to the content of these files in your context.
Your goal is to answer user questions primarily based on the content of these files.
Strict Citation Rules:
1. Whenever you use information from a file, you MUST cite the source filename at the end of the sentence or paragraph.
2. Citation format: [filename.extension].
3. If the answer is not found in the files, state so clearly.
4. ${langInstruction}
`;
};
// --- OpenAI Compatible Implementation ---
const callOpenAICompatible = async (
currentPrompt: string,
files: KnowledgeFile[],
history: Message[],
modelConfig: ModelConfig,
settings: AppSettings,
systemInstruction: string,
apiKey: string
): Promise<string> => {
if (!modelConfig.baseUrl) throw new Error("Base URL is required");
// Construct OpenAI format messages
const messages: any[] = [
{ role: "system", content: systemInstruction }
];
// Add history
history.forEach(msg => {
messages.push({
role: msg.role === Role.USER ? "user" : "assistant",
content: msg.text
});
});
// Current User Message Construction (Supports Vision)
const contentParts: any[] = [];
// 1. Add Text Prompt
contentParts.push({ type: "text", text: currentPrompt });
// 2. Add Images/Files
files.forEach((file) => {
if (file.type.startsWith("image/") && modelConfig.type === "vision") {
contentParts.push({
type: "image_url",
image_url: {
url: `data:${file.type};base64,${file.content}`
}
});
} else {
// For non-image files or if vision is not supported, append as text
try {
const decodedText = atob(file.content);
contentParts.push({
type: "text",
text: `\n--- Context from file: ${file.name} ---\n${decodedText.substring(0, 10000)}... (truncated)\n`
});
} catch (e) {
contentParts.push({
type: "text",
text: `\n[File attached: ${file.name} (${file.type}) - Content processing skipped]\n`
});
}
}
});
messages.push({ role: "user", content: contentParts });
const response = await fetch(`${modelConfig.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
},
body: JSON.stringify({
model: modelConfig.modelId,
messages: messages,
temperature: settings.temperature,
max_tokens: settings.maxTokens,
stream: false
})
});
if (!response.ok) {
const err = await response.text();
throw new Error(`API Error: ${response.status} - ${err}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || "NO_RESPONSE_TEXT";
};
export const generateResponse = async (
currentPrompt: string,
files: KnowledgeFile[],
history: Message[],
language: Language,
modelConfig: ModelConfig,
settings: AppSettings,
authToken?: string,
onSearchStart?: () => void,
onSearchComplete?: (results: any) => void
): Promise<string> => {
// 1. Resolve API Key: Use model specific key
let apiKey = modelConfig.apiKey;
console.log('Model config:', modelConfig);
console.log('API Key present:', !!apiKey);
const langInstructionMap: Record<Language, string> = {
zh: "请始终使用Chinese回答。",
en: "Please always answer in English.",
ja: "常にJapaneseで答えてください。"
};
const langInstruction = langInstructionMap[language];
// RAG search (when knowledge base files exist)
let ragPrompt = currentPrompt;
let ragSources: string[] = [];
if (files.length > 0 && authToken) {
try {
onSearchStart?.();
console.log('Starting RAG search with prompt:', currentPrompt);
const ragResponse = await ragService.search(currentPrompt, {
...settings,
language
}, authToken);
console.log('RAG search response:', ragResponse);
if (ragResponse && ragResponse.hasRelevantContent) {
ragPrompt = ragResponse.ragPrompt;
ragSources = ragResponse.sources;
console.log('Using RAG enhanced prompt');
} else {
console.log('No relevant content found, using original prompt');
}
onSearchComplete?.(ragResponse?.searchResults || []);
} catch (error) {
console.warn('RAG search failed, using original prompt:', error);
onSearchComplete?.([]);
} finally {
// Ensure search status is reset
setTimeout(() => {
onSearchComplete?.([]);
}, 100);
}
}
const systemInstruction = buildSystemInstruction(files, settings, langInstruction);
try {
// API key is optional - allow local models
// --- OpenAI Compatible API Logic ---
return await callOpenAICompatible(
ragPrompt,
files,
history,
modelConfig,
settings,
systemInstruction,
apiKey || "ollama",
);
} catch (error: any) {
console.error("AI Service Error:", error);
// Provide more detailed error information
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Network connection failed. Please check server status');
}
throw new Error(error.message || "API_ERROR");
}
};
+59
View File
@@ -0,0 +1,59 @@
import { apiClient } from './apiClient';
export interface ImportTask {
id: string;
sourcePath: string;
targetGroupId?: string;
targetGroupName?: string;
scheduledAt?: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
logs?: string;
embeddingModelId?: string;
chunkSize?: number;
chunkOverlap?: number;
mode?: string;
createdAt: string;
}
export const importService = {
create: async (authToken: string, data: {
sourcePath: string;
targetGroupId?: string;
targetGroupName?: string;
embeddingModelId: string;
scheduledAt?: string;
chunkSize?: number;
chunkOverlap?: number;
mode?: string;
}): Promise<ImportTask> => {
const response = await apiClient.request('/import-tasks', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to create import task');
return response.json();
},
getAll: async (authToken: string, options: { page?: number; limit?: number } = {}): Promise<{ items: ImportTask[]; total: number; page: number; limit: number }> => {
const queryParams = new URLSearchParams();
if (options.page) queryParams.append('page', options.page.toString());
if (options.limit) queryParams.append('limit', options.limit.toString());
const queryString = queryParams.toString();
const url = `/import-tasks${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.request(url, {});
if (!response.ok) throw new Error('Failed to fetch import tasks');
return response.json();
},
delete: async (token: string, id: string): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/import-tasks/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to delete import task');
}
};
+157
View File
@@ -0,0 +1,157 @@
import { apiClient } from './apiClient';
import { KnowledgeFile } from '../types';
export const knowledgeBaseService = {
async getAll(
authToken: string,
options: {
page?: number;
limit?: number;
name?: string;
status?: string;
groupId?: string;
} = {}
): Promise<{ items: KnowledgeFile[]; total: number; page: number; limit: number }> {
const queryParams = new URLSearchParams();
if (options.page) queryParams.append('page', options.page.toString());
if (options.limit) queryParams.append('limit', options.limit.toString());
if (options.name) queryParams.append('name', options.name);
if (options.status) queryParams.append('status', options.status);
if (options.groupId) queryParams.append('groupId', options.groupId);
const queryString = queryParams.toString();
const url = `/knowledge-bases${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.request(url, {});
if (!response.ok) {
throw new Error('Failed to fetch knowledge base files');
}
const data = await response.json();
console.log('Knowledge base API response:', data);
const items = Array.isArray(data) ? data : (data.items || []);
const total = Array.isArray(data) ? data.length : (data.total || 0);
const page = Array.isArray(data) ? 1 : (data.page || 1);
const limit = Array.isArray(data) ? items.length : (data.limit || 12);
return {
items: items.map((item: any) => ({
id: item.id,
name: item.originalName,
originalName: item.originalName,
type: item.mimetype,
size: item.size,
status: item.status,
groups: item.groups || [],
createdAt: item.createdAt,
updatedAt: item.updatedAt,
})),
total,
page,
limit,
};
},
async getStatuses(ids: string[], authToken: string): Promise<{ id: string, status: KnowledgeFile['status'], updatedAt: string }[]> {
const response = await apiClient.request('/knowledge-bases/statuses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids }),
});
if (!response.ok) {
throw new Error('Failed to fetch knowledge base statuses');
}
return response.json();
},
async getStats(authToken: string): Promise<{ total: number, uncategorized: number }> {
const response = await apiClient.request('/knowledge-bases/stats', {
method: 'GET'
});
if (!response.ok) {
throw new Error('Failed to fetch knowledge base stats');
}
return response.json();
},
async clearAll(authToken: string): Promise<void> {
const response = await apiClient.request('/knowledge-bases/clear', {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to clear knowledge base');
}
},
async search(query: string, topK: number = 5, authToken: string): Promise<any> {
const response = await apiClient.request('/knowledge-bases/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, topK }),
});
if (!response.ok) {
throw new Error('Failed to search knowledge base');
}
return response.json();
},
async deleteFile(fileId: string, authToken: string): Promise<void> {
const response = await apiClient.request(`/knowledge-bases/${fileId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete file');
}
},
async retryFile(fileId: string, authToken: string): Promise<KnowledgeFile> {
const response = await apiClient.request(`/knowledge-bases/${fileId}/retry`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to retry file');
}
const item = await response.json();
return {
id: item.id,
name: item.originalName,
originalName: item.originalName,
type: item.mimetype,
size: item.size,
status: item.status,
groups: item.groups || [],
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
},
async getFileChunks(fileId: string, authToken: string) {
const response = await apiClient.request(`/knowledge-bases/${fileId}/chunks`, {});
if (!response.ok) {
throw new Error('Failed to get file chunks');
}
return response.json();
},
getPageImageUrl(fileId: string, pageIndex: number): string {
return `/api/knowledge-bases/${fileId}/page/${pageIndex}`;
},
};
+69
View File
@@ -0,0 +1,69 @@
import { KnowledgeGroup, CreateGroupData, UpdateGroupData } from '../types';
import { apiClient } from './apiClient';
export const knowledgeGroupService = {
// Fetch all groups
async getGroups(options: { flat?: boolean; page?: number; limit?: number; name?: string } = {}): Promise<any> {
const queryParams = new URLSearchParams();
if (options.flat) queryParams.append('flat', 'true');
if (options.page) queryParams.append('page', options.page.toString());
if (options.limit) queryParams.append('limit', options.limit.toString());
if (options.name) queryParams.append('name', options.name);
const queryString = queryParams.toString();
const url = `/knowledge-groups${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.request(url, {});
if (!response.ok) throw new Error('Failed to fetch groups');
const data = await response.json();
// If it's an array, it's already in the format we expect (tree or simple list)
if (Array.isArray(data)) return data;
// If it's a paginated object, return it as is or handle appropriately
// The callers of getGroups usually expect an array, but NotebooksView expects a tree.
// However, NotebookDetailView and others might soon expect pagination.
return data;
},
// Create group
async createGroup(data: CreateGroupData): Promise<KnowledgeGroup> {
const { data: group } = await apiClient.post<KnowledgeGroup>('/knowledge-groups', data);
return group;
},
// Update group
async updateGroup(id: string, data: UpdateGroupData): Promise<KnowledgeGroup> {
const { data: group } = await apiClient.put<KnowledgeGroup>(`/knowledge-groups/${id}`, data);
return group;
},
// Delete group
async deleteGroup(id: string): Promise<void> {
const response = await apiClient.request(`/knowledge-groups/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete group');
},
// Fetch files in group
async getGroupFiles(id: string): Promise<any[]> {
const response = await apiClient.request(`/knowledge-groups/${id}/files`, {});
if (!response.ok) throw new Error('Failed to fetch group files');
const data = await response.json();
return data.files;
},
async addFileToGroups(fileId: string, groupIds: string[]): Promise<void> {
await apiClient.post(`/knowledge-bases/${fileId}/groups`, { groupIds });
},
// Remove file from group
async removeFileFromGroup(fileId: string, groupId: string): Promise<void> {
const response = await apiClient.request(`/knowledge-bases/${fileId}/groups/${groupId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to remove file from group');
},
};
+82
View File
@@ -0,0 +1,82 @@
import { apiClient } from './apiClient';
import { ModelConfig } from '../types'; // Frontend ModelConfig interface
interface ModelConfigResponse extends Omit<ModelConfig, 'apiKey'> {
// Backend response might omit apiKey for security
id: string; // Ensure id is always present
createdAt: Date;
updatedAt: Date;
}
export const modelConfigService = {
async getAll(token: string): Promise<ModelConfigResponse[]> {
const response = await apiClient.request('/models', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to fetch model configs');
}
return response.json();
},
async create(token: string, modelConfig: Omit<ModelConfig, 'id'>): Promise<ModelConfigResponse> {
const response = await apiClient.request('/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(modelConfig),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to create model config');
}
return response.json();
},
async update(token: string, id: string, modelConfig: Partial<Omit<ModelConfig, 'id'>>): Promise<ModelConfigResponse> {
const response = await apiClient.request(`/models/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(modelConfig),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update model config');
}
return response.json();
},
async remove(token: string, id: string): Promise<void> {
const response = await apiClient.request(`/models/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to delete model config');
}
},
async setDefault(token: string, id: string): Promise<ModelConfigResponse> {
const response = await apiClient.request(`/models/${id}/set-default`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to set default model');
}
return response.json();
},
};
+45
View File
@@ -0,0 +1,45 @@
import { API_BASE_URL, NoteCategory } from '../types';
export const noteCategoryService = {
async getAll(token: string): Promise<NoteCategory[]> {
const res = await fetch(`${API_BASE_URL}/v1/note-categories`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch categories');
return res.json();
},
async create(token: string, name: string, parentId?: string): Promise<NoteCategory> {
const response = await fetch(`${API_BASE_URL}/v1/note-categories`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, parentId })
})
if (!response.ok) throw new Error('Failed to create category')
return response.json()
},
async update(token: string, id: string, name?: string, parentId?: string): Promise<NoteCategory> {
const response = await fetch(`${API_BASE_URL}/v1/note-categories/${id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, parentId })
})
if (!response.ok) throw new Error('Failed to update category')
return response.json()
},
async delete(token: string, id: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/v1/note-categories/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to delete category');
}
};
+103
View File
@@ -0,0 +1,103 @@
import { API_BASE_URL, Note } from '../types'
export const noteService = {
// すべてのノートを取得(オプションでグループによるフィルタリングが可能)
getAll: async (token: string, groupId?: string, categoryId?: string): Promise<Note[]> => {
const url = new URL(`${API_BASE_URL}/notes`, window.location.origin)
if (groupId) {
url.searchParams.append('groupId', groupId)
}
if (categoryId) {
url.searchParams.append('categoryId', categoryId)
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error(`Failed to fetch notes: ${response.status} ${response.statusText}`)
}
return response.json()
},
// ノートを作成
create: async (token: string, data: { title: string, content: string, groupId: string, categoryId?: string }): Promise<Note> => {
const response = await fetch(`${API_BASE_URL}/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('Failed to create note')
}
return response.json()
},
// ノートを更新
update: async (token: string, id: string, data: { title?: string, content?: string, categoryId?: string }): Promise<Note> => {
const response = await fetch(`${API_BASE_URL}/notes/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('Failed to update note')
}
return response.json()
},
// Delete note
delete: async (token: string, id: string): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/notes/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error('Failed to delete note')
}
},
// Index note to knowledge base (vectorize)
createFromPDFSelection: async (
token: string,
fileId: string,
screenshot: Blob,
groupId?: string,
categoryId?: string,
pageNumber?: number,
): Promise<Note> => {
const formData = new FormData()
formData.append('screenshot', screenshot, 'selection.png')
formData.append('fileId', fileId)
if (groupId) {
formData.append('groupId', groupId)
}
if (categoryId) {
formData.append('categoryId', categoryId)
}
if (pageNumber !== undefined) {
formData.append('pageNumber', pageNumber.toString())
}
const response = await fetch(`${API_BASE_URL}/notes/from-pdf-selection`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
},
body: formData
})
if (!response.ok) {
throw new Error('Failed to create note from PDF selection')
}
return response.json()
}
}
+24
View File
@@ -0,0 +1,24 @@
import { apiClient } from './apiClient';
/**
* OCR Service - Handles image text recognition
*/
export const ocrService = {
// Recognize text in image
async recognizeText(authToken: string, image: Blob): Promise<string> {
const formData = new FormData();
formData.append('image', image);
const response = await apiClient.request('/ocr/recognize', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to recognize text');
}
const data = await response.json();
return data.text;
}
};
+33
View File
@@ -0,0 +1,33 @@
import { PDFStatus } from '../types';
import { apiClient } from './apiClient';
export const pdfPreviewService = {
async getPDFUrl(fileId: string): Promise<{ url: string }> {
const { data } = await apiClient.get<{ url: string }>(`/knowledge-bases/${fileId}/pdf-url`);
return data;
},
async getPDFStatus(fileId: string): Promise<PDFStatus> {
const { data } = await apiClient.get<PDFStatus>(`/knowledge-bases/${fileId}/pdf-status`);
return data;
},
async preloadPDF(fileId: string, force: boolean = false): Promise<void> {
try {
const url = `/knowledge-bases/${fileId}/pdf-url` + (force ? '?force=true' : '');
const response = await apiClient.request(url, {
method: 'GET',
signal: AbortSignal.timeout(30000), // Increase timeout for conversion
});
if (response.ok) {
console.log('PDF already exists or conversion completed');
}
} catch (error: any) {
console.log('PDF conversion triggered:', error.message);
}
},
};
+64
View File
@@ -0,0 +1,64 @@
import * as pdfjs from 'pdfjs-dist';
// Set worker path - using a CDN for the worker to avoid complex vite configuration for now
// or we can try to use the bundled worker if vite handles it
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
export const pdfRenderService = {
async renderPageToCanvas(
pdfData: Blob | ArrayBuffer,
pageNumber: number,
canvas: HTMLCanvasElement,
targetWidth: number,
targetHeight: number
): Promise<void> {
const data = pdfData instanceof Blob ? await pdfData.arrayBuffer() : pdfData;
const loadingTask = pdfjs.getDocument({ data });
const pdf = await loadingTask.promise;
if (pageNumber < 1 || pageNumber > pdf.numPages) {
throw new Error(`Invalid page number: ${pageNumber}.`);
}
const page = await pdf.getPage(pageNumber);
// Calculate scale to fit container while maintaining aspect ratio
const unscaledViewport = page.getViewport({ scale: 1 });
// Calculate the scale needed to fit the PDF page within the target dimensions
const fitScale = Math.min(targetWidth / unscaledViewport.width, targetHeight / unscaledViewport.height);
// Calculate a higher scale for rendering quality (anti-aliasing and clarity)
const devicePixelRatio = window.devicePixelRatio || 1;
const renderScale = fitScale * Math.max(2, devicePixelRatio);
const viewport = page.getViewport({ scale: renderScale });
// Set canvas to the high-resolution dimensions to maintain quality
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) return;
// Save the context state before any transformations
context.save();
// Fill background
context.fillStyle = '#f8fafc';
context.fillRect(0, 0, canvas.width, canvas.height);
// Calculate the position to center the PDF page in the canvas
const offsetX = (canvas.width - viewport.width) / 2;
const offsetY = (canvas.height - viewport.height) / 2;
await page.render({
canvasContext: context,
viewport: viewport,
transform: [1, 0, 0, 1, offsetX, offsetY]
}).promise;
// Restore the context state after rendering
context.restore();
}
};
+41
View File
@@ -0,0 +1,41 @@
export interface RagSearchResult {
content: string;
fileName: string;
score: number;
chunkIndex: number;
}
export interface RagResponse {
searchResults: RagSearchResult[];
sources: string[];
ragPrompt: string;
hasRelevantContent: boolean;
}
export const ragService = {
async search(query: string, settings: any, authToken: string): Promise<RagResponse> {
try {
const response = await fetch('/api/knowledge-bases/rag-search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ query, settings }),
});
if (!response.ok) {
const errorData = await response.text();
console.error('RAG search error:', response.status, errorData);
throw new Error(`RAG search failed: ${response.status} - ${errorData}`);
}
const result = await response.json();
return result;
} catch (error) {
console.error('RAG service error:', error);
throw error;
}
},
};
+32
View File
@@ -0,0 +1,32 @@
import { SearchHistoryItem, SearchHistoryDetail } from '../types';
import { apiClient } from './apiClient';
export const searchHistoryService = {
async getHistories(page: number = 1, limit: number = 20): Promise<{
histories: SearchHistoryItem[];
total: number;
page: number;
limit: number;
}> {
const { data } = await apiClient.get(`/search-history?page=${page}&limit=${limit}`);
return data;
},
async getHistoryDetail(id: string): Promise<SearchHistoryDetail> {
const { data } = await apiClient.get(`/search-history/${id}`);
return data;
},
async createHistory(title: string, selectedGroups?: string[]): Promise<{ id: string }> {
const { data } = await apiClient.post(`/search-history`, { title, selectedGroups });
return data;
},
async deleteHistory(id: string): Promise<void> {
await apiClient.delete(`/search-history/${id}`);
},
};
+33
View File
@@ -0,0 +1,33 @@
import { apiClient } from './apiClient';
export const settingsService = {
async getVisionModels() {
const response = await apiClient.get('/models');
// Filter models that support vision or are of type vision
return response.data.filter((m: any) => m.supportsVision || m.type === 'vision');
},
async getVisionModel() {
const response = await apiClient.get('/v1/admin/settings');
return { visionModelId: response.data.selectedVisionId };
},
async updateVisionModel(selectedVisionId: string) {
const response = await apiClient.put('/v1/admin/settings', {
selectedVisionId,
});
return response.data;
},
async getLanguage() {
const response = await apiClient.get('/users/settings');
return response.data.language;
},
async updateLanguage(language: string) {
const response = await apiClient.put('/users/settings/language', {
language,
});
return response.data;
},
};
+28
View File
@@ -0,0 +1,28 @@
import { apiClient } from './apiClient';
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData } from '../types';
export const templateService = {
async getAll(): Promise<AssessmentTemplate[]> {
const response = await apiClient.get<AssessmentTemplate[]>('/assessment/templates');
return response.data;
},
async getById(id: string): Promise<AssessmentTemplate> {
const response = await apiClient.get<AssessmentTemplate>(`/assessment/templates/${id}`);
return response.data;
},
async create(data: CreateTemplateData): Promise<AssessmentTemplate> {
const response = await apiClient.post<AssessmentTemplate>('/assessment/templates', data);
return response.data;
},
async update(id: string, data: UpdateTemplateData): Promise<AssessmentTemplate> {
const response = await apiClient.put<AssessmentTemplate>(`/assessment/templates/${id}`, data);
return response.data;
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/assessment/templates/${id}`);
},
};
+132
View File
@@ -0,0 +1,132 @@
import { apiClient } from './apiClient';
import { IndexingConfig } from '../types';
// web/services/uploadService.ts
export const uploadService = {
async uploadFile(file: File, authToken: string): Promise<any> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.request('/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'fileUploadFailed');
}
return response.json();
},
async uploadFileWithConfig(file: File, config: IndexingConfig, authToken: string): Promise<any> {
const formData = new FormData();
formData.append('file', file);
formData.append('chunkSize', config.chunkSize.toString());
formData.append('chunkOverlap', config.chunkOverlap.toString());
formData.append('embeddingModelId', config.embeddingModelId);
if (config.mode) {
formData.append('mode', config.mode);
}
// 分類を追加(指定されている場合)
if (config.groupIds && config.groupIds.length > 0) {
formData.append('groupIds', JSON.stringify(config.groupIds));
}
const response = await apiClient.request('/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'fileUploadFailed');
}
return response.json();
},
async uploadText(content: string, title: string, config: IndexingConfig, authToken: string): Promise<any> {
const { data } = await apiClient.post('/upload/text', {
content,
title,
chunkSize: config.chunkSize.toString(),
chunkOverlap: config.chunkOverlap.toString(),
embeddingModelId: config.embeddingModelId,
mode: config.mode
});
return data;
},
async recommendMode(file: File): Promise<any> {
if (!file || !file.name) {
return {
recommendedMode: 'fast',
reason: 'invalidFile',
warnings: ['incompleteFileInfo'],
};
}
const ext = file.name.toLowerCase().split('.').pop();
const sizeMB = file.size / (1024 * 1024);
const preciseFormats = ['pdf', 'doc', 'docx', 'ppt', 'pptx'];
const supportedFormats = [...preciseFormats, 'xls', 'xlsx', 'txt', 'md', 'html', 'json', 'csv'];
if (!supportedFormats.includes(ext || '')) {
return {
recommendedMode: 'fast',
reason: `unsupportedFileFormat`,
reasonArgs: [ext],
warnings: ['willUseFastMode'],
};
}
if (!preciseFormats.includes(ext || '')) {
return {
recommendedMode: 'fast',
reason: `formatNoPrecise`,
reasonArgs: [ext],
warnings: ['willUseFastMode'],
};
}
if (sizeMB < 5) {
return {
recommendedMode: 'fast',
reason: 'smallFileFastOk',
estimatedCost: 0,
estimatedTime: sizeMB * 2,
warnings: [],
};
}
if (sizeMB < 50) {
return {
recommendedMode: 'precise',
reason: 'mixedContentPreciseRecommended',
estimatedCost: Math.max(0.01, sizeMB * 0.01),
estimatedTime: sizeMB * 8,
warnings: ['willIncurApiCost'],
};
}
return {
recommendedMode: 'precise',
reason: 'largeFilePreciseRecommended',
estimatedCost: sizeMB * 0.015,
estimatedTime: sizeMB * 12,
warnings: ['longProcessingTime', 'highApiCost', 'considerFileSplitting'],
};
},
};
+58
View File
@@ -0,0 +1,58 @@
import { apiClient } from './apiClient';
export const userService = {
async changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
const { data } = await apiClient.put('/users/password', {
currentPassword,
newPassword,
});
return data;
},
async getUsers(page?: number, limit?: number): Promise<any> {
const params = new URLSearchParams();
if (page) params.append('page', page.toString());
if (limit) params.append('limit', limit.toString());
const { data } = await apiClient.get(`/users?${params.toString()}`);
return data;
},
async updateUser(userId: string, isAdmin: boolean): Promise<{ message: string }> {
const { data } = await apiClient.put(`/users/${userId}`, {
isAdmin,
});
return data;
},
async updateUserInfo(userId: string, userData: { username?: string; isAdmin?: boolean; password?: string; displayName?: string }): Promise<{ message: string }> {
const { data } = await apiClient.put(`/users/${userId}`, userData);
return data;
},
async deleteUser(userId: string): Promise<{ message: string }> {
const { data } = await apiClient.delete(`/users/${userId}`);
return data;
},
async createUser(username: string, password: string, isAdmin: boolean = false, tenantId?: string, displayName?: string): Promise<{ message: string }> {
const { data } = await apiClient.post('/users', {
username,
password,
isAdmin,
tenantId,
displayName,
});
return data;
},
async exportUsers(): Promise<Blob> {
return await apiClient.getBlob('/v1/admin/users/export');
},
async importUsers(file: File): Promise<any> {
const formData = new FormData();
formData.append('file', file);
const { data } = await apiClient.postMultipart('/v1/admin/users/import', formData);
return data;
},
};
+43
View File
@@ -0,0 +1,43 @@
// web/services/userSettingService.ts
import { apiClient } from './apiClient';
import { AppSettings } from '../types'; // Frontend AppSettings interface
// Assuming backend returns language, plus id, userId, createdAt, updatedAt
interface UserPersonalSettingResponse {
id: string;
userId: string;
language: string;
createdAt: Date;
updatedAt: Date;
}
interface UserSettingResponse extends AppSettings {
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}
export const userSettingService = {
async get(_token: string): Promise<UserSettingResponse> {
const { data } = await apiClient.get<UserSettingResponse>('/v1/admin/settings');
return data;
},
async update(_token: string, settings: Partial<AppSettings>): Promise<UserSettingResponse> {
const { data } = await apiClient.put<UserSettingResponse>('/v1/admin/settings', settings);
return data;
},
async getPersonal(_token: string): Promise<UserPersonalSettingResponse> {
const { data } = await apiClient.get<UserPersonalSettingResponse>('/users/settings');
return data;
},
async updateLanguage(_token: string, language: string): Promise<UserPersonalSettingResponse> {
const { data } = await apiClient.put<UserPersonalSettingResponse>('/users/settings/language', { language });
return data;
},
// Unused legacy methods removed
};
@@ -0,0 +1,289 @@
import React, { useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
MessageSquare,
Database,
Settings,
LogOut,
LayoutGrid,
LayoutTemplate,
Plus,
Menu,
BookOpen,
Library,
HardDrive,
Building2,
ChevronRight,
Bot,
Blocks,
ClipboardCheck,
BarChart3
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../../contexts/LanguageContext';
import { cn } from '../../utils/cn';
interface SidebarItemProps {
icon: React.ElementType;
label: string;
path: string;
isActive: boolean;
onClick: (path: string) => void;
badge?: number;
}
const SidebarItem = ({ icon: Icon, label, path, isActive, onClick, badge }: SidebarItemProps) => (
<button
onClick={() => onClick(path)}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group",
isActive
? "bg-blue-50 text-blue-700 font-semibold"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-900 border border-transparent"
)}
>
<div className="flex items-center gap-3">
<Icon size={18} className={cn("transition-colors", isActive ? "text-blue-600" : "text-slate-400 group-hover:text-slate-600")} />
<span className="text-[14px]">{label}</span>
</div>
{badge !== undefined && badge > 0 && (
<span className={cn(
"px-2 py-0.5 text-[11px] font-bold rounded-full",
isActive ? "bg-blue-100 text-blue-700" : "bg-slate-100 text-slate-400"
)}>
{badge}
</span>
)}
</button>
);
const WorkspaceLayout: React.FC = () => {
const { user, logout, availableTenants, activeTenant, switchTenant } = useAuth();
const { t, language } = useLanguage();
const isZh = language === 'zh';
const navigate = useNavigate();
const location = useLocation();
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [showTenantMenu, setShowTenantMenu] = useState(false);
const handleNavClick = (path: string) => {
navigate(path);
};
const buildTenantTree = (memberships: typeof availableTenants) => {
const map = new Map<string, any>();
const roots: any[] = [];
memberships.forEach(m => {
map.set(m.tenantId, { ...m, children: [] });
});
memberships.forEach(m => {
const node = map.get(m.tenantId)!;
const parentId = m.tenant.parentId;
if (parentId && map.has(parentId)) {
const parent = map.get(parentId)!;
parent.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
const renderTenantItem = (membership: any, depth = 0) => (
<React.Fragment key={membership.tenantId}>
<button
onClick={() => {
switchTenant(membership.tenantId);
setShowTenantMenu(false);
}}
className={cn(
"w-full text-left px-4 py-3 rounded-xl flex items-center justify-between group transition-colors",
activeTenant?.tenantId === membership.tenantId
? "bg-blue-50 text-blue-700"
: "hover:bg-slate-50 text-slate-600"
)}
style={{ paddingLeft: `${depth * 1.5 + 1}rem` }}
>
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold truncate">{membership.tenant.name}</span>
<span className="text-[10px] font-medium opacity-60 uppercase tracking-tight">{membership.role}</span>
</div>
{activeTenant?.tenantId === membership.tenantId && (
<div className="w-1.5 h-1.5 bg-blue-600 rounded-full shrink-0" />
)}
</button>
{membership.children?.map((child: any) => renderTenantItem(child, depth + 1))}
</React.Fragment>
);
const tenantTree = buildTenantTree(availableTenants);
return (
<div className="flex h-screen bg-[#FCFDFF] text-slate-900 font-sans selection:bg-blue-100 selection:text-blue-900">
{/* Sidebar */}
<aside className={cn(
"bg-white border-r border-slate-200/60 flex flex-col transition-all duration-300 overflow-hidden",
isSidebarOpen ? "w-[260px]" : "w-0 border-r-0"
)}>
{/* Brand */}
<div className="h-16 flex items-center px-6 border-b border-slate-100/50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shadow-md shadow-blue-200 text-white font-bold shrink-0">
<BookOpen size={18} />
</div>
<h1 className="text-xl font-bold tracking-tight text-slate-900">AuraK</h1>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-6 overflow-y-auto">
<div className="space-y-0.5">
<SidebarItem
icon={MessageSquare}
label={t('navChat')}
path="/chat"
isActive={location.pathname.startsWith('/chat')}
onClick={handleNavClick}
/>
<SidebarItem
icon={Bot}
label={t('navAgent')}
path="/agents"
isActive={location.pathname.startsWith('/agents')}
onClick={handleNavClick}
/>
<SidebarItem
icon={Blocks}
label={t('navPlugin')}
path="/plugins"
isActive={location.pathname.startsWith('/plugins')}
onClick={handleNavClick}
/>
<SidebarItem
icon={Database}
label={t('navKnowledge')}
path="/knowledge"
isActive={location.pathname === '/knowledge' || location.pathname.startsWith('/knowledge/')}
onClick={handleNavClick}
/>
<SidebarItem
icon={BarChart3}
label={isZh ? '评估统计' : 'Assessment Stats'}
path="/assessment-stats"
isActive={location.pathname === '/assessment-stats'}
onClick={handleNavClick}
/>
{(activeTenant?.features?.isNotebookEnabled ?? true) && (
<SidebarItem
icon={BookOpen}
label={t('navNotebook')}
path="/notebook"
isActive={location.pathname.startsWith('/notebook')}
onClick={handleNavClick}
/>
)}
</div>
<div className="space-y-0.5">
<SidebarItem
icon={Settings}
label={t('tabSettings')}
path="/settings"
isActive={location.pathname.startsWith('/settings')}
onClick={handleNavClick}
/>
</div>
</nav>
<div className="p-4 border-t border-slate-100/80 mt-auto">
<button
onClick={logout}
className="flex items-center gap-2 px-3 py-2 text-[13px] font-medium text-red-500 hover:bg-red-50 w-full rounded-lg transition-colors"
>
<LogOut size={14} />
{t('logout')}
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
{/* Top Header */}
<header className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-40">
<div className="flex items-center gap-4 flex-1">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 text-slate-400 rounded-lg hover:bg-slate-100 transition-colors"
>
<Menu size={20} />
</button>
{/* Tenant Switcher */}
<div className="relative">
<button
onClick={() => setShowTenantMenu(!showTenantMenu)}
className="flex items-center gap-2 px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl hover:bg-slate-100 transition-all group"
>
<Building2 size={16} className="text-blue-600" />
<span className="text-sm font-bold text-slate-700">{activeTenant?.tenant?.name || t('selectOrganization')}</span>
<ChevronRight size={14} className={cn("text-slate-400 transition-transform", showTenantMenu ? "rotate-90" : "")} />
</button>
<AnimatePresence>
{showTenantMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowTenantMenu(false)} />
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute top-full mt-2 left-0 w-64 bg-white border border-slate-200 rounded-2xl shadow-xl z-50 overflow-hidden"
>
<div className="p-3 border-b border-slate-100">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2">{t('navTenants')}</span>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{tenantTree.map(membership => renderTenantItem(membership))}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 px-3 py-1.5 bg-slate-50/50 border border-slate-100 rounded-2xl">
<div className="flex items-center justify-center w-8 h-8 text-xs font-bold text-white bg-indigo-600 rounded-full shrink-0 shadow-sm">
{user?.displayName?.[0]?.toUpperCase() || user?.username?.[0]?.toUpperCase() || 'A'}
</div>
<div className="flex flex-col">
<p className="text-sm font-bold text-slate-900 truncate max-w-[120px]">
{user?.displayName || user?.username}
</p>
<span className="text-[9px] font-black text-blue-600 uppercase tracking-tighter">
{activeTenant?.role?.replace('_', ' ') || user?.role?.replace('_', ' ') || 'USER'}
</span>
</div>
</div>
</div>
</header>
{/* Page Content Rendered Here via Outlet */}
<div className="flex-1 bg-[#FCFDFF] overflow-hidden flex flex-col relative">
<div className="max-w-[1400px] w-full mx-auto h-full relative">
<Outlet />
</div>
</div>
</main>
</div>
);
};
export default WorkspaceLayout;
@@ -0,0 +1,509 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Plus, Trash2, Copy, Check, RefreshCcw, ToggleLeft, ToggleRight, ExternalLink, Wifi, WifiOff, Loader2 } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../../contexts/LanguageContext';
interface FeishuBotInfo {
id: string;
appId: string;
botName?: string;
enabled: boolean;
webhookUrl: string;
createdAt: string;
}
type WsState = 'disconnected' | 'connecting' | 'connected' | 'error';
interface WsStatus {
botId: string;
state: WsState;
connectedAt?: string;
lastHeartbeat?: string;
error?: string;
}
export const FeishuPluginConfig: React.FC = () => {
const { apiKey } = useAuth();
const { t } = useLanguage();
const [bots, setBots] = useState<FeishuBotInfo[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [wsStatuses, setWsStatuses] = useState<Record<string, WsStatus>>({});
const [wsLoading, setWsLoading] = useState<Record<string, boolean>>({});
const [form, setForm] = useState({
appId: '',
appSecret: '',
botName: '',
verificationToken: '',
encryptKey: '',
});
const fetchBots = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/feishu/bots', {
headers: { 'x-api-key': apiKey },
});
if (res.ok) {
const data = await res.json();
setBots(data);
}
} catch (e) {
console.error('Failed to fetch Feishu bots', e);
} finally {
setLoading(false);
}
}, [apiKey]);
const fetchWsStatuses = useCallback(async () => {
try {
const res = await fetch('/api/feishu/ws/status', {
headers: { 'x-api-key': apiKey },
});
if (res.ok) {
const data = await res.json();
const map: Record<string, WsStatus> = {};
for (const s of (data.connections ?? [])) {
map[s.botId] = s;
}
setWsStatuses(map);
}
} catch (e) {
console.error('Failed to fetch WS statuses', e);
}
}, [apiKey]);
useEffect(() => {
fetchBots();
}, [fetchBots]);
useEffect(() => {
fetchWsStatuses();
}, [fetchWsStatuses]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.appId || !form.appSecret) return;
setSubmitting(true);
try {
const res = await fetch('/api/feishu/bots', {
method: 'POST',
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (res.ok) {
await fetchBots();
setShowForm(false);
setForm({ appId: '', appSecret: '', botName: '', verificationToken: '', encryptKey: '' });
}
} catch (e) {
console.error('Failed to create bot', e);
} finally {
setSubmitting(false);
}
};
const handleDelete = async (botId: string) => {
if (!window.confirm(t('feishuConfirmDelete'))) return;
try {
await fetch(`/api/feishu/bots/${botId}`, {
method: 'DELETE',
headers: { 'x-api-key': apiKey },
});
await fetchBots();
} catch (e) {
console.error('Failed to delete bot', e);
}
};
const handleToggle = async (bot: FeishuBotInfo) => {
try {
await fetch(`/api/feishu/bots/${bot.id}/toggle`, {
method: 'PATCH',
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !bot.enabled }),
});
await fetchBots();
} catch (e) {
console.error('Failed to toggle bot', e);
}
};
const handleWsConnect = async (botId: string) => {
setWsLoading((prev) => ({ ...prev, [botId]: true }));
try {
const res = await fetch(`/api/feishu/bots/${botId}/ws/connect`, {
method: 'POST',
headers: { 'x-api-key': apiKey },
});
if (res.ok) {
setWsStatuses((prev) => ({
...prev,
[botId]: { botId, state: 'connecting' },
}));
// Poll for status after a short delay
setTimeout(async () => {
await fetchWsStatuses();
setWsLoading((prev) => ({ ...prev, [botId]: false }));
}, 2000);
} else {
setWsLoading((prev) => ({ ...prev, [botId]: false }));
}
} catch (e) {
console.error('Failed to connect WS', e);
setWsLoading((prev) => ({ ...prev, [botId]: false }));
}
};
const handleWsDisconnect = async (botId: string) => {
setWsLoading((prev) => ({ ...prev, [botId]: true }));
try {
const res = await fetch(`/api/feishu/bots/${botId}/ws/disconnect`, {
method: 'POST',
headers: { 'x-api-key': apiKey },
});
if (res.ok) {
setWsStatuses((prev) => ({
...prev,
[botId]: { botId, state: 'disconnected' },
}));
}
} catch (e) {
console.error('Failed to disconnect WS', e);
} finally {
setWsLoading((prev) => ({ ...prev, [botId]: false }));
}
};
const copyWebhookUrl = (url: string, id: string) => {
const fullUrl = `${window.location.origin}${url}`;
navigator.clipboard.writeText(fullUrl);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const getWsStateColor = (state: WsState) => {
switch (state) {
case 'connected': return 'text-emerald-500';
case 'connecting': return 'text-amber-500';
case 'error': return 'text-red-500';
default: return 'text-slate-400';
}
};
const getWsStateBg = (state: WsState) => {
switch (state) {
case 'connected': return 'bg-emerald-50 text-emerald-700 border-emerald-200';
case 'connecting': return 'bg-amber-50 text-amber-700 border-amber-200';
case 'error': return 'bg-red-50 text-red-700 border-red-200';
default: return 'bg-slate-50 text-slate-500 border-slate-200';
}
};
const getWsStateLabel = (state: WsState) => {
switch (state) {
case 'connected': return t('feishuWsConnected');
case 'connecting': return t('feishuWsConnecting');
case 'error': return t('feishuWsError');
default: return t('feishuWsDisconnected');
}
};
return (
<div className="max-w-3xl">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span className="text-3xl">🪶</span>
<div>
<h2 className="text-xl font-bold text-slate-900">{t('pluginFeishuName')}</h2>
<p className="text-sm text-slate-500 mt-0.5">{t('pluginFeishuDesc')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchBots(); fetchWsStatuses(); }}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
title={t('refresh')}
>
<RefreshCcw size={16} />
</button>
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-2 px-4 h-9 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-semibold transition-colors"
>
<Plus size={15} />
{t('feishuAddBot')}
</button>
</div>
</div>
{/* Setup Guide */}
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 mb-6 text-sm text-indigo-800">
<p className="font-semibold mb-2">📌 {t('feishuSetupGuide')}</p>
<ol className="list-decimal list-inside space-y-1 text-indigo-700">
<li>{t('feishuStep1')}</li>
<li>{t('feishuStep2')}</li>
<li>{t('feishuStep3')}</li>
<li>{t('feishuStep4')}</li>
</ol>
<a
href="https://open.feishu.cn/document/faq/bot"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 mt-2 text-indigo-600 hover:underline font-medium"
>
{t('feishuDocs')} <ExternalLink size={12} />
</a>
</div>
{/* Add Bot Form */}
{showForm && (
<form
onSubmit={handleSubmit}
className="bg-white border border-slate-200 rounded-xl p-6 mb-6 shadow-sm"
>
<h3 className="font-semibold text-slate-800 mb-4">{t('feishuAddBot')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">
App ID <span className="text-red-500">*</span>
</label>
<input
type="text"
required
value={form.appId}
onChange={(e) => setForm((f) => ({ ...f, appId: e.target.value }))}
placeholder="cli_xxxxxxxxxxxxxxxx"
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">
App Secret <span className="text-red-500">*</span>
</label>
<input
type="password"
required
value={form.appSecret}
onChange={(e) => setForm((f) => ({ ...f, appSecret: e.target.value }))}
placeholder="••••••••••••••••"
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">
{t('feishuBotDisplayName')}
</label>
<input
type="text"
value={form.botName}
onChange={(e) => setForm((f) => ({ ...f, botName: e.target.value }))}
placeholder={t('feishuBotNamePlaceholder')}
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">
Verification Token
</label>
<input
type="text"
value={form.verificationToken}
onChange={(e) => setForm((f) => ({ ...f, verificationToken: e.target.value }))}
placeholder={t('feishuTokenPlaceholder')}
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-semibold text-slate-600 mb-1">
Encrypt Key ({t('optional')})
</label>
<input
type="password"
value={form.encryptKey}
onChange={(e) => setForm((f) => ({ ...f, encryptKey: e.target.value }))}
placeholder={t('feishuEncryptKeyPlaceholder')}
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-4">
<button
type="button"
onClick={() => setShowForm(false)}
className="px-4 h-9 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
{t('cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 h-9 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-50"
>
{submitting ? t('saving') : t('save')}
</button>
</div>
</form>
)}
{/* Bot List */}
{loading ? (
<div className="text-center py-12 text-slate-400">{t('loading')}</div>
) : bots.length === 0 ? (
<div className="text-center py-12 bg-white rounded-xl border border-dashed border-slate-200">
<span className="text-4xl mb-3 block">🪶</span>
<p className="text-slate-500 text-sm">{t('feishuNoBots')}</p>
</div>
) : (
<div className="space-y-4">
{bots.map((bot) => {
const wsStatus = wsStatuses[bot.id];
const wsState: WsState = wsStatus?.state ?? 'disconnected';
const isWsLoading = wsLoading[bot.id] ?? false;
const isWsConnected = wsState === 'connected';
const isWsConnecting = wsState === 'connecting';
return (
<div
key={bot.id}
className="bg-white rounded-xl border border-slate-200 shadow-sm p-5"
>
{/* Bot Header */}
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-semibold text-slate-800">
{bot.botName || bot.appId}
</p>
<p className="text-xs text-slate-400 mt-0.5">App ID: {bot.appId}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggle(bot)}
className="flex items-center gap-1.5 text-xs font-medium transition-colors"
title={bot.enabled ? t('feishuDisableBot') : t('feishuEnableBot')}
>
{bot.enabled ? (
<ToggleRight size={22} className="text-emerald-500" />
) : (
<ToggleLeft size={22} className="text-slate-300" />
)}
</button>
<button
onClick={() => handleDelete(bot.id)}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
{/* Webhook URL */}
<div className="bg-slate-50 rounded-lg p-3 flex items-center justify-between gap-2 mb-3">
<div className="flex-1 min-w-0">
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-0.5">
{t('feishuWebhookUrl')}
</p>
<code className="text-xs text-slate-700 break-all">
{window.location.origin}{bot.webhookUrl}
</code>
</div>
<button
onClick={() => copyWebhookUrl(bot.webhookUrl, bot.id)}
className="shrink-0 p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
>
{copiedId === bot.id ? (
<Check size={15} className="text-emerald-500" />
) : (
<Copy size={15} />
)}
</button>
</div>
{/* WebSocket Mode Panel */}
<div className={`rounded-lg border p-3 ${isWsConnected ? 'border-emerald-200 bg-emerald-50/50' : 'border-slate-200 bg-slate-50'}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 flex-1 min-w-0">
{isWsConnected ? (
<Wifi size={15} className="mt-0.5 shrink-0 text-emerald-500" />
) : (
<WifiOff size={15} className="mt-0.5 shrink-0 text-slate-400" />
)}
<div>
<p className="text-xs font-semibold text-slate-700">
{t('feishuWsMode')}
</p>
<p className="text-[11px] text-slate-500 mt-0.5">
{t('feishuWsModeDesc')}
</p>
</div>
</div>
{/* WS State Badge + Button */}
<div className="flex items-center gap-2 shrink-0">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold border ${getWsStateBg(wsState)}`}>
{getWsStateLabel(wsState)}
</span>
{isWsConnected || isWsConnecting ? (
<button
onClick={() => handleWsDisconnect(bot.id)}
disabled={isWsLoading || isWsConnecting}
className="flex items-center gap-1.5 px-3 h-7 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors disabled:opacity-50"
>
{isWsLoading ? <Loader2 size={12} className="animate-spin" /> : <WifiOff size={12} />}
{t('feishuWsDisconnect')}
</button>
) : (
<button
onClick={() => handleWsConnect(bot.id)}
disabled={isWsLoading || !bot.enabled}
className="flex items-center gap-1.5 px-3 h-7 text-xs font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors disabled:opacity-50"
title={!bot.enabled ? t('feishuEnableBot') : undefined}
>
{isWsLoading ? <Loader2 size={12} className="animate-spin" /> : <Wifi size={12} />}
{t('feishuWsConnect')}
</button>
)}
</div>
</div>
{/* Hint text */}
{!isWsConnected && (
<p className="text-[11px] text-slate-400 mt-2 pl-5">
💡 {t('feishuWsConnectHint')}
</p>
)}
{/* Connected At info */}
{isWsConnected && wsStatus?.connectedAt && (
<p className="text-[11px] text-emerald-600 mt-2 pl-5">
{t('feishuWsConnected')} · {new Date(wsStatus.connectedAt).toLocaleTimeString()}
</p>
)}
</div>
{/* Footer badges */}
<div className="mt-2 flex items-center gap-2">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold ${
bot.enabled
? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-100 text-slate-500'
}`}
>
{bot.enabled ? t('statusRunning') : t('statusStopped')}
</span>
<span className="text-[11px] text-slate-400">
{t('createdAt')}: {new Date(bot.createdAt).toLocaleDateString()}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
variant?: 'default' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
export const Button: React.FC<ButtonProps> = ({
children,
className = '',
variant = 'default',
size = 'md',
...props
}) => {
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
default: 'bg-blue-600 text-white hover:bg-blue-700',
outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50',
ghost: 'text-slate-700 hover:bg-slate-100'
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
>
{children}
</button>
);
};
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
}
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
return (
<div className={`bg-white rounded-lg border border-slate-200 shadow-sm ${className}`}>
{children}
</div>
);
};
export const CardHeader: React.FC<CardProps> = ({ children, className = '' }) => {
return (
<div className={`p-4 border-b border-slate-100 ${className}`}>
{children}
</div>
);
};
export const CardTitle: React.FC<CardProps> = ({ children, className = '' }) => {
return (
<h3 className={`text-lg font-semibold text-slate-800 ${className}`}>
{children}
</h3>
);
};
export const CardContent: React.FC<CardProps> = ({ children, className = '' }) => {
return (
<div className={`p-4 ${className}`}>
{children}
</div>
);
};
+121
View File
@@ -0,0 +1,121 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
interface SelectProps {
value: string;
onValueChange: (value: string) => void;
children: React.ReactNode;
disabled?: boolean;
}
interface SelectTriggerProps {
children: React.ReactNode;
className?: string;
}
interface SelectContentProps {
children: React.ReactNode;
}
interface SelectItemProps {
value: string;
children: React.ReactNode;
}
interface SelectValueProps {
placeholder?: string;
}
const SelectContext = React.createContext<{
value: string;
onValueChange: (value: string) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}>({
value: '',
onValueChange: () => {},
isOpen: false,
setIsOpen: () => {}
});
export const Select: React.FC<SelectProps> = ({ value, onValueChange, children, disabled = false }) => {
const [isOpen, setIsOpen] = useState(false);
const selectRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<SelectContext.Provider value={{ value, onValueChange, isOpen, setIsOpen }}>
<div ref={selectRef} className="relative">
{children}
</div>
</SelectContext.Provider>
);
};
export const SelectTrigger: React.FC<SelectTriggerProps> = ({ children, className = '' }) => {
const { isOpen, setIsOpen } = React.useContext(SelectContext);
return (
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm border border-slate-300 rounded-md bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 ${className}`}
>
{children}
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
);
};
export const SelectValue: React.FC<SelectValueProps> = ({ placeholder }) => {
const { value } = React.useContext(SelectContext);
return (
<span className={value ? 'text-slate-900' : 'text-slate-500'}>
{value || placeholder}
</span>
);
};
export const SelectContent: React.FC<SelectContentProps> = ({ children }) => {
const { isOpen } = React.useContext(SelectContext);
if (!isOpen) return null;
return (
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-md shadow-lg max-h-60 overflow-auto">
{children}
</div>
);
};
export const SelectItem: React.FC<SelectItemProps> = ({ value, children }) => {
const { value: selectedValue, onValueChange, setIsOpen } = React.useContext(SelectContext);
const handleClick = () => {
onValueChange(value);
setIsOpen(false);
};
return (
<button
type="button"
onClick={handleClick}
className={`w-full px-3 py-2 text-sm text-left hover:bg-slate-100 focus:outline-none focus:bg-slate-100 ${
selectedValue === value ? 'bg-blue-50 text-blue-700' : 'text-slate-900'
}`}
>
{children}
</button>
);
};
+186
View File
@@ -0,0 +1,186 @@
import React from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useNavigate } from 'react-router-dom';
import { Search, Plus, MoreHorizontal, MessageSquare } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../../utils/cn';
// Mock data based on the provided design
interface AgentMock {
id: string;
name: string;
description: string;
status: 'running' | 'stopped';
updatedAt: string;
iconEmoji: string;
iconBgClass: string;
path?: string;
}
const mockAgents: AgentMock[] = [
{
id: 'assessment',
name: 'assessmentTitle',
description: 'assessmentDesc',
status: 'running',
updatedAt: 'agent1Time',
iconEmoji: '📋',
iconBgClass: 'bg-blue-50',
path: '/assessment'
},
{
id: 'data-analyst',
name: 'agent1Name',
description: 'agent1Desc',
status: 'running',
updatedAt: 'agent1Time',
iconEmoji: '📊',
iconBgClass: 'bg-emerald-50'
},
{
id: 'code-review',
name: 'agent2Name',
description: 'agent2Desc',
status: 'running',
updatedAt: 'agent2Time',
iconEmoji: '💻',
iconBgClass: 'bg-indigo-50'
},
{
id: 'paper-polisher',
name: 'agent3Name',
description: 'agent3Desc',
status: 'stopped',
updatedAt: 'agent3Time',
iconEmoji: '✍️',
iconBgClass: 'bg-amber-50'
},
{
id: 'legal-consultant',
name: 'agent4Name',
description: 'agent4Desc',
status: 'running',
updatedAt: 'agent4Time',
iconEmoji: '⚖️',
iconBgClass: 'bg-rose-50'
},
{
id: 'market-researcher',
name: 'agent5Name',
description: 'agent5Desc',
status: 'running',
updatedAt: 'agent5Time',
iconEmoji: '📈',
iconBgClass: 'bg-cyan-50'
}
];
export const AgentsView: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
return (
<div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
{/* Header Area */}
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<h1 className="text-[22px] font-bold text-slate-900 leading-tight">
{t('agentTitle')}
</h1>
<p className="text-[14px] text-slate-500 mt-1">{t('agentDesc')}</p>
</div>
<div className="flex items-center gap-4">
<div className="relative w-64">
<Search className="absolute text-slate-400 left-3 top-1/2 -translate-y-1/2" size={16} />
<input
type="text"
placeholder={t('searchAgent')}
className="w-full h-10 pl-10 pr-4 bg-white border border-slate-200 rounded-lg focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all text-sm font-medium"
/>
</div>
<button className="flex items-center gap-2 px-5 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95">
<Plus size={18} />
<span>{t('createAgent')}</span>
</button>
</div>
</div>
{/* Content Area */}
<div className="px-8 pb-8 flex-1 overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-[1600px] mx-auto">
<AnimatePresence>
{mockAgents.map((agent) => (
<motion.div
key={agent.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-all group flex flex-col h-[220px]",
agent.path && "cursor-pointer hover:border-blue-200"
)}
onClick={() => {
if (agent.path) {
navigate(agent.path);
}
}}
>
{/* Top layer */}
<div className="flex items-center justify-between mb-4">
<div className={`w-12 h-12 flex items-center justify-center rounded-xl ${agent.iconBgClass} text-2xl`}>
{agent.iconEmoji}
</div>
<div className="flex items-center gap-3">
{/* Status Badge */}
{agent.status === 'running' ? (
<div className="px-2.5 py-1 text-[12px] font-semibold text-emerald-600 bg-emerald-50 rounded-full border border-emerald-100/50 flex flex-row items-center justify-center">
{t('statusRunning')}
</div>
) : (
<div className="px-2.5 py-1 text-[12px] font-semibold text-slate-500 bg-slate-50 rounded-full border border-slate-100 flex flex-row items-center justify-center">
{t('statusStopped')}
</div>
)}
{/* Options button */}
<button className="text-slate-400 hover:text-slate-600 transition-colors">
<MoreHorizontal size={20} />
</button>
</div>
</div>
{/* Middle layer */}
<div className="flex-1">
<h3 className="font-bold text-slate-800 text-[17px] mb-2 leading-tight">
{t(agent.name as any)}
</h3>
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-2">
{t(agent.description as any)}
</p>
</div>
{/* Bottom layer */}
<div className="mt-4 pt-4 border-t border-slate-50 flex items-center justify-between">
<span className="text-[12px] font-medium text-slate-400">
{t('updatedAtPrefix')}{t(agent.updatedAt as any)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
if (agent.path) {
navigate(agent.path);
}
}}
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
>
<MessageSquare size={14} className="text-blue-500" />
<span className="text-[13px] font-bold">{t('btnChat')}</span>
</button>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
);
};
+102
View File
@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { Blocks } from 'lucide-react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { FeishuPluginConfig } from '../plugins/FeishuPluginConfig';
interface PluginDef {
id: string;
icon: string;
nameKey: string;
descKey: string;
available: boolean;
component?: React.ComponentType;
}
const PLUGIN_LIST: PluginDef[] = [
{
id: 'feishu',
icon: '🪶',
nameKey: 'pluginFeishuName',
descKey: 'pluginFeishuDesc',
available: true,
component: FeishuPluginConfig,
},
];
export const PluginsView: React.FC = () => {
const { t } = useLanguage();
const [activePlugin, setActivePlugin] = useState<PluginDef | null>(null);
if (activePlugin?.component) {
const ConfigComp = activePlugin.component;
return (
<div className="flex flex-col h-full bg-[#f4f7fb]">
<div className="px-8 pt-8 pb-4 shrink-0">
<button
onClick={() => setActivePlugin(null)}
className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600 transition-colors mb-4"
>
{t('back')}
</button>
</div>
<div className="flex-1 overflow-y-auto px-8 pb-8">
<ConfigComp />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
{/* Header */}
<div className="px-8 pt-8 pb-6 shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-50 rounded-xl flex items-center justify-center">
<Blocks size={20} className="text-indigo-600" />
</div>
<div>
<h1 className="text-[22px] font-bold text-slate-900 leading-tight">
{t('pluginViewTitle')}
</h1>
<p className="text-[14px] text-slate-500 mt-0.5">{t('pluginViewDesc')}</p>
</div>
</div>
</div>
{/* Plugin Grid */}
<div className="px-8 pb-8 flex-1 overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-[1200px]">
{PLUGIN_LIST.map((plugin) => (
<div
key={plugin.id}
className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md hover:border-indigo-200 transition-all cursor-pointer group"
onClick={() => setActivePlugin(plugin)}
>
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center text-2xl">
{plugin.icon}
</div>
<div className="px-2.5 py-1 text-[12px] font-semibold text-emerald-600 bg-emerald-50 rounded-full border border-emerald-100">
{t('pluginStatusAvailable')}
</div>
</div>
<h3 className="font-bold text-slate-800 text-[17px] mb-1.5">
{t(plugin.nameKey as any)}
</h3>
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-2">
{t(plugin.descKey as any)}
</p>
<div className="mt-4 pt-4 border-t border-slate-50">
<button className="text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors group-hover:underline">
{t('pluginConfigure')}
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
};
+162
View File
@@ -0,0 +1,162 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
export type UserRole = 'SUPER_ADMIN' | 'TENANT_ADMIN' | 'USER';
export interface User {
id: string;
username: string;
role: UserRole;
tenantId?: string;
// Legacy support
email?: string;
tenant_name?: string;
isNotebookEnabled?: boolean;
displayName?: string;
}
export interface TenantMembership {
id: string;
tenantId: string;
role: UserRole;
tenant: {
id: string;
name: string;
domain?: string;
parentId?: string | null;
};
features?: {
isNotebookEnabled: boolean;
};
}
interface AuthContextType {
user: User | null;
apiKey: string;
availableTenants: TenantMembership[];
activeTenant: TenantMembership | null;
login: (key: string, userData: Partial<User> & { role?: string }) => void;
logout: () => void;
switchTenant: (tenantId: string) => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [apiKey, setApiKey] = useState<string>(localStorage.getItem('kb_api_key') || '');
const [availableTenants, setAvailableTenants] = useState<TenantMembership[]>([]);
const [activeTenant, setActiveTenant] = useState<TenantMembership | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchTenants = async (key: string, currentTenantId?: string) => {
try {
const res = await fetch('/api/users/tenants', {
headers: { 'x-api-key': key }
});
if (res.ok) {
const tenants: TenantMembership[] = await res.json();
console.log('[AuthContext] Fetched tenants:', tenants);
const filteredTenants = tenants.filter(t => t.tenant?.name !== 'Default');
setAvailableTenants(filteredTenants);
const savedTenantId = localStorage.getItem('kb_active_tenant_id') || currentTenantId;
const active = filteredTenants.find(t => t.tenantId === savedTenantId) || filteredTenants[0] || null;
setActiveTenant(active);
if (active) {
localStorage.setItem('kb_active_tenant_id', active.tenantId);
}
}
} catch (e) {
console.error('Failed to fetch tenants', e);
}
};
// On mount, restore session
useEffect(() => {
const restoreSession = async () => {
if (!apiKey) {
setIsLoading(false);
return;
}
try {
const res = await fetch('/api/users/me', {
headers: { 'x-api-key': apiKey }
});
if (res.ok) {
const data = await res.json();
console.log('[AuthContext] Restored user:', data);
setUser({
id: data.id,
username: data.username,
role: (data.role as UserRole) ?? 'USER',
tenantId: data.tenantId,
tenant_name: data.tenantName,
isNotebookEnabled: data.isNotebookEnabled ?? true,
displayName: data.displayName,
});
await fetchTenants(apiKey, data.tenantId);
} else {
localStorage.removeItem('kb_api_key');
localStorage.removeItem('kb_active_tenant_id');
setApiKey('');
setUser(null);
}
} catch (e) {
console.error('Failed to restore session', e);
} finally {
setIsLoading(false);
}
};
restoreSession();
}, [apiKey]);
const login = (key: string, userData: Partial<User> & { role?: string }) => {
localStorage.setItem('kb_api_key', key);
setApiKey(key);
setUser({
id: userData.id ?? '',
username: userData.username ?? '',
role: (userData.role as UserRole) ?? 'USER',
tenantId: userData.tenantId,
tenant_name: (userData as any).tenantName || userData.tenant_name,
isNotebookEnabled: userData.isNotebookEnabled ?? true,
displayName: userData.displayName,
});
fetchTenants(key, userData.tenantId);
};
const logout = () => {
localStorage.removeItem('kb_api_key');
localStorage.removeItem('kb_active_tenant_id');
setApiKey('');
setUser(null);
setAvailableTenants([]);
setActiveTenant(null);
};
const switchTenant = (tenantId: string) => {
const target = availableTenants.find(t => t.tenantId === tenantId);
if (target) {
setActiveTenant(target);
localStorage.setItem('kb_active_tenant_id', tenantId);
// Optionally reload page to reset all states, or just let components re-render
window.location.reload();
}
};
return (
<AuthContext.Provider value={{ user, apiKey, availableTenants, activeTenant, login, logout, switchTenant, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+178
View File
@@ -0,0 +1,178 @@
import React, { useState } from 'react';
import { BookOpen } from 'lucide-react';
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { cn } from '../../utils/cn';
export default function Login() {
const { login, user } = useAuth();
const navigate = useNavigate();
const [apiKeyInput, setApiKeyInput] = useState('');
const [loginUsername, setLoginUsername] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [loginMode, setLoginMode] = useState<'password' | 'apikey'>('password');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Redirect if already logged in
React.useEffect(() => {
if (user) {
navigate('/');
}
}, [user, navigate]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
if (loginMode === 'apikey') {
if (!apiKeyInput.trim()) {
setError('API Key is required');
setIsLoading(false);
return;
}
// Verify API key is valid by calling the profile endpoint
const res = await fetch('/api/users/me', {
headers: { 'x-api-key': apiKeyInput }
});
if (res.ok) {
const userData = await res.json();
login(apiKeyInput, { ...userData, role: userData.role ?? 'USER' });
} else {
setError('Invalid API Key');
}
} else {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginUsername, password: loginPassword })
});
if (res.ok) {
const data = await res.json();
// data has { access_token, user }
// Get the API key using the JWT token
const keyRes = await fetch('/api/users/api-key', {
headers: { 'Authorization': `Bearer ${data.access_token}` }
});
if (keyRes.ok) {
const keyData = await keyRes.json();
login(keyData.apiKey, { ...data.user, role: data.user.role ?? 'USER' });
navigate('/');
} else {
// Fall back to using JWT access token as the key
login(data.access_token, { ...data.user, role: data.user.role ?? 'USER' });
navigate('/');
}
} else {
setError('Invalid username or password');
}
}
} catch (err) {
setError('An error occurred during login. Please try again.');
console.error(err);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md p-8 bg-white border border-slate-200 shadow-xl rounded-2xl"
>
<div className="flex flex-col items-center mb-8">
<div className="p-3 mb-4 bg-blue-600 rounded-xl">
<BookOpen className="text-white" size={32} />
</div>
<h1 className="text-2xl font-bold text-slate-900">AuraK V2</h1>
<p className="text-slate-500">Sign in to your workspace</p>
</div>
<div className="flex gap-2 p-1 mb-6 bg-slate-100 rounded-lg">
<button
type="button"
onClick={() => { setLoginMode('password'); setError(''); }}
className={cn(
"flex-1 py-1.5 text-sm font-medium rounded-md transition-all",
loginMode === 'password' ? "bg-white shadow-sm text-blue-600" : "text-slate-500 hover:text-slate-700"
)}
>
Password
</button>
<button
type="button"
onClick={() => { setLoginMode('apikey'); setError(''); }}
className={cn(
"flex-1 py-1.5 text-sm font-medium rounded-md transition-all",
loginMode === 'apikey' ? "bg-white shadow-sm text-blue-600" : "text-slate-500 hover:text-slate-700"
)}
>
API Key
</button>
</div>
{error && (
<div className="mb-4 p-3 text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg">
{error}
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
{loginMode === 'password' ? (
<>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700">Username</label>
<input
type="text"
value={loginUsername}
onChange={(e) => setLoginUsername(e.target.value)}
placeholder="admin"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
required
/>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700">Password</label>
<input
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
required
/>
</div>
</>
) : (
<div>
<label className="block mb-1 text-sm font-medium text-slate-700">API Key</label>
<input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="sk-..."
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
required
/>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 mt-2 text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-70 disabled:cursor-not-allowed rounded-lg font-semibold transition-colors flex items-center justify-center"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</motion.div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import { AgentsView } from '../../components/views/AgentsView';
const AgentsPage: React.FC = () => {
return (
<div className="flex flex-col h-full">
<AgentsView />
</div>
);
};
export default AgentsPage;
@@ -0,0 +1,15 @@
import React from 'react';
import { AssessmentView } from '../../../components/views/AssessmentView';
import { useAuth } from '../../contexts/AuthContext';
export default function AssessmentPage() {
const { apiKey, logout, user } = useAuth();
return (
<AssessmentView
onLogout={logout}
onNavigate={() => { }}
isAdmin={user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN'}
/>
);
}
+36
View File
@@ -0,0 +1,36 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { ChatView } from '../../../components/views/ChatView';
import { ModelConfig, DEFAULT_MODELS } from '../../../types';
import { modelConfigService } from '../../../services/modelConfigService';
export default function ChatPage() {
const { apiKey, logout, user } = useAuth();
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>(DEFAULT_MODELS);
const fetchModels = useCallback(async () => {
if (!apiKey) return;
try {
const backendModels = await modelConfigService.getAll(apiKey);
const map = new Map<string, ModelConfig>();
DEFAULT_MODELS.forEach(m => map.set(m.id, m));
backendModels.forEach(m => map.set(m.id, m));
setModelConfigs(Array.from(map.values()));
} catch {
setModelConfigs(DEFAULT_MODELS);
}
}, [apiKey]);
useEffect(() => { fetchModels(); }, [fetchModels]);
return (
<ChatView
authToken={apiKey}
onLogout={logout}
modelConfigs={modelConfigs}
onNavigate={() => { }}
isAdmin={user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN'}
/>
);
}
+35
View File
@@ -0,0 +1,35 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { KnowledgeBaseView } from '../../../components/views/KnowledgeBaseView';
import { modelConfigService } from '../../../services/modelConfigService';
import { ModelConfig } from '../../../types';
export default function KnowledgePage() {
const { apiKey, user, logout, activeTenant } = useAuth();
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
useEffect(() => {
const fetchModels = async () => {
if (!apiKey) return;
try {
const models = await modelConfigService.getAll(apiKey);
setModelConfigs(models);
} catch (error) {
console.error('Failed to fetch model configs:', error);
}
};
fetchModels();
}, [apiKey]);
const isAdmin = user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN' || activeTenant?.role === 'SUPER_ADMIN';
return (
<KnowledgeBaseView
authToken={apiKey || ''}
onLogout={logout}
onNavigate={() => { }}
modelConfigs={modelConfigs}
isAdmin={isAdmin}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { MemosView } from '../../../components/views/MemosView';
export default function MemosPage() {
const { apiKey, user, activeTenant } = useAuth();
const isAdmin = user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN' || activeTenant?.role === 'SUPER_ADMIN';
return (
<MemosView
authToken={apiKey}
isAdmin={isAdmin}
/>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { NotebooksView } from '../../../components/views/NotebooksView';
const NotebooksPage: React.FC = () => {
const { apiKey, user, activeTenant } = useAuth()
const isAdmin = user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN' || activeTenant?.role === 'SUPER_ADMIN';
return (
<div className="flex flex-col h-full">
<NotebooksView
authToken={apiKey}
onChatWithContext={() => { }}
isAdmin={isAdmin}
/>
</div>
)
}
export default NotebooksPage;
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import { PluginsView } from '../../components/views/PluginsView';
const PluginsPage: React.FC = () => {
return (
<div className="flex flex-col h-full">
<PluginsView />
</div>
);
};
export default PluginsPage;
+49
View File
@@ -0,0 +1,49 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { SettingsView } from '../../../components/views/SettingsView';
import { ModelConfig, DEFAULT_MODELS } from '../../../types';
import { modelConfigService } from '../../../services/modelConfigService';
interface SettingsPageProps {
initialTab?: 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base';
}
export default function SettingsPage({ initialTab }: SettingsPageProps) {
const { apiKey, user, activeTenant } = useAuth();
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>(DEFAULT_MODELS);
const fetchModels = useCallback(async () => {
if (!apiKey) return;
try {
const backendModels = await modelConfigService.getAll(apiKey);
const map = new Map<string, ModelConfig>();
DEFAULT_MODELS.forEach(m => map.set(m.id, m));
backendModels.forEach(m => map.set(m.id, m));
setModelConfigs(Array.from(map.values()));
} catch {
setModelConfigs(DEFAULT_MODELS);
}
}, [apiKey]);
useEffect(() => { fetchModels(); }, [fetchModels]);
const handleUpdateModels = useCallback(async (action: 'create' | 'update' | 'delete', model: ModelConfig) => {
if (!apiKey) return;
const { id, ...data } = model;
if (action === 'create') await modelConfigService.create(apiKey, data);
else if (action === 'update') await modelConfigService.update(apiKey, id, data);
else if (action === 'delete') await modelConfigService.remove(apiKey, id);
await fetchModels();
}, [apiKey, fetchModels]);
return (
<SettingsView
models={modelConfigs}
onUpdateModels={handleUpdateModels}
authToken={apiKey}
isAdmin={user?.role === 'SUPER_ADMIN' || activeTenant?.role === 'TENANT_ADMIN'}
currentUser={user}
initialTab={initialTab}
/>
);
}
@@ -0,0 +1,42 @@
import { apiClient } from './apiClient';
export interface AssessmentStats {
totalAttempts: number;
highestScore: number;
averageScore: number;
completionRate: number;
recentRecords: {
id: string;
userId?: string;
knowledgeBase: string;
template: string;
score: number | null;
status: 'IN_PROGRESS' | 'COMPLETED';
createdAt: string;
user?: { id: string };
}[];
}
export interface StatsQueryParams {
startDate?: string;
endDate?: string;
templateId?: string;
knowledgeGroupId?: string;
}
export class AssessmentStatsService {
async getStats(params?: StatsQueryParams): Promise<AssessmentStats> {
const query = new URLSearchParams();
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.templateId) query.set('templateId', params.templateId);
if (params?.knowledgeGroupId) query.set('knowledgeGroupId', params.knowledgeGroupId);
const queryString = query.toString();
const url = `/assessment/stats${queryString ? `?${queryString}` : ''}`;
const { data } = await apiClient.get<AssessmentStats>(url);
return data;
}
}
export const assessmentStatsService = new AssessmentStatsService();
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+31
View File
@@ -0,0 +1,31 @@
type ToastType = 'success' | 'error' | 'warning' | 'info';
interface ToastHandler {
showToast: (type: ToastType, message: string, title?: string) => void;
}
let handler: ToastHandler | null = null;
export const registerToastHandler = (h: ToastHandler) => {
handler = h;
};
export const toast = {
success: (message: string, title?: string) => {
if (handler) handler.showToast('success', message, title);
else console.log(`${message}`);
},
error: (message: string, title?: string) => {
if (handler) handler.showToast('error', message, title);
else console.log(`${message}`);
},
info: (message: string, title?: string) => {
if (handler) handler.showToast('info', message, title);
else console.log(`${message}`);
},
warning: (message: string, title?: string) => {
if (handler) handler.showToast('warning', message, title);
else console.log(`⚠️ ${message}`);
}
};
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+364
View File
@@ -0,0 +1,364 @@
export interface IndexingConfig {
chunkSize: number;
chunkOverlap: number;
embeddingModelId: string;
mode?: 'fast' | 'precise'; // Processing mode: fast/precise
groupIds?: string[]; // Groups to associate with the file upon upload
}
export interface VisionAnalysisResult {
text: string; // Extracted text content
images: ImageDescription[]; // Image descriptions
layout: string; // Layout type
confidence: number; // 信頼度 (0-1)
pageIndex?: number; // Page number
}
export interface ImageDescription {
type: string; // Image type (graph/diagram/flowchart etc.)
description: string; // Detailed description
position?: number; // Position within the page
}
export interface PipelineResult {
success: boolean;
fileId: string;
fileName: string;
totalPages: number;
processedPages: number;
failedPages: number;
results: VisionAnalysisResult[];
cost: number; // Cost (USD)
duration: number; // Duration (seconds)
mode: 'precise';
}
export interface ModeRecommendation {
recommendedMode: 'precise' | 'fast';
reason: string;
estimatedCost?: number; // Estimated cost (USD)
estimatedTime?: number; // Estimated time (seconds)
warnings?: string[];
}
// Type definitions for knowledge base extensions
export interface KnowledgeGroup {
id: string;
name: string;
description?: string;
color: string;
fileCount: number;
parentId?: string | null;
children?: KnowledgeGroup[];
createdAt: string;
updatedAt?: string;
}
export interface CreateGroupData {
name: string;
description?: string;
color?: string;
parentId?: string | null;
}
export interface UpdateGroupData {
name?: string;
description?: string;
color?: string;
parentId?: string | null;
}
export interface KnowledgeFile {
id: string;
name: string;
originalName: string;
size: number;
type: string;
status: 'pending' | 'indexing' | 'extracted' | 'vectorized' | 'failed' | 'ready' | 'error';
groups?: KnowledgeGroup[];
createdAt: string;
updatedAt: string;
}
export interface SearchHistoryItem {
id: string;
title: string;
selectedGroups: string[] | null;
messageCount: number;
lastMessageAt: string;
createdAt: string;
}
export interface SearchHistoryDetail {
id: string;
title: string;
selectedGroups: string[] | null;
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
sources?: Array<{
fileName: string;
content: string;
score: number;
chunkIndex: number;
}>;
createdAt: string;
}>;
}
export interface PDFStatus {
status: 'pending' | 'converting' | 'ready' | 'failed';
pdfPath?: string;
error?: string;
}
export interface NoteCategory {
id: string
name: string
parentId?: string
level: number
userId: string
tenantId: string
createdAt: string
updatedAt: string
}
export interface Note {
id: string;
title: string;
content: string;
userId: string;
tenantId?: string;
groupId?: string;
categoryId?: string;
sharingStatus: 'PRIVATE' | 'TENANT' | 'GLOBAL_PENDING' | 'GLOBAL_APPROVED';
createdAt: string;
updatedAt: string;
user?: {
id: string;
username: string;
name?: string;
};
}
export interface RawFile {
name: string;
type: string;
size: number;
content: string;
preview?: string;
file: File; // Keep original file for upload
isNote?: boolean;
textContent?: string;
}
export enum Role {
USER = 'user',
MODEL = 'model',
}
export interface Message {
id: string;
role: Role;
text: string;
timestamp: number;
isError?: boolean;
sources?: ChatSource[];
}
export interface ChatSource {
fileName: string;
title?: string;
content: string;
score: number;
chunkIndex: number;
fileId?: string;
}
export interface ChatState {
messages: Message[];
isLoading: boolean;
}
export type Language = 'ja' | 'en' | 'zh';
export enum ModelType {
// Changed from type to enum
LLM = "llm",
EMBEDDING = "embedding",
RERANK = "rerank",
VISION = "vision",
}
// 1. Model Definition (The "Provider" setup)
export interface ModelConfig {
id: string;
name: string; // Display name, e.g. "My DeepSeek"
modelId: string; // The actual string ID sent to API, e.g., "gpt-4o"
baseUrl?: string; // Base URL for OpenAI compatible API
apiKey?: string; // API key for the service
type: ModelType;
dimensions?: number; // Vector dimensions of the embedding model
supportsVision?: boolean; // Whether it supports vision capabilities
// ==================== Additional Fields ====================
/**
* Model's input token limit
* e.g., OpenAI=8191, Gemini=2048
*/
maxInputTokens?: number;
/**
* Batch processing limit (maximum number of inputs per request)
* e.g., OpenAI=2048, Gemini=100
*/
maxBatchSize?: number;
/**
* Whether it is a vector model (for identification in system settings)
*/
isVectorModel?: boolean;
/**
* Model provider name (for display)
* e.g., "OpenAI", "Google Gemini", "Custom"
*/
providerName?: string;
/**
* Whether it is enabled
*/
isEnabled?: boolean;
/**
* Whether to use this model as the default
*/
isDefault?: boolean;
}
// 2. Application Logic Settings (The "App" setup)
export interface AppSettings {
// References to ModelConfig IDs
selectedLLMId: string;
selectedEmbeddingId: string; // Default for new uploads, and used for query encoding
selectedRerankId: string;
// Model Hyperparameters
temperature: number;
maxTokens: number;
// Retrieval
enableRerank: boolean;
topK: number;
similarityThreshold: number; // Similarity threshold for vector search
rerankSimilarityThreshold: number; // Similarity threshold for reranking
enableFullTextSearch: boolean; // Whether to enable full-text search
hybridVectorWeight: number; // Vector weight for hybrid search
// Search Enhancement
enableQueryExpansion: boolean;
enableHyDE: boolean;
chunkSize?: number;
chunkOverlap?: number;
// Language
language?: string;
// Coach
coachKbId?: string;
// Vision
selectedVisionId?: string;
}
// Default Models (Frontend specific)
export const DEFAULT_MODELS: ModelConfig[] = [];
export const DEFAULT_SETTINGS: AppSettings = {
selectedLLMId: '',
selectedEmbeddingId: '',
selectedRerankId: '',
selectedVisionId: '',
temperature: 0.3,
maxTokens: 8192,
enableRerank: false,
topK: 4,
similarityThreshold: 0.3, // Default similarity threshold for vector search
rerankSimilarityThreshold: 0.5, // Default similarity threshold for reranking
enableFullTextSearch: false, // Turn off full-text search by default
hybridVectorWeight: 0.7, // Vector weight for hybrid search
enableQueryExpansion: false,
enableHyDE: false,
chunkSize: 1000,
chunkOverlap: 100,
language: 'ja',
};
export const API_BASE_URL = '/api'
export interface Tenant {
id: string;
name: string;
domain?: string;
parentId?: string | null;
children?: Tenant[];
members?: TenantMember[];
settings_obj?: any;
createdAt: string;
updatedAt: string;
}
export interface TenantMember {
id: string;
userId: string;
tenantId: string;
role: string;
user?: {
id: string;
username: string;
displayName?: string;
email?: string;
};
createdAt: string;
updatedAt: string;
}
// Assessment Template Types
export interface AssessmentTemplate {
id: string;
name: string;
description?: string;
keywords?: string[];
questionCount: number;
difficultyDistribution?: Record<string, number>;
style?: string;
knowledgeBaseId?: string;
knowledgeGroupId?: string;
knowledgeGroup?: KnowledgeGroup;
isActive: boolean;
version: number;
creatorId: string;
createdAt: string;
updatedAt: string;
}
export interface CreateTemplateData {
name: string;
description?: string;
keywords?: string[];
questionCount?: number;
difficultyDistribution?: Record<string, number>;
style?: string;
knowledgeBaseId?: string;
knowledgeGroupId?: string;
}
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
isActive?: boolean;
}
+1361
View File
@@ -0,0 +1,1361 @@
a
A
a6cee31f78b4b2df8a33a02cfb9a99e31a1cfdbf6fff7f00cab2d66a3d9affff99b15928
abap
abc
abnf
ABNG5WF4
abort
Accept
acceptedHmrDeps
accesslog
actionscript
ada
add
addFile
addGroup
addUser
admin
adminUser
after
afterend
agda
agentRemove
aiAssistant
aiCoachConfig
aiDisclaimer
al
all
allGroups
allKnowledgeGroups
allStatus
amsrm
analyzing
analyzingFile
angelscript
anonymous
antlr4
apache
apacheconf
apex
api
apiError
apl
applescript
aql
arcade
architectureDiagram
arduino
arff
aria
armasm
arturo
asciidoc
asm6502
asmatmel
aspectj
aspnet
assert
AstNode
async
asynchronous
attentionSequence
Authorization
autoAdjustChunk
autoAdjustOverlap
autohotkey
autoit
autoRefresh
autoRefreshEnabled
avisynth
avrasm
avroIdl
awk
axapta
AYHSOK5B
b
B
b3e2cdfdcdaccbd5e8f4cae4e6f5c9fff2aef1e2cccccccc
back
backdrop
backgroundOpacity
base64
bash
basic
batch
baz
bbcode
bbj
before
beforeend
beforetoggle
bicep
bilkent
bin
binary
bind
birb
bison
bitmaprenderer
blockDiagram
bnf
body
borderOpacity
borderSpacing
boundtest
box
boxend
boxselect
boxShadow
boxstart
bqn
br
BR3WXWH6
braces
brackets
brainfuck
BRHXFKE3
brightscript
bro
bsl
buffer
build
button
c
C
c4Diagram
cacheInvalidation
cal
callback
cancel
canvas
capnproto
ceil
ceylon
cfscript
chaiscript
changePassword
changes
channel
chatDesc
ChatInterface
ChatMessage
chatTitle
ChatView
chatWithFile
chatWithGroup
chatWithNotebook
child
children
chokidar
chooseCoachKb
chunk
chunkConfig
chunkIndex
chunkInfo
ChunkInfoDrawer
chunkNumber
chunkOverlap
chunks
chunkSize
chunkString
cil
cilkc
cilkcpp
circle
citationSources
cjs
class
clean
clearAll
clearFailed
cli
client
clike
cLike
clipPath
clojure
clojureRepl
cloneNodes
close
cluster
cmake
coachKbHelp
cobol
code
coffeescript
color
columnNumber
command
commentAtEnd
component
components
conclude
concurnas
config
ConfigPanel
configurable
confirm
confirmChange
confirmChangeEmbeddingModel
confirmClear
confirmClearKB
confirmDeleteFile
confirmDeleteNote
confirmDeleteNotebook
confirmDeleteUser
confirmPassword
confirmPreciseCost
confirmRemoveFileFromGroup
confirmUnsupportedFile
connection
console
constants
content
Content
contentLength
contexts
contrast
cooklang
copied
copy
copyContent
copySuccess
coq
corePlugins
cos
cose
count
cpp
create
createFailed
createFailedRetry
createNotebook
CreateNotebookDrawer
createNotebookTitle
createNow
CreatePackageJsonAutoImportProvider
createUserFailed
creating
crmsh
crypto
crystal
csharp
cshtml
csp
css
css2
cssExtras
csv
cue
currentPassword
CvfTChi5
cxtdrag
cxtdragout
cxtdragover
cxttap
cxttapend
cxttapstart
cypher
d
D4NMHUTW
dagre
dark
dart
data
dataweave
dax
days
dblclick
DBO2IL
dddd
DEBUG
decorators
deep
default
defaultForUploads
defaultVisionModel
definition
definitionMarker
Definitions
defs
delete
deleteFailed
deleteNotebook
deleteUser
deleteUserFailed
delphi
DEMOZRFA
dep
deps
desc
descPlaceholder
destroy
detailedIntro
develop
dgram
dhall
diagnostics
diff
dist
div
divideOpacity
divideWidth
django
DJJBHJGC
dns
dnsZoneFile
docker
dockerfile
documentmock
dom
domain
done
dos
dot
drag
dragfree
dragfreeon
dragpan
dsconfig
dts
dummy
dust
DWMUTS1A
e
E3DLGNHC
e41a1c377eb84daf4a984ea3ff7f00ffff33a65628f781bf999999
EAHG6JYU
ebnf
econnreset
editNote
editNotebook
EditNotebookDrawer
editNotebookTitle
editorconfig
eiffel
ejs
eles
elixir
elm
embeddingModel
embeddingModelWarning
en
end
enumerable
environment
envLimitWeaker
EOF
erb
erlang
erlangRepl
error
Error
ERROR
errorGeneric
errorLabel
errorLoadData
errorMessage
errorNoModel
errorProcessFile
errorReadFile
errorRenderFlowchart
errorSaveFailed
errorTitleContentRequired
errorUploadFile
ES55IZVV
estree
etlua
EU6I4XYI
event
Event
events
example
excel
excelFormula
execImmediate
execScheduled
expandApplyAtRules
expandMenu
expandTailwindAtRules
Expected
experimental
export
exports
external
factor
Failed
failing
falselang
fastFeature1
fastFeature2
fastFeature3
fastFeature4
fastFeature5
fastMode
fastModeDesc
fastModeFeatures
FATAL
fbb4aeb3cde3ccebc5decbe4fed9a6ffffcce5d8bdfddaecf2f2f2
feature
fetch
file
fileDeleted
fileName
files
filesCountLabel
fileSizeLimitExceeded
fill
fillSourcePath
fillTargetName
filterLowResults
findAtConfigPath
firestoreSecurityRules
first
fix
FJRWPSKW
FKZM4ZOC
flix
floor
flow
flowDiagram
focusin
focusout
foo
forced
form
formatVariantSelector
fortran
FQLTVULR
fred
Fred
FRED
free
freeon
fs
fsharp
ftl
fullTextSearch
FX3D2ARJ
g
G
gams
ganttDiagram
gap
gauss
gc
gcode
gdscript
gedcom
geminiError
generalSettings
generate
generated
generateRules
get
gettext
getUserListFailed
gfm
gfmFootnoteCall
gfmFootnoteCallLabelMarker
gfmFootnoteCallMarker
gfmFootnoteCallString
gfmFootnoteDefinition
gfmFootnoteDefinitionLabel
gfmFootnoteDefinitionLabelMarker
gfmFootnoteDefinitionLabelString
gfmFootnoteDefinitionMarker
gherkin
git
gitGraphDiagram
glob
globalThis
globalVar
glsl
gml
gn
go
golo
goModule
grab
grabon
gradientColorStopPositions
gradientColorStops
gradle
graphql
green
groovy
group
groupsActions
GroupSelectionDrawer
gz
h1
haml
handlebars
has
hasAltText
hasError
hasIntro
haskell
hast
haxe
hcl
headerHyperparams
headerModelSelection
headerRetrieval
headers
height
hex
highlighter
highlightjs
hlsl
hooks
hoon
Host
hours
hpkp
hsp
hsts
html2canvas
html2canvascustomelement
html2canvaspseudoelement
html2canvaswrapper
htmlbars
htmlExtension
http
http2
https
hy
i
I
ichigojam
icon
icuMessageFormat
id
idris
idxCancel
idxDesc
idxEmbeddingModel
idxFiles
idxMethod
idxModalTitle
idxStart
ie
iecst
iframe
ignore
img
importedModules
importFolder
ImportFolderDrawer
importFolderTip
importFolderTitle
importTaskScheduled
importTaskStarted
importToCurrentGroup
index
indexingConfigDesc
indexingConfigTitle
IndexingModal
IndexingModalWithMode
indexIntoKB
info
INFO
inform7
ini
inline
input
inspector
introHelp
introPlaceholder
invalid
Invalid
io
irpf90
is
isbl
IT6M3QCI
j
java
javadoc
javadoclike
javascript
JavaScript
javastacktrace
jbossCli
JELNMOA3
jexl
JF435LOM
JGLXZC4W
jiti
jolie
journeyDiagram
jq
js
jsdoc
jsExtras
json
JSON
json5
jsonp
jsstacktrace
jsTemplates
jsx
jsxfrag
jsximportsource
jsxruntime
julia
juliaRepl
JY2GPXU
kanban
katex
kbCleared
kbManagement
kbManagementDesc
keepalived
KEK3UZK7
keyman
KKYMQMRF
KnowledgeBaseView
kotlin
kumir
kusto
L3YPOIOT
LAJSM3AR
landscape
langEn
langJa
languageSettings
langZh
lasso
latex
latte
layoutready
layouts
layoutstart
layoutstarted
layoutstop
lblEmbedding
lblExecType
lblLLM
lblMaxTokens
lblRerank
lblRerankRef
lblSourcePath
lblStartTime
lblTargetGroup
lblTemperature
lblTopK
ldif
leaf
leaveEmptyNoChange
LEFMGDK7
length
less
lib
Library
lightningcss
lilconfig
lilypond
line
linearGradient
lineClamp
lineNumber
link
linkerScript
liquid
lisp
literalAutolink
literalAutolinkEmail
literalAutolinkHttp
literalAutolinkWww
livecodeserver
livescript
llvm
load
loadFailed
loadHistoryFailed
loading
loadingUserData
loadLimitsFailed
loadVisionModelFailed
local
localVar
log
loginButton
loginDesc
loginError
LoginPage
loginRequired
loginTitle
loginToUpload
logout
lolcode
longer
lsl
ltr
lua
LVJTACZQ
M44RXYMZ
magma
main
major
makefile
makeUserAdmin
makeUserRegular
manhattan
map
markdown
markdownPreviewArea
marker
markup
markupTemplating
mask
mata
matches
matchScore
math
mathematica
mathFlow
mathFlowFence
mathFlowFenceMeta
mathFlowFenceSequence
mathFlowValue
mathrm
mathText
mathTextData
mathTextSequence
mathVsTextAccents
mathVsTextUnits
matlab
max
maxBatchSize
maxChunkSize
maxima
maxOverlapSize
maxscript
maxValueMsg
MC77WVK6
md
mdast
mel
mercury
merged
mermaid
message
meta
metafont
micromark
milliseconds
min
minor
minutes
mipsasm
mizar
mjs
mmAddBtn
mmCancel
mmEdit
mmErrorApiKeyRequired
mmErrorBaseUrlRequired
mmErrorExternalApiNotAllowed
mmErrorInvalidBaseUrl
mmErrorModelIdRequired
mmErrorNameRequired
mmErrorNotAuthenticated
mmErrorTitle
mmFormApiKey
mmFormBaseUrl
mmFormModelId
mmFormName
mmFormType
mmFormVision
mmSave
mocks
model
modelDisabled
modelEnabled
modelLimitsInfo
modelManagement
modifySettings
module
modules
mojolicious
mongodb
monkey
months
moonscript
mop
motion
mount
MQRPNODE
MRDRV4KB
my
myevent
n1ql
n4js
name
nameHelp
namePlaceholder
nand2tetrisHdl
naniscript
nasm
navChat
navKnowledge
navKnowledgeGroups
needLogin
neon
nesting
net
nevod
new
newChat
newNote
newPassword
newPasswordMinLength
nextStep
nginx
nim
nix
no
No
node
nodeRepl
noDescription
noFilesFound
noFilesOrNotes
noGroups
noGroupsFound
noIntro
noNotebooks
noRerankModel
NotebookDetailView
notebooks
notebooksDesc
NotebooksView
noteContentPlaceholder
noteCreatedFailed
noteCreatedSuccess
notes
notesCountLabel
noteTitlePlaceholder
noVisionModels
nsis
NV44I4VS
NY62KEGX
object
Object
objectivec
ocaml
odin
onBeforeInput
oneC
onMouseEnter
onMouseLeave
onPointerEnter
onPointerLeave
onpropertychange
open
opencl
openGroupSelector
openqasm
openscad
optimizationTips
option
os
Other
overlapRatioLimit
oxygene
oz
p
pan
paramTypes
parens
parent
parent1
parent2
parent3
parigp
parse
parser
parser3
pascal
pascaligo
password
passwordChangeFailed
passwordChangeSuccess
passwordMinLength
passwordMismatch
passwordPlaceholder
path
PC3GVU5V
pcaxis
pdfjs
PDFPreview
PDFSelectionTool
peer
peers
pendingFiles
peoplecode
perf
perl
pf
pgsql
php
phpdoc
phpExtras
phpTemplate
picomatch
pinchzoom
ping
placeholder
placeholderEmpty
placeholderNewGroup
placeholderOpacity
placeholderWithFiles
plaintext
plan
planning
plantUml
pleaseSelect
plsql
plugin
plugins
pluginUtils
polygon
pong
pony
portrait
position
postcss
powerquery
powershell
pre
preciseFeature1
preciseFeature2
preciseFeature3
preciseFeature4
preciseFeature5
preciseFeature6
preciseMode
preciseModeDesc
preciseModeFeatures
prefix
prefixSelector
preview
print
private
process
processing
processingMode
profile
Profiler
prolog
promql
properties
protobuf
prototype
proxyReq
proxyReqWs
proxyRes
proxySocket
pseudoElements
psl
public
publicUtils
pug
punycode
puppet
pure
purebasic
purescript
python
pythonRepl
q
qml
qore
qsharp
quadrantDiagram
querystring
r
racket
react
readdirp
readFailed
readingFailed
readline
README
ready
real
reason
reasonml
recommendationMsg
recommendationReason
reconfigureDesc
reconfigureFile
reconfigureTitle
rect
redirect
reflink
refresh
refreshInterval
regex
rego
rehype
release
remark
remove
removeAlphaVariables
render
renpy
repl
rescript
resetform
resolveDefaultsAtRules
response
rest
retry
retryFailed
retrying
retrySuccess
return
returnType
rev
rib
ringOpacity
rip
roboconf
robotframework
rollup
rotate
round
routeros
RSJLCBOL
rsl
rtl
ruby
ruleslanguage
runAll
Runtime
rust
S2TOE2PZ
S5V4N54A
sankeyDiagram
sas
sass
save
saveSettingsFailed
saveVisionModelFailed
saving
scala
scheme
scilab
script
scroll
scrollend
scss
searchGroupsPlaceholder
searchPlaceholder
searchResults
SearchResultsPanel
seconds
section
select
selectedGroupsCount
selectEmbeddingFirst
selectEmbeddingModel
selectExecTime
selection
selectionchange
selectKbPlaceholder
selectKnowledgeGroup
selectKnowledgeGroups
selectLLMModel
selectVisionModel
send
sequenceDiagram
set
sets
setters
settings
SettingsDrawer
SettingsModal
SettingsView
setupContextUtils
shell
shellSession
shortDescription
should
showingRange
SidebarRail
similarityThreshold
size
skipApiValidation
smali
smalltalk
smarty
sml
snapshot
solidity
solutionFile
some
source
sourcemap
sourcePreview
SourcePreviewDrawer
soy
space
span
sparql
spies
splunkSpl
sqf
sql
sqlMore
squirrel
SR4P656
src
ss
ssr
stack
stan
start
startProcessing
stata
stateDiagram
static
status
statusFailedFragment
statusIndexingFragment
statusReadyFragment
step
step21
stream
strikethroughSequenceTemporary
string
stroke
stubs
style
stylus
submitFailed
subscript
subtest
subunit
success
successNoteCreated
successNoteDeleted
successNoteUpdated
successUploadFile
supercollider
superscript
supports
svg
svgo
swift
switchLanguage
synchronous
syntax
Syntax
systemConfiguration
systemd
t
t4Cs
t4Templating
t4Vb
tableCellDivider
tableDelimiterFiller
tableDelimiterMarker
tableDelimiterRow
tableHead
tableRow
taggerscript
tailwind
tailwindcss
tap
tapdragout
tapdragover
taphold
taskListCheck
taskListCheckMarker
taskListCheckValueChecked
taskListCheckValueUnchecked
tcl
template
test
text
textarea
textEnv
textile
textOpacity
this
threads
thrift
Timed
timeline
timeout
timers
tipChunkTooLarge
tipMaxValues
tipOverlapSmall
tipPreciseCost
title
tls
to
ToastContext
toggle
togglePreviewClose
togglePreviewOpen
token
toLowerCase
toml
top
totalChunks
toUpperCase
tp
TPHLMXPU
tr
trace
TRACE
tremor
trough
truetype
ts
tsc
tsx
tt2
tty
turtle
twig
type
typeEmbedding
typeLLM
typeRerank
types
typescript
TypeScript
typoscript
TZEHDZUN
TZWVRMV7
u
U2IU5VE2
udp4
udp6
UFOA5W4
UI2CU6OX
UL2VRFP
Unable
uncaughtException
undici
unexpected
unicodeTextInMathMode
unified
unist
Unknown
unknownError
unknownGroup
unknownSymbol
unmount
unrealscript
unsupportedFileType
uorazor
updateFailedRetry
UpdateGraph
updateUserFailed
upgrade
uploadErrors
uploadFailed
uploadFile
uploading
uploadWarning
uri
url
use
user
userCreatedSuccess
userDeletedSuccessfully
userDemotedFromAdmin
userList
userManagement
username
usernamePlaceholder
userPromotedToAdmin
util
utils
v
v8
vala
validationFailedMsg
value
var
vbnet
vbscript
vbscriptHtml
VD42YOAC
velocity
verilog
vfile
vhdl
viewHistory
viewport
views
vim
visionModelHelp
VisionModelSelector
visionModelSettings
visualBasic
vite
vm
VXUJARFQ
VZ6H4W5F
VZCGN3VN
W4ZIXB7
warn
WARN
warning
warpscript
wasi
wasm
watching
web
webgl
webgl2
webIdl
weeks
welcomeMessage
wgsl
width
wiki
WL72ISMW
woff
woff2
wolfram
worker
wren
wsClientError
x
X
X4I2G4RZ
X6H63G
x86asm
xeora
XKPGCS4Q
xl
xml
xmlDoc
XO2UVAMH
xojo
xquery
XUNR36Y3
xyz
yaml
yang
years
YFKEEEDU
YG6GDRKO
YYYY
zephir
ZERIJLPZ
zig
zlib
zoom
ZYYUYHC5
+39
View File
@@ -0,0 +1,39 @@
/**
* Handles copying text to the clipboard with a fallback for insecure contexts.
* In non-HTTPS/non-localhost environments, navigator.clipboard is not available.
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
// 1. Try modern Clipboard API first (requires secure context)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Clipboard API failed:', err);
// Fall through to fallback
}
}
// 2. Fallback: document.execCommand('copy')
try {
const textArea = document.createElement('textarea');
textArea.value = text;
// Ensure the textarea is not visible but stays in the DOM
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error('Fallback copy failed:', err);
return false;
}
};
+3
View File
@@ -0,0 +1,3 @@
// web/utils/constants.ts
//@ts-ignore
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
+40
View File
@@ -0,0 +1,40 @@
import { RawFile } from '../types';
export const readFile = (file: File): Promise<RawFile> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
// Remove data URL prefix (e.g., "data:image/png;base64,") to get raw base64
const base64Content = base64String.split(',')[1];
resolve({
name: file.name,
type: file.type,
size: file.size,
content: base64Content,
preview: file.type.startsWith('image/') ? base64String : undefined,
file: file,
});
};
reader.onerror = () => {
// Rejection returns the file name to allow the UI to construct a localized message
reject(file.name);
};
// Read as Data URL to easily get Base64
reader.readAsDataURL(file);
});
};
export const formatBytes = (bytes: number, decimals = 2) => {
if (!+bytes) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
+2770
View File
@@ -0,0 +1,2770 @@
export type Language = 'zh' | 'en' | 'ja';
export const translations = {
zh: {
appTitle: "简易知识库",
loginTitle: "系统登录",
loginDesc: "请输入访问密钥以进入知识库系统",
optional: "可选",
loginButton: "进入系统",
usernamePlaceholder: "用户名",
passwordPlaceholder: "密码",
aiCommandsError: "发生错误",
registerButton: "注册",
loginError: "密钥不能为空",
unknown: "未知",
unknownError: "未知错误",
langZh: "语言: Chinese",
langEn: "语言: English",
langJa: "语言: Japanese",
confirm: "确认",
cancel: "Cancel",
confirmTitle: "确认操作",
defaultTenant: "默认租户",
selectOrganization: "选择组织",
confirmDeleteGroup: "确认要删除分组 \"$1\" 吗?",
sidebarTitle: "索引与聊天配置",
backToWorkspace: "返回工作台",
goToAdmin: "管理后台",
sidebarDesc: "管理文档与模型参数",
tabFiles: "文档管理",
files: "文件",
notes: "笔记",
tabSettings: "系统设置",
systemConfiguration: "系统配置",
noFiles: "暂无文件",
noFilesDesc: "支持 PDF、Office 文档、文本、代码、图片等格式",
addFile: "添加文件",
clearAll: "清空知识库",
uploading: "处理中",
statusIndexing: "向量化中...",
statusReady: "已索引",
// RAG Settings
ragSettings: "RAG 设置",
enableRerank: "启用重排序 (Rerank)",
enableRerankDesc: "使用重排序模型对检索结果进行二次精排,提高准确性",
selectRerankModel: "请选择Rerank模型",
selectModelPlaceholder: "请选择模型...",
// Config Panel
headerModelSelection: "模型选择",
headerHyperparams: "推理参数",
headerIndexing: "索引与切片",
headerRetrieval: "召回与排序",
btnManageModels: "管理模型供应商",
lblLLM: "推理模型 (LLM)",
lblEmbedding: "向量模型 (Embedding)",
lblRerankRef: "重排序模型 (Rerank)",
lblTemperature: "随机性 (Temperature)",
lblMaxTokens: "最大输出 (Max Tokens)",
lblChunkSize: "切片大小 (Tokens)",
lblChunkOverlap: "重叠阈值 (Overlap)",
lblTopK: "召回数量 (Top K)",
lblRerank: "开启重排序 (Rerank)",
// Indexing Modal
idxModalTitle: "知识库分段与清洗",
idxDesc: "在文件存入知识库之前,请配置分段规则和 Embedding 模型。",
idxFiles: "待处理文件",
idxMethod: "分段设置",
idxEmbeddingModel: "Embedding 模型",
idxStart: "开始索引",
idxCancel: "Cancel上传",
idxAuto: "自动分段",
idxCustom: "自定义",
// Model Manager
mmTitle: "模型供应商管理",
mmAddBtn: "添加模型",
mmEdit: "编辑",
mmDelete: "删除",
mmEmpty: "暂无配置的模型",
mmFormName: "模型别名 (显示用)",
mmFormProvider: "供应商类型",
mmFormModelId: "模型 ID (如 gpt-4o)",
mmFormBaseUrl: "API Base URL",
mmFormType: "模型功能类型",
mmFormVision: "支持视觉能力",
mmFormDimensions: "向量维度",
mmFormDimensionsHelp: "嵌入向量的维度大小,常见值:1536、3072",
mmSave: "保存配置",
mmCancel: "Cancel",
mmErrorNotAuthenticated: "未登录,无法操作",
mmErrorTitle: "操作失败",
modelEnabled: "模型已启用",
modelDisabled: "模型已禁用",
confirmChangeEmbeddingModel: "警告:更改向量模型可能导致现有索引无法搜索。\n这将无法通过新的向量模型搜索到之前通过旧模型索引的内容。\n是否确认更改?",
embeddingModelWarning: "更改此设置可能需要清空并重新导入知识库。",
sourcePreview: "引用源预览",
matchScore: "匹配度",
copyContent: "复制内容",
copySuccess: "复制成功",
// ConfigPanel 缺失的翻译
selectLLMModel: "请选择LLM模型",
selectEmbeddingModel: "请选择Embedding模型",
defaultForUploads: "用于新上传和查询",
noRerankModel: "无重排序模型",
vectorSimilarityThreshold: "向量检索阈值",
rerankSimilarityThreshold: "重排序阈值",
filterLowResults: "低于此值的结果将被过滤",
noteCreatedSuccess: "笔记创建成功",
noteCreatedFailed: "笔记创建失败",
fullTextSearch: "全文检索",
hybridVectorWeight: "混合检索向量权重",
hybridVectorWeightDesc: "向量平衡: 1.0 = 纯向量, 0.0 = 纯全文",
lblQueryExpansion: "查询扩展 (Multi-Query)",
lblHyDE: "HyDE (假设文档嵌入)",
lblQueryExpansionDesc: "生成多个查询变体以提高覆盖率",
lblHyDEDesc: "生成假设回答以改善语义搜索",
// Model Management
apiKeyValidationFailed: "API Key 验证失败",
keepOriginalKey: "留空保持原 API Key,输入新值则替换",
leaveEmptyNoChange: "留空不修改",
mmFormApiKey: "API Key",
mmFormApiKeyPlaceholder: "请输入 API Key",
// 更多组件缺失的翻译
reconfigureFile: "重新配置文件",
modifySettings: "修改文件的切片和向量化设置",
filesCount: " files",
allFilesIndexed: "所有文件将使用下面的设置进行索引",
noEmbeddingModels: "未配置嵌入模型",
reconfigure: "重新配置",
refresh: "刷新",
settings: "设置",
needLogin: "需要登录才能使用聊天功能",
citationSources: "引用源",
chunkNumber: "片段",
getUserListFailed: "获取用户列表失败",
displayName: '显示名称',
displayNamePlaceholder: '输入完整姓名或显示名称',
globalUserNote: '注意',
roleManagedInOrg: '用户的角色权限主要在所属组织内管理。',
usernamePasswordRequired: '用户名和密码不能为空',
passwordMinLength: "密码长度不能少于6位",
userCreatedSuccess: "用户创建成功",
createUserFailed: "创建用户失败",
userPromotedToAdmin: "用户已提升为管理员",
userDemotedFromAdmin: "用户已降级为普通用户",
updateUserFailed: "更新用户失败",
confirmDeleteUser: "确定要删除此用户吗?",
confirmDeleteTenant: "确定要删除此组织吗?",
deleteUser: "删除用户",
deleteUserFailed: "删除用户失败",
editUser: "编辑用户",
organizations: "所属组织",
saveChanges: "保存更改",
edit: "编辑",
makeUserAdmin: "设为管理员",
makeUserRegular: "设为普通用户",
loading: "加载中...",
noUsers: "暂无用户",
// ChangePasswordModal 和 SearchResultsPanel 缺失的翻译
fillAllFields: "请填写所有字段",
passwordMismatch: "新密码和确认密码不匹配",
newPasswordMinLength: "新密码长度不能少于6位",
changePasswordFailed: "修改密码失败",
changePasswordTitle: "修改密码",
changing: "修改中...",
searchResults: "搜索到的相关内容",
// VisionModelSelector 缺失的翻译
visionModelSettings: "视觉模型设置",
defaultVisionModel: "默认视觉模型",
loadVisionModelFailed: "加载视觉模型失败",
loadFailed: "加载失败,请检查网络连接",
saveVisionModelFailed: "保存视觉模型失败",
defaultSettingFailed: "默认设置保存失败",
noVisionModels: "没有可用的视觉模型",
selectVisionModel: "请选择视觉模型",
visionModelHelp: "用于处理图片文件的视觉模型。如果没有可用模型,请在模型管理中添加并勾选“支持视觉”选项。",
mmErrorNameRequired: "模型名称为必填项。",
mmErrorModelIdRequired: "模型 ID 为必填项。",
mmErrorBaseUrlRequired: "Base URL 为必填项。",
mmRequiredAsterisk: "*",
// Type Labels
typeLLM: "对话推理 (LLM)",
typeEmbedding: "向量化 (Embedding)",
typeRerank: "重排序 (Rerank)",
typeVision: "视觉识别 (Vision)",
welcome: "你好!我是您的智能知识库助手。请在“系统设置”中选择模型,上传文档建立索引,然后即可开始提问。",
placeholderWithFiles: "基于知识库内容提问...",
placeholderEmpty: "请先上传文件并完成索引...",
analyzing: "正在检索并生成答案...",
errorGeneric: "处理您的请求时遇到错误。",
errorLabel: "错误",
errorNoModel: "未选择推理模型或配置无效。",
aiDisclaimer: "AI 可能会犯错。请核实源文件中的重要信息。",
confirmClear: "确定要清空所有文件及索引吗?",
confirmDeleteModel: "确定要删除此模型吗?",
removeFile: "移除文件",
apiError: "缺少配置或 API 密钥无效。",
geminiError: "API 请求失败。",
processedButNoText: "无法生成文本回复。",
unitByte: "字节",
readingFailed: "读取文件失败",
// Copy
copy: "复制内容",
copied: "已复制",
// User Management
logout: "退出登录",
changePassword: "修改密码",
userManagement: "用户管理",
userList: "用户列表",
addUser: "添加用户",
userInfo: "用户信息",
username: "用户名",
password: "密码",
confirmPassword: "确认密码",
currentPassword: "当前密码",
newPassword: "新密码",
createUser: "创建用户",
admin: "管理员",
user: "普通用户",
adminUser: "设为管理员", // 新增,
confirmChange: "确认修改",
changeUserPassword: "修改用户密码",
enterNewPassword: "请输入新密码",
userDeletedSuccessfully: "用户已成功删除",
createdAt: "创建时间",
newChat: "新建对话",
exportUsers: "导出用户",
importUsers: "导入用户",
importSuccess: "完成导入: 成功 $1, 失败 $2",
importFailed: "导入用户失败",
exportFailed: "导出用户失败",
// Knowledge Base View
kbManagement: "知识库管理",
kbManagementDesc: "管理您的文档和知识分组",
searchPlaceholder: "搜索文件名...",
allGroups: "所有分组",
allStatus: "所有状态",
statusReadyFragment: "Ready",
statusFailedFragment: "Failed",
statusIndexingFragment: "Indexing",
inProgress: "进行中",
uploadFile: "上传文件",
fileName: "文件名",
size: "大小",
status: "状态",
groups: "分组",
actions: "操作",
groupsActions: "分组 / 操作",
noFilesFound: "未找到匹配的文件",
showingRange: "显示 $1 到 $2 条,共 $3 条",
confirmDeleteFile: "Confirm要删除此文件吗?",
fileDeleted: "文件已删除",
deleteFailed: "删除失败",
fileAddedToGroup: "文件已添加到分组",
failedToAddToGroup: "添加到分组失败",
fileRemovedFromGroup: "文件已从分组移除",
failedToRemoveFromGroup: "从分组移除失败",
confirmClearKB: "警告:此操作将永久删除所有文件及其索引数据。\n\nConfirm要清空知识库吗?",
kbCleared: "知识库已清空",
clearFailed: "清空失败",
loginRequired: "请先登录",
uploadErrors: "以下文件无法上传",
uploadWarning: "$1 files已准备上传,$2 files被过滤",
uploadFailed: "上传失败",
preview: "预览",
addGroup: "添加分组",
delete: "删除",
retry: "重试",
retrying: "重试中...",
retrySuccess: "重试成功",
retryFailed: "重试失败",
chunkInfo: "分片信息",
totalChunks: "总分片数",
chunkIndex: "分片",
contentLength: "字符",
position: "位置",
// Indexing Modal
reconfigureTitle: "重新配置文件",
reconfigureDesc: "修改文件处理设置",
indexingConfigTitle: "文档索引配置",
indexingConfigDesc: "配置文档处理参数,选择处理模式",
pendingFiles: "待处理文件",
processingMode: "处理模式",
analyzingFile: "Analyzing...",
recommendationReason: "推荐理由",
fastMode: "快速模式",
fastModeDesc: "简单提取文本,速度快,无额外成本,适合纯文本文档",
preciseMode: "精准模式",
preciseModeDesc: "精准识别内容,保留图文混合信息,需要 API 费用",
fastModeFeatures: "快速模式特点:",
fastFeature1: "简单提取文本内容",
fastFeature2: "处理速度快,适合批量处理",
fastFeature3: "无额外成本",
fastFeature4: "仅处理文字信息",
fastFeature5: "适合纯文本文档",
preciseModeFeatures: "精准模式特点:",
preciseFeature1: "精准识别内容结构",
preciseFeature2: "识别图片/图表/表格",
preciseFeature3: "保留图文混合内容",
preciseFeature4: "保留页面布局信息",
preciseFeature5: "需要 API 费用",
preciseFeature6: "处理时间较长",
embeddingModel: "嵌入模型",
pleaseSelect: "请选择...",
pleaseSelectKnowledgeGroupFirst: "请先选择知识组再保存",
selectUnassignGroupWarning: "如果您想Cancel分配知识组,请确认此操作",
chunkConfig: "切片配置",
chunkSize: "切片大小 (Tokens)",
min: "最小",
max: "上限",
chunkOverlap: "重叠大小 (Tokens)",
modelLimitsInfo: "模型限制信息",
model: "模型",
maxChunkSize: "切片上限",
maxOverlapSize: "重叠上限",
maxBatchSize: "批量限制",
envLimitWeaker: "环境变量限制更严格",
optimizationTips: "优化建议",
tipChunkTooLarge: "切片较大,可能影响检索精度",
tipOverlapSmall: "建议重叠至少 $1 tokens",
tipMaxValues: "使用最大值,处理速度可能较慢",
tipPreciseCost: "精准模式会产生 API 费用,请确认预算",
selectEmbeddingFirst: "请先选择嵌入模型",
confirmPreciseCost: "精准模式会产生 API 费用,是否继续?",
startProcessing: "开始处理",
// Knowledge Group Management (formerly Notebooks View)
notebooks: "知识组",
notebooksDesc: "管理您的文档和知识分组",
createNotebook: "新建知识组",
chatWithNotebook: "基于此知识组对话",
editNotebook: "编辑知识组",
deleteNotebook: "删除知识组 (包含文件)",
noDescription: "无描述",
noNotebooks: "暂无知识组,点击右上角创建",
createFailed: "创建失败",
confirmDeleteNotebook: "Confirm要删除知识组 \"$1\" 吗?\n\n注意:这将同时永久删除该知识组下的所有文件及其索引数据!",
// New Notebook (Personal Notes)
personalNotebook: "Notebook",
personalNotebookDesc: "记录您的个人笔记与灵感",
noNotes: "暂无个人笔记",
createNote: "新建笔记",
// Create/Edit Knowledge Group Drawer (formerly Create/Edit Notebook Drawer)
createNotebookTitle: "新建知识组",
editNotebookTitle: "编辑知识组",
createFailedRetry: "创建失败,请重试",
updateFailedRetry: "更新失败,请重试",
name: "名称",
nameHelp: "一个清晰的名称能帮你快速找到它。",
namePlaceholder: "例如:量子物理研究...",
shortDescription: "简短描述",
descPlaceholder: "一句话描述这个知识组的用途",
creating: "正在创建...",
createNow: "立即创建",
saving: "正在保存...",
save: "保存",
// Chat
chatTitle: "知识库问答",
chatDesc: "与您的知识库进行智能对话",
viewHistory: "查看对话历史",
saveSettingsFailed: "保存设置失败",
loginToUpload: "请先登录再进行上传",
fileSizeLimitExceeded: "$1 ($2 - 超过 $3MB 限制)",
unsupportedFileType: "$1 - 不支持的文件类型 ($2)",
readFailed: "$1 - 读取失败",
loadHistoryFailed: "加载对话历史失败",
loadingUserData: "正在加载用户数据...",
errorMessage: "错误: $1",
welcomeMessage: "你好!我是您的智能知识库助手。请选择知识库然后进行提问。",
selectKnowledgeGroup: "选择知识库分组",
allKnowledgeGroups: "全部知识库",
unknownGroup: "未知分组",
selectedGroupsCount: "Selected $1 groups",
// Settings
generalSettings: "一般配置",
modelManagement: "模型管理",
languageSettings: "语言设置",
passwordChangeSuccess: "密码修改成功",
passwordChangeFailed: "密码修改失败",
create: "创建",
validationFailedMsg: "validationFailedMsg",
// Sidebar
navChat: "对话",
navCoach: "教练",
navKnowledge: "知识库",
navKnowledgeGroups: "知识组",
navNotebook: "笔记本",
navAgent: "智能体",
navAssessment: "评测",
navPlugin: "插件",
notebookDesc: "记录您的个人想法和研究笔记。",
newNote: "新建笔记",
editNote: "编辑笔记",
noNotesFound: "未找到笔记",
startByCreatingNote: "开始创建您的第一条个人笔记。",
navCrawler: "资源获取",
expandMenu: "展开菜单",
switchLanguage: "切换语言",
navGlobal: "全局",
navTenants: "租户管理",
navSystemModels: "系统模型",
navTenantManagement: "租户管理",
navUsersTeam: "用户与团队",
navTenantSettings: "租户设置",
adminConsole: "管理控制台",
globalDashboard: "全局仪表盘",
// Group Selection Drawer
selectKnowledgeGroups: "选择知识库分组",
searchGroupsPlaceholder: "Search groups...",
done: "完成",
all: "全部",
noGroupsFound: "No related groups found",
noGroups: "No groups",
// Auto-refresh functionality
autoRefresh: "自动刷新",
refreshInterval: "刷新间隔",
// NotebookDetailView
errorRenderFlowchart: "无法渲染流程图",
errorLoadData: "加载数据失败",
confirmUnsupportedFile: "文件类型 .$1 可能不受支持,是否继续?",
errorReadFile: "文件读取失败: $1",
successUploadFile: "文件上传并关联成功",
errorUploadFile: "上传失败: $1",
errorProcessFile: "文件处理失败",
errorTitleContentRequired: "标题和内容不能为空",
successNoteUpdated: "笔记已更新",
successNoteCreated: "笔记已创建",
errorSaveFailed: "保存失败: $1",
confirmDeleteNote: "Confirm要删除这条笔记吗?",
successNoteDeleted: "笔记已删除",
confirmRemoveFileFromGroup: "确定要将文件 \"$1\" 从此知识组移除吗?(文件仍保留在知识库中)",
togglePreviewOpen: "开启预览",
togglePreviewClose: "关闭预览",
aiAssistant: "AI 智能助手",
polishContent: "润色内容",
expandContent: "扩写",
summarizeContent: "精简摘要",
translateToEnglish: "翻译成英文",
fixGrammar: "修复语法",
aiCommandInstructPolish: "请帮我润色这段文字,使其表达更地道、更专业。",
aiCommandInstructExpand: "请帮我扩写这段文字,增加更多细节,使其更充实和详细。",
aiCommandInstructSummarize: "请帮我精简这段文字,提取核心观点,生成简洁的摘要。",
aiCommandInstructTranslateToEn: "请帮我将这段文字翻译成英文。",
aiCommandInstructFixGrammar: "请检查并修复这段文字中的语法和拼写错误。",
aiCommandsPreset: "常用指令",
aiCommandsCustom: "自定义需求",
aiCommandsCustomPlaceholder: "例如:把这段话改写得更正式一点...",
aiCommandsReferenceContext: "参考上下文 (前200字):",
aiCommandsStartGeneration: "开始生成",
aiCommandsResult: "AI 建议",
aiCommandsGenerating: "生成中...",
aiCommandsApplyResult: "替换选区",
aiCommandsGoBack: "返回修改",
aiCommandsReset: "清空重置",
aiCommandsModalPreset: "选择预设指令",
aiCommandsModalCustom: "或输入自定义指令",
aiCommandsModalCustomPlaceholder: "告诉 AI 你想做什么...",
aiCommandsModalBasedOnSelection: "将基于以下选Chinese本处理:",
aiCommandsModalResult: "生成结果",
aiCommandsModalApply: "采用此结果",
noteTitlePlaceholder: "笔记标题",
noteContentPlaceholder: "开始写作 (支持 Markdown)...",
markdownPreviewArea: "Markdown 预览区域",
back: "返回",
chatWithGroup: "基于此知识组进行对话",
chatWithFile: "基于此文件进行对话",
filesCountLabel: "文件 ($1)",
notesCountLabel: "笔记 ($1)",
indexIntoKB: "索引到知识库",
noFilesOrNotes: "暂无$1",
importFolder: "导入文件夹",
// CreateNoteFromPDFDialog keys
createPDFNote: "创建PDF笔记",
screenshotPreview: "截图预览",
associateKnowledgeGroup: "关联知识组",
globalNoSpecificGroup: "全局 (无特定知识组)",
title: "标题",
enterNoteTitle: "输入笔记标题",
contentOCR: "内容 (OCR提取的文本)",
extractingText: "正在提取文本...",
analyzingImage: "正在分析图片并提取文字...",
noTextExtracted: "未提取到文本",
saveNote: "保存笔记",
// Additional keys used in CreateNoteFromPDFDialog
page: "第",
placeholderText: "OCR提取的文本将显示在这里,您可以编辑...",
// CreateNotebookDialog keys
createNewNotebook: "新建知识组",
nameField: "名称",
required: "*",
exampleResearch: "例如:量子物理研究...",
shortDescriptionField: "简短描述",
describePurpose: "一句话描述这个知识组的用途",
creationFailed: "创建失败,请重试",
// PDFPreview keys
preparingPDFConversion: "准备转换PDF...",
pleaseWait: "请稍候,这可能需要几分钟时间",
convertingPDF: "正在转换PDF...",
pdfConversionFailed: "PDF转换失败",
pdfConversionError: "无法转换此文件为PDF格式,请检查文件是否损坏或格式不支持",
pdfLoadFailed: "PDF加载失败",
pdfLoadError: "无法在浏览器中显示PDF,请尝试下载或在新窗口中打开",
downloadingPDF: "下载PDF中...",
loadingPDF: "加载PDF中...",
zoomOut: "缩小",
zoomIn: "放大",
resetZoom: "重置缩放",
selectPageNumber: "选择页码:",
enterPageNumber: "输入想要选取的页码",
exitSelectionMode: "退出选择模式",
clickToSelectAndNote: "点击框选区域并记笔记",
regeneratePDF: "重新生成 PDF",
downloadPDF: "下载 PDF",
openInNewWindow: "在新窗口中打开",
exitFullscreen: "退出全屏",
fullscreenDisplay: "全屏显示",
pdfPreview: "PDF预览",
converting: "转换中...",
generatePDFPreview: "生成PDF预览",
previewNotSupported: "该格式不支持预览",
// Confirmation message
confirmRegeneratePDF: "Confirm要重新生成 PDF 吗?这将覆盖当前的预览文件。",
// PDFPreviewButton keys
pdfPreviewReady: "PDF预览",
convertingInProgress: "转换中...",
conversionFailed: "转换失败",
generatePDFPreviewButton: "生成PDF预览",
// Error messages
checkPDFStatusFailed: "Check PDF status failed",
requestRegenerationFailed: "请求重新生成失败",
downloadPDFFailed: "PDF 下载失败",
openPDFInNewTabFailed: "在新标签页打开 PDF 失败",
// uploadService Recommendations
invalidFile: "无效的文件",
incompleteFileInfo: "文件信息不完整,将使用快速模式",
unsupportedFileFormat: "不支持的文件格式: .$1",
willUseFastMode: "将使用快速模式 (仅文本提取)",
formatNoPrecise: "格式 .$1 不支持精准模式",
smallFileFastOk: "文件较小,快速模式即可满足需求",
mixedContentPreciseRecommended: "文件包含图文混合内容,建议使用精准模式",
willIncurApiCost: "会产生 API 费用",
largeFilePreciseRecommended: "文件较大,精准模式可保留完整信息",
longProcessingTime: "处理时间可能较长",
highApiCost: "会产生较高 API 费用",
considerFileSplitting: "建议考虑文件拆分",
// Drag and drop upload
dragDropUploadTitle: "拖拽文件到这里上传",
dragDropUploadDesc: "或点击下方按钮选择文件",
supportedFormats: "支持格式",
browseFiles: "浏览文件",
// IndexingModal
recommendationMsg: "推荐使用$1模式: $2",
autoAdjustChunk: "切片大小自动调整为上限 $1",
autoAdjustOverlap: "重叠大小自动调整为上限 $1",
autoAdjustOverlapMin: "重叠大小自动调整为最小值 $1",
loadLimitsFailed: "无法加载模型限制,将使用默认配置",
maxValueMsg: "最大值为 $1",
overlapRatioLimit: "不能超过切片大小的50% ($1)",
onlyAdminCanModify: "只有管理员可以修改系统设置",
dragToSelect: "拖动鼠标选择范围 • 按 ESC Cancel",
// ImportFolderDrawer
fillTargetName: "请填写目标知识组名称",
submitFailed: "提交失败: $1",
importFolderTitle: "导入本地文件夹",
importFolderTip: "提示: 请选择一个本地文件夹。系统将读取并上传文件夹内所有支持的文档。",
lblTargetGroup: "目标知识组名称",
placeholderNewGroup: "新分组名称",
importToCurrentGroup: "将导入到当前所在的分组",
nextStep: "下一步",
selectedFilesCount: "已选择 $1 files",
clickToSelectFolder: "点击选择本地文件夹",
selectFolderTip: "将读取文件夹内所有支持的文件",
importComplete: "导入完成",
importedFromLocalFolder: "从本地文件夹导入: $1",
// History
historyTitle: "对话历史",
confirmDeleteHistory: "Confirm要删除这条对话历史吗?",
deleteHistorySuccess: "对话历史删除成功",
deleteHistoryFailed: "删除对话历史失败",
yesterday: "昨天",
daysAgo: "$1天前",
historyMessages: "$1 条消息",
noHistory: "暂无对话历史",
noHistoryDesc: "开始一次对话来创建历史记录",
loadMore: "加载更多",
loadingHistoriesFailed: "加载搜索历史失败",
generalSettingsSubtitle: "管理您的应用程序首选项。",
userManagementSubtitle: "查看和管理系统用户信息。",
modelManagementSubtitle: "配置全局 AI 模型。",
kbSettingsSubtitle: "索引和聊天参数的技术配置。",
tenantsSubtitle: "管理组织架构、成员及其权限。",
assessmentTemplates: "测评模板",
assessmentTemplatesSubtitle: "管理和配置测评生成模板。",
templateName: "模板名称",
keywords: "关键词",
keywordsHint: "请输入关键词,用逗号分隔",
questionCount: "题目数量",
questionsCountLabel: "道题目",
difficultyDistribution: "难度分布",
style: "风格要求",
createTemplate: "创建模板",
editTemplate: "编辑模板",
allNotes: "所有笔记",
filterNotesPlaceholder: "筛选笔记...",
startWritingPlaceholder: "开始写作...",
previewHeader: "预览",
noContentToPreview: "没有可预览的内容",
hidePreview: "隐藏预览",
showPreview: "显示预览",
directoryLabel: "目录",
uncategorized: "未分类",
enterNamePlaceholder: "输入名称...",
subFolderPlaceholder: "子文件夹...",
categoryCreated: "分类已创建",
failedToCreateCategory: "创建分类失败",
failedToDeleteCategory: "删除分类失败",
confirmDeleteCategory: "您确定要删除此分类吗?",
kbSettingsSaved: "检索与对话配置已保存",
failedToSaveSettings: "保存设置失败",
actionFailed: "操作失败",
userAddedToOrganization: "用户已添加到组织",
featureUpdated: "功能已更新",
roleTenantAdmin: "租户管理员",
roleRegularUser: "普通用户",
creatingRegularUser: "正在创建普通用户",
editUserRole: "修改用户角色",
targetRole: "目标角色",
editCategory: "编辑分类",
totalTenants: "总租户数",
systemUsers: "系统用户",
systemHealth: "系统健康",
operational: "运行正常",
orgManagement: "组织管理",
globalTenantControl: "全局租户控制",
newTenant: "新租户",
tenantName: "租户名称",
domainOptional: "域名 (可选)",
noOrganizations: "暂无组织",
activeOrg: "当前组织",
noCustomDomain: "未绑定自定义域名",
orgSettings: "组织设置",
deleteOrg: "删除组织",
orgMembers: "组织成员",
membersCount: "$1 个成员",
noMembersAssigned: "未分配成员",
addMembers: "添加成员",
searchSystemUsers: "搜索系统用户...",
editOrg: "编辑组织",
parentOrg: "上级组织",
noneRoot: "无 (根组织)",
selectOrg: "选择一个组织",
selectOrgDesc: "从左侧目录中选择一个组织以管理其成员、设置和管理权限。",
totalSystemUsers: "系统用户总数",
rootOrgs: "根组织",
createSubOrg: "创建子组织",
update: "更新",
modelConfiguration: "模型配置",
defaultLLMModel: "默认推理模型",
selectLLM: "选择 LLM",
selectEmbedding: "选择 Embedding",
rerankModel: "Rerank 模型",
none: "无",
indexingChunkingConfig: "索引与切片配置",
chatHyperparameters: "聊天超参数",
temperature: "随机性 (Temperature)",
precise: "精确",
creative: "创意",
maxResponseTokens: "最大响应标识 (Max Tokens)",
retrievalSearchSettings: "检索与搜索设置",
topK: "召回数量 (Top K)",
similarityThreshold: "相似度阈值",
enableHybridSearch: "启用混合检索",
hybridSearchDesc: "同时使用向量和全文检索以提高召回率",
hybridWeight: "混合权重 (0.0=全文, 1.0=向量)",
pureText: "纯文本",
pureVector: "纯向量",
enableQueryExpansion: "启用查询扩展",
queryExpansionDesc: "生成多个查询变体以提高覆盖率",
enableHyDE: "启用 HyDE",
hydeDesc: "生成假设回答以改善语义搜索",
enableReranking: "启用重排序 (Rerank)",
rerankingDesc: "使用 Rerank 模型对结果进行二次排序",
broad: "宽泛",
strict: "严格",
maxInput: "最大输入",
dimensions: "维度",
defaultBadge: "默认",
dims: "维度: $1",
ctx: "上下文: $1",
baseApi: "Base API: $1",
configured: "已配置",
groupUpdated: "分组已更新",
groupDeleted: "分组已删除",
groupCreated: "分组已创建",
navCatalog: "目录",
allDocuments: "所有文档",
categories: "分类",
uncategorizedFiles: "未分类文件",
category: "分类",
statusReadyDesc: "已索引可查询",
statusIndexingDesc: "正在建立词向量索引",
selectCategory: "选择分类",
noneUncategorized: "无未分类文件",
previous: "上一页",
next: "下一页",
createCategory: "创建分类",
categoryDesc: "描述您的知识分类",
categoryName: "分类名称",
createCategoryBtn: "立即创建",
newGroup: "新建分组",
noKnowledgeGroups: "暂无知识库分组",
createGroupDesc: "开始创建您的第一个知识库分组并上传相关文档。",
noDescriptionProvided: "未提供描述",
browseManageFiles: "浏览并管理该分组下的文件和笔记。",
filterGroupFiles: "根据名称搜索分组内文件...",
"2d": "2d",
Authorization: "Authorization",
a: "a",
agentTitle: "智能体中心",
agentDesc: "管理和运行您的 AI 助手,协助完成复杂任务。",
createAgent: "创建智能体",
searchAgent: "搜索智能体...",
statusRunning: "运行中",
statusStopped: "已停止",
updatedAtPrefix: "最后更新于 ",
btnChat: "开始对话",
// Agent Mock Data
assessmentTitle: "人才评测",
assessmentDesc: "专业的评估工具,助您发现和发展人才潜力。",
agent1Name: "数据分析专家",
agent1Desc: "精通 SQL 和数据可视化,能够从复杂数据中提取洞察。",
agent2Name: "代码审查助手",
agent2Desc: "自动检查代码质量,提供重构建议和性能优化方案。",
agent3Name: "学术论文润色",
agent3Desc: "专业的学术写作助手,帮助优化论文结构和语言表达。",
agent4Name: "法律顾问",
agent4Desc: "提供法律条文查询和基础法律建议,协助起草合同。",
agent5Name: "市场研究员",
agent5Desc: "分析行业趋势,生成竞争对手调研报告。",
agent6Name: "系统运维专家",
agent6Desc: "监控系统健康,自动处理常见告警和排障。",
agent7Name: "财务审计师",
agent7Desc: "自动化报表审计,识别财务风险和异常交易。",
agent1Time: "2 小时前",
agent2Time: "5 小时前",
agent3Time: "昨天",
agent4Time: "2 天前",
agent5Time: "3 天前",
agent6Time: "5 天前",
agent7Time: "1 周前",
// Assessment View Keys
readyForAssessment: "准备开始评测",
readyForAssessmentDesc: "选择一个知识分组开始专业的人才能力评估",
startProfessionalEvaluation: "开始专业评估",
aiPoweredAnalysis: "AI 驱动分析",
masteryScoring: "能力评分",
recentAssessments: "最近评测",
questionProgress: "问题 $1/$2",
initializingQuestion: "正在初始化问题 $1...",
aiIsProcessing: "AI 正在处理...",
typeAnswerPlaceholder: "请输入您的答案...",
liveFeedback: "实时反馈",
currentScore: "当前得分",
aiExplanation: "AI 解释",
masteryProgress: "能力进度",
trackedInRealTime: "实时追踪",
submitAnswerToSeeFeedback: "提交答案以查看反馈",
assessmentGuide: "评测指南",
assessmentGuideDesc: "了解评测流程和评分标准",
level: "等级",
assessmentResultsAvailable: "评测结果已就绪",
knowledgeCoverage: "知识覆盖度",
precisionScore: "精准度评分",
questionBasis: "出题依据",
viewBasis: "查看依据",
hideBasis: "隐藏依据",
verified: "已验证",
fail: "失败",
comprehensiveMasteryReport: "综合能力报告",
newAssessmentSession: "新评测会话",
downloadPdfReport: "下载 PDF 报告",
statusGeneratingQuestions: "正在生成测评问题...",
statusEvaluatingAnswer: "正在评估您的回答...",
statusPreparingQuestion: "正在准备下一个问题...",
statusGeneratingReport: "正在生成最终报告...",
statusProcessing: "正在处理...",
filesAvailable: "文件可用",
confirmDeleteAssessment: "确定要删除这条评测记录吗?",
deleteAssessmentSuccess: "评测记录已成功删除",
deleteAssessmentFailed: '删除评估记录失败',
view: '查看',
// Plugins
pluginTitle: "插件中心",
pluginDesc: "扩展知识库的功能,集成外部工具和服务。",
searchPlugin: "搜索插件...",
installPlugin: "安装插件",
installedPlugin: "已安装",
updatePlugin: "有更新",
pluginOfficial: "官方",
pluginCommunity: "社区",
pluginBy: "由 ",
pluginConfig: "插件配置",
pluginViewTitle: "插件中心",
pluginViewDesc: "集成外部工具与第三方平台,扩展系统能力。",
pluginStatusAvailable: "可用",
pluginConfigure: "配置插件",
pluginFeishuName: "飞书机器人",
pluginFeishuDesc: "将 AuraK 知识库问答能力接入飞书,支持私聊与群聊消息。",
feishuAddBot: "添加机器人",
feishuSetupGuide: "配置指引",
feishuStep1: "登录飞书开放平台,创建一个自建应用。",
feishuStep2: "在应用后台获取 App ID 和 App Secret。",
feishuStep3: "填写下方表单并保存,系统将生成 Webhook URL。",
feishuStep4: "将 Webhook URL 粘贴到应用的「事件订阅」配置中。",
feishuDocs: "查看官方文档",
feishuBotDisplayName: "机器人显示名称",
feishuBotNamePlaceholder: "例如: AuraK 助手",
feishuTokenPlaceholder: "飞书事件验证 Token",
feishuEncryptKeyPlaceholder: "飞书消息加密 Key (可选)",
feishuNoBots: "暂无绑定的机器人,点击右上角添加",
feishuWebhookUrl: "Webhook 回调地址",
feishuConfirmDelete: "确定要删除此机器人绑定吗?",
feishuEnableBot: "启用机器人",
feishuDisableBot: "禁用机器人",
feishuWsMode: "长连接模式 (WebSocket)",
feishuWsModeDesc: "无需公网域名,服务器主动连接飞书云端",
feishuWsConnect: "启用长连接",
feishuWsDisconnect: "断开长连接",
feishuWsStatus: "连接状态",
feishuWsConnected: "已连接",
feishuWsConnecting: "连接中...",
feishuWsDisconnected: "未连接",
feishuWsError: "连接错误",
feishuWsConnectHint: "启用后,飞书事件将通过 WebSocket 长连接推送,无需配置 Webhook 回调地址。",
plugin1Name: "Web 搜索",
plugin1Desc: "赋予 AI 实时访问互联网的能力,获取最新信息。",
plugin2Name: "PDF 文档解析",
plugin2Desc: "深度解析复杂 PDF 布局,提取表格和数学公式。",
plugin3Name: "GitHub 集成",
plugin3Desc: "直接访问 GitHub 仓库,进行代码提交和 issue 管理。",
plugin4Name: "Google 日历",
plugin4Desc: "同步您的日程安排,自动创建会议提醒。",
plugin5Name: "SQL 数据库连接",
plugin5Desc: "安全地连接到您的数据库,执行自然语言查询。",
plugin6Name: "Slack 通知",
plugin6Desc: "将 AI 生成的报告直接发送到指定的 Slack 频道。",
// Hierarchical categories new keys
addSubcategory: "添加子分类",
parentCategory: "父分类 (可选)",
noParentTopLevel: "无父分类(顶级)",
useHierarchyImport: "按文件夹层级创建分类",
useHierarchyImportDesc: "启用后将为每个子文件夹创建对应的子分类,并将文件导入到匹配的分类中。",
// Folder import drawer
importImmediate: "立即导入",
importScheduled: "定时导入",
lblServerPath: "服务器文件夹路径",
placeholderServerPath: "例如: /data/documents",
scheduledImportTip: "服务器端定时导入:服务器将在指定时间读取该路径下的文件并自动导入。请确保服务器有访问该路径的权限。",
lblScheduledTime: "执行时间",
scheduledTimeHint: "到达指定时间后,服务器将自动执行导入任务。",
scheduleImport: "创建定时任务",
scheduleTaskCreated: "定时导入任务已创建",
fillServerPath: "请输入服务器文件夹路径",
invalidDateTime: "请输入有效的日期时间",
// Import Tasks Drawer
importTasksTitle: "导入任务",
noTasksFound: "暂无任务",
sourcePath: "源路径",
targetGroup: "目标分组",
scheduledAt: "计划执行时间",
confirmDeleteTask: "确定要删除此导入任务记录吗?",
deleteTaskFailed: "删除任务记录失败",
noOrganization: "暂无组织",
},
en: {
aiCommandsError: "An error occurred",
appTitle: "Gemini Knowledge Base",
loginTitle: "System Login",
loginDesc: "Enter access key to enter the system",
optional: "Optional",
loginButton: "Enter System",
loginError: "Key cannot be empty",
unknown: "Unknown",
unknownError: "Unknown Error",
usernamePlaceholder: "Username",
passwordPlaceholder: "Password",
registerButton: "Register",
confirm: "Confirm",
cancel: "Cancel",
confirmTitle: "Confirm Action",
defaultTenant: "Default Tenant",
selectOrganization: "Select Organization",
confirmDeleteGroup: "Are you sure you want to delete group \"$1\"?",
systemConfiguration: "System Configuration",
noFiles: "No Files",
noFilesDesc: "Supports PDF, Office documents, text, code, images, etc.",
addFile: "Add File",
clearAll: "Clear Knowledge Base",
uploading: "Processing",
editNotebookTitle: "Edit Knowledge Group",
noteCreatedSuccess: "Note created successfully",
noteCreatedFailed: "Failed to create note",
errorRenderFlowchart: "Unable to render flowchart",
errorLoadData: "Failed to load data",
confirmUnsupportedFile: "File type .$1 may not be supported, continue anyway?",
errorReadFile: "File read failed: $1",
successUploadFile: "File uploaded and associated successfully",
errorUploadFile: "Upload failed: $1",
fileAddedToGroup: "File added to group",
failedToAddToGroup: "Failed to add to group",
fileRemovedFromGroup: "File removed from group",
failedToRemoveFromGroup: "Failed to remove from group",
errorProcessFile: "File processing failed",
errorTitleContentRequired: "Title and content cannot be empty",
successNoteUpdated: "Note updated",
successNoteCreated: "Note created",
errorSaveFailed: "Save failed: $1",
confirmDeleteNote: "Are you sure you want to delete this note?",
successNoteDeleted: "Note deleted",
confirmRemoveFileFromGroup: "Are you sure you want to remove file \"$1\" from this knowledge group? (File remains in knowledge base)",
togglePreviewOpen: "Open Preview",
togglePreviewClose: "Close Preview",
noteTitlePlaceholder: "Note Title",
noteContentPlaceholder: "Start writing (supports Markdown)...",
markdownPreviewArea: "Markdown Preview Area",
back: "Back",
chatWithGroup: "Chat with this group",
chatWithFile: "Chat with this file",
filesCountLabel: "Files ($1)",
notesCountLabel: "Notes ($1)",
indexIntoKB: "Index into knowledge base",
noFilesOrNotes: "No $1",
sidebarTitle: "Index Chat Config",
backToWorkspace: "Back to Workspace",
goToAdmin: "Admin Dashboard",
sidebarDesc: "Manage Docs & Settings",
tabFiles: "Documents",
files: "Files",
notes: "Notes",
tabSettings: "Settings",
langZh: "Language: Chinese",
langEn: "Language: English",
langJa: "Language: Japanese",
navGlobal: "Global",
navTenants: "Tenants",
navSystemModels: "System Models",
navTenantManagement: "Tenant Management",
navUsersTeam: "Users & Teams",
navTenantSettings: "Tenant Settings",
adminConsole: "Admin Console",
globalDashboard: "Global Dashboard",
statusIndexing: "Indexing...",
statusReady: "Indexed",
// RAG Settings
ragSettings: "RAG Settings",
enableRerank: "Enable Rerank",
enableRerankDesc: "Use a rerank model to re-rank retrieval results for better accuracy",
selectRerankModel: "Please select Rerank model",
selectModelPlaceholder: "Select a model...",
headerModelSelection: "Model Selection",
headerHyperparams: "Inference Params",
headerIndexing: "Indexing & Chunking",
headerRetrieval: "Retrieval & Ranking",
btnManageModels: "Manage Providers",
lblLLM: "Inference Model (LLM)",
lblEmbedding: "Embedding Model",
lblRerankRef: "Rerank Model",
lblTemperature: "Temperature",
lblMaxTokens: "Max Output Tokens",
lblChunkSize: "Chunk Size (Tokens)",
lblChunkOverlap: "Overlap",
lblTopK: "Recall Count (Top K)",
lblRerank: "Enable Rerank",
idxModalTitle: "Indexing & Segmentation",
idxDesc: "Configure chunking rules and embedding model before ingestion.",
idxFiles: "Pending Files",
idxMethod: "Segmentation",
idxEmbeddingModel: "Embedding Model",
idxStart: "Start Indexing",
idxCancel: "Cancel",
idxAuto: "Automatic",
idxCustom: "Custom",
mmTitle: "Model Provider Manager",
mmAddBtn: "Add Model",
mmEdit: "Edit",
mmDelete: "Delete",
mmEmpty: "No models configured",
mmFormName: "Model Name (Display)",
mmFormProvider: "Provider",
mmFormModelId: "Model ID (e.g., gpt-4o)",
mmFormBaseUrl: "API Base URL",
mmFormType: "Function Type",
mmFormVision: "Supports Vision",
mmFormDimensions: "Vector Dimensions",
mmFormDimensionsHelp: "Embedding vector dimensions, common values: 1536, 3072",
mmSave: "Save Configuration",
mmCancel: "Cancel",
mmErrorNotAuthenticated: "Not authenticated, cannot operate",
mmErrorTitle: "Operation Failed",
modelEnabled: "Model Enabled",
modelDisabled: "Model Disabled",
confirmChangeEmbeddingModel: "WARNING: Changing the embedding model may make existing indices unsearchable.\nYou will not be able to search content indexed with the old model using the new model.\nAre you sure you want to change it?",
embeddingModelWarning: "Changing this setting may require clearing and re-importing your knowledge base.",
sourcePreview: "Source Preview",
matchScore: "Match Score",
copyContent: "Copy Content",
copySuccess: "Copied successfully",
// ConfigPanel missing translations
selectLLMModel: "Please select LLM model",
selectEmbeddingModel: "Please select Embedding model",
defaultForUploads: "Default for new uploads & queries",
noRerankModel: "No Rerank Model",
vectorSimilarityThreshold: "Vector Similarity Threshold",
rerankSimilarityThreshold: "Rerank Similarity Threshold",
filterLowResults: "Results below this value will be filtered",
fullTextSearch: "Full Text Search",
hybridVectorWeight: "Hybrid Vector Weight",
hybridVectorWeightDesc: "Weight: 1.0 = Pure Vector, 0.0 = Pure Keyword",
lblQueryExpansion: "Query Expansion (Multi-Query)",
lblHyDE: "HyDE (Hypothetical Doc)",
lblQueryExpansionDesc: "Generate multiple query variations for better coverage",
lblHyDEDesc: "Generate a hypothetical answer to improve semantic search",
// Model Management
apiKeyValidationFailed: "API Key validation failed",
keepOriginalKey: "Leave empty to keep original API Key, input new value to replace",
leaveEmptyNoChange: "Leave empty to keep unchanged",
mmFormApiKey: "API Key",
mmFormApiKeyPlaceholder: "Enter API Key",
// More missing translations
reconfigureFile: "Reconfigure File",
modifySettings: "Modify file chunking and vectorization settings",
filesCount: " Files",
allFilesIndexed: "All files will be indexed with the settings below.",
noEmbeddingModels: "No Embedding Models Configured",
reconfigure: "Reconfigure",
refresh: "Refresh",
settings: "Settings",
needLogin: "Login required to use chat function",
citationSources: "Citation Sources",
chunkNumber: "Chunk",
getUserListFailed: "Failed to get user list",
displayName: 'Display Name',
displayNamePlaceholder: 'Enter full name or display name',
globalUserNote: 'Note',
roleManagedInOrg: 'User roles are primarily managed within organizations.',
editUser: 'Edit User',
edit: 'Edit',
saveChanges: 'Save Changes',
usernamePasswordRequired: 'Username and password cannot be empty',
passwordMinLength: "Password must be at least 6 characters",
userCreatedSuccess: "User created successfully",
createUserFailed: "Failed to create user",
userPromotedToAdmin: "User promoted to administrator",
userDemotedFromAdmin: "User demoted from administrator",
updateUserFailed: "Failed to update user",
confirmDeleteUser: "Are you sure you want to delete this user?",
confirmDeleteTenant: "Are you sure you want to delete this organization?",
deleteUser: "Delete User",
deleteUserFailed: "Failed to delete user",
userDeletedSuccessfully: "User deleted successfully",
makeUserAdmin: "Set as admin",
makeUserRegular: "Set as regular user",
loading: "Loading...",
noUsers: "No users",
// AI Assistant
aiAssistant: "AI Assistant",
polishContent: "Polish Content",
expandContent: "Expand",
summarizeContent: "Summarize",
translateToEnglish: "Translate to English",
fixGrammar: "Fix Grammar",
aiCommandInstructPolish: "Please polish this text to make it more professional and idiomatic.",
aiCommandInstructExpand: "Please expand this text with more details to make it more comprehensive.",
aiCommandInstructSummarize: "Please summarize this text by extracting the core points into a concise summary.",
aiCommandInstructTranslateToEn: "Please translate this text into English.",
aiCommandInstructFixGrammar: "Please check and fix any grammar and spelling errors in this text.",
aiCommandsPreset: "Common Commands",
aiCommandsCustom: "Custom Request",
aiCommandsCustomPlaceholder: "e.g., rewrite this text to be more formal...",
aiCommandsReferenceContext: "Reference Context (first 200 chars):",
aiCommandsStartGeneration: "Start Generation",
aiCommandsResult: "AI Suggestions",
aiCommandsGenerating: "Generating...",
aiCommandsApplyResult: "Replace Selection",
aiCommandsGoBack: "Go Back",
aiCommandsReset: "Reset",
aiCommandsModalPreset: "Select Preset Command",
aiCommandsModalCustom: "Or Enter Custom Command",
aiCommandsModalCustomPlaceholder: "Tell AI what you want to do...",
aiCommandsModalBasedOnSelection: "Based on selected text:",
aiCommandsModalResult: "Generated Result",
aiCommandsModalApply: "Apply Result",
// ChangePasswordModal and SearchResultsPanel missing translations
fillAllFields: "Please fill in all fields",
passwordMismatch: "New password and confirmation password do not match",
newPasswordMinLength: "New password must be at least 6 characters",
changePasswordFailed: "Failed to change password",
changePasswordTitle: "Change Password",
changing: "Changing...",
searchResults: "Related Content Found",
// VisionModelSelector missing translations
visionModelSettings: "Vision Model Settings",
defaultVisionModel: "Default Vision Model",
loadVisionModelFailed: "Failed to load vision models",
loadFailed: "Loading failed, please check network connection",
saveVisionModelFailed: "Failed to save vision model",
defaultSettingFailed: "Failed to save default settings",
noVisionModels: "No vision models available",
selectVisionModel: "Please select vision model",
visionModelHelp: "Vision model for processing image files. If no models are available, please add one in model management and check the 'Supports Vision' option.",
mmErrorNameRequired: "Model name is required.",
mmErrorModelIdRequired: "Model ID is required.",
mmErrorBaseUrlRequired: "Base URL is required for the selected provider.",
mmRequiredAsterisk: "*",
typeLLM: "Chat/Inference (LLM)",
typeEmbedding: "Embedding",
typeRerank: "Rerank",
typeVision: "Vision",
welcome: "Hello! Select a model in Settings, upload documents to index, and start asking questions.",
placeholderWithFiles: "Ask about knowledge base...",
placeholderEmpty: "Upload files to start...",
analyzing: "Retrieving & Generating...",
errorGeneric: "Error processing your request.",
errorLabel: "Error",
errorNoModel: "No inference model selected or config invalid.",
aiDisclaimer: "AI can make mistakes. Verify important info.",
confirmClear: "Delete all files and indices?",
confirmDeleteModel: "Are you sure you want to delete this model?",
removeFile: "Remove file",
apiError: "Missing config or invalid API Key.",
geminiError: "API Request Failed.",
processedButNoText: "Could not generate text response.",
unitByte: "Bytes",
readingFailed: "Failed to read file",
copy: "Copy",
copied: "Copied",
// User Management
logout: "Logout",
changePassword: "Change Password",
userManagement: "User Management",
userList: "User List",
addUser: "Add User",
userInfo: "User Info",
username: "Username",
password: "Password",
confirmPassword: "Confirm Password",
currentPassword: "Current Password",
newPassword: "New Password",
createUser: "Create User",
admin: "Admin",
user: "User",
adminUser: "Set as Administrator",
confirmChange: "Confirm Change",
changeUserPassword: "Change User Password",
enterNewPassword: "Please enter new password",
createdAt: "Created At",
newChat: "New Chat",
exportUsers: "Export Users",
importUsers: "Import Users",
importSuccess: "Import completed: Success $1, Failed $2",
importFailed: "Failed to import users",
exportFailed: "Failed to export users",
// Group Selection Drawer
selectKnowledgeGroups: "Select Knowledge Groups",
searchGroupsPlaceholder: "Search groups...",
done: "Done",
all: "All",
noGroupsFound: "No groups found",
noGroups: "No groups",
// Auto-refresh functionality
autoRefresh: "Auto Refresh",
refreshInterval: "Refresh Interval",
// Knowledge Base View
kbManagement: "Knowledge Base Management",
kbManagementDesc: "Manage your documents and knowledge groups",
searchPlaceholder: "Search filename...",
allGroups: "All Groups",
allStatus: "All Status",
statusReadyFragment: "Ready",
statusFailedFragment: "Failed",
statusIndexingFragment: "Indexing",
inProgress: "In Progress",
uploadFile: "Upload File",
fileName: "Filename",
size: "Size",
status: "Status",
groups: "Groups",
actions: "Actions",
groupsActions: "Groups / Actions",
noFilesFound: "No files found",
showingRange: "Showing $1 to $2 of $3",
confirmDeleteFile: "Are you sure you want to delete this file?",
fileDeleted: "File deleted",
deleteFailed: "Delete failed",
confirmClearKB: "WARNING: This will permanently delete all files and indices.\n\nAre you sure you want to clear the knowledge base?",
kbCleared: "Knowledge base cleared",
clearFailed: "Clear failed",
loginRequired: "Please login first",
uploadErrors: "The following files could not be uploaded",
uploadWarning: "$1 files ready to upload, $2 files filtered",
uploadFailed: "Upload failed",
preview: "Preview",
addGroup: "Add Group",
delete: "Delete",
retry: "Retry",
retrying: "Retrying...",
retrySuccess: "Retry successful",
retryFailed: "Retry failed",
chunkInfo: "Chunk Information",
totalChunks: "Total Chunks",
chunkIndex: "Chunk",
contentLength: "characters",
position: "Position",
// Indexing Modal
reconfigureTitle: "Reconfigure File",
reconfigureDesc: "Modify file processing settings",
indexingConfigTitle: "Document Indexing Config",
indexingConfigDesc: "Configure document processing options and mode",
pendingFiles: "Pending Files",
processingMode: "Processing Mode",
analyzingFile: "Analyzing...",
recommendationReason: "Reason",
fastMode: "Fast Mode",
fastModeDesc: "Simple text extraction, fast speed, no extra cost, suitable for pure text",
preciseMode: "Precise Mode",
preciseModeDesc: "Precise layout analysis, keeps tables/images, requires API cost",
fastModeFeatures: "Fast Mode Features:",
fastFeature1: "Simple text extraction",
fastFeature2: "Fast processing speed",
fastFeature3: "No extra cost",
fastFeature4: "Process text only",
fastFeature5: "Suitable for plain text",
preciseModeFeatures: "Precise Mode Features:",
preciseFeature1: "Precise structure recognition",
preciseFeature2: "Recognize images/tables/charts",
preciseFeature3: "Preserve mixed content",
preciseFeature4: "Preserve layout information",
preciseFeature5: "Requires API cost",
preciseFeature6: "Longer processing time",
embeddingModel: "Embedding Model",
pleaseSelect: "Please select...",
pleaseSelectKnowledgeGroupFirst: "Please select a knowledge group first before saving",
selectUnassignGroupWarning: "If you want to unassign the knowledge group, please confirm this action",
chunkConfig: "Chunk Config",
chunkSize: "Chunk Size (Tokens)",
min: "Min",
max: "Max",
chunkOverlap: "Overlap (Tokens)",
modelLimitsInfo: "Model Limits Info",
model: "Model",
maxChunkSize: "Max Chunk",
maxOverlapSize: "Max Overlap",
maxBatchSize: "Max Batch",
envLimitWeaker: "Environment limit is stricter",
optimizationTips: "Optimization Tips",
tipChunkTooLarge: "Large chunk size may affect retrieval accuracy",
tipOverlapSmall: "Recommended overlap at least $1 tokens",
tipMaxValues: "Using max values may result in slower processing",
tipPreciseCost: "Precise mode incurs API costs, please check budget",
selectEmbeddingFirst: "Please select embedding model first",
confirmPreciseCost: "Precise mode will incur API costs. Continue?",
startProcessing: "Start Processing",
// Notebooks View
notebooks: "Knowledge Groups",
notebooksDesc: "Manage your research projects and knowledge collections",
createNotebook: "New Knowledge Group",
chatWithNotebook: "Chat with this group",
editNotebook: "Edit Group",
deleteNotebook: "Delete Group (including files)",
noDescription: "No description",
hasIntro: "Contains intro",
noIntro: "No intro",
noNotebooks: "No knowledge groups yet, click top right to create",
createFailed: "Creation failed",
confirmDeleteNotebook: "Are you sure you want to delete knowledge group \"$1\"?\n\nNote: This will also permanently delete all files and their indexing data in this knowledge group!",
// Missing keys for new views
errorFileTooLarge: "File too large (exceeds 20MB)",
noFilesYet: "No files yet",
// Create/Edit Notebook Drawer
createNotebookTitle: "New Knowledge Group",
createFailedRetry: "Create failed, please try again",
updateFailedRetry: "Update failed, please try again",
name: "Name",
nameHelp: "A clear name helps you find it quickly.",
namePlaceholder: "E.g. Quantum Physics Research...",
shortDescription: "Short Description",
descPlaceholder: "One sentence describing the purpose of this group",
detailedIntro: "Detailed Intro",
introPlaceholder: "Every paragraph here may be included in the context of Q&A. Please describe the core topics, background knowledge, or goals of this group in as much detail as possible.",
introHelp: "This intro can be used as extra context for conversations.",
creating: "Creating...",
createNow: "Create Now",
saving: "Saving...",
save: "Save",
// Chat
chatTitle: "Knowledge Base Chat",
chatDesc: "Smart conversation with your knowledge base",
viewHistory: "View Chat History",
saveSettingsFailed: "Failed to save settings",
loginToUpload: "Please login to upload",
fileSizeLimitExceeded: "$1 ($2 - exceeds $3MB limit)",
unsupportedFileType: "$1 - Unsupported file type ($2)",
readFailed: "$1 - Read failed",
loadHistoryFailed: "Failed to load chat history",
loadingUserData: "Loading user data...",
errorMessage: "Error: $1",
welcomeMessage: "Hello! I am your AI Knowledge Base Assistant. Please select a knowledge group to start chatting.",
selectKnowledgeGroup: "Select Knowledge Group",
allKnowledgeGroups: "All Knowledge Groups",
unknownGroup: "Unknown Group",
selectedGroupsCount: "$1 groups selected",
// Settings
generalSettings: "General Settings",
modelManagement: "Model Management",
languageSettings: "Language Settings",
passwordChangeSuccess: "Password changed successfully",
passwordChangeFailed: "Failed to change password",
create: "Create",
validationFailedMsg: "validationFailedMsg",
// Sidebar
navChat: "Chat",
navCoach: "Coach",
navKnowledge: "Knowledge Base",
navKnowledgeGroups: "Knowledge Groups",
navNotebook: "Notebook",
navAgent: "Agents",
navAssessment: "Assessment",
navPlugin: "Plugins",
notebookDesc: "Capture your personal thoughts and research notes.",
newNote: "New Note",
editNote: "Edit Note",
noNotesFound: "No Notes Found",
startByCreatingNote: "Start by creating your first personal note.",
navCrawler: "Crawler",
expandMenu: "Expand Menu",
switchLanguage: "Switch Language",
importFolder: "Import Folder",
// CreateNoteFromPDFDialog keys
createPDFNote: "Create PDF Note",
screenshotPreview: "Screenshot Preview",
associateKnowledgeGroup: "Associate Knowledge Group",
globalNoSpecificGroup: "Global (No Specific Group)",
title: "Title",
enterNoteTitle: "Enter note title",
contentOCR: "Content (OCR Extracted Text)",
extractingText: "Extracting text...",
analyzingImage: "Analyzing image and extracting text...",
noTextExtracted: "No text extracted",
saveNote: "Save Note",
// Additional keys used in CreateNoteFromPDFDialog
page: "Page",
placeholderText: "OCR extracted text will be displayed here, you can edit...",
// CreateNotebookDialog keys
createNewNotebook: "New Knowledge Group",
nameField: "Name",
required: "*",
exampleResearch: "e.g., Quantum Physics Research...",
shortDescriptionField: "Short Description",
describePurpose: "Describe the purpose of this group in one sentence",
detailedIntroField: "Detailed Intro",
provideBackgroundInfo: "Provide detailed background, goals, or context information...",
creationFailed: "Creation failed, please try again",
// PDFPreview keys
preparingPDFConversion: "Preparing PDF conversion...",
pleaseWait: "Please wait, this may take a few minutes",
convertingPDF: "Converting PDF...",
pdfConversionFailed: "PDF conversion failed",
pdfConversionError: "Unable to convert this file to PDF format, please check if the file is corrupted or format is unsupported",
pdfLoadFailed: "PDF loading failed",
pdfLoadError: "Cannot display PDF in browser, please try downloading or opening in a new window",
downloadingPDF: "Downloading PDF...",
loadingPDF: "Loading PDF...",
zoomOut: "Zoom Out",
zoomIn: "Zoom In",
resetZoom: "Reset Zoom",
selectPageNumber: "Select page number:",
enterPageNumber: "Enter the page number you want to select",
exitSelectionMode: "Exit selection mode",
clickToSelectAndNote: "Click to select area and take notes",
regeneratePDF: "Regenerate PDF",
downloadPDF: "Download PDF",
openInNewWindow: "Open in new window",
exitFullscreen: "Exit fullscreen",
fullscreenDisplay: "Fullscreen display",
pdfPreview: "PDF Preview",
converting: "Converting...",
generatePDFPreview: "Generate PDF preview",
previewNotSupported: "Preview not supported for this format",
// Confirmation message
confirmRegeneratePDF: "Are you sure you want to regenerate the PDF? This will overwrite the current preview file.",
// PDFPreviewButton keys
pdfPreviewReady: "PDF Preview",
convertingInProgress: "Converting...",
conversionFailed: "Conversion failed",
generatePDFPreviewButton: "Generate PDF preview",
// Error messages
checkPDFStatusFailed: "Check PDF status failed",
requestRegenerationFailed: "Request regeneration failed",
downloadPDFFailed: "PDF download failed",
openPDFInNewTabFailed: "Failed to open PDF in new tab",
// uploadService Recommendations
invalidFile: "Invalid file",
incompleteFileInfo: "Incomplete file info, will use Fast Mode",
unsupportedFileFormat: "Unsupported file format: .$1",
willUseFastMode: "Will use Fast Mode (text extraction only)",
formatNoPrecise: "Format .$1 does not support Precise Mode",
smallFileFastOk: "Small file, Fast Mode is sufficient",
mixedContentPreciseRecommended: "Contains mixed content, Precise Mode recommended",
willIncurApiCost: "Incurs API costs",
largeFilePreciseRecommended: "Large file, Precise Mode recommended for layout",
longProcessingTime: "Processing time may be longer",
highApiCost: "Higher API costs will apply",
considerFileSplitting: "Consider splitting the file",
// Drag and drop upload
dragDropUploadTitle: "Drag and Drop Files to Upload",
dragDropUploadDesc: "Or click the button below to select files",
supportedFormats: "Supported Formats",
browseFiles: "Browse Files",
// Indexing Modal
recommendationMsg: "Recommend $1 Mode: $2",
autoAdjustChunk: "Chunk size auto-adjusted to maximum $1",
autoAdjustOverlap: "Overlap size auto-adjusted to maximum $1",
autoAdjustOverlapMin: "Overlap size auto-adjusted to minimum $1",
loadLimitsFailed: "Failed to load model limits, using default configuration",
maxValueMsg: "Max value is $1",
overlapRatioLimit: "Cannot exceed 50% of chunk size ($1)",
onlyAdminCanModify: "Only administrators can modify system settings",
dragToSelect: "Drag mouse to select area • Press ESC to cancel",
// ImportFolderDrawer
fillTargetName: "Please fill in the target knowledge group name",
submitFailed: "Submission failed: $1",
importFolderTitle: "Import Local Folder",
importFolderTip: "Tip: Select a local folder. The system will read and upload all supported documents in the folder.",
lblTargetGroup: "Target Knowledge Group Name",
placeholderNewGroup: "New group name",
importToCurrentGroup: "Will be imported into the current group",
nextStep: "Next",
lblImportSource: "Import Source",
serverPath: "Server Path",
localFolder: "Local Folder",
selectedFilesCount: "$1 files selected",
clickToSelectFolder: "Click to select local folder",
selectFolderTip: "Will read all supported files in the folder",
importComplete: "Import complete",
importedFromLocalFolder: "Imported from local folder: $1",
// History
historyTitle: "Chat History",
confirmDeleteHistory: "Are you sure you want to delete this chat history?",
deleteHistorySuccess: "Chat history deleted successfully",
deleteHistoryFailed: "Failed to delete chat history",
yesterday: "Yesterday",
daysAgo: "$1 days ago",
historyMessages: "$1 messages",
noHistory: "No chat history",
noHistoryDesc: "Start a conversation to create history",
loadMore: "Load More",
loadingHistoriesFailed: "Failed to load chat history",
supportedFormatsInfo: "Supports documents, images and code formats",
generalSettingsSubtitle: "Manage your application preferences.",
userManagementSubtitle: "View and manage system user information.",
modelManagementSubtitle: "Configure global AI models.",
kbSettingsSubtitle: "Technical configuration for indexing and chat parameters.",
tenantsSubtitle: "Manage organizational structure, members and their permissions.",
assessmentTemplates: "Assessment Templates",
assessmentTemplatesSubtitle: "Manage and configure assessment generation templates.",
templateName: "Template Name",
keywords: "Keywords",
keywordsHint: "Enter keywords, separated by commas",
questionCount: "Question Count",
questionsCountLabel: "Questions",
difficultyDistribution: "Difficulty Distribution",
style: "Style Requirements",
createTemplate: "Create Template",
editTemplate: "Edit Template",
allNotes: "All Notes",
filterNotesPlaceholder: "Filter notes...",
startWritingPlaceholder: "Start writing...",
previewHeader: "Preview",
noContentToPreview: "No content to preview",
hidePreview: "Hide Preview",
showPreview: "Show Preview",
directoryLabel: "Directory",
uncategorized: "Uncategorized",
enterNamePlaceholder: "Enter name...",
subFolderPlaceholder: "Sub-folder...",
categoryCreated: "Category created",
failedToCreateCategory: "Failed to create category",
failedToDeleteCategory: "Failed to delete category",
confirmDeleteCategory: "Are you sure you want to delete this category?",
groupUpdated: "Group updated",
groupDeleted: "Group deleted",
actionFailed: "Action failed",
kbSettingsSaved: "Knowledge base settings saved",
failedToSaveSettings: "Failed to save settings",
userAddedToOrganization: "User added to organization",
featureUpdated: "Feature updated",
roleTenantAdmin: "Tenant Administrator",
roleRegularUser: "Regular User",
creatingRegularUser: "Creating regular user",
editUserRole: "Edit user role",
targetRole: "Target Role",
editCategory: "Edit category",
totalTenants: "Total Tenants",
systemUsers: "System Users",
systemHealth: "System Health",
operational: "Operational",
orgManagement: "Organization Management",
globalTenantControl: "Global Tenant Control",
newTenant: "New Tenant",
tenantName: "Tenant Name",
domainOptional: "Domain (Optional)",
noOrganizations: "No Organizations",
activeOrg: "Active Org",
noCustomDomain: "No custom domain linked",
orgSettings: "Organization Settings",
deleteOrg: "Delete Organization",
orgMembers: "Organization Members",
membersCount: "$1 Members",
noMembersAssigned: "No members assigned",
addMembers: "Add Members",
searchSystemUsers: "Search system users...",
editOrg: "Edit Organization",
parentOrg: "Parent Organization",
noneRoot: "None (Root)",
selectOrg: "Select an Organization",
selectOrgDesc: "Choose an organization from the left directory to manage its members, settings, and administrative access.",
totalSystemUsers: "Total System Users",
rootOrgs: "Root Organizations",
createSubOrg: "Create Sub-organization",
update: "Update",
modelConfiguration: "Model Configuration",
defaultLLMModel: "Default LLM Model",
selectLLM: "Select LLM",
selectEmbedding: "Select Embedding",
rerankModel: "Rerank Model",
none: "None",
indexingChunkingConfig: "Indexing & Chunking Config",
chatHyperparameters: "Chat Hyperparameters",
temperature: "Temperature",
precise: "Precise",
creative: "Creative",
maxResponseTokens: "Max Response Tokens",
retrievalSearchSettings: "Retrieval & Search Settings",
topK: "Top K",
similarityThreshold: "Similarity Threshold",
enableHybridSearch: "Enable Hybrid Search",
hybridSearchDesc: "Use both vector and full-text search to improve recall",
hybridWeight: "Hybrid Weight (0.0=Fulltext, 1.0=Vector)",
pureText: "Pure Text",
pureVector: "Pure Vector",
enableQueryExpansion: "Enable Query Expansion",
queryExpansionDesc: "Generate multiple query variations for better coverage",
enableHyDE: "Enable HyDE",
hydeDesc: "Generate hypothetical answers to improve semantic search",
enableReranking: "Enable Reranking",
rerankingDesc: "Use Rerank model to re-sort results",
broad: "Broad",
strict: "Strict",
maxInput: "Max Input",
dimensions: "Dimensions",
defaultBadge: "Default",
dims: "Dims: $1",
ctx: "Ctx: $1",
baseApi: "Base API: $1",
configured: "Configured",
groupCreated: "Group created",
navCatalog: "Catalog",
allDocuments: "All Documents",
categories: "Categories",
uncategorizedFiles: "Uncategorized Files",
category: "Category",
statusReadyDesc: "Indexed and searchable",
statusIndexingDesc: "Building vector index",
selectCategory: "Select Category",
noneUncategorized: "No uncategorized files",
previous: "Previous",
next: "Next",
createCategory: "Create Category",
categoryDesc: "Describe your knowledge category",
categoryName: "Category Name",
createCategoryBtn: "Create Now",
newGroup: "New Group",
noKnowledgeGroups: "No knowledge groups yet",
createGroupDesc: "Start by creating your first knowledge group and uploading documents.",
noDescriptionProvided: "No description provided",
browseManageFiles: "Browse and manage files and notes in this group.",
filterGroupFiles: "Search files in group by name...",
"2d": "2d",
Authorization: "Authorization",
a: "a",
agentTitle: "Agent Center",
agentDesc: "Manage and run your AI assistants to help with complex tasks.",
createAgent: "Create Agent",
searchAgent: "Search agents...",
statusRunning: "Running",
statusStopped: "Stopped",
updatedAtPrefix: "Last updated at ",
btnChat: "Start Chat",
// Agent Mock Data
assessmentTitle: "Talent Assessment",
assessmentDesc: "Professional assessment tool to help you discover and develop talent potential.",
agent1Name: "Data Analyst Pro",
agent1Desc: "Expert in SQL and data visualization, capable of extracting insights from complex data.",
agent2Name: "Code Review Assistant",
agent2Desc: "Automatically checks code quality, provides refactoring suggestions and performance optimizations.",
agent3Name: "Academic Paper Polisher",
agent3Desc: "Professional academic writing assistant to help optimize paper structure and language.",
agent4Name: "Legal Consultant",
agent4Desc: "Provides legal article search and basic legal advice, assists in drafting contracts.",
agent5Name: "Market Researcher",
agent5Desc: "Analyze industry trends and generate competitor research reports.",
agent6Name: "SRE Expert",
agent6Desc: "Monitor system health, automatically handle common alerts and troubleshooting.",
agent7Name: "Financial Auditor",
agent7Desc: "Automate report auditing and identify financial risks and abnormal transactions.",
agent1Time: "2 hours ago",
agent2Time: "5 hours ago",
agent3Time: "Yesterday",
agent4Time: "2 days ago",
agent5Time: "3 days ago",
agent6Time: "5 days ago",
agent7Time: "1 week ago",
// Assessment View Keys
readyForAssessment: "Ready for Assessment",
readyForAssessmentDesc: "Select a knowledge group to start professional talent evaluation",
startProfessionalEvaluation: "Start Professional Evaluation",
aiPoweredAnalysis: "AI-Powered Analysis",
masteryScoring: "Mastery Scoring",
recentAssessments: "Recent Assessments",
questionProgress: "Question $1 of $2",
initializingQuestion: "Initializing question $1...",
aiIsProcessing: "AI is processing...",
typeAnswerPlaceholder: "Type your answer here...",
liveFeedback: "Live Feedback",
currentScore: "Current Score",
aiExplanation: "AI Explanation",
masteryProgress: "Mastery Progress",
trackedInRealTime: "Tracked in Real Time",
submitAnswerToSeeFeedback: "Submit answer to see feedback",
assessmentGuide: "Assessment Guide",
assessmentGuideDesc: "Learn about the assessment process and scoring criteria",
level: "Level",
assessmentResultsAvailable: "Assessment Results Available",
knowledgeCoverage: "Knowledge Coverage",
precisionScore: "Precision Score",
questionBasis: "Question Basis",
viewBasis: "View Basis",
hideBasis: "Hide Basis",
verified: "Verified",
fail: "Fail",
comprehensiveMasteryReport: "Comprehensive Mastery Report",
newAssessmentSession: "New Assessment Session",
downloadPdfReport: "Download PDF Report",
statusGeneratingQuestions: "Generating questions...",
statusEvaluatingAnswer: "Evaluating your answer...",
statusPreparingQuestion: "Preparing next question...",
statusGeneratingReport: "Generating final report...",
statusProcessing: "Processing...",
filesAvailable: "files available",
confirmDeleteAssessment: "Are you sure you want to delete this assessment record?",
deleteAssessmentSuccess: "Assessment record deleted successfully",
deleteAssessmentFailed: 'Failed to delete assessment record',
view: 'View',
// Plugins
pluginTitle: "Plugin Store",
pluginDesc: "Extend the functionality of your knowledge base with external tools and services.",
searchPlugin: "Search plugins...",
installPlugin: "Install",
installedPlugin: "Installed",
updatePlugin: "Update Available",
pluginOfficial: "Official",
pluginCommunity: "Community",
pluginBy: "By ",
pluginConfig: "Plugin Config",
pluginViewTitle: "Plugins",
pluginViewDesc: "Integrate external tools and platforms to extend system capabilities.",
pluginStatusAvailable: "Available",
pluginConfigure: "Configure",
pluginFeishuName: "Feishu Bot",
pluginFeishuDesc: "Connect AuraK knowledge base Q&A to Feishu, supporting direct and group messages.",
feishuAddBot: "Add Bot",
feishuSetupGuide: "Setup Guide",
feishuStep1: "Log in to Feishu Open Platform and create a custom app.",
feishuStep2: "In the app settings, obtain the App ID and App Secret.",
feishuStep3: "Fill in the form below and save to generate a Webhook URL.",
feishuStep4: "Paste the Webhook URL into the app's Event Subscription configuration.",
feishuDocs: "View Official Docs",
feishuBotDisplayName: "Bot Display Name",
feishuBotNamePlaceholder: "e.g., AuraK Assistant",
feishuTokenPlaceholder: "Feishu Event Verification Token",
feishuEncryptKeyPlaceholder: "Feishu Message Encryption Key (optional)",
feishuNoBots: "No bots linked yet. Click the button above to add one.",
feishuWebhookUrl: "Webhook Callback URL",
feishuConfirmDelete: "Are you sure you want to remove this bot?",
feishuEnableBot: "Enable Bot",
feishuDisableBot: "Disable Bot",
feishuWsMode: "Long Connection Mode (WebSocket)",
feishuWsModeDesc: "No public domain required; server connects to Feishu cloud directly",
feishuWsConnect: "Enable Long Connection",
feishuWsDisconnect: "Disconnect",
feishuWsStatus: "Connection Status",
feishuWsConnected: "Connected",
feishuWsConnecting: "Connecting...",
feishuWsDisconnected: "Disconnected",
feishuWsError: "Connection Error",
feishuWsConnectHint: "When enabled, Feishu events are pushed via WebSocket. No Webhook callback URL required.",
// Plugin Mock Data
plugin1Name: "Web Search",
plugin1Desc: "Gives AI real-time access to the internet for the latest information.",
plugin2Name: "PDF Document Parser",
plugin2Desc: "Deeply parse complex PDF layouts, extracting tables and mathematical formulas.",
plugin3Name: "GitHub Integration",
plugin3Desc: "Directly access GitHub repositories for code commits and issue management.",
plugin4Name: "Google Calendar",
plugin4Desc: "Sync your schedule and automatically create meeting reminders.",
plugin5Name: "SQL Database Connector",
plugin5Desc: "Securely connect to your databases and perform natural language queries.",
plugin6Name: "Slack Notifier",
plugin6Desc: "Send AI-generated reports directly to specified Slack channels.",
personalNotebook: "Personal Notebook",
success: "Success",
warning: "Warning",
"x-api-key": "API Key",
"x-tenant-id": "Tenant ID",
"x-user-language": "User Language",
// Hierarchical categories new keys
addSubcategory: "Add Subcategory",
parentCategory: "Parent Category (optional)",
noParentTopLevel: "None (top-level)",
useHierarchyImport: "Create categories by folder hierarchy",
useHierarchyImportDesc: "When enabled, a sub-category will be created for each sub-folder and files imported into matching categories.",
// Folder import drawer
importImmediate: "Import Now",
importScheduled: "Scheduled Import",
lblServerPath: "Server Folder Path",
placeholderServerPath: "e.g. /data/documents",
scheduledImportTip: "Server-side scheduled import: the server will read files from the given path at the specified time and import them automatically. Ensure the server has access to the path.",
lblScheduledTime: "Execution Time",
scheduledTimeHint: "The server will automatically run the import task at the specified time.",
scheduleImport: "Create Scheduled Task",
scheduleTaskCreated: "Scheduled import task created",
fillServerPath: "Please enter the server folder path",
invalidDateTime: "Please enter a valid date and time",
// Import Tasks Drawer
importTasksTitle: "Import Tasks",
noTasksFound: "No tasks found",
sourcePath: "Source Path",
targetGroup: "Target Group",
scheduledAt: "Scheduled At",
confirmDeleteTask: "Are you sure you want to delete this import task record?",
deleteTaskFailed: "Failed to delete task record",
noOrganization: "No Organization",
},
ja: {
aiCommandsError: "エラーが発生しました",
appTitle: "Gemini ナレッジベース",
loginTitle: "ログイン",
loginDesc: "システムに入るためのキーを入力してください",
optional: "任意",
loginButton: "ログイン",
loginError: "キーは必須です",
unknown: "不明",
unknownError: "未知のエラー",
usernamePlaceholder: "ユーザー名",
passwordPlaceholder: "パスワード",
registerButton: "登録",
langZh: "言語: 中国語",
langEn: "言語: 英語",
langJa: "言語: Japanese",
confirm: "確認",
cancel: "キャンセル",
confirmTitle: "操作の確認",
defaultTenant: "デフォルトテナント",
selectOrganization: "組織を選択",
confirmDeleteGroup: "グループ \"$1\" を削除してもよろしいですか?",
sidebarTitle: "索引とチャットの設定",
backToWorkspace: "ワークスペースに戻る",
goToAdmin: "管理画面へ",
sidebarDesc: "ドキュメントとモデル管理",
tabFiles: "ドキュメント",
files: "ファイル",
notes: "メモ",
tabSettings: "設定",
systemConfiguration: "システム構成",
noFiles: "ファイルなし",
noFilesDesc: "PDF、Office文書、テキスト、コード、画像などをサポート",
addFile: "ファイルAdded",
clearAll: "全削除",
uploading: "処理中",
statusIndexing: "ベクトル化中...",
statusReady: "完了",
// RAG Settings
ragSettings: "RAG 設定",
enableRerank: "リランクを有効にする",
enableRerankDesc: "リランクモデルを使用して検索結果を再ランク付けし、精度を向上させます",
selectRerankModel: "リランクモデルを選択してください",
selectModelPlaceholder: "モデルを選択...",
headerModelSelection: "モデル選択",
headerHyperparams: "推論パラメータ",
headerIndexing: "インデックスと分割",
headerRetrieval: "検索とランク付け",
btnManageModels: "プロバイダー管理",
lblLLM: "推論モデル (LLM)",
lblEmbedding: "埋め込みモデル",
lblRerankRef: "リランクモデル",
lblTemperature: "温度 (Temperature)",
lblMaxTokens: "最大トークン数",
lblChunkSize: "Chunk size",
lblChunkOverlap: "オーバーラップ",
lblTopK: "検索数 (Top K)",
lblRerank: "リランク有効化",
idxModalTitle: "インデックス設定",
idxDesc: "取り込みの前に分割ルールと埋め込みモデルを設定してください。",
idxFiles: "対象ファイル",
idxMethod: "分割設定",
idxEmbeddingModel: "埋め込みモデル",
idxStart: "インデックス開始",
idxCancel: "キャンセル",
idxAuto: "自動",
idxCustom: "カスタム",
mmTitle: "モデルプロバイダー管理",
mmAddBtn: "モデルAdded",
mmEdit: "編集",
mmDelete: "削除",
mmEmpty: "設定されたモデルはありません",
mmFormName: "表示名",
mmFormProvider: "プロバイダー",
mmFormModelId: "モデルID (例: gpt-4o)",
mmFormBaseUrl: "API Base URL",
mmFormType: "機能タイプ",
mmFormVision: "画像認識対応",
mmFormDimensions: "ベクトル次元",
mmFormDimensionsHelp: "埋め込みベクトルの次元数、一般的な値:1536、3072",
mmSave: "保存",
mmCancel: "キャンセル",
mmErrorNotAuthenticated: "認証されていません、操作できません",
mmErrorTitle: "操作失敗",
modelEnabled: "モデル有効",
modelDisabled: "モデル無効",
confirmChangeEmbeddingModel: "警告:埋め込みモデルを変更すると、既存のインデックスが検索できなくなる可能性があります。\n変更してよろしいですか?",
embeddingModelWarning: "この設定を変更すると、ナレッジベースのクリアと再インポートが必要になる場合があります。",
sourcePreview: "引用元プレビュー",
matchScore: "一致度",
copyContent: "内容をコピー",
copySuccess: "コピーしました",
// ConfigPanel 缺失の翻訳
selectLLMModel: "LLMモデルを選択してください",
selectEmbeddingModel: "Embeddingモデルを選択してください",
defaultForUploads: "新しいアップロードとクエリのデフォルト",
noRerankModel: "リランクモデルなし",
vectorSimilarityThreshold: "ベクトル検索しきい値",
rerankSimilarityThreshold: "リランクしきい値",
filterLowResults: "この値を下回る結果はフィルタリングされます",
noteCreatedSuccess: "Create noteしました",
noteCreatedFailed: "ノートの作成に失敗しました",
fullTextSearch: "全文検索",
hybridVectorWeight: "ハイブリッド検索ベクトル重み",
hybridVectorWeightDesc: "重み: 1.0 = ベクトルのみ, 0.0 = キーワードのみ",
lblQueryExpansion: "クエリ拡張 (Multi-Query)",
lblHyDE: "HyDE (仮想ドキュメント埋め込み)",
lblQueryExpansionDesc: "検索カバレッジ向上のために複数のクエリを生成",
lblHyDEDesc: "セマンティック検索改善のために仮想回答を生成",
// Model Management
apiKeyValidationFailed: "API Key検証に失敗しました",
keepOriginalKey: "空のままにすると元のAPI Keyを保持、新しい値を入力すると置換",
leaveEmptyNoChange: "空のままで変更なし",
mmFormApiKey: "API Key",
mmFormApiKeyPlaceholder: "API Key を入力してください",
// さらに缺失している翻訳
reconfigureFile: "ファイルの再設定",
modifySettings: "ファイルの分割とベクトル化設定を変更",
filesCount: "ファイル",
allFilesIndexed: "以下の設定ですべてのファイルがインデックス化されます。",
noEmbeddingModels: "埋め込みモデルが設定されていません",
reconfigure: "再設定",
refresh: "更新",
settings: "設定",
needLogin: "チャット機能を使用するにはログインが必要です",
citationSources: "引用元",
chunkNumber: "フラグメント",
getUserListFailed: "ユーザー一覧の取得に失敗しました",
displayName: '表示名',
displayNamePlaceholder: '氏名または表示名を入力',
globalUserNote: '注意',
roleManagedInOrg: 'ユーザーの役割(ロール)は、所属する組織内で管理されます。',
editUser: 'ユーザー編集',
edit: '編集',
saveChanges: '変更を保存',
usernamePasswordRequired: 'ユーザー名とパスワードは必須です',
passwordMinLength: "パスワードは6文字以上で入力してください",
userCreatedSuccess: "ユーザーが作成されました",
createUserFailed: "ユーザー作成に失敗しました",
userPromotedToAdmin: "ユーザーを管理者に昇格しました",
userDemotedFromAdmin: "ユーザーを一般ユーザーに降格しました",
updateUserFailed: "ユーザー情報の更新に失敗しました",
confirmDeleteUser: "このユーザーを削除してもよろしいですか?",
confirmDeleteTenant: "この組織を削除してもよろしいですか?",
deleteUser: "ユーザー削除",
deleteUserFailed: "ユーザーの削除に失敗しました",
userDeletedSuccessfully: "ユーザーを削除しました",
makeUserAdmin: "管理者にする",
makeUserRegular: "一般ユーザーにする",
loading: "読み込み中...",
noUsers: "ユーザーなし",
// AI Assistant
aiAssistant: "AI アシスタント",
polishContent: "内容を洗練",
expandContent: "展開",
summarizeContent: "要約",
translateToEnglish: "英語に翻訳",
fixGrammar: "文法修正",
aiCommandInstructPolish: "このテキストをよりプロフェッショナルで自然な表現に推敲してください。",
aiCommandInstructExpand: "このテキストに詳細をAddedして内容を充実させ、詳しく書き広げてください。",
aiCommandInstructSummarize: "このテキストの要点を抽出し、簡潔な要約を作成してください。",
aiCommandInstructTranslateToEn: "このテキストを英語に翻訳してください。",
aiCommandInstructFixGrammar: "このテキストの文法やスペルの誤りをチェックし、修正してください。",
aiCommandsPreset: "よく使うコマンド",
aiCommandsCustom: "カスタムリクエスト",
aiCommandsCustomPlaceholder: "例:この文章をより正式なものに書き直してください...",
aiCommandsReferenceContext: "参照コンテキスト (最初の200文字):",
aiCommandsStartGeneration: "生成開始",
aiCommandsResult: "AIの提案",
aiCommandsGenerating: "生成中...",
aiCommandsApplyResult: "選択範囲を置換",
aiCommandsGoBack: "戻る",
aiCommandsReset: "リセット",
aiCommandsModalPreset: "プリセットコマンドを選択",
aiCommandsModalCustom: "またはカスタムコマンドを入力",
aiCommandsModalCustomPlaceholder: "AIに何をしたいか伝えてください...",
aiCommandsModalBasedOnSelection: "選択したテキストに基づいて:",
aiCommandsModalResult: "生成結果",
aiCommandsModalApply: "結果を適用",
// ChangePasswordModal と SearchResultsPanel の缺失翻訳
fillAllFields: "すべてのフィールドを入力してください",
passwordMismatch: "新しいパスワードと確認パスワードが一致しません",
newPasswordMinLength: "新しいパスワードは6文字以上で入力してください",
changePasswordFailed: "パスワードの変更に失敗しました",
changePasswordTitle: "パスワード変更",
changing: "変更中...",
searchResults: "関連コンテンツが見つかりました",
// VisionModelSelector の缺失翻訳
visionModelSettings: "ビジョンモデル設定",
defaultVisionModel: "デフォルトビジョンモデル",
loadVisionModelFailed: "ビジョンモデルの読み込みに失敗しました",
loadFailed: "読み込みに失敗しました、ネットワーク接続を確認してください",
saveVisionModelFailed: "ビジョンモデルの保存に失敗しました",
defaultSettingFailed: "デフォルト設定の保存に失敗しました",
noVisionModels: "利用可能なビジョンモデルがありません",
selectVisionModel: "ビジョンモデルを選択してください",
visionModelHelp: "画像ファイルを処理するためのビジョンモデル。利用可能なモデルがない場合は、モデル管理でAddedし、「ビジョン対応」オプションをチェックしてください。",
mmErrorNameRequired: "Model nameは必須要素です",
mmErrorModelIdRequired: "モデルIDは必須です。",
mmErrorBaseUrlRequired: "選択されたプロバイダーにはBase URLが必要です。",
mmRequiredAsterisk: "*",
typeLLM: "推論 (LLM)",
typeEmbedding: "ベクトル化 (Embedding)",
typeRerank: "リランク (Rerank)",
typeVision: "画像認識 (Vision)",
welcome: "こんにちは!設定でモデルを選択し、ドキュメントをアップロードして質問を開始してください。",
placeholderWithFiles: "ナレッジベースについて質問...",
placeholderEmpty: "開始するにはファイルをアップロード...",
analyzing: "検索して生成中...",
errorGeneric: "エラーが発生しました。",
errorLabel: "エラー",
errorNoModel: "モデルが選択されていないか、設定が無効です。",
aiDisclaimer: "AIは間違いを犯す可能性があります。",
confirmClear: "すべてのファイルを削除しますか?",
confirmDeleteModel: "このモデルを削除してもよろしいですか?",
removeFile: "ファイルを削除",
apiError: "設定が不足しているか、APIキーが有効ではありません。",
geminiError: "APIRequest failed。",
processedButNoText: "応答を生成できませんでした。",
unitByte: "バイト",
readingFailed: "読み込み失敗",
copy: "コピー",
copied: "コピーしました",
// User Management
logout: "ログアウト",
changePassword: "パスワード変更",
userManagement: "ユーザー管理",
userList: "ユーザー一覧",
userInfo: "ユーザー情報",
username: "ユーザー名",
password: "パスワード",
confirmPassword: "パスワード確認",
currentPassword: "現在のパスワード",
newPassword: "新しいパスワード",
createUser: "ユーザー作成",
admin: "管理者",
user: "ユーザー",
adminUser: "管理者として設定",
confirmChange: "変更を確定",
changeUserPassword: "ユーザーのパスワードを変更",
enterNewPassword: "新しいパスワードを入力してください",
createdAt: "作成日時",
newChat: "新しい会話",
// Knowledge Base View
kbManagement: "ナレッジベース管理",
kbManagementDesc: "ドキュメントとナレッジグループの管理",
searchPlaceholder: "ファイル名を検索...",
allGroups: "すべてのグループ",
allStatus: "すべてのステータス",
statusReadyFragment: "完了",
statusFailedFragment: "失敗",
statusIndexingFragment: "インデックス中",
inProgress: "進行中",
uploadFile: "ファイルをアップロード",
fileName: "ファイル名",
size: "サイズ",
status: "ステータス",
groups: "グループ",
actions: "アクション",
groupsActions: "グループ / アクション",
noFilesFound: "ファイルが見つかりません",
showingRange: "$3 件中 $1 - $2 を表示",
confirmDeleteFile: "このファイルを削除してもよろしいですか?",
fileDeleted: "ファイルを削除しました",
deleteFailed: "削除に失敗しました",
fileAddedToGroup: "ファイルがグループにAddedされました",
failedToAddToGroup: "グループへのAddedに失敗しました",
fileRemovedFromGroup: "ファイルがグループから削除されました",
failedToRemoveFromGroup: "グループからの削除に失敗しました",
confirmClearKB: "警告:これによりすべてのファイルとインデックスが完全に削除されます。\n\n本当にナレッジベースをクリアしますか?",
kbCleared: "ナレッジベースをクリアしました",
clearFailed: "クリアに失敗しました",
loginRequired: "先にログインしてください",
uploadErrors: "以下のファイルはアップロードできませんでした",
uploadWarning: "$1 つのファイルがアップロード準備完了、$2 つがフィルタリングされました",
uploadFailed: "アップロードに失敗しました",
preview: "プレビュー",
addGroup: "グループAdded",
delete: "削除",
retry: "再試行",
retrying: "再試行中...",
retrySuccess: "再試行成功",
retryFailed: "再試行失敗",
chunkInfo: "チャンク情報",
totalChunks: "総チャンク数",
chunkIndex: "チャンク",
contentLength: "文字",
position: "位置",
// Indexing Modal
reconfigureTitle: "ファイルの再設定",
reconfigureDesc: "ファイル処理設定を変更",
indexingConfigTitle: "ドキュメントインデックス設定",
indexingConfigDesc: "ドキュメント処理オプションとモードを設定",
pendingFiles: "待機中のファイル",
processingMode: "処理モード",
analyzingFile: "Analyzing...",
recommendationReason: "理由",
fastMode: "Fast Mode",
fastModeDesc: "単純なテキスト抽出、Fast、No additional cost、純粋なテキストに適しています",
preciseMode: "Precise Mode",
preciseModeDesc: "精密なレイアウト分析、表/画像を保持、APIコストがかかります",
fastModeFeatures: "Fast Modeの特徴:",
fastFeature1: "単純なテキスト抽出",
fastFeature2: "Fastな処理速度",
fastFeature3: "No additional cost",
fastFeature4: "テキストのみ処理",
fastFeature5: "プレーンテキストに適しています",
preciseModeFeatures: "Precise Modeの特徴:",
preciseFeature1: "精密な構造認識",
preciseFeature2: "画像/表/チャートを認識",
preciseFeature3: "混合コンテンツを保持",
preciseFeature4: "Retains layout information",
preciseFeature5: "APIコストが必要",
preciseFeature6: "処理時間が長くなります",
embeddingModel: "埋め込みモデル",
pleaseSelect: "選択してください...",
pleaseSelectKnowledgeGroupFirst: "保存する前に知識グループを選択してください",
selectUnassignGroupWarning: "ナレッジグループの割り当てを解除する場合は、この操作を確認してください",
chunkConfig: "Chunk configuration",
chunkSize: "Chunk size (トークン)",
min: "最小",
max: "最大",
chunkOverlap: "オーバーラップ (トークン)",
modelLimitsInfo: "モデル制限情報",
model: "モデル",
maxChunkSize: "最大チャンク",
maxOverlapSize: "最大オーバーラップ",
maxBatchSize: "最大バッチ",
envLimitWeaker: "環境制限の方が厳しいです",
optimizationTips: "最適化のヒント",
tipChunkTooLarge: "Chunk sizeが大きいと検索精度に影響する可能性があります",
tipOverlapSmall: "オーバーラップは少なくとも $1 トークンを推奨します",
tipMaxValues: "最大値を使用すると処理が遅くなる可能性があります",
tipPreciseCost: "Precise ModeはAPIコストが発生します。予算を確認してください",
selectEmbeddingFirst: "先に埋め込みモデルを選択してください",
confirmPreciseCost: "Precise ModeはAPIコストが発生します。続けますか?",
startProcessing: "処理開始",
// Notebooks View
notebooks: "ナレッジグループ",
notebooksDesc: "研究プロジェクトやナレッジコレクションを管理",
createNotebook: "新しいナレッジグループ",
chatWithNotebook: "このグループとチャット",
editNotebook: "グループを編集",
deleteNotebook: "Delete group (ファイルを含む)",
noDescription: "説明なし",
hasIntro: "紹介あり",
noIntro: "紹介なし",
noNotebooks: "ナレッジグループはまだありません。右上の作成ボタンをクリックしてください",
createFailed: "作成に失敗しました",
confirmDeleteNotebook: "知識グループ \"$1\" を削除してもよろしいですか?\n\n注意:これにより、この知識グループ内のすべてのファイルとそのインデックスデータも完全に削除されます!",
// Missing keys for new views
errorFileTooLarge: "ファイルサイズが大きすぎます (20MB以下)",
noFilesYet: "ファイルがありません",
// Create/Edit Notebook Drawer
createNotebookTitle: "新しいナレッジグループ",
editNotebookTitle: "ナレッジグループを編集",
createFailedRetry: "作成に失敗しました。再試行してください",
updateFailedRetry: "更新に失敗しました。再試行してください",
name: "名前",
nameHelp: "明確な名前を付けると見つけやすくなります。",
namePlaceholder: "例: 量子物理学研究...",
shortDescription: "短い説明",
descPlaceholder: "このグループの目的を一文で説明",
detailedIntro: "詳細な紹介",
introPlaceholder: "ここの段落は、Q&Aのコンテキストに含まれる可能性があります。このグループの主要なトピック、背景知識、または目標をできるだけ詳しく説明してください。",
introHelp: "この紹介は、会話のAddedコンテキストとして使用されます。",
creating: "作成中...",
createNow: "今すぐ作成",
saving: "保存中...",
save: "保存",
// Chat
chatTitle: "ナレッジベースチャット",
chatDesc: "ナレッジベースとのスマートな会話",
viewHistory: "チャット履歴を表示",
saveSettingsFailed: "設定の保存に失敗しました",
loginToUpload: "アップロードするにはログインしてください",
fileSizeLimitExceeded: "$1 ($2 - $3MB の制限)",
unsupportedFileType: "$1 - サポートされていないファイルタイプ ($2)",
readFailed: "$1 - 読み込みに失敗しました",
loadHistoryFailed: "履歴の読み込みに失敗しました",
loadingUserData: "ユーザーデータを読み込み中...",
errorMessage: "エラー: $1",
welcomeMessage: "こんにちは!私はあなたのAIナレッジベースアシスタントです。チャットを開始するにはナレッジグループを選択してください。",
selectKnowledgeGroup: "ナレッジグループを選択",
allKnowledgeGroups: "すべてのナレッジグループ",
unknownGroup: "不明なグループ",
selectedGroupsCount: "$1 グループ選択済み",
// Settings
generalSettings: "一般設定",
modelManagement: "モデル管理",
languageSettings: "言語設定",
passwordChangeSuccess: "パスワードを変更しました",
passwordChangeFailed: "パスワードの変更に失敗しました",
create: "作成",
validationFailedMsg: "validationFailedMsg",
// Sidebar
exportUsers: "ユーザーをエクスポート",
importUsers: "ユーザーをインポート",
importSuccess: "インポート完了: 成功 $1, 失敗 $2",
importFailed: "ユーザーのインポートに失敗しました",
exportFailed: "ユーザーのエクスポートに失敗しました",
navChat: "チャット",
navCoach: "コーチ",
navKnowledge: "ナレッジベース",
navKnowledgeGroups: "ナレッジグループ",
navNotebook: "ノートブック",
navAgent: "エージェント",
navAssessment: "アセスメント",
navPlugin: "プラグイン",
navCrawler: "リソース取得",
navGlobal: "グローバル",
navSystemModels: "システムモデル",
navTenantManagement: "テナント管理",
navUsersTeam: "ユーザーとチーム",
navTenantSettings: "テナント設定",
adminConsole: "管理コンソール",
globalDashboard: "グローバルダッシュボード",
expandMenu: "メニューを展開",
switchLanguage: "言語を切り替える",
// Group Selection Drawer
selectKnowledgeGroups: "ナレッジグループを選択",
searchGroupsPlaceholder: "グループを検索...",
done: "完了",
all: "すべて",
noGroupsFound: "関連するグループが見つかりません",
noGroups: "グループなし",
// Auto-refresh functionality
autoRefresh: "自動更新",
refreshInterval: "更新間隔",
// NotebookDetailView
errorRenderFlowchart: "フローチャートを生成できません",
errorLoadData: "データの読み込みに失敗しました",
confirmUnsupportedFile: "拡張子 .$1 はサポートされていない可能性があります。続行しますか?",
errorReadFile: "ファイルの読み込みに失敗しました: $1",
successUploadFile: "ファイルのアップロードと関連付けに成功しました",
errorUploadFile: "アップロードに失敗しました: $1",
errorProcessFile: "ファイルの処理に失敗しました",
errorTitleContentRequired: "タイトルと内容は必須です",
successNoteUpdated: "メモを更新しました",
successNoteCreated: "メモを作成しました",
errorSaveFailed: "保存に失敗しました: $1",
confirmDeleteNote: "このメモを削除してもよろしいですか?",
successNoteDeleted: "メモを削除しました",
confirmRemoveFileFromGroup: "ファイル「$1」をこのグループから削除しますか?(ナレッジベースには残ります)",
editNote: "メモを編集",
newNote: "新規メモ",
togglePreviewOpen: "プレビューを表示",
togglePreviewClose: "プレビューを閉じる",
noteTitlePlaceholder: "メモのタイトル",
noteContentPlaceholder: "書き始める (Markdown 対応)...",
markdownPreviewArea: "Markdown プレビューエリア",
back: "戻る",
chatWithGroup: "このグループとチャット",
chatWithFile: "このファイルとチャット",
filesCountLabel: "ファイル ($1)",
notesCountLabel: "メモ ($1)",
indexIntoKB: "インデックスに登録",
noFilesOrNotes: "$1がありません",
importFolder: "フォルダをインポート",
// CreateNoteFromPDFDialog keys
createPDFNote: "PDFCreate note",
screenshotPreview: "スクリーンショットプレビュー",
associateKnowledgeGroup: "ナレッジグループに関連付ける",
globalNoSpecificGroup: "全体 (特定のグループなし)",
title: "タイトル",
enterNoteTitle: "ノートのタイトルを入力",
contentOCR: "内容 (OCR抽出されたテキスト)",
extractingText: "テキストを抽出中...",
analyzingImage: "画像を分析してテキストを抽出中...",
noTextExtracted: "テキストが抽出されませんでした",
saveNote: "ノートを保存",
// Additional keys used in CreateNoteFromPDFDialog
page: "ページ",
placeholderText: "OCR抽出されたテキストがここに表示されます。編集できます...",
// CreateNotebookDialog keys
createNewNotebook: "新しいナレッジグループ",
nameField: "名前",
required: "*",
exampleResearch: "例: 量子物理学研究...",
shortDescriptionField: "簡単な説明",
describePurpose: "このグループの目的を一文で説明してください",
detailedIntroField: "詳細な紹介",
provideBackgroundInfo: "詳細な背景、目標、またはコンテキスト情報を提供してください...",
creationFailed: "作成に失敗しました。もう一度お試しください",
// PDFPreview keys
preparingPDFConversion: "PDF変換を準備中...",
pleaseWait: "しばらくお待ちください。これには数分かかる場合があります",
convertingPDF: "PDFを変換中...",
pdfConversionFailed: "PDF変換に失敗しました",
pdfConversionError: "このファイルをPDF形式に変換できません。ファイルが破損していないか、サポートされていない形式でないか確認してください",
pdfLoadFailed: "PDF読み込みに失敗しました",
pdfLoadError: "ブラウザでPDFを表示できません。ダウンロードするか、新しいウィンドウで開いてみてください",
downloadingPDF: "PDFをダウンロード中...",
loadingPDF: "PDFを読み込み中...",
zoomOut: "ズームアウト",
zoomIn: "ズームイン",
resetZoom: "ズームをリセット",
selectPageNumber: "Page numberを選択:",
enterPageNumber: "選択したいPage numberを入力してください",
exitSelectionMode: "選択モードを終了",
clickToSelectAndNote: "クリックして領域を選択し、メモを取る",
regeneratePDF: "PDFを再生成",
downloadPDF: "PDFをダウンロード",
openInNewWindow: "新しいウィンドウで開く",
exitFullscreen: "全画面表示を終了",
fullscreenDisplay: "全画面表示",
pdfPreview: "PDFプレビュー",
converting: "変換中...",
generatePDFPreview: "PDFプレビューを生成",
previewNotSupported: "この形式はプレビューをサポートしていません",
// Confirmation message
confirmRegeneratePDF: "PDFを再生成してもよろしいですか?これにより現在のプレビューファイルが上書きされます。",
// PDFPreviewButton keys
pdfPreviewReady: "PDFプレビュー",
convertingInProgress: "変換中...",
conversionFailed: "変換失敗",
generatePDFPreviewButton: "PDFプレビューを生成",
// Error messages
checkPDFStatusFailed: "PDFステータスの確認に失敗しました",
requestRegenerationFailed: "再生成要求に失敗しました",
downloadPDFFailed: "PDFのダウンロードに失敗しました",
openPDFInNewTabFailed: "新しいウィンドウでのPDFオープンに失敗しました",
// uploadService Recommendations
invalidFile: "無効なファイル",
incompleteFileInfo: "ファイル情報が不完全です。Fast Modeを使用します",
unsupportedFileFormat: "サポートされていない形式: .$1",
willUseFastMode: "Fast Mode(テキスト抽出のみ)を使用します",
formatNoPrecise: "形式 .$1 はPrecise Modeをサポートしていません",
smallFileFastOk: "ファイルサイズが小さいため、Fast Modeで十分です",
mixedContentPreciseRecommended: "図表などが含まれるため、Precise Modeを推奨します",
willIncurApiCost: "APIコストが発生します",
largeFilePreciseRecommended: "大きなファイルです。構造保持のためPrecise Modeを推奨します",
longProcessingTime: "処理に時間がかかる可能性があります",
highApiCost: "高いAPIコストが発生します",
considerFileSplitting: "ファイルの分割を検討してください",
// Drag and drop upload
dragDropUploadTitle: "File to uploadをドラッグ&ドロップ",
dragDropUploadDesc: "または下のボタンをクリックしてファイルを選択",
supportedFormats: "対応フォーマット",
browseFiles: "ファイルを参照",
// IndexingModal
recommendationMsg: "$1モードを推奨します: $2",
autoAdjustChunk: "Chunk sizeを上限の $1 に調整しました",
autoAdjustOverlap: "重なりサイズを上限の $1 に調整しました",
autoAdjustOverlapMin: "重なりサイズを最小値の $1 に調整しました",
loadLimitsFailed: "モデル制限の読み込みに失敗しました。デフォルト設定を使用します",
maxValueMsg: "最大値は $1 です",
overlapRatioLimit: "切片サイズの50% ($1) を超えることはできません",
onlyAdminCanModify: "システム設定は管理者のみ変更可能です",
dragToSelect: "マウスをドラッグして範囲を選択 • ESCでキャンセル",
// ImportFolderDrawer
fillTargetName: "対象のナレッジグループ名を入力してください",
submitFailed: "送信に失敗しました: $1",
importFolderTitle: "ローカルフォルダをインポート",
importFolderTip: "ヒント: ローカルフォルダを選択してください。フォルダ内のすべてのサポートされているドキュメントが読み込まれ、アップロードされます。",
lblTargetGroup: "対象のナレッジグループ名",
placeholderNewGroup: "新規グループ名",
importToCurrentGroup: "現在のグループにインポートされます",
nextStep: "次へ",
lblImportSource: "インポート元",
serverPath: "サーバーのパス",
localFolder: "ローカルフォルダ",
selectedFilesCount: "$1 個のファイルが選択されました",
clickToSelectFolder: "クリックしてローカルフォルダを選択",
selectFolderTip: "フォルダ内のすべてのサポートされているファイルを読み込みます",
importComplete: "インポート完了",
importedFromLocalFolder: "ローカルフォルダからインポート: $1",
// History
historyTitle: "会話履歴",
confirmDeleteHistory: "この会話履歴を削除してもよろしいですか?",
deleteHistorySuccess: "会話履歴を削除しました",
deleteHistoryFailed: "会話履歴の削除に失敗しました",
yesterday: "昨日",
daysAgo: "$1日前",
historyMessages: "$1 件のメッセージ",
noHistory: "会話履歴はありません",
noHistoryDesc: "会話を開始して履歴を作成してください",
loadMore: "もっと読み込む",
loadingHistoriesFailed: "履歴の読み込みに失敗しました",
supportedFormatsInfo: "ドキュメント、画像、ソースコードをサポート",
kbSettingsSaved: "設定を保存しました",
failedToSaveSettings: "設定の保存に失敗しました",
actionFailed: "操作に失敗しました",
userAddedToOrganization: "ユーザーが組織に追加されました",
featureUpdated: "機能が更新されました",
roleTenantAdmin: "テナント管理者",
roleRegularUser: "一般ユーザー",
creatingRegularUser: "一般ユーザーを作成中",
editUserRole: "ユーザーロールを編集",
targetRole: "対象のロール",
editCategory: "カテゴリを編集",
totalTenants: "総テナント数",
systemUsers: "システムユーザー",
systemHealth: "システムヘルス",
operational: "正常稼働中",
orgManagement: "組織管理",
globalTenantControl: "グローバルテナントコントロール",
newTenant: "新規テナント",
tenantName: "テナント名",
domainOptional: "ドメイン (オプション)",
noOrganizations: "組織がありません",
activeOrg: "現在の組織",
noCustomDomain: "カスタムドメインがリンクされていません",
orgSettings: "組織設定",
deleteOrg: "組織を削除",
orgMembers: "組織メンバー",
membersCount: "$1 メンバー",
noMembersAssigned: "メンバーが割り当てられていません",
addMembers: "メンバーを追加",
searchSystemUsers: "システムユーザーを検索...",
editOrg: "組織を編集",
parentOrg: "親組織",
noneRoot: "なし (ルート)",
selectOrg: "組織を選択してください",
selectOrgDesc: "左側のディレクトリから組織を選択して、そのメンバー、設定、および管理アクセスを管理します。",
totalSystemUsers: "システムユーザーの総数",
rootOrgs: "ルート組織",
createSubOrg: "サブ組織を作成",
update: "更新",
modelConfiguration: "モデル設定",
defaultLLMModel: "デフォルト推論モデル",
selectLLM: "LLMを選択",
selectEmbedding: "埋め込みを選択",
rerankModel: "リランクモデル",
none: "なし",
indexingChunkingConfig: "インデックスとチャンク設定",
chatHyperparameters: "チャットハイパーパラメータ",
temperature: "温度",
precise: "精密",
creative: "クリエイティブ",
maxResponseTokens: "最大応答トークン数",
retrievalSearchSettings: "検索設定",
topK: "Top K",
similarityThreshold: "類似度しきい値",
enableHybridSearch: "ハイブリッド検索を有効にする",
hybridSearchDesc: "ベクトル検索と全文検索を併用して検索精度を向上させます",
hybridWeight: "ハイブリッド重み (0.0=全文, 1.0=ベクトル)",
pureText: "純粋なテキスト",
pureVector: "純粋なベクトル",
enableQueryExpansion: "クエリ拡張を有効にする",
queryExpansionDesc: "複数のクエリバリアントを生成してカバレッジを向上させます",
enableHyDE: "HyDEを有効にする",
hydeDesc: "仮想的な回答を生成してセマンティック検索を改善します",
enableReranking: "リランクを有効にする",
rerankingDesc: "リランクモデルを使用して結果を再ソートします",
broad: "広範",
strict: "厳格",
maxInput: "最大入力",
dimensions: "次元",
defaultBadge: "デフォルト",
dims: "次元: $1",
ctx: "コンテキスト: $1",
baseApi: "Base API: $1",
configured: "設定済み",
groupUpdated: "グループが更新されました",
groupDeleted: "グループが削除されました",
groupCreated: "グループが作成されました",
navCatalog: "カタログ",
allDocuments: "すべてのドキュメント",
categories: "カテゴリ",
uncategorizedFiles: "未分類ファイル",
category: "カテゴリ",
statusReadyDesc: "インデックス済みで検索可能",
statusIndexingDesc: "ベクトルインデックスを作成中",
selectCategory: "カテゴリを選択",
noneUncategorized: "未分類ファイルなし",
previous: "前へ",
next: "次へ",
createCategory: "カテゴリを作成",
categoryDesc: "ナレッジカテゴリを説明します",
categoryName: "カテゴリ名",
createCategoryBtn: "今すぐ作成",
newGroup: "新規グループ",
noKnowledgeGroups: "ナレッジグループがまだありません",
createGroupDesc: "最初のナレッジグループを作成してドキュメントをアップロードしてください。",
noDescriptionProvided: "説明なし",
browseManageFiles: "このグループ内のファイルとメモを閲覧・管理します。",
filterGroupFiles: "名前でグループ内のファイルを検索...",
generalSettingsSubtitle: "アプリケーションの設定を管理します。",
userManagementSubtitle: "システムユーザー情報の表示と管理。",
modelManagementSubtitle: "グローバルなAIモデルを設定します。",
kbSettingsSubtitle: "インデックス作成とチャットパラメータの技術設定。",
tenantsSubtitle: "組織、メンバー、およびその権限を管理します。",
assessmentTemplates: "評価テンプレート",
assessmentTemplatesSubtitle: "評価生成テンプレートを管理および設定します。",
templateName: "テンプレート名",
keywords: "キーワード",
keywordsHint: "キーワードを入力してください(カンマ区切り)",
questionCount: "問題数",
questionsCountLabel: "問",
difficultyDistribution: "難易度分布",
style: "スタイル要件",
createTemplate: "テンプレートを作成",
editTemplate: "テンプレートを編集",
allNotes: "すべてのノート",
filterNotesPlaceholder: "ノートをフィルタリング...",
startWritingPlaceholder: "書き始める...",
previewHeader: "プレビュー",
noContentToPreview: "プレビューするコンテンツがありません",
hidePreview: "プレビューを非表示",
showPreview: "プレビューを表示",
directoryLabel: "ディレクトリ",
uncategorized: "未分類",
enterNamePlaceholder: "名前を入力...",
subFolderPlaceholder: "サブフォルダ...",
categoryCreated: "カテゴリが作成されました",
failedToCreateCategory: "カテゴリの作成に失敗しました",
failedToDeleteCategory: "カテゴリの削除に失敗しました",
confirmDeleteCategory: "このカテゴリを削除してもよろしいですか?",
"2d": "2d",
Authorization: "Authorization",
a: "a",
agentTitle: "エージェントセンター",
agentDesc: "複雑なタスクを支援する AI アシスタントを管理および実行します。",
createAgent: "エージェント作成",
searchAgent: "エージェントを検索...",
statusRunning: "実行中",
statusStopped: "停止中",
updatedAtPrefix: "最終更新日: ",
btnChat: "会話を開始",
// Agent Mock Data
assessmentTitle: "人材アセスメント",
assessmentDesc: "才能の可能性を発見し、育成するための専門的なアセスメントツールです。",
agent1Name: "データ分析エキスパート",
agent1Desc: "SQL とデータ視覚化に精通し、複雑なデータから洞察を抽出できます。",
agent2Name: "コードレビュー助手",
agent2Desc: "コードの品質を自動的にチェックし、リファクタリングの提案やパフォーマンス最適化案を提供します。",
agent3Name: "学術論文校閲",
agent3Desc: "専門的な学術ライティングアシスタント。論文の構成と表現の最適化を支援します。",
agent4Name: "法律顧問",
agent4Desc: "法律条文の検索や基本的な法的アドバイスを提供し、契約書の作成を支援します。",
agent5Name: "市場調査員",
agent5Desc: "業界のトレンドを分析し、競合他社の調査レポートを生成します。",
agent6Name: "システム運用保守エキスパート",
agent6Desc: "システムの健康状態を監視し、一般的なアラートへの対応やトラブルシューティングを自動化します。",
agent7Name: "財務監査人",
agent7Desc: "レポート監査を自動化し、財務リスクや異常な取引を特定します。",
agent1Time: "2 時間前",
agent2Time: "5 時間前",
agent3Time: "昨日",
agent4Time: "2 日前",
agent5Time: "3 日前",
agent6Time: "5 日前",
agent7Time: "1 週間前",
// Assessment View Keys
readyForAssessment: "アセスメント準備完了",
readyForAssessmentDesc: "ナレッジグループを選択して専門的な人材評価を開始",
startProfessionalEvaluation: "専門評価を開始",
aiPoweredAnalysis: "AI駆動分析",
masteryScoring: "習熟度スコアリング",
recentAssessments: "最近のアセスメント",
questionProgress: "質問 $1/$2",
initializingQuestion: "質問 $1 を初期化中...",
aiIsProcessing: "AI 処理中...",
typeAnswerPlaceholder: "回答を入力してください...",
liveFeedback: "ライブフィードバック",
currentScore: "現在のスコア",
aiExplanation: "AI説明",
masteryProgress: "習熟度進捗",
trackedInRealTime: "リアルタイム追跡",
submitAnswerToSeeFeedback: "回答を提出してフィードバックを表示",
assessmentGuide: "アセスメントガイド",
assessmentGuideDesc: "アセスメントプロセスと評価基準について学ぶ",
level: "レベル",
assessmentResultsAvailable: "アセスメント結果あり",
knowledgeCoverage: "知識カバレッジ",
precisionScore: "精度スコア",
questionBasis: "出題の根拠",
viewBasis: "根拠を表示",
hideBasis: "根拠を非表示",
verified: "検証済み",
fail: "失敗",
comprehensiveMasteryReport: "包括的習熟度レポート",
newAssessmentSession: "新しいアセスメントセッション",
downloadPdfReport: "PDFレポートをダウンロード",
statusGeneratingQuestions: "問題を生成中...",
statusEvaluatingAnswer: "回答を評価中...",
statusPreparingQuestion: "次の質問を準備中...",
statusGeneratingReport: "最終レポートを生成中...",
statusProcessing: "処理中...",
filesAvailable: "ファイル利用可能",
confirmDeleteAssessment: "この評価記録を削除してもよろしいですか?",
deleteAssessmentSuccess: "評価記録が正常に削除されました",
deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました',
view: '表示',
// Plugins
pluginTitle: "プラグインストア",
pluginDesc: "外部ツールやサービスを統合して、ナレッジベースの機能を拡張します。",
searchPlugin: "プラグインを検索...",
installPlugin: "インストール",
installedPlugin: "インストール済み",
updatePlugin: "アップデートあり",
pluginOfficial: "公式",
pluginCommunity: "コミュニティ",
pluginBy: "開発者: ",
pluginConfig: "設定",
pluginViewTitle: "プラグイン",
pluginViewDesc: "外部ツールやサードパーティプラットフォームを統合してシステムを拡張します。",
pluginStatusAvailable: "利用可能",
pluginConfigure: "設定する",
pluginFeishuName: "Feishu Bot",
pluginFeishuDesc: "AuraK ナレッジベースの Q&A 機能を Feishu に接続し、個人チャットとグループメッセージをサポートします。",
feishuAddBot: "Bot を追加",
feishuSetupGuide: "セットアップガイド",
feishuStep1: "Feishu オープンプラットフォームにログインし、カスタムアプリを作成します。",
feishuStep2: "アプリ設定から App ID と App Secret を取得します。",
feishuStep3: "以下のフォームに入力して保存すると、Webhook URL が生成されます。",
feishuStep4: "生成された Webhook URL をアプリのイベントサブスクリプション設定に貼り付けます。",
feishuDocs: "公式ドキュメントを確認",
feishuBotDisplayName: "Bot 表示名",
feishuBotNamePlaceholder: "例: AuraK アシスタント",
feishuTokenPlaceholder: "Feishu イベント検証 Token",
feishuEncryptKeyPlaceholder: "Feishu メッセージ暗号化 Key (省略可)",
feishuNoBots: "Bot がまだ登録されていません。右上のボタンから追加してください。",
feishuWebhookUrl: "Webhook コールバック URL",
feishuConfirmDelete: "この Bot の登録を削除してもよろしいですか?",
feishuEnableBot: "Bot を有効化",
feishuDisableBot: "Bot を無効化",
feishuWsMode: "長接続モード (WebSocket)",
feishuWsModeDesc: "公開ドメイン不要。サーバーが Feishu クラウドに接続します。",
feishuWsConnect: "長接続を有効化",
feishuWsDisconnect: "切断",
feishuWsStatus: "接続状態",
feishuWsConnected: "接続済み",
feishuWsConnecting: "接続中...",
feishuWsDisconnected: "未接続",
feishuWsError: "接続エラー",
feishuWsConnectHint: "有効にすると、Feishu イベントが WebSocket 経由でプッシュされます。Webhook コールバック URL は不要です。",
// Plugin Mock Data
plugin1Name: "Web 検索",
plugin1Desc: "最新情報を取得するために、AI にインターネットへのリアルタイムアクセスを提供します。",
plugin2Name: "PDF ドキュメント解析",
plugin2Desc: "複雑な PDF レイアウトを詳細に解析し、表や数式を抽出します。",
plugin3Name: "GitHub 連携",
plugin3Desc: "GitHub リポジトリに直接アクセスし、コードのコミットやイシュー管理を行います。",
plugin4Name: "Google カレンダー",
plugin4Desc: "スケジュールを同期し、会議のリマインダーを自動的に作成します。",
plugin5Name: "SQL データベース接続",
plugin5Desc: "データベースに安全に接続し、自然語言でクエリを実行します。",
plugin6Name: "Slack 通知",
plugin6Desc: "AI が生成したレポートを指定された Slack チャンネルに直接送信します。",
navTenants: "テナント管理",
noNotesFound: "ノートが見つかりません",
notebookDesc: "ノートブックは知識の整理と要約に役立ちます。",
personalNotebook: "個人用ノートブック",
warning: "警告",
"x-api-key": "APIキー",
"x-tenant-id": "テナントID",
"x-user-language": "ユーザー言語",
// Hierarchical categories new keys
addSubcategory: "サブカテゴリを追加",
parentCategory: "親カテゴリ(任意)",
noParentTopLevel: "なし(トップレベル)",
useHierarchyImport: "フォルダ階層でカテゴリを作成",
useHierarchyImportDesc: "有効にすると、各サブフォルダにサブカテゴリが作成され、ファイルが対応するカテゴリにインポートされます。",
// Folder import drawer
importImmediate: "今すぐインポート",
importScheduled: "スケジュールインポート",
lblServerPath: "サーバーフォルダパス",
placeholderServerPath: "例: /data/documents",
scheduledImportTip: "サーバー側のスケジュールインポート:指定した時刻にサーバーがパスのファイルを読み込んで自動インポートします。サーバーがそのパスにアクセスできることを確認してください。",
lblScheduledTime: "実行日時",
scheduledTimeHint: "指定した時刻に、サーバーが自動的にインポートタスクを実行します。",
scheduleImport: "スケジュールタスクを作成",
scheduleTaskCreated: "スケジュールインポートタスクが作成されました",
fillServerPath: "サーバーフォルダパスを入力してください",
invalidDateTime: "有効な日付と時刻を入力してください",
// Import Tasks Drawer
importTasksTitle: "インポートタスク",
noTasksFound: "タスクは見つかりませんでした",
sourcePath: "ソースパス",
targetGroup: "ターゲットグループ",
scheduledAt: "実行予定日時",
confirmDeleteTask: "このインポートタスクレコードを削除してもよろしいですか?",
deleteTaskFailed: "タスクレコードの削除に失敗しました",
noOrganization: "組織なし",
},
};
+17
View File
@@ -0,0 +1,17 @@
/**
* Utility function to generate a UUID.
* A wrapper for crypto.randomUUID() that provides a fallback for non-secure contexts (HTTP, older browsers, etc.).
*/
export const generateUUID = (): string => {
// Check if crypto.randomUUID is available (only available in secure contexts like HTTPS/localhost)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback for non-secure contexts or older browsers (RFC4122 v4 compatible)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
+39
View File
@@ -0,0 +1,39 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: Number(env.VITE_PORT) || 13001,
host: env.VITE_HOST || '0.0.0.0',
proxy: {
'/api': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
changeOrigin: true,
},
},
},
plugins: [tailwindcss() as any, react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
optimizeDeps: {
exclude: ['tailwindcss']
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
},
publicDir: 'public'
};
});
// touch