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:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
build
|
||||
*.log
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
coverage
|
||||
.nyc_output
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"deviceId": "ed82e87006e058c331c9aa377c98736db271e2f339d3613c715eb07cf69358fc"
|
||||
}
|
||||
+11
@@ -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 { };
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
“{source.content}”
|
||||
</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')} →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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} < {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;
|
||||
@@ -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} < {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;
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-[#FCFDFF] text-slate-800 antialiased;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "簡易ナレッジベース",
|
||||
"description": "大規模モデルの長いコンテキストウィンドウを活用したRAGスタイルのQAシステムです。PDF、ドキュメント、画像、またはテキストファイルをアップロードし、正確な引用付きで質問に回答できます。",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
@@ -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
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}`;
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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'],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// web/utils/constants.ts
|
||||
//@ts-ignore
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
@@ -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]}`;
|
||||
};
|
||||
@@ -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: "組織なし",
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user