Update 2026-05-13 16:43:53

This commit is contained in:
yi
2026-05-13 16:43:53 +08:00
parent 6af5c584f4
commit afd7c5fe85
490 changed files with 850 additions and 922 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# Frontend Dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦞</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>全源灵动</title>
<script>
try {
const storedTheme = localStorage.getItem('theme-storage');
if (storedTheme) {
const { state } = JSON.parse(storedTheme);
if (state.theme === 'dark') {
document.documentElement.classList.add('dark');
}
}
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
# conf.d/*.conf is included inside http { } — map is valid here.
# 不可对所有请求强行 Connection: upgrade,否则部分客户端/流式 POST 可能被错误处理。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name localhost;
client_max_body_size 100m;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://dataclaw-api:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /reports/ {
proxy_pass http://dataclaw-api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /agent-core/ {
proxy_pass http://dataclaw-api:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+11793
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/postcss": "^4.2.1",
"@types/dagre": "^0.7.54",
"@types/react-grid-layout": "^1.3.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.10.1",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"i18next": "^25.9.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.8",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"react-syntax-highlighter": "^16.1.1",
"react-vega": "^8.0.0",
"recharts": "^3.8.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.0.6",
"sql-formatter": "^15.7.2",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vega": "^6.2.0",
"vega-lite": "^6.4.2",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "~5.6.3",
"typescript-eslint": "^8.56.1",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+180
View File
@@ -0,0 +1,180 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Sidebar } from "./components/Sidebar";
import { ProjectSwitcher } from "./components/ProjectSwitcher";
import { ChatInterface } from "./components/ChatInterface";
import { Dashboard } from "./pages/Dashboard";
import { Skills } from "./pages/Skills";
import { Settings } from "./pages/Settings";
import { Users } from "./pages/Users";
import { Projects } from "./pages/Projects";
import { Login } from "./pages/Login";
import { ModelConfigs } from "./pages/ModelConfigs";
import { EmbeddingModels } from "./pages/EmbeddingModels";
import { KnowledgeBases } from "./pages/KnowledgeBases";
import { DataSources } from "./pages/DataSources";
import { Modeling } from "./pages/Modeling";
import { Subagents } from "./pages/Subagents";
import { WebSearchConfig } from "./pages/WebSearchConfig";
import { VerifyEmail } from "./pages/VerifyEmail";
import { useAuthStore } from "./store/authStore";
import { ThemeToggle } from "./components/ThemeToggle";
// Protected Route Component
function ProtectedRoute({ children, requireAdmin = false }: { children: React.ReactNode, requireAdmin?: boolean }) {
const { isAuthenticated, user } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requireAdmin && !user?.is_admin) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
function MainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden">
<Sidebar />
<main className="flex-1 flex flex-col overflow-hidden h-screen">
<div className="h-14 shrink-0 flex items-center justify-between z-30 px-4">
<div className="flex-1">
{/* Left side empty for balance */}
</div>
<div className="flex-1 flex justify-center">
<ProjectSwitcher />
</div>
<div className="flex-1 flex justify-end">
<ThemeToggle />
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{children}
</div>
</main>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/verify-email" element={<VerifyEmail />} />
{/* Protected Routes */}
<Route path="/" element={
<ProtectedRoute>
<MainLayout>
<div className="h-full overflow-hidden bg-background">
<ChatInterface />
</div>
</MainLayout>
</ProtectedRoute>
} />
<Route path="/dashboard" element={
<ProtectedRoute>
<MainLayout>
<Dashboard />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/skills" element={
<ProtectedRoute>
<MainLayout>
<Skills />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute>
<MainLayout>
<Settings />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/projects" element={
<ProtectedRoute>
<MainLayout>
<Projects />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/projects/:projectId/subagents" element={
<ProtectedRoute>
<MainLayout>
<Subagents />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute requireAdmin={true}>
<MainLayout>
<Users />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/model-configs" element={
<ProtectedRoute>
<MainLayout>
<ModelConfigs />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/embedding-models" element={
<ProtectedRoute>
<MainLayout>
<EmbeddingModels />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/web-search-config" element={
<ProtectedRoute requireAdmin={true}>
<MainLayout>
<WebSearchConfig />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/knowledge-bases" element={
<ProtectedRoute>
<MainLayout>
<KnowledgeBases />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/datasources" element={
<ProtectedRoute requireAdmin={true}>
<MainLayout>
<DataSources />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/modeling/:id" element={
<ProtectedRoute requireAdmin={true}>
<MainLayout>
<Modeling />
</MainLayout>
</ProtectedRoute>
} />
</Routes>
</BrowserRouter>
);
}
export default App;
+405
View File
@@ -0,0 +1,405 @@
import { api } from "@/lib/api";
export interface A2APartText {
kind: "text";
text: string;
}
export interface A2APartUrl {
kind: "url";
url: string;
}
export interface A2APartFile {
kind: "file";
data: string;
mediaType?: string;
filename?: string;
}
export type A2APart = A2APartText | A2APartUrl | A2APartFile;
export interface A2AMessage {
messageId?: string;
contextId?: string;
taskId?: string;
role: "user" | "agent" | "system";
parts: A2APart[];
extensions?: Record<string, unknown>[];
referenceTaskIds?: string[];
}
export interface A2AArtifact {
artifactId?: string;
name?: string;
description?: string;
parts: A2APart[];
metadata?: Record<string, unknown>;
extensions?: Record<string, unknown>[];
}
export interface A2ATask {
id: string;
project_id?: number;
context_id?: string;
source: string;
state: string;
remote_agent_id?: number | null;
input_text: string;
input_parts?: A2APart[];
output_text?: string | null;
output_parts?: A2APart[];
error_message?: string | null;
compatibility_mode: boolean;
metadata: Record<string, unknown>;
artifacts?: A2AArtifact[];
history?: A2AMessage[];
history_length?: number;
created_at: string;
updated_at: string;
finished_at?: string | null;
}
export interface A2AAgentCard {
id?: string;
name: string;
description?: string;
url?: string;
provider?: {
organization?: string;
url?: string;
};
skills?: Array<{
id?: string;
name: string;
description?: string;
tags?: string[];
examples?: string[];
inputModes?: string[];
outputModes?: string[];
securityRequirements?: Array<Record<string, unknown>>;
}>;
supportedInterfaces?: Array<{
type: string;
url?: string;
protocolBinding?: string;
protocolVersion?: string;
tenant?: string;
}>;
defaultInputModes?: string[];
defaultOutputModes?: string[];
securitySchemes?: Record<string, unknown>;
security?: Array<Record<string, unknown>>;
signatures?: string[];
iconUrl?: string;
documentationUrl?: string;
}
export interface A2ARemoteAgent {
id: number;
project_id: number;
name: string;
base_url: string;
auth_scheme: "none" | "bearer";
protocol_version?: string | null;
capabilities: string[];
healthy: boolean;
failure_count: number;
circuit_open_until?: string | null;
card_fetched_at?: string | null;
agent_card?: A2AAgentCard;
}
export interface A2ASendMessagePayload {
project_id: number;
message: A2AMessage;
session_id?: string;
remote_agent_id?: number;
route_mode?: "auto" | "local" | "a2a" | "a2a_first" | "local_first" | "mcp_first";
fallback_chain?: Array<"a2a" | "local" | "mcp">;
idempotency_key?: string;
metadata?: Record<string, unknown>;
}
export interface A2ASendMessageResponse {
task: A2ATask;
routing?: {
selected?: string;
fallback_chain?: string[];
canary_hit?: boolean;
reason?: string;
};
}
export interface A2ASubscribeEvent {
type?: string;
event?: string;
task_id?: string;
context_id?: string;
task_status?: string;
status?: string;
artifact?: A2AArtifact;
append?: boolean;
last_chunk?: boolean;
message?: A2AMessage;
output?: string;
source?: string;
timestamp?: string;
}
type SubscribeHandler = (event: A2ASubscribeEvent) => void;
const parseSseEvents = (chunk: string): A2ASubscribeEvent[] => {
const blocks = chunk.split("\n\n");
const events: A2ASubscribeEvent[] = [];
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.split("\n");
const dataLine = lines.find((line) => line.startsWith("data:"));
if (!dataLine) continue;
const raw = dataLine.slice(5).trim();
if (!raw) continue;
try {
const parsed = JSON.parse(raw) as A2ASubscribeEvent;
events.push(parsed);
} catch {
continue;
}
}
return events;
};
const getAuthHeaders = (): Record<string, string> => {
const token = localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : {};
};
export const a2aApi = {
listRemoteAgents(projectId: number) {
return api.get<A2ARemoteAgent[]>(`/api/v1/a2a/remote-agents?project_id=${projectId}`);
},
createRemoteAgent(payload: {
project_id: number;
name: string;
base_url: string;
auth_scheme: "none" | "bearer";
auth_token?: string;
}) {
return api.post<A2ARemoteAgent>("/api/v1/a2a/remote-agents", payload);
},
updateRemoteAgent(agentId: number, payload: {
name?: string;
base_url?: string;
auth_scheme?: "none" | "bearer";
auth_token?: string;
}) {
return api.put<A2ARemoteAgent>(`/api/v1/a2a/remote-agents/${agentId}`, payload);
},
deleteRemoteAgent(agentId: number) {
return api.delete<{ status: string }>(`/api/v1/a2a/remote-agents/${agentId}`);
},
refreshRemoteAgentCard(agentId: number) {
return api.post<A2ARemoteAgent>(`/api/v1/a2a/remote-agents/${agentId}/refresh-card`, {});
},
healthCheckRemoteAgent(agentId: number) {
return api.post<{ healthy: boolean; failure_count: number }>(`/api/v1/a2a/remote-agents/${agentId}/health-check`, {});
},
listTasks(projectId: number, state?: string, contextId?: string) {
const params = new URLSearchParams({ project_id: String(projectId), limit: "100" });
if (state && state !== "all") {
params.set("state", state);
}
if (contextId) {
params.set("context_id", contextId);
}
return api.get<A2ATask[]>(`/api/v1/a2a/tasks?${params.toString()}`);
},
getTask(taskId: string, historyLength?: number) {
const params = new URLSearchParams();
if (historyLength !== undefined) {
params.set("historyLength", String(historyLength));
}
const queryString = params.toString();
return api.get<A2ATask>(`/api/v1/a2a/tasks/${taskId}${queryString ? `?${queryString}` : ""}`);
},
cancelTask(taskId: string) {
return api.post<{ task_id: string; state: string }>(`/api/v1/a2a/tasks/${taskId}:cancel`, {});
},
sendMessage(payload: A2ASendMessagePayload) {
return api.post<A2ASendMessageResponse>("/api/v1/a2a/message:send", payload);
},
streamMessage(payload: A2ASendMessagePayload) {
return api.post<A2ASendMessageResponse>("/api/v1/a2a/message:stream", payload);
},
subscribeTask(taskId: string, onEvent: SubscribeHandler, signal?: AbortSignal): () => void {
const controller = new AbortController();
void (async () => {
const response = await fetch(`/api/v1/a2a/tasks/${taskId}:subscribe`, {
method: "GET",
headers: {
...getAuthHeaders(),
},
signal: signal || controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Subscribe failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const splitIndex = buffer.lastIndexOf("\n\n");
if (splitIndex === -1) continue;
const complete = buffer.slice(0, splitIndex);
buffer = buffer.slice(splitIndex + 2);
const events = parseSseEvents(complete);
for (const event of events) {
onEvent(event);
}
}
if (buffer.trim()) {
const events = parseSseEvents(buffer);
for (const event of events) {
onEvent(event);
}
}
})();
return () => controller.abort();
},
subscribeTaskSSE(taskId: string, onEvent: SubscribeHandler, signal?: AbortSignal): () => void {
const controller = new AbortController();
void (async () => {
const response = await fetch(`/api/v1/a2a/tasks/${taskId}/subscribe`, {
method: "GET",
headers: {
...getAuthHeaders(),
},
signal: signal || controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Subscribe failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const splitIndex = buffer.lastIndexOf("\n\n");
if (splitIndex === -1) continue;
const complete = buffer.slice(0, splitIndex);
buffer = buffer.slice(splitIndex + 2);
const events = parseSseEvents(complete);
for (const event of events) {
onEvent(event);
}
}
if (buffer.trim()) {
const events = parseSseEvents(buffer);
for (const event of events) {
onEvent(event);
}
}
})();
return () => controller.abort();
},
};
export function renderPart(part: A2APart): string {
switch (part.kind) {
case "text":
return part.text;
case "url":
return `[URL: ${part.url}]`;
case "file":
if (part.mediaType?.startsWith("image/")) {
return `[Image: ${part.filename || "image"}]`;
}
if (part.mediaType?.includes("json")) {
try {
const decoded = atob(part.data);
return `[JSON File: ${part.filename || "data.json"}]\n${decoded}`;
} catch {
return `[Binary File: ${part.filename || "data"}]`;
}
}
return `[File: ${part.filename || "file"}]`;
default:
return "[Unknown Part]";
}
}
export function renderParts(parts: A2APart[]): string {
return parts.map(renderPart).join("\n");
}
export function extractTextFromParts(parts: A2APart[]): string {
return parts
.filter((p): p is A2APartText => p.kind === "text")
.map((p) => p.text)
.join("\n");
}
export function getArtifactPreview(artifact: A2AArtifact): { type: "text" | "image" | "html" | "json" | "unknown"; content: string } {
if (!artifact.parts || artifact.parts.length === 0) {
return { type: "unknown", content: "" };
}
const firstPart = artifact.parts[0];
if (firstPart.kind === "text") {
return { type: "text", content: firstPart.text };
}
if (firstPart.kind === "url") {
const url = firstPart.url.toLowerCase();
if (url.endsWith(".png") || url.endsWith(".jpg") || url.endsWith(".jpeg") || url.endsWith(".gif") || url.endsWith(".webp")) {
return { type: "image", content: firstPart.url };
}
if (url.endsWith(".html") || url.endsWith(".htm")) {
return { type: "html", content: firstPart.url };
}
return { type: "unknown", content: firstPart.url };
}
if (firstPart.kind === "file") {
const mediaType = firstPart.mediaType || "";
if (mediaType.startsWith("image/")) {
return { type: "image", content: `data:${mediaType};base64,${firstPart.data}` };
}
if (mediaType.includes("html")) {
try {
const decoded = atob(firstPart.data);
return { type: "html", content: decoded };
} catch {
return { type: "unknown", content: "[HTML content]" };
}
}
if (mediaType.includes("json")) {
try {
const decoded = atob(firstPart.data);
return { type: "json", content: decoded };
} catch {
return { type: "unknown", content: "[JSON content]" };
}
}
return { type: "unknown", content: `[File: ${firstPart.filename || "file"}]` };
}
return { type: "unknown", content: "" };
}
export function groupTasksByContextId(tasks: A2ATask[]): Map<string, A2ATask[]> {
const grouped = new Map<string, A2ATask[]>();
for (const task of tasks) {
const contextId = task.context_id || "no-context";
const existing = grouped.get(contextId) || [];
existing.push(task);
grouped.set(contextId, existing);
}
return grouped;
}
+51
View File
@@ -0,0 +1,51 @@
import axios from 'axios';
const API_BASE_URL = '/api/v1/projects';
// Add interceptor to include token
const axiosInstance = axios.create();
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export interface Subagent {
id: string;
name: string;
description: string;
model: string;
instructions: string;
status: string;
projectId: string;
createdAt?: string;
}
export const subagentApi = {
list: async (projectId: string) => {
const response = await axiosInstance.get<Subagent[]>(`${API_BASE_URL}/${projectId}/subagents`);
return response.data;
},
get: async (_projectId: string, id: string) => {
const response = await axiosInstance.get<Subagent>(`/api/v1/subagents/${id}`);
return response.data;
},
create: async (projectId: string, data: Partial<Subagent>) => {
const response = await axiosInstance.post<Subagent>(`${API_BASE_URL}/${projectId}/subagents`, data);
return response.data;
},
update: async (_projectId: string, id: string, data: Partial<Subagent>) => {
const response = await axiosInstance.put<Subagent>(`/api/v1/subagents/${id}`, data);
return response.data;
},
delete: async (_projectId: string, id: string) => {
const response = await axiosInstance.delete(`/api/v1/subagents/${id}`);
return response.data;
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,257 @@
import { useState, useEffect, useMemo } from "react";
import { Code2, Eye, X, Download, Copy, ExternalLink, Check, ChevronDown, FileIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { cn } from "@/lib/utils";
interface ArtifactPreviewTarget {
name: string;
mimeType: string;
previewUrl: string;
downloadUrl: string;
}
interface ArtifactPanelProps {
artifact: ArtifactPreviewTarget;
onClose: () => void;
}
export function ArtifactPanel({ artifact, onClose }: ArtifactPanelProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'code' | 'preview' | 'fallback'>('preview');
const [code, setCode] = useState<string>('');
const [loadingCode, setLoadingCode] = useState(false);
const [copied, setCopied] = useState(false);
const { canPreview, canCode, isMarkdown } = useMemo(() => {
const extension = artifact.name.split('.').pop()?.toLowerCase() || '';
const textExtensions = ['py', 'js', 'ts', 'jsx', 'tsx', 'json', 'csv', 'md', 'txt', 'css', 'html', 'xml', 'yaml', 'yml', 'sql', 'sh', 'bat'];
const isTextExt = textExtensions.includes(extension);
const isHtmlExt = extension === 'html' || extension === 'htm';
const isMd = extension === 'md' || artifact.mimeType === 'text/markdown';
const isImage = artifact.mimeType.startsWith('image/');
const isPdf = artifact.mimeType === 'application/pdf' || extension === 'pdf';
const isHtml = artifact.mimeType === 'text/html' || isHtmlExt;
const isText = artifact.mimeType.startsWith('text/') ||
["application/json", "application/javascript", "application/xml", "application/sql", "application/x-sh"].includes(artifact.mimeType) ||
isTextExt;
return {
canPreview: isImage || isPdf || isHtml || isMd,
canCode: isText || isHtml || isMd,
isMarkdown: isMd
};
}, [artifact.mimeType, artifact.name]);
useEffect(() => {
// Reset state when artifact changes
setCode('');
if (canPreview) {
setActiveTab('preview');
} else if (canCode) {
setActiveTab('code');
} else {
setActiveTab('fallback');
}
}, [artifact, canPreview, canCode]);
useEffect(() => {
// Need to fetch code for both 'code' view and markdown 'preview' view
const needsCodeFetch = (activeTab === 'code' || (activeTab === 'preview' && isMarkdown)) && !code && artifact.downloadUrl;
if (needsCodeFetch) {
setLoadingCode(true);
fetch(artifact.downloadUrl)
.then(res => res.text())
.then(text => {
setCode(text);
setLoadingCode(false);
})
.catch(err => {
console.error("Failed to fetch code", err);
setCode("Failed to load code.");
setLoadingCode(false);
});
}
}, [activeTab, artifact.downloadUrl, code, isMarkdown]);
const handleCopy = async () => {
if (code) {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
}
};
const showToggle = canPreview && canCode;
return (
<div className="h-full flex flex-col bg-background border-l border-border shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-background z-10">
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-sm truncate">{artifact.name}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
{showToggle && (
<div className="flex items-center bg-muted/50 rounded-lg p-0.5 ml-4">
<button
onClick={() => setActiveTab('code')}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all",
activeTab === 'code'
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
<Code2 className="h-4 w-4" />
</button>
<button
onClick={() => setActiveTab('preview')}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all",
activeTab === 'preview'
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
<Eye className="h-4 w-4" />
</button>
</div>
)}
<div className="flex items-center gap-1 ml-auto">
{activeTab === 'code' && (
<button
onClick={handleCopy}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
title={t('copy', 'Copy')}
>
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
</button>
)}
<a
href={artifact.previewUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
title={t('openInNewTab', 'Open in new tab')}
>
<ExternalLink className="h-4 w-4" />
</a>
<a
href={artifact.downloadUrl}
download={artifact.name}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
title={t('download', 'Download')}
>
<Download className="h-4 w-4" />
</a>
<div className="w-px h-4 bg-border mx-1" />
<button
onClick={onClose}
className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
title={t('close', 'Close')}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 min-h-0 relative bg-zinc-950">
{activeTab === 'fallback' ? (
<div className="w-full h-full bg-background flex flex-col items-center justify-center p-6 text-center">
<div className="h-16 w-16 bg-muted rounded-full flex items-center justify-center mb-4">
<FileIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('noPreviewAvailable', 'No preview available')}
</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
{t('noPreviewDesc', 'This file type cannot be previewed in the browser. Please download the file to view its contents.')}
</p>
<a
href={artifact.downloadUrl}
download={artifact.name}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg text-sm font-medium transition-colors"
>
<Download className="h-4 w-4" />
{t('downloadFile', 'Download File')}
</a>
</div>
) : activeTab === 'preview' ? (
<div className="w-full h-full bg-white overflow-auto">
{isMarkdown ? (
<div className="prose prose-sm max-w-none p-6">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{code || "Loading..."}
</ReactMarkdown>
</div>
) : artifact.mimeType.startsWith("image/") ? (
<img
src={artifact.previewUrl}
alt={artifact.name}
className="w-full h-full object-contain bg-muted/50"
/>
) : (
<iframe
title={artifact.name}
src={artifact.previewUrl}
className="w-full h-full border-0"
sandbox="allow-same-origin allow-scripts"
onLoad={(e) => {
try {
const doc = (e.target as HTMLIFrameElement).contentDocument;
if (doc) {
const style = doc.createElement('style');
style.textContent = `html, body { overflow: auto !important; }`;
doc.head.appendChild(style);
}
} catch (err) {
console.error("Failed to inject styles into iframe", err);
}
}}
/>
)}
</div>
) : (
<div className="w-full h-full overflow-auto text-[13px]">
{loadingCode ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
Loading code...
</div>
</div>
) : (
<SyntaxHighlighter
language={artifact.name.split('.').pop() || 'text'}
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1rem',
background: 'transparent',
minHeight: '100%'
}}
showLineNumbers
>
{code}
</SyntaxHighlighter>
)}
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Check, AlertTriangle, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api } from "@/lib/api";
export interface DataSourceConfig {
id?: number;
name: string;
type: string;
config: Record<string, any>;
}
interface DataSourceFormProps {
initialData?: DataSourceConfig | null;
onSubmit: (data: Omit<DataSourceConfig, "id">) => Promise<void>;
onTest: (type: string, config: Record<string, any>) => Promise<boolean>;
onCancel: () => void;
}
export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: DataSourceFormProps) {
const { t } = useTranslation();
const [name, setName] = useState(initialData?.name || "");
const [type, setType] = useState(initialData?.type || "postgres");
const [config, setConfig] = useState<Record<string, any>>(initialData?.config || {});
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleConfigChange = (key: string, value: any) => {
setConfig(prev => ({ ...prev, [key]: value }));
};
const handleFileSelect = () => {
fileInputRef.current?.click();
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
try {
// @ts-ignore
const res = await api.post("/api/v1/upload/file", formData);
if (res && (res as any).url) {
handleConfigChange("file_path", (res as any).url);
}
} catch (error) {
console.error("Upload failed", error);
alert(t('uploadFailed'));
} finally {
setIsUploading(false);
// Clear input value so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleTest = async () => {
setIsTesting(true);
setTestResult(null);
try {
const success = await onTest(type, config);
setTestResult({
success,
message: success ? t('connectionSuccess') : t('connectionFailed'),
});
} catch (e: any) {
setTestResult({
success: false,
message: e.message || t('connectionFailed'),
});
} finally {
setIsTesting(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
await onSubmit({ name, type, config });
} finally {
setIsSaving(false);
}
};
const renderConfigFields = () => {
switch (type) {
case "supabase":
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Host</label>
<Input
value={config.host || ""}
onChange={e => handleConfigChange("host", e.target.value)}
placeholder="aws-0-[region].pooler.supabase.com"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Port</label>
<Input
type="number"
value={config.port || 6543}
onChange={e => handleConfigChange("port", parseInt(e.target.value))}
placeholder="6543"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Database</label>
<Input
value={config.database || "postgres"}
onChange={e => handleConfigChange("database", e.target.value)}
placeholder="postgres"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<Input
value={config.user || ""}
onChange={e => handleConfigChange("user", e.target.value)}
placeholder="postgres.[project-ref]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Password</label>
<Input
type="password"
value={config.password || ""}
onChange={e => handleConfigChange("password", e.target.value)}
placeholder="••••••"
/>
</div>
<div className="text-xs text-muted-foreground pt-2">
{t('orUseSupabaseConnectionString')}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Connection String</label>
<Input
value={config.connection_string || ""}
onChange={e => handleConfigChange("connection_string", e.target.value)}
placeholder="postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?sslmode=require"
/>
</div>
</div>
);
case "postgres":
case "postgresql":
case "mysql":
case "sqlserver":
case "oracle":
case "redshift":
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Host</label>
<Input
value={config.host || ""}
onChange={e => handleConfigChange("host", e.target.value)}
placeholder="localhost"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Port</label>
<Input
type="number"
value={config.port || (type === "postgres" ? 5432 : type === "mysql" ? 3306 : 5432)}
onChange={e => handleConfigChange("port", parseInt(e.target.value))}
placeholder={type === "postgres" ? "5432" : type === "mysql" ? "3306" : "5432"}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Database</label>
<Input
value={config.database || ""}
onChange={e => handleConfigChange("database", e.target.value)}
placeholder="database_name"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<Input
value={config.user || ""}
onChange={e => handleConfigChange("user", e.target.value)}
placeholder="username"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Password</label>
<Input
type="password"
value={config.password || ""}
onChange={e => handleConfigChange("password", e.target.value)}
placeholder="••••••"
/>
</div>
<div className="text-xs text-muted-foreground pt-2">
{t('orUseConnectionString')}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Connection String</label>
<Input
value={config.connection_string || ""}
onChange={e => handleConfigChange("connection_string", e.target.value)}
placeholder={type === "postgres" ? "postgresql://user:pass@host:5432/db" : "mysql://user:pass@host:3306/db"}
/>
</div>
</div>
);
case "clickhouse":
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Host</label>
<Input
value={config.host || ""}
onChange={e => handleConfigChange("host", e.target.value)}
placeholder="localhost"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Port</label>
<Input
type="number"
value={config.port || 9000}
onChange={e => handleConfigChange("port", parseInt(e.target.value))}
placeholder="9000"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Database</label>
<Input
value={config.database || ""}
onChange={e => handleConfigChange("database", e.target.value)}
placeholder="default"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<Input
value={config.user || ""}
onChange={e => handleConfigChange("user", e.target.value)}
placeholder="default"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Password</label>
<Input
type="password"
value={config.password || ""}
onChange={e => handleConfigChange("password", e.target.value)}
placeholder="••••••"
/>
</div>
</div>
);
case "sqlite":
case "parquet":
case "csv":
return (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('fileUpload')}</label>
<div className="flex gap-2">
<Input
value={config.file_path || ""}
onChange={e => handleConfigChange("file_path", e.target.value)}
placeholder="/path/to/file"
/>
<Button type="button" variant="outline" onClick={handleFileSelect} disabled={isUploading}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
</Button>
<input
key={`${type}-input`}
type="file"
ref={fileInputRef}
className="hidden"
accept={type === "sqlite" ? ".db,.sqlite,.sqlite3" : type === "parquet" ? ".parquet" : ".csv"}
onChange={handleFileUpload}
/>
</div>
<p className="text-xs text-muted-foreground">{t('uploadFileOrEnterPath')}</p>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertTriangle className="h-10 w-10 text-amber-500 mb-3" />
<h3 className="font-medium text-foreground">{t('unsupportedDataSourceType')}</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-[300px]">
{t('dataSourceConnectorInDevelopment')}
</p>
</div>
);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium">{t('name')}</label>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('myDataSource')}
required
/>
</div>
{!initialData?.type && (
<div className="space-y-2">
<label className="text-sm font-medium">{t('type')}</label>
<select
className="w-full h-10 px-3 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
value={type}
onChange={e => setType(e.target.value)}
>
<option value="postgres">PostgreSQL</option>
<option value="clickhouse">ClickHouse</option>
<option value="sqlite">SQLite</option>
<option value="supabase">Supabase</option>
<option value="parquet">Parquet</option>
<option value="mysql">MySQL</option>
<option value="csv">CSV</option>
</select>
</div>
)}
<div className="p-4 border border-border rounded-lg bg-muted/50/50">
{renderConfigFields()}
</div>
{testResult && (
<div className={`p-3 rounded-md flex items-center gap-2 text-sm ${testResult.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{testResult.success ? <Check className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
{testResult.message}
</div>
)}
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
{t('cancel')}
</Button>
<Button
type="button"
variant="secondary"
onClick={handleTest}
disabled={isTesting}
>
{isTesting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('testConnection')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save')}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,47 @@
import { Component, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
type ErrorBoundaryProps = {
children: ReactNode;
} & WithTranslation;
type ErrorBoundaryState = {
hasError: boolean;
message: string;
};
class ErrorBoundaryComponent extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = {
hasError: false,
message: "",
};
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
message: error.message || "Unknown error",
};
}
componentDidCatch(error: Error) {
console.error(error);
}
render() {
const { t } = this.props;
if (this.state.hasError) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-background text-foreground p-6">
<div className="max-w-lg text-center">
<h1 className="text-xl font-semibold mb-2">{t('pageRenderFailed')}</h1>
<p className="text-sm text-muted-foreground break-words">{this.state.message}</p>
</div>
</div>
);
}
return this.props.children;
}
}
export const ErrorBoundary = withTranslation()(ErrorBoundaryComponent);
@@ -0,0 +1,261 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard, Copy, Check } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
import { useProjectStore } from "@/store/projectStore";
import { useTranslation } from "react-i18next";
import type { ChartSpec } from "@/store/visualizationStore";
import { VegaChart } from "./VegaChart";
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { format } from 'sql-formatter';
interface InlineVisualizationCardProps {
viz: {
sql: string;
rows: unknown[];
chartSpec: ChartSpec | null;
canVisualize: boolean;
reasoning?: string;
error?: string | null;
};
}
export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
const { t } = useTranslation();
const [view, setView] = useState<'table' | 'chart'>('chart');
const [confirmOpen, setConfirmOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>('');
const { dashboards, addChart, loadDashboards } = useDashboardStore();
const { currentProject } = useProjectStore();
const objectRows = viz.rows.filter((row) => row && typeof row === "object" && !Array.isArray(row)) as Record<string, unknown>[];
const columns = objectRows.length > 0 ? Object.keys(objectRows[0]) : [];
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
useEffect(() => {
if (dashboards.length > 0 && !selectedDashboardId) {
setSelectedDashboardId(dashboards[0].id);
}
}, [dashboards, selectedDashboardId]);
const buildPendingChart = (): Omit<ChartConfig, 'layout'> => {
if (view === "table") {
return {
id: Date.now().toString(),
title: viz.chartSpec?.title || "Generated Analysis",
type: "table",
data: objectRows,
sql: viz.sql,
chartSpec: null,
};
}
const mark = viz.chartSpec?.mark;
const markType = typeof mark === "string" ? mark : mark?.type;
const dashboardType = markType === "line" ? "line" : "bar";
return {
id: Date.now().toString(),
title: viz.chartSpec?.title || "Generated Analysis",
type: dashboardType,
data: objectRows,
sql: viz.sql,
chartSpec: viz.chartSpec,
};
};
const handleAddToDashboard = () => {
if (!currentProject) return;
const chart = buildPendingChart();
setPendingChart(chart);
setConfirmOpen(true);
};
const handleConfirmAdd = () => {
if (!pendingChart || !currentProject || !selectedDashboardId) return;
addChart(pendingChart, selectedDashboardId, currentProject.id);
setConfirmOpen(false);
setPendingChart(null);
};
const handleCopySql = () => {
navigator.clipboard.writeText(viz.sql || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const formattedSql = viz.sql ? format(viz.sql, { language: 'postgresql' }) : "--";
if (viz.error) {
return <div className="text-sm text-red-500">{viz.error}</div>;
}
return (
<Card className="w-full border border-border shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-base">{viz.chartSpec?.title || t('visualizationResult')}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center justify-between mb-3">
<div className="flex bg-muted rounded-md p-1">
<Button
variant={view === "table" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setView("table")}
>
<TableIcon className="h-3.5 w-3.5 mr-1.5" />
Table
</Button>
<Button
variant={view === "chart" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setView("chart")}
>
<ChartIcon className="h-3.5 w-3.5 mr-1.5" />
Chart
</Button>
<Dialog>
<DialogTrigger render={
<Button variant="ghost" size="sm" className="h-7 px-3 text-xs">
<Code className="h-3.5 w-3.5 mr-1.5" />
SQL
</Button>
} />
<DialogContent className="sm:max-w-[700px]">
<DialogHeader className="flex flex-row items-start justify-between pr-8">
<div>
<DialogTitle>Generated SQL Query</DialogTitle>
<DialogDescription className="mt-1">{t('sqlQueryDescription')}</DialogDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 shrink-0"
onClick={handleCopySql}
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-emerald-500" />
<span>{t('copied')}</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
<span>{t('copy')}</span>
</>
)}
</Button>
</DialogHeader>
<div className="relative rounded-md overflow-hidden bg-[#1e1e1e] border border-border shadow-inner mt-2">
<ScrollArea className="max-h-[500px]">
<SyntaxHighlighter
language="sql"
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1.25rem',
fontSize: '0.875rem',
lineHeight: '1.5',
background: 'transparent',
}}
>
{formattedSql}
</SyntaxHighlighter>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={objectRows.length === 0 || dashboards.length === 0}>
<LayoutDashboard className="h-3.5 w-3.5 mr-1.5" />
Add to Dashboard
</Button>
</div>
</div>
{view === "chart" ? (
viz.chartSpec && objectRows.length > 0 ? (
<div className="w-full h-80 min-h-[320px] rounded-xl border border-border p-2">
<VegaChart data={objectRows} spec={viz.chartSpec} />
</div>
) : (
<div className="text-sm text-muted-foreground">{t('resultNotSuitableForChart')}</div>
)
) : objectRows.length > 0 ? (
<ScrollArea className="h-80 border rounded-md">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => <TableHead key={col}>{col}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{objectRows.map((row, i) => (
<TableRow key={i}>
{columns.map((col) => (
<TableCell key={`${i}-${col}`}>{String(row[col] ?? "")}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
) : (
<div className="text-sm text-muted-foreground">{t('noStructuredDataToRender')}</div>
)}
</CardContent>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('pinChartToDashboard')}</DialogTitle>
<DialogDescription>
{t('selectDashboardToPin')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium mb-2 block">{t('dashboardMenu')}</label>
<Select value={selectedDashboardId} onValueChange={(val) => { if (val) setSelectedDashboardId(val); }}>
<SelectTrigger>
<SelectValue placeholder={t('selectDashboard')}>
{dashboards.find(d => d.id === selectedDashboardId)?.name || t('selectDashboard')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{dashboards.map(d => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setConfirmOpen(false);
setPendingChart(null);
}}
>
{t('cancel')}
</Button>
<Button onClick={handleConfirmAdd} disabled={!selectedDashboardId}>
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Save, Loader2 } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useProjectStore } from "@/store/projectStore";
import { api } from "@/lib/api";
export interface KnowledgeBaseFormValues {
name: string;
description: string;
embedding_model: string;
chunk_size: number;
chunk_overlap: number;
top_k: number;
is_active: boolean;
}
export interface KnowledgeBaseFormProps {
initialData?: KnowledgeBaseFormValues | null;
onSubmit: (data: KnowledgeBaseFormValues) => Promise<void>;
onCancel: () => void;
isSubmitting?: boolean;
}
interface EmbeddingModelConfig {
id: string;
name?: string;
provider: string;
model: string;
}
const defaultFormValues: KnowledgeBaseFormValues = {
name: '',
description: '',
embedding_model: '',
chunk_size: 512,
chunk_overlap: 50,
top_k: 3,
is_active: true,
};
export function KnowledgeBaseForm({ initialData, onSubmit, onCancel, isSubmitting = false }: KnowledgeBaseFormProps) {
const { t } = useTranslation();
const { currentProject } = useProjectStore();
const [form, setForm] = useState<KnowledgeBaseFormValues>(initialData || defaultFormValues);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModelConfig[]>([]);
const [error, setError] = useState('');
useEffect(() => {
void fetchEmbeddingModels();
}, []);
const fetchEmbeddingModels = async () => {
try {
const data = await api.get<EmbeddingModelConfig[]>('/api/v1/embedding-models');
setEmbeddingModels(data);
} catch (err) {
console.error('Failed to fetch embedding models', err);
}
};
const validate = () => {
if (!currentProject) {
return t('selectProjectBeforeManageKnowledgeBase', 'Please select a project before managing knowledge bases');
}
if (!form.name.trim()) {
return t('knowledgeBaseNameRequired', 'Knowledge base name is required');
}
if (form.chunk_size < 64 || form.chunk_size > 4096) {
return t('knowledgeBaseChunkSizeRange', 'Chunk size must be between 64 and 4096');
}
if (form.chunk_overlap < 0 || form.chunk_overlap > 512) {
return t('knowledgeBaseChunkOverlapRange', 'Chunk overlap must be between 0 and 512');
}
if (form.chunk_overlap >= form.chunk_size) {
return t('knowledgeBaseChunkOverlapTooLarge', 'Chunk overlap must be less than chunk size');
}
if (form.top_k < 1 || form.top_k > 20) {
return t('knowledgeBaseTopKRange', 'Top K must be between 1 and 20');
}
return '';
};
const handleSubmit = async () => {
setError('');
const validationMessage = validate();
if (validationMessage) {
setError(validationMessage);
return;
}
try {
await onSubmit(form);
} catch (err: any) {
setError(err.message || t('knowledgeBaseSaveFailed', 'Failed to save knowledge base'));
}
};
const selectedEmbeddingModel = embeddingModels.find(m => m.id === form.embedding_model);
return (
<div className="space-y-6">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
{!currentProject ? (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-3">
{t('selectProjectBeforeManageKnowledgeBase')}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-name">{t('knowledgeBaseName')}</Label>
<Input
id="knowledge-base-name"
value={form.name}
placeholder={t('knowledgeBaseNamePlaceholder')}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
disabled={!currentProject || isSubmitting}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-description">{t('description')}</Label>
<Input
id="knowledge-base-description"
value={form.description}
placeholder={t('knowledgeBaseDescriptionPlaceholder')}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
disabled={!currentProject || isSubmitting}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="knowledge-base-embedding-model">{t('knowledgeBaseEmbeddingModel')}</Label>
<Select
value={form.embedding_model}
onValueChange={(val) => setForm((prev) => ({ ...prev, embedding_model: val || '' }))}
disabled={!currentProject || isSubmitting}
>
<SelectTrigger id="knowledge-base-embedding-model">
<SelectValue placeholder={t('knowledgeBaseEmbeddingModelPlaceholder')}>
{selectedEmbeddingModel
? `${selectedEmbeddingModel.name || selectedEmbeddingModel.model} (${selectedEmbeddingModel.provider})`
: form.embedding_model || undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{embeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.model} ({model.provider})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-chunk-size">{t('knowledgeBaseChunkSize')}</Label>
<Input
id="knowledge-base-chunk-size"
type="number"
value={form.chunk_size}
onChange={(e) => setForm((prev) => ({ ...prev, chunk_size: Number(e.target.value) || 0 }))}
disabled={!currentProject || isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-chunk-overlap">{t('knowledgeBaseChunkOverlap')}</Label>
<Input
id="knowledge-base-chunk-overlap"
type="number"
value={form.chunk_overlap}
onChange={(e) => setForm((prev) => ({ ...prev, chunk_overlap: Number(e.target.value) || 0 }))}
disabled={!currentProject || isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="knowledge-base-top-k">{t('knowledgeBaseTopK')}</Label>
<Input
id="knowledge-base-top-k"
type="number"
value={form.top_k}
onChange={(e) => setForm((prev) => ({ ...prev, top_k: Number(e.target.value) || 0 }))}
disabled={!currentProject || isSubmitting}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2 mt-7 md:col-span-2">
<Label htmlFor="knowledge-base-active">{t('activeStatus')}</Label>
<Switch
id="knowledge-base-active"
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))}
disabled={!currentProject || isSubmitting}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t border-border">
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t('cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!currentProject || isSubmitting} className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground">
{isSubmitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{t('save')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react';
import { ChevronDown, Plus, Folder, Check, Brain } from 'lucide-react';
import { useProjectStore } from '@/store/projectStore';
import { useTranslation } from "react-i18next";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuGroup,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
interface ModelConfig {
id: string;
name: string;
model: string;
provider: string;
is_active: boolean;
}
export function ProjectSwitcher() {
const { t } = useTranslation();
const { projects, currentProject, fetchProjects, setCurrentProject, addProject } = useProjectStore();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Model Selection State
const [models, setModels] = useState<ModelConfig[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [modelOpen, setModelOpen] = useState(false);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
useEffect(() => {
const fetchModels = async () => {
try {
const data = await api.get<ModelConfig[]>("/api/v1/llm");
setModels(data);
const active = data.find(m => m.is_active);
if (active) {
setSelectedModelId(active.id);
} else if (data.length > 0) {
setSelectedModelId(data[0].id);
}
} catch (e) {
console.error("Failed to fetch models", e);
}
};
fetchModels();
}, []);
const handleCreateProject = async () => {
if (!newProjectName.trim()) return;
setIsSubmitting(true);
try {
await addProject(newProjectName);
setNewProjectName('');
setIsCreateDialogOpen(false);
} catch (error) {
console.error('Failed to create project:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex items-center gap-2 bg-transparent h-10">
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 items-center gap-1 rounded-md px-2 font-semibold hover:bg-accent hover:text-accent-foreground outline-none transition-colors">
<Folder className="h-4 w-4 mr-1 text-blue-500" />
{currentProject?.name || 'Select Project'}
<ChevronDown className="h-4 w-4 opacity-50" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center justify-between">
PROJECTS
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsCreateDialogOpen(true);
}}
>
<Plus className="h-3 w-3" />
</Button>
</DropdownMenuLabel>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<div className="max-h-64 overflow-y-auto">
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => {
setCurrentProject(project);
}}
className={currentProject?.id === project.id ? 'bg-accent' : ''}
>
<Folder className="h-4 w-4 mr-2 text-muted-foreground" />
{project.name}
</DropdownMenuItem>
))}
{projects.length === 0 && (
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
No projects found
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<div className="h-4 w-px bg-border mx-1" />
<Popover open={modelOpen} onOpenChange={setModelOpen}>
<PopoverTrigger className="flex items-center gap-1 px-2 py-1 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors group">
<Brain className="h-4 w-4 mr-1 text-purple-500" />
<span className="font-semibold text-[14px]">
{selectedModelId ? (models.find(m => m.id === selectedModelId)?.name || models.find(m => m.id === selectedModelId)?.model || '全源灵动') : '全源灵动'}
</span>
<ChevronDown className="h-4 w-4 opacity-50 group-hover:opacity-100 transition-colors" />
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder={t('searchModel')} />
<CommandList className="max-h-[300px]">
<CommandEmpty>{t('modelNotFound')}</CommandEmpty>
<CommandGroup heading={t('availableModels')}>
{models.map((model) => (
<CommandItem
key={model.id}
onSelect={() => {
setSelectedModelId(model.id);
setModelOpen(false);
// Fire custom event to notify ChatInterface if needed
window.dispatchEvent(new CustomEvent("nanobot:model-changed", { detail: model.id }));
}}
className="flex items-center gap-2 py-2.5 cursor-pointer"
>
<div className="flex flex-col">
<span className="font-medium text-foreground">{model.name || model.model}</span>
<span className="text-xs text-muted-foreground">{model.provider}</span>
</div>
<Check
className={cn(
"ml-auto h-4 w-4",
selectedModelId === model.id ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Enter project name"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateProject} disabled={isSubmitting || !newProjectName.trim()}>
{isSubmitting ? 'Creating...' : 'Create Project'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from "@/lib/utils";
interface Skill {
id: string;
name: string;
description?: string;
type: string;
}
interface SlashCommandMenuProps {
isOpen: boolean;
skills: Skill[];
selectedIndex: number;
onSelect: (skill: Skill) => void;
onClose: () => void;
}
export function SlashCommandMenu({ isOpen, skills, selectedIndex, onSelect, onClose }: SlashCommandMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen && selectedRef.current) {
selectedRef.current.scrollIntoView({ block: 'nearest' });
}
}, [isOpen, selectedIndex]);
// Click outside to close
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen || skills.length === 0) return null;
return (
<div
ref={menuRef}
className="absolute bottom-full left-0 mb-2 w-full max-w-md overflow-hidden rounded-xl border border-border bg-popover shadow-2xl animate-in fade-in slide-in-from-bottom-2 duration-100 z-50"
>
<div className="max-h-[240px] overflow-y-auto py-1.5 custom-scrollbar">
{skills.map((skill, index) => (
<button
key={skill.id}
ref={index === selectedIndex ? selectedRef : null}
onClick={() => onSelect(skill)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors",
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="font-bold text-blue-400 shrink-0 font-mono">/{skill.name}</span>
<span className="text-muted-foreground truncate text-xs">{skill.description || t('noDescription')}</span>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { Moon, Sun } from 'lucide-react';
import { useThemeStore } from '../store/themeStore';
import { Button } from './ui/button';
import { useEffect } from 'react';
export function ThemeToggle() {
const { theme, toggleTheme } = useThemeStore();
// Ensure the theme class is applied correctly on mount in case hydration doesn't match the DOM
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme]);
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="w-9 h-9 text-muted-foreground hover:text-foreground"
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
>
{theme === 'light' ? (
<Moon className="h-[1.2rem] w-[1.2rem] transition-all" />
) : (
<Sun className="h-[1.2rem] w-[1.2rem] transition-all" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
);
}
+98
View File
@@ -0,0 +1,98 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { VegaEmbed } from 'react-vega';
import type { ChartSpec } from '@/store/visualizationStore';
interface VegaChartProps {
data: any[];
spec: ChartSpec;
}
export const VegaChart: React.FC<VegaChartProps> = ({ data, spec }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const nextWidth = Math.max(0, Math.floor(entry.contentRect.width));
const nextHeight = Math.max(0, Math.floor(entry.contentRect.height));
setSize((prev) => (
prev.width === nextWidth && prev.height === nextHeight
? prev
: { width: nextWidth, height: nextHeight }
));
});
observer.observe(node);
return () => observer.disconnect();
}, []);
const vegaSpec: any = useMemo(() => {
// Deep clone spec to avoid mutating React state/props
const baseSpec = JSON.parse(JSON.stringify(spec));
// Ensure tooltip is enabled in mark if not already specified
if (typeof baseSpec.mark === 'string') {
baseSpec.mark = { type: baseSpec.mark, tooltip: true };
} else if (typeof baseSpec.mark === 'object' && baseSpec.mark !== null) {
baseSpec.mark.tooltip = true;
}
// Add highlight effect: hover over an element makes others transparent
// 1. Define hover param
if (!baseSpec.params) {
baseSpec.params = [];
}
const hasHighlight = baseSpec.params.some((p: any) => p.name === "highlight");
if (!hasHighlight) {
baseSpec.params.push({
name: "highlight",
select: { type: "point", on: "mouseover", clear: "mouseout" }
});
}
// 2. Add conditional opacity to encoding
if (!baseSpec.encoding) {
baseSpec.encoding = {};
}
// Only add opacity highlight if not explicitly set
if (!baseSpec.encoding.opacity) {
baseSpec.encoding.opacity = {
condition: { param: "highlight", value: 1 },
value: 0.3
};
}
// Also add cursor: pointer for marks
if (typeof baseSpec.mark === 'object' && baseSpec.mark !== null) {
baseSpec.mark.cursor = "pointer";
}
return {
$schema: typeof spec.$schema === 'string' ? spec.$schema : 'https://vega.github.io/schema/vega-lite/v5.json',
...baseSpec,
width: size.width > 0 ? size.width : "container",
height: size.height > 0 ? size.height : "container",
data: { values: data },
autosize: { type: "fit", contains: "padding", resize: true },
};
}, [data, size.height, size.width, spec]);
const handleError = (error: any) => {
console.error("VegaEmbed rendering error:", error, "Spec:", vegaSpec);
};
return (
<div className="w-full h-full overflow-hidden" ref={containerRef}>
<VegaEmbed
spec={vegaSpec}
options={{ actions: false }}
style={{width: '100%', height: '100%'}}
onError={handleError}
/>
</div>
);
};
@@ -0,0 +1,261 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Code, Table as TableIcon, BarChart as ChartIcon, Download, LayoutDashboard, Loader2 } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
import { useVisualizationStore } from "@/store/visualizationStore";
import { useProjectStore } from "@/store/projectStore";
import { useTranslation } from "react-i18next";
import { VegaChart } from "./VegaChart";
export function VisualizationPanel() {
const { t } = useTranslation();
const [view, setView] = useState<'table' | 'chart'>('chart');
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>('');
const { dashboards, addChart, loadDashboards } = useDashboardStore();
const { currentProject } = useProjectStore();
const { currentData, currentSQL, currentChartSpec, currentChartInfo, isLoading, error } = useVisualizationStore();
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
useEffect(() => {
if (dashboards.length > 0 && !selectedDashboardId) {
setSelectedDashboardId(dashboards[0].id);
}
}, [dashboards, selectedDashboardId]);
const buildPendingChart = (): Omit<ChartConfig, 'layout'> | null => {
if (!currentData || !currentSQL) return null;
if (view === "table") {
return {
id: Date.now().toString(),
title: currentChartSpec?.title || 'Generated Analysis',
type: "table",
data: currentData,
sql: currentSQL,
chartSpec: null,
};
}
const mark = currentChartSpec?.mark;
const markType = typeof mark === "string" ? mark : mark?.type;
const dashboardType = markType === "line" ? "line" : "bar";
return {
id: Date.now().toString(),
title: currentChartSpec?.title || 'Generated Analysis',
type: dashboardType,
data: currentData,
sql: currentSQL,
chartSpec: currentChartSpec,
};
};
const handleAddToDashboard = () => {
if (!currentProject) return;
const chart = buildPendingChart();
if (!chart) return;
setPendingChart(chart);
setConfirmOpen(true);
};
const handleConfirmAdd = () => {
if (!pendingChart || !currentProject || !selectedDashboardId) return;
addChart(pendingChart, selectedDashboardId, currentProject.id);
setConfirmOpen(false);
setPendingChart(null);
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-muted/10">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Generating visualization...</span>
</div>
);
}
if (error) {
return (
<div className="h-full flex flex-col items-center justify-center bg-muted/10 p-4">
<div className="text-destructive font-semibold mb-2">Visualization Error</div>
<div className="text-sm text-muted-foreground text-center">{error}</div>
</div>
)
}
if (!currentData || currentData.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center bg-muted/10 text-muted-foreground">
<ChartIcon className="h-12 w-12 mb-4 opacity-20" />
<p>No data to visualize.</p>
<p className="text-sm">Ask the chat to generate some insights!</p>
</div>
);
}
const objectRows = currentData.filter((row) => row && typeof row === "object" && !Array.isArray(row));
if (objectRows.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center bg-muted/10 text-muted-foreground">
<ChartIcon className="h-12 w-12 mb-4 opacity-20" />
<p>Data format is not supported for visualization.</p>
</div>
);
}
const columns = Object.keys(objectRows[0] as Record<string, unknown>);
return (
<div className="h-full flex flex-col bg-muted/10 overflow-hidden">
{/* Toolbar */}
<div className="border-b p-3 bg-background flex justify-between items-center shrink-0">
<h2 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground ml-2">Visualization</h2>
<div className="flex gap-2 items-center">
<div className="flex bg-muted rounded-md p-1 mr-2">
<Button
variant={view === 'table' ? "secondary" : "ghost"}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setView('table')}
>
<TableIcon className="h-3.5 w-3.5 mr-1.5" />
Table
</Button>
<Button
variant={view === 'chart' ? "secondary" : "ghost"}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setView('chart')}
>
<ChartIcon className="h-3.5 w-3.5 mr-1.5" />
Chart
</Button>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={dashboards.length === 0}>
<LayoutDashboard className="h-3.5 w-3.5 mr-1.5" />
Add to Dashboard
</Button>
<Dialog>
<DialogTrigger render={
<Button variant="outline" size="sm" className="h-7 text-xs">
<Code className="h-3.5 w-3.5 mr-1.5" />
SQL
</Button>
} />
<DialogContent className="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle>Generated SQL Query</DialogTitle>
<DialogDescription>
This is the SQL query generated by the AI to retrieve the data shown below.
</DialogDescription>
</DialogHeader>
<div className="bg-slate-950 text-slate-50 p-4 rounded-md overflow-x-auto relative group">
<pre className="text-sm font-mono">{currentSQL}</pre>
<Button size="icon" variant="secondary" className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 h-6 w-6">
<Code className="h-3 w-3" />
</Button>
</div>
</DialogContent>
</Dialog>
<Button variant="outline" size="sm" className="h-7 text-xs">
<Download className="h-3.5 w-3.5 mr-1.5" />
Export
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 p-4 overflow-hidden min-h-0">
<Card className="h-full flex flex-col shadow-sm border-muted">
<CardHeader className="pb-2 shrink-0">
<CardTitle>{currentChartSpec?.title || 'Analysis Result'}</CardTitle>
<CardDescription>{currentChartInfo?.reasoning || currentChartSpec?.description || 'Generated from your query'}</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0 p-4">
{view === 'chart' ? (
<div className="h-full w-full">
{currentChartSpec ? (
<VegaChart data={objectRows} spec={currentChartSpec} />
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<ChartIcon className="h-12 w-12 mb-4 opacity-20" />
<p>No chart configuration available for this data.</p>
<Button variant="link" onClick={() => setView('table')}>View Table</Button>
</div>
)}
</div>
) : (
<ScrollArea className="h-full border rounded-md">
<Table>
<TableHeader>
<TableRow>
{columns.map(col => <TableHead key={col}>{col}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{objectRows.map((row, i) => (
<TableRow key={i}>
{columns.map(col => (
<TableCell key={`${i}-${col}`}>{String((row as Record<string, unknown>)[col] ?? "")}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('pinChartToDashboard')}</DialogTitle>
<DialogDescription>
{t('selectDashboardToPin')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium mb-2 block">{t('dashboardMenu')}</label>
<Select value={selectedDashboardId} onValueChange={(val) => { if (val) setSelectedDashboardId(val); }}>
<SelectTrigger>
<SelectValue placeholder={t('selectDashboard')}>
{dashboards.find(d => d.id === selectedDashboardId)?.name || t('selectDashboard')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{dashboards.map(d => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setConfirmOpen(false);
setPendingChart(null);
}}
>
{t('cancel')}
</Button>
<Button onClick={handleConfirmAdd} disabled={!selectedDashboardId}>{t('submit')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,78 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Table as TableIcon } from "lucide-react";
interface Column {
name: string;
type: string;
properties?: {
is_primary_key?: boolean;
is_foreign_key?: boolean;
};
}
interface TableNodeData {
name: string;
columns: Column[];
onDetailClick: (name: string) => void;
}
export const TableNode = memo(({ data }: { data: TableNodeData }) => {
return (
<Card className="min-w-[220px] max-w-[280px] shadow-md border-t-4 border-t-blue-500 text-xs bg-background">
<Handle type="target" position={Position.Top} className="!bg-blue-500" />
<CardHeader
className="py-2 px-3 bg-muted/50 border-b flex flex-row items-center justify-between cursor-pointer hover:bg-muted"
onClick={() => data.onDetailClick(data.name)}
>
<div className="font-semibold flex items-center gap-2 truncate" title={data.name}>
<TableIcon className="w-3 h-3 text-blue-500 shrink-0" />
<span className="truncate">{data.name}</span>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[250px] overflow-y-auto">
<table className="w-full text-left border-collapse">
<tbody>
{data.columns.map((col) => {
const isPk = col.properties?.is_primary_key;
const isFk = col.properties?.is_foreign_key;
let keyText = "";
if (isPk && isFk) keyText = "PK, FK";
else if (isPk) keyText = "PK";
else if (isFk) keyText = "FK";
// Simplify type display, e.g., INTEGER -> int, CHARACTER VARYING -> string
let displayType = (col.type || "string").toLowerCase();
if (displayType.includes("int")) displayType = "int";
else if (displayType.includes("char") || displayType.includes("text")) displayType = "string";
else if (displayType.includes("time") || displayType.includes("date")) displayType = "date";
else if (displayType.includes("bool")) displayType = "boolean";
else if (displayType.includes("float") || displayType.includes("double") || displayType.includes("numeric") || displayType.includes("decimal")) displayType = "float";
return (
<tr
key={col.name}
className="border-b last:border-0 hover:bg-muted/50"
title={`${col.name} (${col.type})`}
>
<td className="py-1.5 px-3 w-16 text-muted-foreground font-mono truncate border-r border-border">{displayType}</td>
<td className="py-1.5 px-3 font-medium truncate text-foreground">{col.name}</td>
<td className="py-1.5 px-3 w-10 text-center text-muted-foreground font-semibold text-[10px] border-l border-border">
{keyText}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
<Handle type="source" position={Position.Bottom} className="!bg-blue-500" />
</Card>
);
});
+60
View File
@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+194
View File
@@ -0,0 +1,194 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+155
View File
@@ -0,0 +1,155 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
@@ -0,0 +1,54 @@
"use client"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+22
View File
@@ -0,0 +1,22 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import zh from './locales/zh.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
fallbackLng: 'zh',
interpolation: {
escapeValue: false // React already escapes by default
}
});
export default i18n;
+434
View File
@@ -0,0 +1,434 @@
{
"selectAllOrCancel": "Select All / Cancel",
"invertSelection": "Invert Selection",
"batchDelete": "Batch Delete",
"cancel": "Cancel",
"rename": "Rename",
"pin": "Pin",
"unpin": "Unpin",
"archive": "Archive",
"unarchive": "Unarchive",
"delete": "Delete",
"deleteSession": "Delete Session",
"confirmDeleteSession": "Are you sure you want to delete this session?",
"confirmBatchDeleteSessions": "Are you sure you want to delete the selected {{count}} threads?",
"filterSessionName": "Filter thread name",
"renameSession": "Rename thread",
"enterNewSessionTitle": "Enter new thread title",
"save": "Save",
"lobsterDataQA": "QuanYuan LingDong",
"skillCenter": "Skill Center",
"projectManagement": "Project Management",
"dataSourceManagement": "Data Source Management",
"personalSettings": "Personal Settings",
"modelConfig": "Model Configuration",
"userManagement": "User Management",
"logout": "Logout",
"searchModel": "Search model...",
"modelNotFound": "Model not found",
"availableModels": "Available Models",
"dataSource": "Data Source",
"clearSelected": "Clear Selection",
"clearSelectedWithCount": "Clear Selected ({{count}})",
"noAvailableSkills": "No available skills",
"askAnything": "Ask anything...",
"dataClawDisclaimer": "QuanYuan LingDong can make mistakes. Consider verifying important information.",
"processing": "Processing...",
"processCompleted": "Completed",
"thinkingProcess": "Thinking Process",
"thinkingTokens": "{{count}} tokens",
"thinkingCharCount": "{{count}} chars",
"expandThinking": "Expand",
"collapseThinking": "Collapse",
"modelThinking": "Model is thinking, please wait...",
"openReportInNewTab": "Open report in new tab",
"artifactPreview": "File Preview",
"preview": "Preview",
"download": "Download",
"outputInterrupted": "Output interrupted",
"requestSubmittedRouting": "Request submitted, preparing to route...",
"routingInfo": "Routing: {{selected}} {{reason}}",
"sqlAnalysis": "SQL Analysis",
"generalConversation": "General Conversation",
"answerGenerationCompleted": "Answer generation completed",
"noReply": "No reply",
"chartGenerationCompleted": "Chart generation completed",
"dataQueryCompleted": "Data query completed",
"streamResponseFailed": "Stream response failed",
"streamResponseError": "Stream response error",
"userUploadedFile": "User uploaded file",
"fileContentSummary": "File content summary",
"none": "None",
"dataColumns": "Data columns",
"fileDownloadLink": "File download link",
"spreadsheet": "Spreadsheet",
"name": "Name",
"myDataSource": "My Data Source",
"type": "Type",
"uploadFileOrEnterPath": "Upload file or enter server path",
"unsupportedDataSourceType": "Unsupported data source type",
"dataSourceConnectorInDevelopment": "This data source connector is under development. Please try PostgreSQL, ClickHouse, or file upload.",
"testConnection": "Test Connection",
"uploadFailed": "Upload failed",
"connectionSuccess": "Connection successful",
"connectionFailed": "Connection failed",
"orUseSupabaseConnectionString": "Or use the Connection String (URI) provided by Supabase console:",
"orUseConnectionString": "Or use connection string (overrides above settings):",
"fileUpload": "File Upload",
"noDescription": "No description",
"confirmAddToDashboard": "Confirm Add to Dashboard",
"confirmAddChartToDashboardDesc": "Add the current chart to Dashboard, continue?",
"confirmAdd": "Confirm Add",
"visualizationResult": "Visualization Result",
"sqlQueryDescription": "The data query statement used to generate the current chart.",
"copied": "Copied",
"copy": "Copy",
"resultNotSuitableForChart": "This result is not suitable for chart display.",
"noStructuredDataToRender": "No structured data to render in the current result.",
"pageRenderFailed": "Page render failed",
"chinese": "Chinese",
"chartIntentPattern": "(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)",
"processingIndicator": "正在",
"confirmDeleteDataSource": "Are you sure you want to delete this data source?",
"saveFailed": "Save failed: ",
"editDataSource": "Edit Data Source",
"createNewDataSourceWithType": "Create {{type}} Data Source",
"editDataSourceWithType": "Edit {{type}} Data Source",
"backToList": "Back to List",
"dataSourceConfig": "Data Source Configuration",
"manageDataSourceConnections": "Manage data source connections for Q&A",
"newDataSource": "New Data Source",
"noDataSources": "No data source available in current project, please create one in settings",
"clickTopRightToAddFirstDataSource": "Click the button on the top right to add your first data source",
"newProject": "New Project",
"projectList": "Project List",
"manageProjectsDesc": "Manage your projects, different projects have independent data sources",
"loading": "Loading...",
"noProjectsCreateOne": "No projects, please create one first",
"description": "Description",
"createdAt": "Created At",
"actions": "Actions",
"manageDataSources": "Manage Data Sources",
"editProject": "Edit Project",
"deleteProject": "Delete Project",
"enterProjectName": "Enter project name",
"descriptionOptional": "Description (Optional)",
"enterProjectDescription": "Enter project description",
"creating": "Creating...",
"create": "Create",
"saving": "Saving...",
"confirmDeleteProject": "Are you sure you want to delete this project? All associated data sources will be deleted.",
"selectProjectToViewDashboard": "Please select a project to view the dashboard.",
"noChartsInCurrentProject": "No charts in the current project.",
"goToChatToAddCharts": "Go to the chat page and add visualization results!",
"currentTableNoData": "Current table has no data to display",
"currentTableMissingFields": "Current table data is missing fields to display",
"previewTableRows": "Preview first {{previewLimit}} rows / Total {{rowCount}} rows, {{colCount}} columns",
"totalTableRows": "Total {{rowCount}} rows, {{colCount}} columns",
"currentChartMissingFields": "Current chart data is missing fields to plot",
"passwordsDoNotMatch": "The two passwords entered do not match",
"personalSettingsSaved": "Personal settings saved successfully!",
"personalSettingsAndPasswordSaved": "Personal settings and password modified successfully!",
"failedToSaveSettings": "Failed to save settings",
"accountInfo": "Account Information",
"modifyLoginEmailAndPassword": "Modify your login email and password",
"username": "Username",
"usernameCannotBeModified": "Username cannot be modified",
"emailAddress": "Email Address",
"newPassword": "New Password",
"leaveBlankIfNotModifying": "Leave blank if not modifying",
"confirmNewPassword": "Confirm New Password",
"saveSettings": "Save Settings",
"knowledgeBase": "Knowledge Base",
"knowledgeBaseSettings": "Knowledge Base Configuration",
"knowledgeBaseSettingsDesc": "Manage knowledge bases and their documents",
"knowledgeGlobalConfigTitle": "Knowledge Global Configuration",
"knowledgeGlobalConfigDesc": "Configure global API base and key for knowledge service shared across projects.",
"knowledgeGlobalApiBase": "API Base",
"knowledgeGlobalApiBasePlaceholder": "e.g. https://api.siliconflow.cn/v1 (without /embeddings)",
"knowledgeGlobalApiKey": "API Key",
"knowledgeGlobalApiKeyPlaceholder": "Leave blank to keep the current key",
"knowledgeGlobalApiKeyMasked": "Saved key: {{masked}}",
"knowledgeGlobalApiKeyEmpty": "No API key configured",
"knowledgeGlobalDefaultEmbeddingModel": "Default Embedding Model",
"knowledgeGlobalDefaultEmbeddingModelPlaceholder": "e.g. text-embedding-3-small",
"knowledgeGlobalModelNameHint": "API Base should be the provider base URL (without /embeddings), and model name must be explicit for testing and indexing.",
"knowledgeGlobalModelNameTooLong": "Default embedding model name cannot exceed 200 characters",
"knowledgeGlobalConfigLoadFailed": "Failed to load knowledge global configuration",
"knowledgeGlobalConfigSaveFailed": "Failed to save knowledge global configuration",
"knowledgeGlobalConfigSaved": "Knowledge global configuration saved successfully",
"knowledgeGlobalConfigApiBaseInvalid": "API Base must start with http:// or https://",
"knowledgeGlobalConfigApiBaseShouldBeBaseUrl": "API Base must be a base URL and should not include /embeddings",
"testKnowledgeGlobalConnection": "Test Connection",
"knowledgeGlobalConnectionTestPassed": "Connection test passed",
"knowledgeGlobalConnectionTestFailed": "Connection test failed",
"knowledgeGlobalConnectionModelResult": "Model: {{model}}",
"knowledgeGlobalConnectionDimensionResult": "Embedding dimension: {{dim}}",
"knowledgeGlobalConnectionAvailableModelsResult": "Available model examples: {{models}}",
"knowledgeGlobalModelNameRequiredForTest": "Model name is required for connection testing",
"knowledgeGlobalArkModelRequiredForTest": "For Volcengine Ark, model name is required for connection testing (Model ID or Endpoint ID)",
"saveKnowledgeGlobalConfig": "Save Global Configuration",
"refresh": "Refresh",
"knowledgeBaseName": "Name",
"knowledgeBaseNamePlaceholder": "e.g. Sales Q&A",
"knowledgeBaseDescriptionPlaceholder": "Optional description",
"knowledgeBaseEmbeddingModel": "Embedding Model",
"knowledgeBaseEmbeddingModelPlaceholder": "Select an embedding model",
"knowledgeBaseChunkSize": "Chunk Size",
"knowledgeBaseChunkOverlap": "Chunk Overlap",
"knowledgeBaseTopK": "Top K",
"createKnowledgeBase": "New Knowledge Base",
"updateKnowledgeBase": "Update Knowledge Base",
"knowledgeBaseList": "Knowledge Base List",
"knowledgeBaseMeta": "{{count}} docs · Updated {{updatedAt}}",
"manageKnowledgeDocuments": "Manage Documents",
"knowledgeDocumentManagerTitle": "Document Management ({{name}})",
"knowledgeDocumentManagerTitleEmpty": "Knowledge Document Management",
"selectKnowledgeBaseToManageDocuments": "Select a knowledge base above to manage documents",
"knowledgeDocumentTitle": "Document Title",
"knowledgeDocumentTitlePlaceholder": "e.g. Refund Policy",
"knowledgeDocumentContent": "Document Content",
"knowledgeDocumentContentPlaceholder": "Enter document content",
"knowledgeDocumentMetadata": "Document Metadata (Optional JSON)",
"knowledgeDocumentMetadataPlaceholder": "e.g. {\"source\":\"manual\",\"lang\":\"en\"}",
"knowledgeDocumentMeta": "Updated {{updatedAt}}",
"knowledgeDocumentTitleRequired": "Please enter a document title",
"knowledgeDocumentContentRequired": "Please enter document content",
"knowledgeDocumentMetadataInvalid": "Document metadata must be valid JSON",
"createKnowledgeDocument": "Create Document",
"updateKnowledgeDocument": "Update Document",
"editKnowledgeDocument": "Edit Document",
"deleteKnowledgeDocument": "Delete Document",
"confirmDeleteKnowledgeDocument": "Are you sure you want to delete this document?",
"knowledgeDocumentCreated": "Document created successfully",
"knowledgeDocumentUpdated": "Document updated successfully",
"knowledgeDocumentDeleted": "Document deleted successfully",
"knowledgeDocumentLoadFailed": "Failed to load documents",
"knowledgeDocumentSaveFailed": "Failed to save document",
"knowledgeDocumentDeleteFailed": "Failed to delete document",
"noKnowledgeDocuments": "No documents in this knowledge base",
"knowledgeDocumentUploadTitle": "Upload Documents to Knowledge Base",
"knowledgeDocumentUploadHint": "Supports Text/Markdown/Code, Office(Word/Excel/PPT) and PDF. Max 15MB per file.",
"knowledgeDocumentUploadSelected": "{{count}} file(s) selected",
"knowledgeDocumentUploadNone": "No files selected",
"knowledgeDocumentUploadAction": "Upload and Add",
"knowledgeDocumentUploadEmpty": "Please select files to upload",
"knowledgeDocumentUploadSuccess": "{{count}} file(s) uploaded successfully",
"knowledgeDocumentUploadFailed": "Failed to upload documents",
"knowledgeCitations": "Knowledge Citations",
"matchScore": "Score: {{score}}",
"editKnowledgeBase": "Edit Knowledge Base",
"deleteKnowledgeBase": "Delete Knowledge Base",
"reindexKnowledgeBase": "Reindex",
"refreshKnowledgeBaseList": "Refresh Knowledge Bases",
"knowledgeBaseLoadFailed": "Failed to load knowledge bases",
"knowledgeBaseNameRequired": "Please enter a knowledge base name",
"knowledgeBaseChunkSizeRange": "Chunk Size must be between 64 and 4096",
"knowledgeBaseChunkOverlapRange": "Chunk Overlap must be between 0 and 512",
"knowledgeBaseChunkOverlapTooLarge": "Chunk Overlap must be smaller than Chunk Size",
"knowledgeBaseTopKRange": "Top K must be between 1 and 20",
"knowledgeBaseCreated": "Knowledge base created successfully",
"knowledgeBaseUpdated": "Knowledge base updated successfully",
"knowledgeBaseSaveFailed": "Failed to save knowledge base",
"confirmDeleteKnowledgeBase": "Are you sure you want to delete this knowledge base?",
"knowledgeBaseDeleted": "Knowledge base deleted successfully",
"knowledgeBaseDeleteFailed": "Failed to delete knowledge base",
"knowledgeBaseReindexSuccess": "Knowledge base reindexed successfully",
"knowledgeBaseReindexFailed": "Failed to reindex knowledge base",
"selectProjectBeforeManageKnowledgeBase": "Please select a project before managing knowledge bases",
"noKnowledgeBases": "No knowledge base available in current project, please create one in settings",
"confirmDeleteUser": "Are you sure you want to delete this user?",
"newUserMustHavePassword": "New users must have a password",
"anErrorOccurred": "An error occurred",
"addUser": "Add User",
"editUser": "Edit User",
"addNewUser": "Add New User",
"email": "Email",
"password": "Password",
"activeStatus": "Active Status",
"adminPrivileges": "Admin Privileges",
"id": "ID",
"status": "Status",
"role": "Role",
"noUserData": "No user data",
"normal": "Normal",
"disabled": "Disabled",
"admin": "Admin",
"regularUser": "Regular User",
"fillRequiredInfoFirst": "Please fill in required information first (Provider, Model ID)",
"extraConfigMustBeValidJson": "Extra config must be valid JSON",
"connectionTestSuccessful": "Connection test successful!",
"connectionTestFailed": "Connection test failed",
"fillRequiredFields": "Please fill in required fields",
"failedToSaveConfig": "Failed to save config",
"confirmDeleteModel": "Are you sure you want to delete this model?",
"noPermissionAdminOnly": "No permission to access this page, please log in with an admin account.",
"addModel": "Add Model",
"modelName": "Model Name",
"provider": "Provider",
"modelIdentifier": "Model Identifier",
"noModelData": "No model data",
"currentDefaultModel": "Current default model",
"clickToSetDefault": "Click to set as default",
"default": "Default",
"setDefault": "Set as Default",
"editModel": "Edit Model",
"providerRequired": "Provider *",
"egGpt4": "e.g., GPT-4",
"modelIdRequired": "Model ID *",
"egGpt4Turbo": "e.g., gpt-4-turbo",
"apiDomain": "API Domain",
"egApiDomain": "e.g., https://api.openai.com/v1",
"extraConfigJson": "Extra Config (JSON)",
"unknownError": "Unknown error",
"confirmDeleteSkill": "Are you sure you want to delete this skill?",
"selectProjectToManageSkills": "Please select a project at the top first to manage its skills",
"skillsRepository": "Skill Center",
"manageAiSkillsDesc": "Manage AI skills and MCP server configurations for this project",
"uploadSkill": "Upload Skill",
"source": "Source",
"installationTime": "Installation Time",
"noSkillsInProjectClickImport": "No skills in this project yet, click \"Upload Skill\" to start",
"viewOrEditSkill": "View/Edit Skill",
"addNewSkill": "Add New Skill",
"skillName": "Skill Name",
"selectType": "Select Type",
"selectStatus": "Select Status",
"brieflyDescribeSkillFunction": "Briefly describe the function of the skill...",
"content": "Content",
"pythonSqlApiContentPlaceholder": "Python code, SQL query template or API specification...",
"saveSkill": "Save Skill",
"safe": "Safe",
"lowRisk": "Low Risk",
"localImport": "Local Import",
"systemBuiltin": "System Built-in",
"backendGenerated": "Backend Generated",
"uploadedFile": "File Upload",
"filterBySource": "Filter by source",
"allSources": "All sources",
"zhipuAi": "ZhipuAI",
"dashScope": "DashScope",
"volcengine": "Volcengine",
"tableRowColDesc": "TABLE · {{rowCount}} rows · {{colCount}} columns",
"retry": "Retry",
"allStates": "All States",
"refreshHealth": "Refresh Health",
"a2aConfig": "A2A Config",
"a2aAgentManagement": "A2A Agent Management",
"a2aTaskObservability": "A2A Task Observability",
"a2aStatus": "A2A Status",
"a2aTaskCreated": "A2A Task Created",
"a2aTaskFailed": "A2A Task Failed",
"a2aModeEnabled": "A2A Mode: On",
"a2aModeDisabled": "A2A Mode: Off",
"autoSelectAgent": "Auto Select Agent",
"addA2aAgent": "Add A2A Agent",
"editA2aAgent": "Edit A2A Agent",
"saveA2aAgent": "Save A2A Agent",
"a2aAgentName": "A2A Agent Name",
"authScheme": "Auth Scheme",
"authToken": "Auth Token",
"leaveEmptyToKeepUnchanged": "Leave empty to keep unchanged",
"healthStatus": "Health",
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"protocol": "Protocol",
"capabilities": "Capabilities",
"taskId": "Task ID",
"taskSource": "Task Source",
"time": "Time",
"noA2aAgents": "No A2A agents configured",
"noA2aTasks": "No A2A tasks",
"confirmDeleteA2aAgent": "Are you sure you want to delete this A2A agent?",
"projectName": "Project Name",
"dashboardMenu": "Dashboard",
"newThread": "New Thread",
"threads": "THREADS",
"archivedThreads": "ARCHIVED THREADS",
"defaultUser": "Default User",
"searchSkills": "Search skills...",
"selectDashboard": "Select a dashboard",
"submit": "Submit",
"noDashboardsInCurrentProject": "No dashboards in current project",
"createDashboardToGetStarted": "Create a new dashboard from the sidebar to get started",
"confirmDeleteDashboard": "Are you sure you want to delete this dashboard?",
"renameDashboard": "Rename Dashboard",
"enterNewDashboardName": "Enter new dashboard name",
"newDashboardNameDefault": "New Dashboard",
"dashboardLimitReached": "You can only create up to 3 dashboards.",
"dashboards": "Dashboards",
"new": "New",
"pinChartToDashboard": "Pin chart to dashboard",
"selectDashboardToPin": "Select a dashboard to pin this chart to.",
"welcomeBack": "Welcome Back",
"createAccount": "Create Account",
"username": "Username",
"enterUsername": "Enter your username",
"email": "Email",
"enterEmail": "Enter your email",
"password": "Password",
"enterPassword": "Enter your password",
"signIn": "Sign In",
"signUp": "Sign Up",
"dontHaveAccount": "Don't have an account?",
"alreadyHaveAccount": "Already have an account?",
"registrationSuccess": "Registration successful! Please login.",
"registrationSuccessWithVerification": "Registration successful! Please check your email to verify your account.",
"inactiveUserError": "Account is inactive. Please verify your email.",
"resendVerification": "Resend Verification Email",
"verificationSent": "Verification email sent. Please check your inbox.",
"verifyEmailTitle": "Email Verification",
"verifyingEmail": "Verifying your email...",
"verifyEmailSuccess": "Email verified successfully! You can now log in.",
"verifyEmailFailed": "Email verification failed or link expired.",
"goToLogin": "Go to Login",
"errorOccurred": "An error occurred",
"mcpConfig": "MCP Configuration",
"mcp": "MCP",
"skills": "Skills Configuration",
"transport": "Transport",
"command": "Command",
"args": "Args (JSON Array)",
"env": "Env (JSON Object)",
"url": "URL",
"headers": "Headers (JSON Object)",
"addMcpServer": "Add MCP Server",
"editMcpServer": "Edit MCP Server",
"mcpServerName": "MCP Server Name",
"noMcpServers": "No MCP servers configured",
"confirmDeleteMcpServer": "Are you sure you want to delete this MCP server?",
"saveMcpServer": "Save MCP Server",
"subagents": "Subagents",
"subagentManagement": "Subagent Management",
"manageSubagentsDesc": "Manage subagents for this project",
"createSubagent": "New Subagent",
"addSubagent": "Add Subagent",
"editSubagent": "Edit Subagent",
"subagentName": "Subagent Name",
"systemInstructionsPlaceholder": "You are a helpful AI assistant...",
"selectModel": "Select a model",
"noSubagents": "No subagents configured",
"confirmDeleteSubagent": "Are you sure you want to delete this subagent?",
"selectProjectToManageSubagents": "Please select a project to manage subagents",
"knowledgeBaseGroup": "Knowledge Base",
"moreGroup": "More",
"knowledgeBases": "Knowledge Bases",
"voiceSettings": "Voice Input Settings",
"enableVoiceInput": "Enable Voice Input",
"voiceSettingsDisabledHint": "Enable voice input first, then configure server URL",
"voiceInputDisabledHint": "Please enable voice input first",
"voiceInputNotEnabled": "Voice input is disabled. Enable it from profile menu -> More -> Voice Input Settings first.",
"embeddingModels": "Embedding Models",
"webSearchConfig": "Web Search Config",
"configureWebSearchProvider": "Configure the default web search provider and settings for the AI agent.",
"selectProvider": "Select a provider",
"enterApiKey": "Enter API Key",
"apiKeyRequiredFor": "An API Key is required for {{provider}}",
"baseUrl": "Base URL",
"baseUrlRequiredFor": "A Base URL is required for {{provider}}",
"maxResults": "Max Results",
"maxResultsDescription": "Maximum number of search results to return (1-20)",
"failedToLoadConfig": "Failed to load configuration",
"failedToSaveConfig": "Failed to save configuration",
"configSaved": "Configuration saved successfully. Note: Active agents may require a restart to pick up the new configuration."
}
+436
View File
@@ -0,0 +1,436 @@
{
"selectAllOrCancel": "全选 / 取消全选",
"invertSelection": "反选",
"batchDelete": "批量删除",
"cancel": "取消",
"rename": "重命名",
"pin": "置顶",
"unpin": "取消置顶",
"archive": "归档",
"unarchive": "取消归档",
"delete": "删除",
"deleteSession": "删除会话",
"confirmDeleteSession": "确定要删除这个会话吗?",
"confirmBatchDeleteSessions": "确定要删除选中的 {{count}} 个会话吗?",
"confirmDeleteDashboard": "确定要删除此仪表盘吗?",
"renameDashboard": "重命名仪表盘",
"enterNewDashboardName": "输入新的仪表盘名称",
"newDashboardNameDefault": "新仪表盘",
"dashboardLimitReached": "最多只能创建 3 个仪表盘。",
"dashboards": "仪表盘",
"new": "新建",
"pinChartToDashboard": "固定图表到仪表盘",
"selectDashboardToPin": "选择一个仪表盘以固定此图表。",
"selectDashboard": "选择一个仪表盘",
"submit": "提交",
"noDashboardsInCurrentProject": "当前项目下没有仪表盘",
"createDashboardToGetStarted": "从侧边栏创建一个新仪表盘以开始",
"filterSessionName": "过滤会话名称",
"renameSession": "重命名会话",
"enterNewSessionTitle": "输入新的会话标题",
"save": "保存",
"lobsterDataQA": "全源灵动数据智能分析系统",
"skillCenter": "技能中心",
"projectManagement": "项目管理",
"dataSourceManagement": "数据源管理",
"personalSettings": "个人设置",
"modelConfig": "模型配置",
"userManagement": "用户管理",
"logout": "退出登录",
"searchModel": "搜索模型...",
"modelNotFound": "未找到模型",
"availableModels": "可用模型",
"dataSource": "数据源",
"clearSelected": "清除已选",
"clearSelectedWithCount": "清除已选 ({{count}})",
"noAvailableSkills": "暂无可用技能",
"askAnything": "有问题,尽管问",
"dataClawDisclaimer": "全源灵动系统可能会出错。请核查重要信息。",
"processing": "正在处理中",
"processCompleted": "处理完成",
"thinkingProcess": "思考过程",
"thinkingTokens": "{{count}} tokens",
"thinkingCharCount": "{{count}} 字",
"expandThinking": "展开",
"collapseThinking": "收起",
"modelThinking": "模型思考中,请稍候...",
"openReportInNewTab": "在新标签页中打开分析报告",
"artifactPreview": "文件预览",
"preview": "预览",
"download": "下载",
"outputInterrupted": "已中断输出",
"requestSubmittedRouting": "请求已提交,准备路由...",
"routingInfo": "路由:{{selected}}{{reason}}",
"sqlAnalysis": "SQL 分析",
"generalConversation": "通用对话",
"answerGenerationCompleted": "回答生成完成",
"noReply": "暂无回复",
"chartGenerationCompleted": "图表生成完成",
"dataQueryCompleted": "数据查询完成",
"streamResponseFailed": "流式响应失败",
"streamResponseError": "流式响应错误",
"userUploadedFile": "用户上传了文件",
"fileContentSummary": "文件内容摘要",
"none": "无",
"dataColumns": "数据列",
"fileDownloadLink": "文件下载链接",
"spreadsheet": "电子表格",
"name": "名称",
"myDataSource": "我的数据源",
"type": "类型",
"uploadFileOrEnterPath": "上传文件或输入服务器路径",
"unsupportedDataSourceType": "暂不支持该数据源类型",
"dataSourceConnectorInDevelopment": "该数据源连接器正在开发中。请尝试使用 PostgreSQL, ClickHouse 或文件上传。",
"testConnection": "测试连接",
"uploadFailed": "上传失败",
"connectionSuccess": "连接成功",
"connectionFailed": "连接失败",
"orUseSupabaseConnectionString": "或者直接使用 Supabase 控制台提供的 Connection String (URI):",
"orUseConnectionString": "或者使用连接字符串 (覆盖上述设置):",
"fileUpload": "文件上传",
"noDescription": "无描述",
"confirmAddToDashboard": "确认加入 Dashboard",
"confirmAddChartToDashboardDesc": "将当前图表添加到 Dashboard,是否继续?",
"confirmAdd": "确认添加",
"visualizationResult": "可视化结果",
"sqlQueryDescription": "用于生成当前图表的数据查询语句。",
"copied": "已复制",
"copy": "复制",
"resultNotSuitableForChart": "本次结果不适合图表展示。",
"noStructuredDataToRender": "当前结果没有可渲染的结构化数据。",
"pageRenderFailed": "页面渲染失败",
"chinese": "中文",
"chartIntentPattern": "(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)",
"processingIndicator": "正在",
"confirmDeleteDataSource": "确定要删除这个数据源吗?",
"saveFailed": "保存失败: ",
"editDataSource": "编辑数据源",
"createNewDataSourceWithType": "新建 {{type}} 数据源",
"editDataSourceWithType": "编辑 {{type}} 数据源",
"backToList": "返回列表",
"dataSourceConfig": "数据源配置",
"manageDataSourceConnections": "管理可用于问答的数据源连接",
"newDataSource": "新建数据源",
"noDataSources": "暂无数据源",
"clickTopRightToAddFirstDataSource": "点击右上角按钮添加第一个数据源",
"newProject": "新建项目",
"projectList": "项目列表",
"manageProjectsDesc": "管理您的项目,不同项目拥有独立的数据源",
"loading": "加载中...",
"noProjectsCreateOne": "暂无项目,请先创建一个",
"description": "描述",
"createdAt": "创建时间",
"actions": "操作",
"manageDataSources": "管理数据源",
"editProject": "编辑项目",
"deleteProject": "删除项目",
"enterProjectName": "输入项目名称",
"descriptionOptional": "描述 (可选)",
"enterProjectDescription": "输入项目描述",
"creating": "创建中...",
"create": "创建",
"saving": "保存中...",
"confirmDeleteProject": "确定要删除这个项目吗?所有相关的数据源都将被删除。",
"selectProjectToViewDashboard": "请选择一个项目以查看仪表板。",
"noChartsInCurrentProject": "当前项目暂无图表。",
"goToChatToAddCharts": "前往对话页并添加可视化结果!",
"currentTableNoData": "当前表格没有可展示数据",
"currentTableMissingFields": "当前表格数据缺少可展示字段",
"previewTableRows": "预览前 {{previewLimit}} 行 / 共 {{rowCount}} 行,{{colCount}} 列",
"totalTableRows": "共 {{rowCount}} 行,{{colCount}} 列",
"currentChartMissingFields": "当前图表数据缺少可绘制字段",
"passwordsDoNotMatch": "两次输入的密码不一致",
"personalSettingsSaved": "个人设置保存成功!",
"personalSettingsAndPasswordSaved": "个人设置及密码修改成功!",
"failedToSaveSettings": "保存设置失败",
"accountInfo": "账号信息",
"modifyLoginEmailAndPassword": "修改您的登录邮箱和密码",
"avatar": "头像",
"username": "用户名",
"usernameCannotBeModified": "用户名不可修改",
"emailAddress": "邮箱地址",
"newPassword": "新密码",
"leaveBlankIfNotModifying": "如不修改请留空",
"confirmNewPassword": "确认新密码",
"saveSettings": "保存设置",
"knowledgeBase": "知识库",
"knowledgeBaseSettings": "知识库配置与建库管理",
"knowledgeBaseSettingsDesc": "管理知识库及其文档内容",
"knowledgeGlobalConfigTitle": "知识库全局配置",
"knowledgeGlobalConfigDesc": "配置知识库服务的全局 API 地址与密钥,所有项目共享。",
"knowledgeGlobalApiBase": "API Base",
"knowledgeGlobalApiBasePlaceholder": "例如:https://api.siliconflow.cn/v1(不要填写 /embeddings",
"knowledgeGlobalApiKey": "API Key",
"knowledgeGlobalApiKeyPlaceholder": "留空表示保持当前密钥不变",
"knowledgeGlobalApiKeyMasked": "当前已保存密钥:{{masked}}",
"knowledgeGlobalApiKeyEmpty": "当前未配置 API Key",
"knowledgeGlobalDefaultEmbeddingModel": "默认向量模型名称",
"knowledgeGlobalDefaultEmbeddingModelPlaceholder": "例如:text-embedding-3-small",
"knowledgeGlobalModelNameHint": "API Base 请填写模型服务基地址(不含 /embeddings),模型名称需显式填写用于测试与建库。",
"knowledgeGlobalModelNameTooLong": "默认向量模型名称长度不能超过 200 个字符",
"knowledgeGlobalConfigLoadFailed": "加载知识库全局配置失败",
"knowledgeGlobalConfigSaveFailed": "保存知识库全局配置失败",
"knowledgeGlobalConfigSaved": "知识库全局配置保存成功",
"knowledgeGlobalConfigApiBaseInvalid": "API Base 需以 http:// 或 https:// 开头",
"knowledgeGlobalConfigApiBaseShouldBeBaseUrl": "API Base 需填写基地址,不要包含 /embeddings",
"testKnowledgeGlobalConnection": "测试连接",
"knowledgeGlobalConnectionTestPassed": "测试连接成功",
"knowledgeGlobalConnectionTestFailed": "测试连接失败",
"knowledgeGlobalConnectionModelResult": "模型:{{model}}",
"knowledgeGlobalConnectionDimensionResult": "向量维度:{{dim}}",
"knowledgeGlobalConnectionAvailableModelsResult": "可用模型示例:{{models}}",
"knowledgeGlobalModelNameRequiredForTest": "测试连接必须填写向量模型名称",
"knowledgeGlobalArkModelRequiredForTest": "火山方舟测试连接需填写向量模型名称(Model ID 或 Endpoint ID",
"saveKnowledgeGlobalConfig": "保存全局配置",
"refresh": "刷新",
"knowledgeBaseName": "知识库名称",
"knowledgeBaseNamePlaceholder": "例如:销售问答库",
"knowledgeBaseDescriptionPlaceholder": "选填,知识库描述",
"knowledgeBaseEmbeddingModel": "Embedding 模型",
"knowledgeBaseEmbeddingModelPlaceholder": "请选择一个嵌入模型",
"knowledgeBaseChunkSize": "Chunk Size",
"knowledgeBaseChunkOverlap": "Chunk Overlap",
"knowledgeBaseTopK": "Top K",
"createKnowledgeBase": "新建知识库",
"updateKnowledgeBase": "更新知识库",
"knowledgeBaseList": "知识库列表",
"knowledgeBaseMeta": "文档 {{count}} 个 · 更新时间 {{updatedAt}}",
"manageKnowledgeDocuments": "管理文档",
"knowledgeDocumentManagerTitle": "文档管理({{name}}",
"knowledgeDocumentManagerTitleEmpty": "知识库文档管理",
"selectKnowledgeBaseToManageDocuments": "请先从上方知识库列表选择一个知识库后再管理文档",
"knowledgeDocumentTitle": "文档标题",
"knowledgeDocumentTitlePlaceholder": "例如:退款政策说明",
"knowledgeDocumentContent": "文档内容",
"knowledgeDocumentContentPlaceholder": "请输入文档正文内容",
"knowledgeDocumentMetadata": "文档元数据(JSON,可选)",
"knowledgeDocumentMetadataPlaceholder": "例如:{\"source\":\"manual\",\"lang\":\"zh\"}",
"knowledgeDocumentMeta": "更新于 {{updatedAt}}",
"knowledgeDocumentTitleRequired": "请输入文档标题",
"knowledgeDocumentContentRequired": "请输入文档内容",
"knowledgeDocumentMetadataInvalid": "文档元数据必须是合法的 JSON",
"createKnowledgeDocument": "新增文档",
"updateKnowledgeDocument": "更新文档",
"editKnowledgeDocument": "编辑文档",
"deleteKnowledgeDocument": "删除文档",
"confirmDeleteKnowledgeDocument": "确定删除该文档吗?",
"knowledgeDocumentCreated": "文档创建成功",
"knowledgeDocumentUpdated": "文档更新成功",
"knowledgeDocumentDeleted": "文档删除成功",
"knowledgeDocumentLoadFailed": "加载文档失败",
"knowledgeDocumentSaveFailed": "保存文档失败",
"knowledgeDocumentDeleteFailed": "删除文档失败",
"noKnowledgeDocuments": "当前知识库还没有文档",
"knowledgeDocumentUploadTitle": "上传文档到知识库",
"knowledgeDocumentUploadHint": "支持 文本/Markdown/代码、Office (Word/Excel/PPT) 及 PDF 文件,单文件不超过 15MB。",
"knowledgeDocumentUploadSelected": "已选择 {{count}} 个文件",
"knowledgeDocumentUploadNone": "尚未选择文件",
"knowledgeDocumentUploadAction": "上传并入库",
"knowledgeDocumentUploadEmpty": "请先选择要上传的文件",
"knowledgeDocumentUploadSuccess": "已成功上传 {{count}} 个文件",
"knowledgeDocumentUploadFailed": "上传文档失败",
"knowledgeCitations": "知识库引用片段",
"matchScore": "匹配分:{{score}}",
"editKnowledgeBase": "编辑知识库",
"deleteKnowledgeBase": "删除知识库",
"reindexKnowledgeBase": "重建索引",
"refreshKnowledgeBaseList": "刷新知识库列表",
"knowledgeBaseLoadFailed": "加载知识库失败",
"knowledgeBaseNameRequired": "请输入知识库名称",
"knowledgeBaseChunkSizeRange": "Chunk Size 需在 64 到 4096 之间",
"knowledgeBaseChunkOverlapRange": "Chunk Overlap 需在 0 到 512 之间",
"knowledgeBaseChunkOverlapTooLarge": "Chunk Overlap 需小于 Chunk Size",
"knowledgeBaseTopKRange": "Top K 需在 1 到 20 之间",
"knowledgeBaseCreated": "知识库创建成功",
"knowledgeBaseUpdated": "知识库更新成功",
"knowledgeBaseSaveFailed": "保存知识库失败",
"confirmDeleteKnowledgeBase": "确定要删除这个知识库吗?",
"knowledgeBaseDeleted": "知识库删除成功",
"knowledgeBaseDeleteFailed": "删除知识库失败",
"knowledgeBaseReindexSuccess": "知识库重建索引成功",
"knowledgeBaseReindexFailed": "知识库重建索引失败",
"selectProjectBeforeManageKnowledgeBase": "请先选择一个项目,再进行知识库管理",
"noKnowledgeBases": "当前项目暂无知识库,请先在设置中创建",
"noDataSources": "当前项目暂无数据源,请先在设置中创建",
"confirmDeleteUser": "确认删除该用户吗?",
"newUserMustHavePassword": "新建用户必须填写密码",
"anErrorOccurred": "发生错误",
"addUser": "添加用户",
"editUser": "编辑用户",
"addNewUser": "添加新用户",
"email": "邮箱",
"password": "密码",
"activeStatus": "激活状态",
"adminPrivileges": "管理员权限",
"id": "ID",
"status": "状态",
"role": "角色",
"noUserData": "暂无用户数据",
"normal": "正常",
"disabled": "禁用",
"admin": "管理员",
"regularUser": "普通用户",
"fillRequiredInfoFirst": "请先填写必要信息(供应商、模型ID)",
"extraConfigMustBeValidJson": "额外配置必须是有效的JSON",
"connectionTestSuccessful": "连接测试成功!",
"connectionTestFailed": "连接测试失败",
"fillRequiredFields": "请填写必填项",
"failedToSaveConfig": "保存配置失败",
"confirmDeleteModel": "确认删除该模型吗?",
"noPermissionAdminOnly": "无权限访问此页面,请使用管理员账号登录。",
"addModel": "添加模型",
"modelName": "模型名称",
"provider": "供应商",
"modelIdentifier": "模型标识",
"noModelData": "暂无模型数据",
"currentDefaultModel": "当前默认模型",
"clickToSetDefault": "点击设为默认",
"default": "默认",
"setDefault": "设为默认",
"editModel": "编辑模型",
"providerRequired": "供应商 *",
"egGpt4": "如:GPT-4",
"modelIdRequired": "模型ID *",
"egGpt4Turbo": "如:gpt-4-turbo",
"apiDomain": "API 域名",
"egApiDomain": "如:https://api.openai.com/v1",
"extraConfigJson": "额外配置 (JSON)",
"unknownError": "未知错误",
"confirmDeleteSkill": "确定要删除这个技能吗?",
"selectProjectToManageSkills": "请先在顶部选择一个项目以管理其技能",
"skillsRepository": "技能中心",
"manageAiSkillsDesc": "管理该项目的 AI 技能和 MCP 服务器配置",
"uploadSkill": "上传 Skill",
"source": "来源",
"installationTime": "安装时间",
"noSkillsInProjectClickImport": "该项目尚无技能,点击“导入 Skill”开始",
"viewOrEditSkill": "查看/编辑技能",
"addNewSkill": "添加新技能",
"skillName": "技能名称",
"selectType": "选择类型",
"selectStatus": "选择状态",
"brieflyDescribeSkillFunction": "简要描述技能的功能...",
"content": "内容",
"pythonSqlApiContentPlaceholder": "Python 代码、SQL 查询模板或 API 规范...",
"saveSkill": "保存技能",
"safe": "安全",
"lowRisk": "低风险",
"localImport": "本地导入",
"systemBuiltin": "系统内置",
"backendGenerated": "后台生成",
"uploadedFile": "文件上传",
"filterBySource": "筛选来源",
"allSources": "全部来源",
"zhipuAi": "ZhipuAI (智谱)",
"dashScope": "DashScope (通义千问)",
"volcengine": "Volcengine (火山引擎)",
"tableRowColDesc": "TABLE · {{rowCount}} 行 · {{colCount}} 列",
"retry": "重试",
"allStates": "全部状态",
"refreshHealth": "刷新健康检查",
"a2aConfig": "A2A 配置",
"a2aAgentManagement": "A2A Agent 管理",
"a2aTaskObservability": "A2A 任务观测",
"a2aStatus": "A2A 状态",
"a2aTaskCreated": "A2A 任务已创建",
"a2aTaskFailed": "A2A 任务失败",
"a2aModeEnabled": "A2A 模式:开启",
"a2aModeDisabled": "A2A 模式:关闭",
"autoSelectAgent": "自动选择 Agent",
"addA2aAgent": "添加 A2A Agent",
"editA2aAgent": "编辑 A2A Agent",
"saveA2aAgent": "保存 A2A Agent",
"a2aAgentName": "A2A Agent 名称",
"authScheme": "认证方式",
"authToken": "认证 Token",
"leaveEmptyToKeepUnchanged": "留空表示不修改",
"healthStatus": "健康状态",
"healthy": "健康",
"unhealthy": "异常",
"protocol": "协议版本",
"capabilities": "能力",
"taskId": "任务 ID",
"taskSource": "任务来源",
"time": "时间",
"noA2aAgents": "暂无 A2A Agent",
"noA2aTasks": "暂无 A2A 任务",
"confirmDeleteA2aAgent": "确定要删除这个 A2A Agent 吗?",
"projectName": "项目名称",
"dashboardMenu": "仪表盘",
"newThread": "新会话",
"threads": "会话",
"archivedThreads": "已归档会话",
"defaultUser": "默认用户",
"searchSkills": "搜索技能...",
"welcomeBack": "欢迎回来",
"createAccount": "创建账号",
"username": "用户名",
"enterUsername": "请输入您的用户名",
"email": "邮箱",
"enterEmail": "请输入您的邮箱",
"password": "密码",
"enterPassword": "请输入您的密码",
"signIn": "登录",
"signUp": "注册",
"dontHaveAccount": "还没有账号?",
"alreadyHaveAccount": "已经有账号了?",
"registrationSuccess": "注册成功!请登录。",
"registrationSuccessWithVerification": "注册成功!请前往邮箱点击验证链接激活账号。",
"inactiveUserError": "账号未激活,请前往邮箱激活。",
"resendVerification": "重新发送验证邮件",
"verificationSent": "验证邮件已发送,请查收。",
"verifyEmailTitle": "邮箱验证",
"verifyingEmail": "正在验证您的邮箱...",
"verifyEmailSuccess": "邮箱验证成功!您现在可以登录了。",
"verifyEmailFailed": "邮箱验证失败或链接已过期。",
"goToLogin": "前往登录",
"errorOccurred": "发生了一个错误",
"mcpConfig": "MCP 配置",
"mcp": "MCP",
"skills": "Skills 配置",
"transport": "传输协议",
"command": "命令",
"args": "参数 (JSON 数组)",
"env": "环境变量 (JSON 对象)",
"url": "URL",
"headers": "请求头 (JSON 对象)",
"addMcpServer": "添加 MCP 服务器",
"editMcpServer": "编辑 MCP 服务器",
"mcpServerName": "MCP 服务器名称",
"noMcpServers": "暂无 MCP 服务器",
"confirmDeleteMcpServer": "确定要删除这个 MCP 服务器吗?",
"saveMcpServer": "保存 MCP 服务器",
"subagents": "智能体编排",
"subagentManagement": "智能体编排",
"manageSubagentsDesc": "管理该项目的子智能体",
"createSubagent": "新建子智能体",
"addSubagent": "添加子智能体",
"editSubagent": "编辑子智能体",
"subagentName": "子智能体名称",
"selectModel": "请选择一个模型",
"systemInstructionsPlaceholder": "你是一个有用的 AI 助手...",
"noSubagents": "暂无配置的子智能体",
"confirmDeleteSubagent": "确定要删除这个子智能体吗?",
"selectProjectToManageSubagents": "请先选择一个项目以管理其子智能体",
"knowledgeBaseGroup": "知识库",
"moreGroup": "更多",
"knowledgeBases": "知识库管理",
"voiceSettings": "语音输入配置",
"enableVoiceInput": "启用语音输入",
"voiceSettingsDisabledHint": "请先开启语音输入,再配置服务地址",
"voiceInputDisabledHint": "请先开启语音输入",
"voiceInputNotEnabled": "语音输入未开启,请先到左下角用户名 -> 更多 -> 语音输入配置中开启",
"embeddingModels": "Embedding 模型",
"webSearchConfig": "Web 搜索配置",
"configureWebSearchProvider": "配置 AI 智能体默认的网页搜索提供商及参数。",
"selectProvider": "选择提供商",
"enterApiKey": "输入 API Key",
"apiKeyRequiredFor": "{{provider}} 需要提供 API Key",
"baseUrl": "基础 URL",
"baseUrlRequiredFor": "{{provider}} 需要提供基础 URL",
"maxResults": "最大结果数",
"maxResultsDescription": "搜索返回的最大结果数量 (1-20)",
"failedToLoadConfig": "加载配置失败",
"failedToSaveConfig": "保存配置失败",
"configSaved": "配置保存成功。注意:活跃的智能体可能需要重启新会话才能应用新配置。"
}
+130
View File
@@ -0,0 +1,130 @@
@import "@fontsource-variable/geist";
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* @plugin "tw-animate-css"; */
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+63
View File
@@ -0,0 +1,63 @@
const API_BASE_URL = ''; // Relative path because of proxy
interface RequestOptions extends RequestInit {
headers?: Record<string, string>;
}
async function request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const token = localStorage.getItem('token');
const defaultHeaders: Record<string, string> = {
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
};
// Only set Content-Type to application/json if data is not FormData
if (!(options.body instanceof FormData)) {
defaultHeaders['Content-Type'] = 'application/json';
}
const config: RequestInit = {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
};
try {
const response = await fetch(`${API_BASE_URL}${url}`, config);
if (!response.ok) {
if (response.status === 401) {
// Handle unauthorized (e.g., redirect to login or clear store)
localStorage.removeItem('token');
localStorage.removeItem('user');
// You might want to trigger a custom event or use window.location here
}
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || errorData.message || `API Error: ${response.statusText}`);
}
// Handle empty responses (e.g. 204 No Content)
if (response.status === 204) {
return {} as T;
}
return await response.json();
} catch (error) {
console.error('API Request Failed:', error);
throw error;
}
}
export const api = {
get: <T>(url: string, options?: RequestOptions) => request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, data: any, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'POST', body: data instanceof FormData ? data : JSON.stringify(data) }),
put: <T>(url: string, data: any, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'PUT', body: data instanceof FormData ? data : JSON.stringify(data) }),
delete: <T>(url: string, options?: RequestOptions) => request<T>(url, { ...options, method: 'DELETE' }),
patch: <T>(url: string, data: any, options?: RequestOptions) =>
request<T>(url, { ...options, method: 'PATCH', body: data instanceof FormData ? data : JSON.stringify(data) }),
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
import { ErrorBoundary } from './components/ErrorBoundary'
import './i18n/config'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)
+367
View File
@@ -0,0 +1,367 @@
import { useState, useMemo, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Responsive, WidthProvider } from 'react-grid-layout/legacy';
import { useDashboardStore } from '../store/dashboardStore';
import { useProjectStore } from '../store/projectStore';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { X, Type, Bold, Italic, Underline } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from 'recharts';
import { VegaChart } from "@/components/VegaChart";
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
const CHART_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
const TABLE_PREVIEW_LIMIT = 20;
function isNumericValue(value: unknown) {
if (typeof value === 'number') return Number.isFinite(value);
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return false;
const parsed = Number(trimmed);
return Number.isFinite(parsed);
}
return false;
}
function inferChartKeys(data: Record<string, unknown>[]) {
if (data.length === 0) {
return { xKey: null as string | null, yKeys: [] as string[] };
}
const allKeys = Object.keys(data[0] || {});
if (allKeys.length === 0) {
return { xKey: null as string | null, yKeys: [] as string[] };
}
const preferredX = ['name', 'date', 'time', 'category', 'label'];
const xKey = preferredX.find((k) => allKeys.includes(k)) || allKeys[0];
const candidateY = allKeys.filter((k) => k !== xKey);
const numericY = candidateY.filter((key) => data.some((row) => isNumericValue(row[key])));
const yKeys = (numericY.length > 0 ? numericY : candidateY).slice(0, 3);
return { xKey, yKeys };
}
export function Dashboard() {
const { t } = useTranslation();
const { dashboards, activeDashboardId, removeChart, updateLayout, loadDashboards, renameDashboard, updateDashboardTitleStyle } = useDashboardStore();
const { currentProject } = useProjectStore();
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState("");
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
const activeDashboard = useMemo(() => {
return dashboards.find((d) => d.id === activeDashboardId) || dashboards[0] || null;
}, [dashboards, activeDashboardId]);
const charts = activeDashboard?.charts || [];
const ResponsiveGridLayout = useMemo(
() => WidthProvider(Responsive as any) as any,
[]
);
const layouts = useMemo(() => ({
lg: charts.map((c) => c.layout)
}), [charts]);
const onLayoutChange = (currentLayout: any[]) => {
if (currentProject && activeDashboard) {
updateLayout(
currentLayout.map((item) => ({
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
})),
activeDashboard.id,
currentProject.id
);
}
};
const handleTitleSubmit = () => {
if (activeDashboard && currentProject && editTitle.trim()) {
renameDashboard(activeDashboard.id, editTitle.trim(), currentProject.id);
}
setIsEditingTitle(false);
};
const handleStyleChange = (key: string, value: string) => {
if (activeDashboard && currentProject) {
updateDashboardTitleStyle(activeDashboard.id, { [key]: value }, currentProject.id);
}
};
if (!currentProject) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p>{t('selectProjectToViewDashboard')}</p>
</div>
);
}
if (dashboards.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p>{t('noDashboardsInCurrentProject')}</p>
<p className="text-sm">{t('createDashboardToGetStarted')}</p>
</div>
);
}
if (!activeDashboard || charts.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p>{t('noChartsInCurrentProject')}</p>
<p className="text-sm">{t('goToChatToAddCharts')}</p>
</div>
);
}
return (
<div className="p-4 h-full overflow-y-auto">
<div className="mb-4 flex items-center justify-between group">
{isEditingTitle ? (
<Input
ref={titleInputRef}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={handleTitleSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') handleTitleSubmit();
if (e.key === 'Escape') setIsEditingTitle(false);
}}
className="text-2xl font-bold h-auto py-1 px-2 -ml-2 bg-transparent border-transparent hover:border-border focus:border-indigo-500 focus:ring-indigo-500 max-w-md"
/>
) : (
<div className="flex items-center gap-2">
<h1
className="text-2xl font-bold cursor-pointer hover:bg-muted px-2 py-1 -ml-2 rounded transition-colors"
style={{
fontSize: activeDashboard.titleStyle?.fontSize || '1.5rem',
fontWeight: activeDashboard.titleStyle?.fontWeight || '700',
color: activeDashboard.titleStyle?.color || 'inherit',
fontStyle: activeDashboard.titleStyle?.fontStyle || 'normal',
textDecoration: activeDashboard.titleStyle?.textDecoration || 'none',
textAlign: activeDashboard.titleStyle?.textAlign || 'left',
}}
onClick={() => {
setEditTitle(activeDashboard.name || t('dashboardMenu'));
setIsEditingTitle(true);
setTimeout(() => titleInputRef.current?.focus(), 0);
}}
>
{activeDashboard.name || t('dashboardMenu')}
</h1>
<Popover>
<PopoverTrigger>
<div className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity">
<Type className="h-4 w-4 text-muted-foreground" />
</div>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">{t('fontSize') || 'Font Size'}</label>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleStyleChange('fontSize', '1.25rem')}>S</Button>
<Button variant="outline" size="sm" onClick={() => handleStyleChange('fontSize', '1.5rem')}>M</Button>
<Button variant="outline" size="sm" onClick={() => handleStyleChange('fontSize', '1.875rem')}>L</Button>
<Button variant="outline" size="sm" onClick={() => handleStyleChange('fontSize', '2.25rem')}>XL</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">{t('textStyle') || 'Text Style'}</label>
<div className="flex items-center gap-2">
<Button
variant={activeDashboard.titleStyle?.fontWeight === 'normal' ? 'default' : 'outline'}
size="icon"
className="h-8 w-8"
onClick={() => handleStyleChange('fontWeight', activeDashboard.titleStyle?.fontWeight === 'normal' ? '700' : 'normal')}
>
<Bold className="h-4 w-4" />
</Button>
<Button
variant={activeDashboard.titleStyle?.fontStyle === 'italic' ? 'default' : 'outline'}
size="icon"
className="h-8 w-8"
onClick={() => handleStyleChange('fontStyle', activeDashboard.titleStyle?.fontStyle === 'italic' ? 'normal' : 'italic')}
>
<Italic className="h-4 w-4" />
</Button>
<Button
variant={activeDashboard.titleStyle?.textDecoration === 'underline' ? 'default' : 'outline'}
size="icon"
className="h-8 w-8"
onClick={() => handleStyleChange('textDecoration', activeDashboard.titleStyle?.textDecoration === 'underline' ? 'none' : 'underline')}
>
<Underline className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">{t('textColor') || 'Text Color'}</label>
<div className="flex items-center gap-2">
{['inherit', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6'].map(color => (
<button
key={color}
className={`w-6 h-6 rounded-full border border-border flex items-center justify-center ${activeDashboard.titleStyle?.color === color ? 'ring-2 ring-indigo-500 ring-offset-1' : ''}`}
style={{ backgroundColor: color === 'inherit' ? '#18181b' : color }}
onClick={() => handleStyleChange('color', color)}
/>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
onLayoutChange={onLayoutChange}
isDraggable
isResizable
>
{charts.map((chart) => {
const rows = chart.data as Record<string, unknown>[];
const columns = Object.keys(rows[0] || {});
const previewRows = chart.type === "table" ? rows.slice(0, TABLE_PREVIEW_LIMIT) : rows;
const isTableTruncated = chart.type === "table" && rows.length > TABLE_PREVIEW_LIMIT;
return (
<div key={chart.id} className="relative group">
<Card className="h-full flex flex-col shadow-sm border-muted">
<CardHeader className="p-3 pb-1 shrink-0 flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-sm">{chart.title}</CardTitle>
<CardDescription className="text-[10px] mt-0.5">
{chart.type === "table"
? t('tableRowColDesc', { rowCount: rows.length, colCount: columns.length })
: `${chart.type.toUpperCase()} Chart`}
</CardDescription>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity -mr-1"
onClick={() => removeChart(chart.id, activeDashboard.id, currentProject.id)}
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="flex-1 min-h-0 p-3 pt-1 flex flex-col">
{(() => {
if (chart.type === "table") {
if (rows.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground">
</div>
);
}
if (columns.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground">
</div>
);
}
return (
<div className="flex-1 w-full flex flex-col min-h-0">
<div className="text-[11px] text-muted-foreground mb-1 shrink-0">
{isTableTruncated ? t('previewTableRows', { previewLimit: TABLE_PREVIEW_LIMIT, rowCount: rows.length, colCount: columns.length }) : t('totalTableRows', { rowCount: rows.length, colCount: columns.length })}
</div>
<ScrollArea className="flex-1 w-full border border-border rounded-md bg-background">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => <TableHead key={col}>{col}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{previewRows.map((row, i) => (
<TableRow key={i}>
{columns.map((col) => (
<TableCell key={`${i}-${col}`}>{String(row[col] ?? "")}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
);
}
if (chart.chartSpec && rows.length > 0) {
return (
<div className="flex-1 w-full overflow-hidden">
<VegaChart data={rows} spec={chart.chartSpec} />
</div>
);
}
const { xKey, yKeys } = inferChartKeys(rows);
if (!xKey || yKeys.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground">
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
{chart.type === 'bar' ? (
<BarChart data={rows}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
<XAxis dataKey={xKey} tickLine={false} axisLine={false} tick={{ fontSize: 10, fill: '#6b7280' }} />
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 10, fill: '#6b7280' }} />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.05)' }}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
{yKeys.map((key, index) => (
<Bar key={key} dataKey={key} fill={CHART_COLORS[index % CHART_COLORS.length]} radius={[4, 4, 0, 0]} name={key} />
))}
</BarChart>
) : (
<LineChart data={rows}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
<XAxis dataKey={xKey} tickLine={false} axisLine={false} tick={{ fontSize: 10, fill: '#6b7280' }} />
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 10, fill: '#6b7280' }} />
<Tooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
{yKeys.map((key, index) => (
<Line key={key} type="monotone" dataKey={key} stroke={CHART_COLORS[index % CHART_COLORS.length]} strokeWidth={2} dot={{ r: 3 }} />
))}
</LineChart>
)}
</ResponsiveContainer>
);
})()}
</CardContent>
</Card>
</div>
)})}
</ResponsiveGridLayout>
</div>
);
}
+413
View File
@@ -0,0 +1,413 @@
import { useState, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { api } from "@/lib/api";
import { DataSourceForm, type DataSourceConfig } from "@/components/DataSourceForm";
import { Button } from "@/components/ui/button";
import { Plus, Database, Pencil, Trash2, Loader2, Info, ChevronLeft, FileText, Search, Network, GripVertical } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useProjectStore } from "@/store/projectStore";
import { useNavigate } from "react-router-dom";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
useSortable
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
const SOURCE_TYPES = [
{ id: "csv", name: "CSV Upload", icon: <FileText className="h-6 w-6 text-green-600" /> },
{ id: "bigquery", name: "BigQuery", icon: <Database className="h-6 w-6 text-blue-500" /> },
{ id: "postgres", name: "PostgreSQL", icon: <Database className="h-6 w-6 text-indigo-600" /> },
{ id: "supabase", name: "Supabase", icon: <Database className="h-6 w-6 text-emerald-500" /> },
{ id: "mysql", name: "MySQL", icon: <Database className="h-6 w-6 text-cyan-600" /> },
{ id: "oracle", name: "Oracle", icon: <Database className="h-6 w-6 text-red-600" /> },
{ id: "sqlserver", name: "SQL Server", icon: <Database className="h-6 w-6 text-red-500" /> },
{ id: "clickhouse", name: "ClickHouse", icon: <Database className="h-6 w-6 text-yellow-500" /> },
{ id: "trino", name: "Trino", icon: <Database className="h-6 w-6 text-pink-500" /> },
{ id: "snowflake", name: "Snowflake", icon: <Database className="h-6 w-6 text-blue-400" /> },
{ id: "athena-trino", name: "Athena (Trino)", icon: <Search className="h-6 w-6 text-purple-600" /> },
{ id: "redshift", name: "Redshift", icon: <Database className="h-6 w-6 text-purple-700" /> },
{ id: "databricks", name: "Databricks", icon: <Database className="h-6 w-6 text-orange-600" /> },
{ id: "emr-spark", name: "EMR (Spark)", icon: <Database className="h-6 w-6 text-indigo-800" /> },
{ id: "athena-spark", name: "Athena (Spark)", icon: <Search className="h-6 w-6 text-purple-500" /> },
{ id: "spark", name: "Spark", icon: <Database className="h-6 w-6 text-orange-500" /> },
{ id: "sqlite", name: "SQLite", icon: <Database className="h-6 w-6 text-blue-600" /> },
{ id: "parquet", name: "Parquet", icon: <FileText className="h-6 w-6 text-yellow-600" /> },
];
export function DataSources() {
const { t } = useTranslation();
const [datasources, setDatasources] = useState<DataSourceConfig[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [view, setView] = useState<"list" | "select-type">("list");
const [isOpen, setIsOpen] = useState(false);
const [editingDs, setEditingDs] = useState<DataSourceConfig | null>(null);
const [selectedType, setSelectedType] = useState<string | null>(null);
const { currentProject } = useProjectStore();
const navigate = useNavigate();
useEffect(() => {
if (currentProject) {
fetchDataSources();
}
}, [currentProject]);
const fetchDataSources = async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const data = await api.get<DataSourceConfig[]>(`/api/v1/datasources?project_id=${currentProject.id}`);
// 从 localStorage 中恢复顺序
const savedOrderStr = localStorage.getItem(`datasources_order_${currentProject.id}`);
if (savedOrderStr) {
try {
const savedOrder = JSON.parse(savedOrderStr) as string[];
// 按照保存的 ID 顺序重新排列,同时把新添加的数据源放在末尾
data.sort((a, b) => {
const indexA = savedOrder.indexOf(a.id as unknown as string);
const indexB = savedOrder.indexOf(b.id as unknown as string);
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
} catch (e) {
console.error("Failed to parse saved datasource order", e);
}
}
setDatasources(data);
} catch (e) {
console.error("Failed to fetch data sources", e);
} finally {
setIsLoading(false);
}
}
const handleCreate = () => {
setEditingDs(null);
setSelectedType(null);
setView("select-type");
};
const handleSelectType = (typeId: string) => {
setSelectedType(typeId);
setIsOpen(true);
};
const handleEdit = (ds: DataSourceConfig) => {
setEditingDs(ds);
setSelectedType(ds.type);
setIsOpen(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm(t('confirmDeleteDataSource'))) return;
try {
await api.delete(`/api/v1/datasources/${id}`);
fetchDataSources();
} catch (e) {
console.error("Failed to delete data source", e);
}
};
const handleSubmit = async (data: Omit<DataSourceConfig, "id">) => {
if (!currentProject) return;
try {
if (editingDs?.id) {
await api.put(`/api/v1/datasources/${editingDs.id}`, { ...data, project_id: currentProject.id });
} else {
await api.post("/api/v1/datasources", { ...data, project_id: currentProject.id });
}
setIsOpen(false);
fetchDataSources();
} catch (e) {
console.error("Failed to save data source", e);
alert(t('saveFailed') + (e as any).message);
}
};
const handleTest = async (type: string, config: Record<string, any>) => {
try {
const res = await api.post<{ success: boolean; message: string }>("/api/v1/datasources/test", { type, config });
return res.success;
} catch (e) {
console.error("Test connection failed", e);
throw e;
}
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px movement before drag starts to avoid accidental drags
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
if (view === "select-type") {
return (
<div className="h-full flex flex-col bg-[#F9FAFB]">
<div className="px-12 py-8">
<button
onClick={() => setView("list")}
className="flex items-center text-muted-foreground hover:text-foreground/90 transition-colors mb-6 group"
>
<ChevronLeft className="h-4 w-4 mr-1 group-hover:-translate-x-0.5 transition-transform" />
{t('backToList')}
</button>
<h1 className="text-2xl font-semibold text-foreground/90 mb-6">Connect an external data source</h1>
<div className="bg-blue-50 border border-blue-100 rounded-md p-3 mb-8 flex items-start gap-3">
<Info className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<p className="text-sm text-blue-800">
<span className="font-semibold">dbt integration</span> is available for PostgreSQL, MySQL, BigQuery, Redshift, and Snowflake (For Essential Plan and above). <a href="#" className="text-blue-600 hover:underline">Contact Us</a> to suggest new data sources.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{SOURCE_TYPES.map((type) => (
<button
key={type.id}
onClick={() => handleSelectType(type.id)}
className="flex items-center gap-4 bg-background p-4 rounded-lg border border-border hover:border-blue-500 hover:shadow-sm transition-all text-left group"
>
<div className="w-10 h-10 flex items-center justify-center rounded bg-muted/50 group-hover:bg-blue-50 transition-colors">
{type.icon}
</div>
<span className="font-medium text-foreground/80 group-hover:text-blue-600 transition-colors">
{type.name}
</span>
</button>
))}
</div>
</div>
<Dialog open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
if (!open && !editingDs) setSelectedType(null);
}}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingDs ? t('editDataSource') : t('createNewDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === selectedType)?.name || "" })}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<DataSourceForm
initialData={editingDs ? editingDs : (selectedType ? { name: "", type: selectedType, config: {} } : null)}
onSubmit={handleSubmit}
onTest={handleTest}
onCancel={() => setIsOpen(false)}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// Helper function to extract host and db from connection string
const parseConnectionString = (url: string | undefined, type: 'host' | 'database') => {
if (!url) return null;
try {
// Very basic parser for postgresql://user:pass@host:port/dbname format
// Works for postgresql, mysql, etc.
const withoutScheme = url.split('://')[1];
if (!withoutScheme) return null;
const parts = withoutScheme.split('@');
const hostPortPath = parts.length > 1 ? parts[1] : parts[0];
const pathParts = hostPortPath.split('/');
const hostAndPort = pathParts[0];
const host = hostAndPort.split(':')[0];
let db = pathParts.length > 1 ? pathParts[1] : null;
if (db && db.includes('?')) {
db = db.split('?')[0]; // Remove query params like ?sslmode=require
}
return type === 'host' ? host : db;
} catch (e) {
return null;
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setDatasources((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// 保存新的顺序到 localStorage
if (currentProject) {
localStorage.setItem(
`datasources_order_${currentProject.id}`,
JSON.stringify(newItems.map(i => i.id))
);
}
return newItems;
});
}
};
// Sortable Item Component
const SortableDataSourceCard = ({ ds }: { ds: DataSourceConfig }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: ds.id! });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group relative bg-background border border-border rounded-xl p-5 hover:shadow-md transition-all hover:border-border
${isDragging ? 'opacity-50 z-50 ring-2 ring-indigo-500 shadow-xl' : ''}
`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-2 text-muted-foreground/50 hover:text-foreground/80 transition-colors"
title="Drag to reorder"
>
<GripVertical className="h-5 w-5" />
</div>
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center text-indigo-600">
<Database className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-foreground">{ds.name}</h3>
<p className="text-xs text-muted-foreground font-mono mt-0.5 uppercase">{ds.type}</p>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-blue-600" onClick={() => navigate(`/modeling/${ds.id}`)} title="Data Modeling">
<Network className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-muted-foreground" onClick={() => handleEdit(ds)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(ds.id!)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2 ml-8">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Host</span>
<span className="font-medium text-foreground/80 truncate max-w-[150px]" title={ds.config.host || ds.config.connection_string || "Local / File"}>
{ds.config.host || parseConnectionString(ds.config.connection_string, 'host') || "Local / File"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Database</span>
<span className="font-medium text-foreground/80 truncate max-w-[150px]" title={ds.config.database || (ds.config.file_path ? ds.config.file_path.split('/').pop() : ds.config.connection_string || "-")}>
{ds.config.database || parseConnectionString(ds.config.connection_string, 'database') || (ds.config.file_path ? ds.config.file_path.split('/').pop() : "-")}
</span>
</div>
</div>
</div>
);
};
return (
<div className="h-full flex flex-col bg-background">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Database className="h-5 w-5 text-indigo-500" />
{t('dataSourceConfig')}
</div>
<Button onClick={handleCreate} className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3">
<Plus className="h-4 w-4" />
{t('newDataSource')}
</Button>
</div>
<div className="flex-1 overflow-auto p-8">
{isLoading ? (
<div className="flex justify-center items-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : datasources.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-border rounded-xl bg-muted/50/50">
<Database className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground font-medium">{t('noDataSources')}</p>
<p className="text-muted-foreground text-sm mt-1">{t('clickTopRightToAddFirstDataSource')}</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={datasources.map(ds => ds.id!)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{datasources.map((ds) => (
<SortableDataSourceCard key={ds.id} ds={ds} />
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<Dialog open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
if (!open && !editingDs) setSelectedType(null);
}}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingDs ? t('editDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === editingDs.type)?.name || editingDs.type }) : t('createNewDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === selectedType)?.name || "" })}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<DataSourceForm
initialData={editingDs ? editingDs : (selectedType ? { name: "", type: selectedType, config: {} } : null)}
onSubmit={handleSubmit}
onTest={handleTest}
onCancel={() => setIsOpen(false)}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import { Loader2, Plus, RefreshCw, Search, Trash2, Pencil, Eye, EyeOff, Brain } from "lucide-react";
import { useAuthStore } from "@/store/authStore";
interface EmbeddingModelConfig {
id: string;
name?: string;
provider: string;
model: string;
api_key?: string;
api_base?: string;
}
const defaultForm: Omit<EmbeddingModelConfig, "id"> = {
name: "",
provider: "openai",
model: "",
api_key: "",
api_base: "",
};
export function EmbeddingModels() {
const { t } = useTranslation();
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const [configs, setConfigs] = useState<EmbeddingModelConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState("");
const [form, setForm] = useState<Omit<EmbeddingModelConfig, "id">>(defaultForm);
const fetchConfigs = async () => {
setIsLoading(true);
try {
const data = await api.get<EmbeddingModelConfig[]>("/api/v1/embedding-models");
setConfigs(data);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchConfigs();
}, []);
const filteredConfigs = useMemo(() => {
const value = keyword.trim().toLowerCase();
if (!value) return configs;
return configs.filter((item) =>
[item.name, item.model, item.provider].filter(Boolean).some((v) => String(v).toLowerCase().includes(value))
);
}, [configs, keyword]);
const openCreate = () => {
setEditingId(null);
setForm(defaultForm);
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const openEdit = (item: EmbeddingModelConfig) => {
setEditingId(item.id);
setForm({
name: item.name || "",
provider: item.provider || "openai",
model: item.model || "",
api_key: item.api_key || "",
api_base: item.api_base || "",
});
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const [isTesting, setIsTesting] = useState(false);
const handleTestConnection = async () => {
if (!form.model || !form.provider) {
setError(t('fillRequiredInfoFirst', 'Please fill required info first'));
return;
}
setIsTesting(true);
setError("");
try {
const payload = {
provider: form.provider,
model: form.model,
api_key: form.api_key,
api_base: form.api_base,
};
await api.post("/api/v1/embedding-models/test", payload);
alert(t('connectionTestSuccessful', 'Connection test successful'));
} catch (e: any) {
setError(e.message || t('connectionTestFailed', 'Connection test failed'));
} finally {
setIsTesting(false);
}
};
const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!form.model || !form.provider) {
setError(t('fillRequiredFields', 'Please fill required fields'));
return;
}
setIsSaving(true);
setError("");
try {
const payload = {
...form,
name: form.name || form.model,
};
if (editingId) {
await api.put(`/api/v1/embedding-models/${editingId}`, payload);
} else {
await api.post("/api/v1/embedding-models", payload);
}
setDialogOpen(false);
await fetchConfigs();
} catch (e: any) {
setError(e.message || t('failedToSaveConfig', 'Failed to save config'));
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm(t('confirmDeleteModel', 'Are you sure to delete this model?'))) return;
try {
await api.delete(`/api/v1/embedding-models/${id}`);
await fetchConfigs();
} catch (e) {
console.error(e);
}
};
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Brain className="h-5 w-5 text-indigo-500" />{t('embeddingModels')}</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="h-4 w-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
<Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder={t('searchModel', 'Search model')} className="w-[200px] pl-9 h-8 text-sm" />
</div>
<Button variant="outline" size="icon" className="h-8 w-8 text-muted-foreground" onClick={fetchConfigs}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button className="h-8 px-3 bg-indigo-600 hover:bg-indigo-700 text-primary-foreground text-sm" onClick={openCreate} disabled={!isAdmin}>
<Plus className="h-4 w-4 mr-1" />{t('addModel', 'Add Model')}</Button>
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="bg-background rounded-xl border border-border shadow-sm overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('modelName', 'Model Name')}</TableHead>
<TableHead>{t('provider', 'Provider')}</TableHead>
<TableHead>{t('modelIdentifier', 'Model Identifier')}</TableHead>
<TableHead className="text-right">{t('actions', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredConfigs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center h-24 text-muted-foreground">{t('noModelData', 'No model data')}</TableCell>
</TableRow>
) : (
filteredConfigs.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.name || item.model}
</TableCell>
<TableCell className="capitalize">{item.provider}</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">{item.model}</TableCell>
<TableCell className="text-right">
{isAdmin && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-indigo-600"
onClick={() => openEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-red-600"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSave}>
<DialogHeader>
<DialogTitle>{editingId ? t('editModel', 'Edit Model') : t('addModel', 'Add Model')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-2">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('modelName', 'Model Name')}</Label>
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder={t('egTextEmbedding3Small', 'e.g. text-embedding-3-small')} />
</div>
<div className="space-y-2">
<Label>{t('providerRequired', 'Provider (Required)')}</Label>
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="azure">Azure OpenAI</SelectItem>
<SelectItem value="ollama">Ollama</SelectItem>
<SelectItem value="local">Local (OpenAI Compatible)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('modelIdRequired', 'Model ID (Required)')}</Label>
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder="text-embedding-3-small" required />
</div>
<div className="space-y-2">
<Label>{t('apiDomain', 'API Base URL')}</Label>
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder={t('egApiDomain', 'e.g. https://api.openai.com/v1')} />
</div>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
type={showApiKey ? "text" : "password"}
value={form.api_key || ""}
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
className="pr-10"
placeholder={t('leaveBlankIfNotModifying', 'Leave blank if not modifying')}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground"
onClick={() => setShowApiKey((v) => !v)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
<DialogFooter className="flex items-center justify-between gap-2">
<Button type="button" variant="outline" onClick={handleTestConnection} disabled={isTesting}>
{isTesting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t('testConnection', 'Test Connection')}
</Button>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>{t('cancel', 'Cancel')}</Button>
<Button type="submit" disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t('save', 'Save')}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+624
View File
@@ -0,0 +1,624 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Save, Loader2, RefreshCw, Pencil, Trash2, FileText, Plus, BookOpen, GripVertical } from "lucide-react";
import { api } from "@/lib/api";
import { useProjectStore } from "@/store/projectStore";
import { Textarea } from "@/components/ui/textarea";
import { KnowledgeBaseForm, type KnowledgeBaseFormValues } from "@/components/KnowledgeBaseForm";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
useSortable
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface KnowledgeBase {
id: string;
name: string;
description?: string;
project_id?: number | null;
embedding_model?: string | null;
chunk_size: number;
chunk_overlap: number;
top_k: number;
is_active: boolean;
updated_at: string;
documents?: Array<{ id: string }>;
}
interface KnowledgeDocument {
id: string;
title: string;
content: string;
metadata?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
const defaultKnowledgeDocumentForm = {
title: '',
content: '',
metadata: '',
};
export function KnowledgeBases() {
const { t } = useTranslation();
const { currentProject } = useProjectStore();
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [isLoading, setIsLoading] = useState(false);
// KB Form Dialog State
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingKb, setEditingKb] = useState<KnowledgeBase | null>(null);
const [isSavingKb, setIsSavingKb] = useState(false);
const [reindexingKbId, setReindexingKbId] = useState('');
// Docs Dialog State
const [docsDialogOpen, setDocsDialogOpen] = useState(false);
const [selectedKbForDocs, setSelectedKbForDocs] = useState<KnowledgeBase | null>(null);
const [knowledgeDocuments, setKnowledgeDocuments] = useState<KnowledgeDocument[]>([]);
const [isLoadingDocs, setIsLoadingDocs] = useState(false);
// Doc Form State inside Docs Dialog
const [isSavingDoc, setIsSavingDoc] = useState(false);
const [editingDocId, setEditingDocId] = useState('');
const [docForm, setDocForm] = useState(defaultKnowledgeDocumentForm);
const [uploadingDocs, setUploadingDocs] = useState(false);
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
useEffect(() => {
void fetchKnowledgeBases();
}, [currentProject?.id]);
const fetchKnowledgeBases = async () => {
if (!currentProject) {
setKnowledgeBases([]);
return;
}
setIsLoading(true);
try {
const data = await api.get<KnowledgeBase[]>(`/api/v1/knowledge-bases?project_id=${currentProject.id}`);
// 从 localStorage 中恢复顺序
const savedOrderStr = localStorage.getItem(`knowledge_bases_order_${currentProject.id}`);
if (savedOrderStr) {
try {
const savedOrder = JSON.parse(savedOrderStr) as string[];
data.sort((a, b) => {
const indexA = savedOrder.indexOf(a.id);
const indexB = savedOrder.indexOf(b.id);
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
} catch (e) {
console.error("Failed to parse saved kb order", e);
}
}
setKnowledgeBases(data);
} catch (err: any) {
console.error(err);
} finally {
setIsLoading(false);
}
};
const handleCreateKb = () => {
setEditingKb(null);
setIsFormOpen(true);
};
const handleEditKb = (kb: KnowledgeBase) => {
setEditingKb(kb);
setIsFormOpen(true);
};
const handleSaveKb = async (data: KnowledgeBaseFormValues) => {
if (!currentProject) return;
setIsSavingKb(true);
try {
const payload = {
...data,
project_id: currentProject.id,
};
if (editingKb) {
await api.put(`/api/v1/knowledge-bases/${editingKb.id}`, payload);
} else {
await api.post('/api/v1/knowledge-bases', payload);
}
setIsFormOpen(false);
await fetchKnowledgeBases();
} finally {
setIsSavingKb(false);
}
};
const handleDeleteKb = async (id: string) => {
if (!window.confirm(t('confirmDeleteKnowledgeBase', 'Are you sure to delete this knowledge base?'))) {
return;
}
try {
await api.delete(`/api/v1/knowledge-bases/${id}`);
await fetchKnowledgeBases();
} catch (err: any) {
console.error(err);
}
};
const handleReindexKb = async (id: string) => {
setReindexingKbId(id);
try {
await api.post(`/api/v1/knowledge-bases/${id}/reindex`, {});
alert(t('knowledgeBaseReindexSuccess', 'Knowledge base reindexed successfully'));
} catch (err: any) {
console.error(err);
alert(t('knowledgeBaseReindexFailed', 'Failed to reindex knowledge base'));
} finally {
setReindexingKbId('');
}
};
// --- Document Management Methods ---
const handleManageDocs = async (kb: KnowledgeBase) => {
setSelectedKbForDocs(kb);
setDocsDialogOpen(true);
setEditingDocId('');
setDocForm(defaultKnowledgeDocumentForm);
await fetchKnowledgeDocuments(kb.id);
};
const fetchKnowledgeDocuments = async (kbId: string) => {
if (!kbId) return;
setIsLoadingDocs(true);
try {
const data = await api.get<KnowledgeDocument[]>(`/api/v1/knowledge-bases/${kbId}/documents`);
setKnowledgeDocuments(data);
} catch (err: any) {
console.error(err);
} finally {
setIsLoadingDocs(false);
}
};
const resetDocForm = () => {
setEditingDocId('');
setDocForm(defaultKnowledgeDocumentForm);
};
const handleSaveDoc = async () => {
if (!selectedKbForDocs) return;
if (!docForm.title.trim() || !docForm.content.trim()) {
alert(t('knowledgeDocumentTitleRequired', 'Document title and content are required'));
return;
}
let parsedMetadata = {};
if (docForm.metadata.trim()) {
try {
parsedMetadata = JSON.parse(docForm.metadata.trim());
} catch {
alert(t('knowledgeDocumentMetadataInvalid', 'Document metadata must be valid JSON'));
return;
}
}
setIsSavingDoc(true);
try {
const payload = {
title: docForm.title.trim(),
content: docForm.content.trim(),
metadata: parsedMetadata,
};
if (editingDocId) {
await api.put(`/api/v1/knowledge-bases/${selectedKbForDocs.id}/documents/${editingDocId}`, payload);
} else {
await api.post(`/api/v1/knowledge-bases/${selectedKbForDocs.id}/documents`, payload);
}
resetDocForm();
await fetchKnowledgeDocuments(selectedKbForDocs.id);
await fetchKnowledgeBases(); // To update doc count
} catch (err: any) {
console.error(err);
} finally {
setIsSavingDoc(false);
}
};
const handleEditDoc = (doc: KnowledgeDocument) => {
setEditingDocId(doc.id);
setDocForm({
title: doc.title || '',
content: doc.content || '',
metadata: doc.metadata && Object.keys(doc.metadata).length > 0 ? JSON.stringify(doc.metadata, null, 2) : '',
});
};
const handleDeleteDoc = async (docId: string) => {
if (!selectedKbForDocs) return;
if (!window.confirm(t('confirmDeleteKnowledgeDocument', 'Are you sure to delete this document?'))) return;
try {
await api.delete(`/api/v1/knowledge-bases/${selectedKbForDocs.id}/documents/${docId}`);
if (editingDocId === docId) resetDocForm();
await fetchKnowledgeDocuments(selectedKbForDocs.id);
await fetchKnowledgeBases();
} catch (err: any) {
console.error(err);
}
};
const handleUploadDocs = async () => {
if (!selectedKbForDocs || uploadFiles.length === 0) return;
setUploadingDocs(true);
try {
const formData = new FormData();
uploadFiles.forEach((file) => formData.append('files', file));
await api.post(`/api/v1/knowledge-bases/${selectedKbForDocs.id}/documents/upload`, formData);
setUploadFiles([]);
await fetchKnowledgeDocuments(selectedKbForDocs.id);
await fetchKnowledgeBases();
} catch (err: any) {
console.error(err);
alert(t('knowledgeDocumentUploadFailed', 'Failed to upload knowledge documents'));
} finally {
setUploadingDocs(false);
}
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setKnowledgeBases((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// 保存新的顺序到 localStorage
if (currentProject) {
localStorage.setItem(
`knowledge_bases_order_${currentProject.id}`,
JSON.stringify(newItems.map(i => i.id))
);
}
return newItems;
});
}
};
const SortableKbCard = ({ kb }: { kb: KnowledgeBase }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: kb.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group relative bg-background border border-border rounded-xl p-5 hover:shadow-md transition-all hover:border-border
${isDragging ? 'opacity-50 z-50 ring-2 ring-indigo-500 shadow-xl' : ''}
`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-2 text-muted-foreground/50 hover:text-foreground/80 transition-colors"
title="Drag to reorder"
>
<GripVertical className="h-5 w-5" />
</div>
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center text-indigo-600">
<BookOpen className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-foreground flex items-center gap-2">
{kb.name}
{!kb.is_active && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted text-muted-foreground">Inactive</span>
)}
</h3>
<p className="text-xs text-muted-foreground font-mono mt-0.5">
{kb.documents?.length || 0} Documents
</p>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-blue-600" onClick={() => handleManageDocs(kb)} title={t('manageKnowledgeDocuments')}>
<FileText className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-green-600" onClick={() => handleReindexKb(kb.id)} disabled={reindexingKbId === kb.id} title={t('reindexKnowledgeBase')}>
{reindexingKbId === kb.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-muted-foreground" onClick={() => handleEditKb(kb)} title={t('editKnowledgeBase')}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-600 hover:bg-red-50" onClick={() => handleDeleteKb(kb.id)} title={t('deleteKnowledgeBase')}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2 ml-8">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Model</span>
<span className="font-medium text-foreground/80 truncate max-w-[150px]" title={kb.embedding_model || 'Default'}>
{kb.embedding_model ? kb.embedding_model.substring(0, 15) + (kb.embedding_model.length > 15 ? '...' : '') : 'Default'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Chunking</span>
<span className="font-medium text-foreground/80">
{kb.chunk_size} / {kb.chunk_overlap}
</span>
</div>
{kb.description && (
<p className="text-xs text-muted-foreground mt-3 line-clamp-2" title={kb.description}>
{kb.description}
</p>
)}
</div>
</div>
);
};
return (
<div className="h-full flex flex-col bg-background">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<BookOpen className="h-5 w-5 text-indigo-500" />
{t('knowledgeBases')}
</div>
<Button onClick={handleCreateKb} className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3" disabled={!currentProject}>
<Plus className="h-4 w-4" />
{t('createKnowledgeBase', 'New Knowledge Base')}
</Button>
</div>
<div className="flex-1 overflow-auto p-8">
{!currentProject ? (
<div className="flex justify-center items-center h-64">
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-4">
{t('selectProjectBeforeManageKnowledgeBase')}
</div>
</div>
) : isLoading ? (
<div className="flex justify-center items-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : knowledgeBases.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-border rounded-xl bg-muted/50/50">
<BookOpen className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground font-medium">{t('noKnowledgeBases')}</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={knowledgeBases.map(kb => kb.id)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{knowledgeBases.map((kb) => (
<SortableKbCard key={kb.id} kb={kb} />
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Form Dialog */}
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingKb ? t('updateKnowledgeBase', 'Edit Knowledge Base') : t('createKnowledgeBase', 'New Knowledge Base')}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<KnowledgeBaseForm
initialData={editingKb ? {
name: editingKb.name,
description: editingKb.description || '',
embedding_model: editingKb.embedding_model || '',
chunk_size: editingKb.chunk_size,
chunk_overlap: editingKb.chunk_overlap,
top_k: editingKb.top_k,
is_active: editingKb.is_active,
} : null}
onSubmit={handleSaveKb}
onCancel={() => setIsFormOpen(false)}
isSubmitting={isSavingKb}
/>
</div>
</DialogContent>
</Dialog>
{/* Docs Management Dialog */}
<Dialog open={docsDialogOpen} onOpenChange={(open) => {
setDocsDialogOpen(open);
if (!open) {
setSelectedKbForDocs(null);
setKnowledgeDocuments([]);
}
}}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader className="px-6 py-4 border-b border-border shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-indigo-500" />
{selectedKbForDocs ? t('knowledgeDocumentManagerTitle', { name: selectedKbForDocs.name }) : 'Manage Documents'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Upload Section */}
<div className="rounded-lg border border-border p-4 bg-muted/30">
<div className="text-sm font-medium text-foreground mb-1">{t('knowledgeDocumentUploadTitle')}</div>
<div className="text-xs text-muted-foreground mb-3">{t('knowledgeDocumentUploadHint')}</div>
<div className="flex items-center gap-3">
<Input
type="file"
multiple
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
disabled={uploadingDocs}
className="flex-1"
/>
<Button onClick={handleUploadDocs} disabled={uploadingDocs || uploadFiles.length === 0} className="shrink-0">
{uploadingDocs ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />}
{t('knowledgeDocumentUploadAction')}
</Button>
</div>
{uploadFiles.length > 0 && (
<div className="text-xs text-muted-foreground mt-2">
{t('knowledgeDocumentUploadSelected', { count: uploadFiles.length })}
</div>
)}
</div>
{/* Edit/Create Doc Form */}
<div className="rounded-lg border border-border p-4">
<div className="text-sm font-medium text-foreground mb-3">
{editingDocId ? t('updateKnowledgeDocument') : t('createKnowledgeDocument')}
</div>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="doc-title">{t('knowledgeDocumentTitle')}</Label>
<Input
id="doc-title"
value={docForm.title}
placeholder={t('knowledgeDocumentTitlePlaceholder')}
onChange={(e) => setDocForm((p) => ({ ...p, title: e.target.value }))}
disabled={isSavingDoc}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-content">{t('knowledgeDocumentContent')}</Label>
<Textarea
id="doc-content"
value={docForm.content}
placeholder={t('knowledgeDocumentContentPlaceholder')}
onChange={(e) => setDocForm((p) => ({ ...p, content: e.target.value }))}
disabled={isSavingDoc}
rows={4}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-metadata">{t('knowledgeDocumentMetadata')} (JSON)</Label>
<Textarea
id="doc-metadata"
value={docForm.metadata}
placeholder='{"key": "value"}'
onChange={(e) => setDocForm((p) => ({ ...p, metadata: e.target.value }))}
disabled={isSavingDoc}
rows={2}
className="font-mono text-sm"
/>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
{editingDocId && (
<Button variant="outline" size="sm" onClick={resetDocForm} disabled={isSavingDoc}>
{t('cancel')}
</Button>
)}
<Button size="sm" onClick={handleSaveDoc} disabled={isSavingDoc}>
{isSavingDoc ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{t('save')}
</Button>
</div>
</div>
</div>
{/* Document List */}
<div>
<div className="font-medium text-sm text-foreground/80 mb-3 flex items-center justify-between">
Documents ({knowledgeDocuments.length})
<Button variant="ghost" size="sm" className="h-8 text-muted-foreground" onClick={() => selectedKbForDocs && fetchKnowledgeDocuments(selectedKbForDocs.id)}>
<RefreshCw className={`h-4 w-4 mr-1 ${isLoadingDocs ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{isLoadingDocs ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : knowledgeDocuments.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground border border-dashed border-border rounded-lg bg-muted/20">
{t('noKnowledgeDocuments')}
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto pr-2">
{knowledgeDocuments.map((doc) => (
<div key={doc.id} className="group rounded-lg border border-border p-3 flex items-start justify-between gap-3 bg-background hover:border-indigo-200 transition-colors">
<div className="space-y-1 min-w-0 flex-1">
<div className="font-medium text-sm text-foreground truncate">{doc.title}</div>
<div className="text-xs text-muted-foreground truncate">{doc.content.slice(0, 100)}...</div>
<div className="text-[10px] text-muted-foreground mt-1">
{new Date(doc.updated_at).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-indigo-600" onClick={() => handleEditDoc(doc)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-red-600" onClick={() => handleDeleteDoc(doc.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, Languages } from "lucide-react";
import { api } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function Login() {
const { t, i18n } = useTranslation();
const [isLogin, setIsLogin] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [isInactiveError, setIsInactiveError] = useState(false);
const [resendStatus, setResendStatus] = useState("");
const [isResending, setIsResending] = useState(false);
const navigate = useNavigate();
const login = useAuthStore((state) => state.login);
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
});
const handleResendVerification = async () => {
setIsResending(true);
setResendStatus("");
try {
await api.post("/api/v1/auth/resend-verification", {
username: formData.username,
});
setResendStatus(t("verificationSent"));
} catch (err: any) {
setResendStatus(err.message || t("errorOccurred"));
} finally {
setIsResending(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsInactiveError(false);
setResendStatus("");
setIsLoading(true);
try {
if (isLogin) {
// Handle Login using application/x-www-form-urlencoded as required by OAuth2PasswordRequestForm
const params = new URLSearchParams();
params.append("username", formData.username);
params.append("password", formData.password);
const response = await fetch("/api/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.detail || "Login failed");
}
const data = await response.json();
login(data.user, data.access_token);
navigate("/");
} else {
// Handle Registration
await api.post("/api/v1/auth/register", {
username: formData.username,
email: formData.email,
password: formData.password,
});
// Auto login after successful registration
setIsLogin(true);
// Assuming backend returns is_active=false for users requiring verification
// For now, we will show the verification prompt based on the translation
setError(t("registrationSuccessWithVerification"));
}
} catch (err: any) {
if (err.message && err.message.toLowerCase().includes("inactive user")) {
setError(t("inactiveUserError"));
setIsInactiveError(true);
} else {
setError(err.message || t("errorOccurred"));
}
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-muted/50 px-4">
<div className="w-full max-w-md">
<div className="mb-10 text-center flex flex-col items-center gap-4 select-none relative">
<div className="text-[56px] leading-none animate-bounce-slow pb-2">
📊
</div>
<h1 className="text-[40px] font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 tracking-tight">
</h1>
<div className="absolute right-0 bottom-0 translate-y-4">
<DropdownMenu>
<DropdownMenuTrigger className="h-9 w-9 rounded-full bg-background/50 backdrop-blur-sm shadow-sm border border-border/50 text-muted-foreground hover:text-foreground hover:bg-background transition-all inline-flex items-center justify-center">
<Languages className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={() => i18n.changeLanguage('zh')} className={i18n.language === 'zh' ? 'bg-muted' : ''}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage('en')} className={i18n.language === 'en' ? 'bg-muted' : ''}>
English
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="bg-background rounded-2xl shadow-xl border border-border p-8">
<h2 className="text-2xl font-bold text-foreground/90 mb-6 text-center">
{isLogin ? t("welcomeBack") : t("createAccount")}
</h2>
{error && (
<div className={`p-3 rounded-lg mb-6 text-sm flex flex-col gap-2 ${error.includes(t("registrationSuccessWithVerification")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
<span>{error}</span>
{isInactiveError && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleResendVerification}
disabled={isResending}
className="w-fit mt-1 border-red-200 text-red-700 hover:bg-red-100"
>
{isResending ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t("resendVerification")}
</Button>
)}
</div>
)}
{resendStatus && (
<div className={`p-3 rounded-lg mb-6 text-sm ${resendStatus.includes(t("verificationSent")) ? "bg-emerald-50 text-emerald-600" : "bg-red-50 text-red-600"}`}>
{resendStatus}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label>
<Input
id="username"
type="text"
placeholder={t("enterUsername")}
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
className="h-11"
/>
</div>
{!isLogin && (
<div className="space-y-2">
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
type="email"
placeholder={t("enterEmail")}
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
className="h-11"
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
type="password"
placeholder={t("enterPassword")}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
className="h-11"
/>
</div>
<Button
type="submit"
disabled={isLoading || !formData.username || !formData.password || (!isLogin && !formData.email)}
className="w-full h-11 bg-indigo-600 hover:bg-indigo-700 text-primary-foreground font-medium text-base rounded-xl transition-all shadow-md"
>
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
isLogin ? t("signIn") : t("signUp")
)}
</Button>
</form>
<div className="mt-6 text-center text-sm text-muted-foreground">
{isLogin ? t("dontHaveAccount") : t("alreadyHaveAccount")}
<button
onClick={() => {
setIsLogin(!isLogin);
setError("");
}}
className="ml-2 font-medium text-indigo-600 hover:text-indigo-700 transition-colors"
>
{isLogin ? t("signUp") : t("signIn")}
</button>
</div>
</div>
</div>
</div>
);
}
+388
View File
@@ -0,0 +1,388 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import { Loader2, Plus, RefreshCw, Search, Trash2, Pencil, Eye, EyeOff, Brain } from "lucide-react";
import { useAuthStore } from "@/store/authStore";
interface ModelConfig {
id: string;
name?: string;
provider: string;
model: string;
api_key?: string;
api_base?: string;
extra_headers?: Record<string, string>;
is_active: boolean;
}
const defaultForm: Omit<ModelConfig, "id"> = {
name: "",
provider: "openai",
model: "",
api_key: "",
api_base: "",
extra_headers: {},
is_active: true,
};
export function ModelConfigs() {
const { t } = useTranslation();
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const [configs, setConfigs] = useState<ModelConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState("");
const [extraConfigText, setExtraConfigText] = useState("{}");
const [form, setForm] = useState<Omit<ModelConfig, "id">>(defaultForm);
const fetchConfigs = async () => {
setIsLoading(true);
try {
const data = await api.get<ModelConfig[]>("/api/v1/llm");
setConfigs(data);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchConfigs();
}, []);
const filteredConfigs = useMemo(() => {
const value = keyword.trim().toLowerCase();
if (!value) return configs;
return configs.filter((item) =>
[item.name, item.model, item.provider].filter(Boolean).some((v) => String(v).toLowerCase().includes(value))
);
}, [configs, keyword]);
const openCreate = () => {
setEditingId(null);
setForm(defaultForm);
setExtraConfigText("{}");
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const openEdit = (item: ModelConfig) => {
setEditingId(item.id);
setForm({
name: item.name || "",
provider: item.provider || "openai",
model: item.model || "",
api_key: item.api_key || "",
api_base: item.api_base || "",
extra_headers: item.extra_headers || {},
is_active: item.is_active,
});
setExtraConfigText(JSON.stringify(item.extra_headers || {}, null, 2));
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const [isTesting, setIsTesting] = useState(false);
const handleTestConnection = async () => {
if (!form.model || !form.provider) {
setError(t('fillRequiredInfoFirst'));
return;
}
setIsTesting(true);
setError("");
try {
let extraHeaders: Record<string, string> = {};
if (extraConfigText.trim()) {
try {
const parsed = JSON.parse(extraConfigText);
if (parsed && typeof parsed === "object") extraHeaders = parsed;
} catch (err) {
setError(t('extraConfigMustBeValidJson'));
setIsTesting(false);
return;
}
}
const payload = {
provider: form.provider,
model: form.model,
api_key: form.api_key,
api_base: form.api_base,
extra_headers: extraHeaders
};
await api.post("/api/v1/llm/test", payload);
alert(t('connectionTestSuccessful'));
} catch (e: any) {
setError(e.message || t('connectionTestFailed'));
} finally {
setIsTesting(false);
}
};
const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!form.model || !form.provider) {
setError(t('fillRequiredFields'));
return;
}
setIsSaving(true);
setError("");
try {
let extraHeaders: Record<string, string> = {};
if (extraConfigText.trim()) {
try {
const parsed = JSON.parse(extraConfigText);
if (parsed && typeof parsed === "object") extraHeaders = parsed;
} catch (err) {
setError(t('extraConfigMustBeValidJson'));
setIsSaving(false);
return;
}
}
const payload = {
...form,
extra_headers: extraHeaders,
name: form.name || form.model,
};
if (editingId) {
await api.put(`/api/v1/llm/${editingId}`, payload);
} else {
const id = `${Date.now()}`;
await api.post("/api/v1/llm", { ...payload, id });
}
setDialogOpen(false);
await fetchConfigs();
} catch (e: any) {
setError(e.message || t('failedToSaveConfig'));
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm(t('confirmDeleteModel'))) return;
try {
await api.delete(`/api/v1/llm/${id}`);
await fetchConfigs();
} catch (e) {
console.error(e);
}
};
const handleSetDefault = async (item: ModelConfig) => {
if (!isAdmin || item.is_active) return;
try {
await api.put(`/api/v1/llm/${item.id}`, { is_active: true });
await fetchConfigs();
} catch (e) {
console.error(e);
}
};
if (!isAdmin) {
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden items-center justify-center">
<div className="text-muted-foreground text-lg">{t('noPermissionAdminOnly')}</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Brain className="h-5 w-5 text-indigo-500" />{t('modelConfig')}</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="h-4 w-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
<Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder={t('searchModel')} className="w-[200px] pl-9 h-8 text-sm" />
</div>
<Button variant="outline" size="icon" className="h-8 w-8 text-muted-foreground" onClick={fetchConfigs}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button className="h-9 px-3 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white text-sm rounded-md" onClick={openCreate}>
<Plus className="h-4 w-4 mr-1" />{t('addModel')}</Button>
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="bg-background rounded-xl border border-border shadow-sm overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('modelName')}</TableHead>
<TableHead>{t('provider')}</TableHead>
<TableHead>{t('modelIdentifier')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredConfigs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24 text-muted-foreground">{t('noModelData')}</TableCell>
</TableRow>
) : (
filteredConfigs.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.name || item.model}
</TableCell>
<TableCell className="capitalize">{item.provider}</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">{item.model}</TableCell>
<TableCell>
<span
onClick={() => handleSetDefault(item)}
className={`inline-flex px-2 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors ${item.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground hover:bg-muted/80'}`}
title={item.is_active ? t('currentDefaultModel') : t('clickToSetDefault')}
>
{item.is_active ? t('default') : t('setDefault')}
</span>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-indigo-600"
onClick={() => openEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-red-600"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSave}>
<DialogHeader>
<DialogTitle>{editingId ? t('editModel') : t('addModel')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-2">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('modelName')}</Label>
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder={t('egGpt4')} />
</div>
<div className="space-y-2">
<Label>{t('providerRequired')}</Label>
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="azure">Azure OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="vertex_ai">Google Vertex AI</SelectItem>
<SelectItem value="gemini">Google AI Studio (Gemini)</SelectItem>
<SelectItem value="bedrock">AWS Bedrock</SelectItem>
<SelectItem value="deepseek">DeepSeek</SelectItem>
<SelectItem value="zhipuai">{t('zhipuAi')}</SelectItem>
<SelectItem value="moonshot">Moonshot (Kimi)</SelectItem>
<SelectItem value="dashscope">{t('dashScope')}</SelectItem>
<SelectItem value="volcengine">{t('volcengine')}</SelectItem>
<SelectItem value="groq">Groq</SelectItem>
<SelectItem value="cohere">Cohere</SelectItem>
<SelectItem value="mistral">Mistral</SelectItem>
<SelectItem value="openrouter">OpenRouter</SelectItem>
<SelectItem value="ollama">Ollama</SelectItem>
<SelectItem value="huggingface">HuggingFace</SelectItem>
<SelectItem value="local">Local (OpenAI Compatible)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('modelIdRequired')}</Label>
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder={t('egGpt4Turbo')} required />
</div>
<div className="space-y-2">
<Label>{t('apiDomain')}</Label>
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder={t('egApiDomain')} />
</div>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
type={showApiKey ? "text" : "password"}
value={form.api_key || ""}
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
className="pr-10"
placeholder={t('leaveBlankIfNotModifying')}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground"
onClick={() => setShowApiKey((v) => !v)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label>{t('extraConfigJson')}</Label>
<Textarea value={extraConfigText} onChange={(e) => setExtraConfigText(e.target.value)} className="min-h-[80px] font-mono text-xs" placeholder='{"timeout": "60"}' />
</div>
</div>
<DialogFooter className="flex items-center justify-between gap-2">
<Button type="button" variant="outline" onClick={handleTestConnection} disabled={isTesting}>
{isTesting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
</Button>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>{t('cancel')}</Button>
<Button type="submit" disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+637
View File
@@ -0,0 +1,637 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ReactFlow, Background, Controls, useNodesState, useEdgesState, MarkerType, type Node, type Edge, ConnectionLineType } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "dagre";
import { api } from "../lib/api";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Label } from "../components/ui/label";
import { ScrollArea } from "../components/ui/scroll-area";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { ArrowLeft, Table as TableIcon } from "lucide-react";
import { TableNode } from "../components/modeling/TableNode";
interface RawSchema {
[table: string]: { name: string; type: string }[];
}
interface Column {
name: string;
type: string;
isCalculated: boolean;
relationship?: string;
expression?: string;
properties?: Record<string, unknown>;
}
interface Model {
name: string;
columns: Column[];
primaryKey?: string;
properties?: Record<string, any>;
}
interface Relationship {
name: string;
models: string[];
joinType: string;
condition: string;
}
interface MDLManifest {
catalog: string;
schema: string;
dataSource: string;
models: Model[];
relationships: Relationship[];
}
interface ModelDetailResponse {
model: {
name: string;
tableReference?: {
table: string;
schema?: string;
catalog?: string;
} | null;
primaryKey?: string;
properties?: Record<string, unknown>;
columns: Column[];
};
relationships: {
name: string;
models: string[];
joinType: string;
condition: string;
properties?: Record<string, unknown>;
}[];
preview_rows: Record<string, unknown>[];
}
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
// If there are few or no edges, use grid layout to spread out nodes
if (edges.length === 0 || edges.length < nodes.length * 0.3) {
const COLUMNS = 4;
const ROW_HEIGHT = 400; // Height per row including spacing
const COL_WIDTH = 300; // Width per column including spacing
return {
nodes: nodes.map((node, index) => {
const col = index % COLUMNS;
const row = Math.floor(index / COLUMNS);
return {
...node,
position: {
x: col * COL_WIDTH,
y: row * ROW_HEIGHT,
},
};
}),
edges,
};
}
// Otherwise use Dagre for connected graphs
dagreGraph.setGraph({ rankdir: 'TB', nodesep: 100, ranksep: 120 });
nodes.forEach((node) => {
// Estimating height based on column count
const height = 50 + (node.data.columns as Column[]).length * 28;
dagreGraph.setNode(node.id, { width: 240, height });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - 120, // center offset (width/2)
y: nodeWithPosition.y - (nodeWithPosition.height / 2),
},
};
});
return { nodes: layoutedNodes, edges };
};
export function Modeling() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [schema, setSchema] = useState<RawSchema | null>(null);
const [mdl, setMdl] = useState<MDLManifest | null>(null);
const [selectedTables, setSelectedTables] = useState<string[]>([]);
const [selectedColumns, setSelectedColumns] = useState<Record<string, string[]>>({});
const [expandedTables, setExpandedTables] = useState<Record<string, boolean>>({});
const [step, setStep] = useState<"select" | "view">("select");
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [modelDetail, setModelDetail] = useState<ModelDetailResponse | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const nodeTypes = useMemo(() => ({ table: TableNode }), []);
// Save layout to localStorage when nodes change (dragged)
const onNodeDragStop = useCallback(() => {
if (nodes.length > 0) {
const layoutData = nodes.map(n => ({ id: n.id, position: n.position }));
localStorage.setItem(`er-layout-${id}`, JSON.stringify(layoutData));
}
}, [nodes, id]);
useEffect(() => {
fetchInitialData();
}, [id]);
useEffect(() => {
if (step === 'view' && mdl) {
// Try to load saved layout
const savedLayoutStr = localStorage.getItem(`er-layout-${id}`);
let savedPositions: Record<string, {x: number, y: number}> = {};
if (savedLayoutStr) {
try {
const parsed = JSON.parse(savedLayoutStr);
if (Array.isArray(parsed)) {
parsed.forEach((item: any) => {
if (item.id && item.position) {
savedPositions[item.id] = item.position;
}
});
}
} catch (e) {
console.error("Failed to parse saved layout", e);
}
}
const initialNodes: Node[] = mdl.models.map((model) => ({
id: model.name,
type: 'table',
position: savedPositions[model.name] || { x: 0, y: 0 },
data: {
name: model.name,
columns: model.columns,
onDetailClick: openModelDetail
},
}));
const initialEdges: Edge[] = mdl.relationships.map((rel, index) => {
// Assuming rel.models has at least 2 elements
if (rel.models.length < 2) return null;
return {
id: `e-${index}`,
source: rel.models[0],
target: rel.models[1],
type: ConnectionLineType.SmoothStep,
animated: false,
label: rel.joinType,
style: { stroke: '#94a3b8' },
labelStyle: { fill: '#64748b', fontSize: 11 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#94a3b8',
},
};
}).filter(Boolean) as Edge[];
// Only run auto-layout if we don't have saved positions for most nodes
// or if user explicitly requests it (future feature)
const hasSavedLayout = Object.keys(savedPositions).length >= initialNodes.length * 0.5;
if (hasSavedLayout) {
setNodes(initialNodes);
setEdges(initialEdges);
} else {
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
initialNodes,
initialEdges
);
setNodes(layoutedNodes);
setEdges(layoutedEdges);
}
}
}, [step, mdl, id]);
const handleAutoLayout = () => {
if (!mdl) return;
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
nodes,
edges
);
setNodes([...layoutedNodes]);
setEdges([...layoutedEdges]);
// Clear saved layout to prefer auto layout
localStorage.removeItem(`er-layout-${id}`);
};
const initSelectionFromSchema = (schemaRes: RawSchema) => {
const tableNames = Object.keys(schemaRes);
const columnsMap: Record<string, string[]> = {};
const expanded: Record<string, boolean> = {};
for (const tableName of tableNames) {
columnsMap[tableName] = schemaRes[tableName].map((c) => c.name);
expanded[tableName] = true;
}
setSchema(schemaRes);
setSelectedTables(tableNames);
setSelectedColumns(columnsMap);
setExpandedTables(expanded);
};
const fetchSchemaOnly = async () => {
const schemaRes = await api.get(`/api/v1/semantic/${id}/schema`) as RawSchema;
initSelectionFromSchema(schemaRes);
setStep("select");
};
const fetchInitialData = async () => {
try {
setLoading(true);
const mdlRes = await api.get(`/api/v1/semantic/${id}`) as any;
if (mdlRes && mdlRes.models && mdlRes.models.length > 0) {
setMdl(mdlRes as MDLManifest);
setStep("view");
} else {
await fetchSchemaOnly();
}
} catch (error) {
console.error("Failed to fetch modeling data:", error);
try {
await fetchSchemaOnly();
} catch (e) {
console.error("Failed to fetch schema:", e);
}
} finally {
setLoading(false);
}
};
const handleGenerate = async () => {
try {
setLoading(true);
const res = await api.post(`/api/v1/semantic/${id}/generate`, {
selected_tables: selectedTables,
selected_columns: Object.fromEntries(
selectedTables.map((table) => [table, selectedColumns[table] ?? []])
),
}) as MDLManifest;
setMdl(res);
setStep("view");
} catch (error) {
console.error("Failed to generate MDL:", error);
} finally {
setLoading(false);
}
};
const toggleTable = (table: string) => {
setSelectedTables((prev) =>
prev.includes(table) ? prev.filter((t) => t !== table) : [...prev, table]
);
if (!schema) return;
if (!selectedTables.includes(table) && (!selectedColumns[table] || selectedColumns[table].length === 0)) {
setSelectedColumns((prev) => ({
...prev,
[table]: schema[table].map((c) => c.name),
}));
}
};
const toggleColumn = (table: string, column: string) => {
setSelectedColumns((prev) => {
const current = prev[table] ?? [];
const has = current.includes(column);
const next = has ? current.filter((c) => c !== column) : [...current, column];
return { ...prev, [table]: next };
});
setSelectedTables((prev) => {
const exists = prev.includes(table);
const current = selectedColumns[table] ?? [];
const has = current.includes(column);
const nextLen = has ? current.length - 1 : current.length + 1;
if (nextLen <= 0) {
return prev.filter((t) => t !== table);
}
if (!exists) {
return [...prev, table];
}
return prev;
});
};
const toggleExpandTable = (table: string) => {
setExpandedTables((prev) => ({ ...prev, [table]: !prev[table] }));
};
const handleSelectAll = () => {
if (!schema) return;
const tableNames = Object.keys(schema);
setSelectedTables(tableNames);
setSelectedColumns(
Object.fromEntries(
tableNames.map((table) => [table, schema[table].map((c) => c.name)])
)
);
};
const handleClearAll = () => {
setSelectedTables([]);
setSelectedColumns({});
};
const handleReselectTables = async () => {
try {
setLoading(true);
await fetchSchemaOnly();
} finally {
setLoading(false);
}
};
const openModelDetail = async (modelName: string) => {
try {
setDetailOpen(true);
setDetailLoading(true);
const detail = await api.get<ModelDetailResponse>(
`/api/v1/semantic/${id}/models/${encodeURIComponent(modelName)}?limit=10`
);
setModelDetail(detail);
} catch (error) {
console.error("Failed to fetch model detail:", error);
setModelDetail(null);
} finally {
setDetailLoading(false);
}
};
if (loading) {
return <div className="p-8 text-center">Loading modeling data...</div>;
}
return (
<div className="flex flex-col h-full bg-muted/50">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-background border-b">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/datasources")}>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-xl font-semibold">Data Modeling</h1>
<p className="text-sm text-muted-foreground">
DataSource ID: {id} {step === "select" ? "Select Tables" : "Entity Relationship Diagram"}
</p>
</div>
</div>
{step === "view" && (
<div className="flex gap-2">
<Button variant="outline" onClick={handleAutoLayout}>
Auto Layout
</Button>
<Button variant="outline" onClick={handleReselectTables}>
Reselect Tables
</Button>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-hidden p-6">
{step === "select" ? (
<div className="max-w-4xl mx-auto h-full flex flex-col">
<Card className="flex-1 flex flex-col overflow-hidden">
<CardHeader>
<CardTitle>Select tables to create data models</CardTitle>
<p className="text-sm text-muted-foreground">
Choose the tables you want to include in your semantic model.
</p>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{selectedTables.length} / {schema ? Object.keys(schema).length : 0} selected
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
>
Select All
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
>
Clear
</Button>
</div>
</div>
<ScrollArea className="flex-1 border rounded-md p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{schema && Object.keys(schema).map((table) => (
<div
key={table}
className={`p-3 rounded-lg border transition-colors ${
selectedTables.includes(table)
? "bg-primary/5 border-primary"
: "bg-background hover:bg-muted/50"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center space-x-3">
<input
type="checkbox"
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
checked={selectedTables.includes(table)}
onChange={() => toggleTable(table)}
/>
<Label className="cursor-pointer font-medium flex items-center gap-2">
<TableIcon className="w-4 h-4 text-muted-foreground" />
{table}
</Label>
</div>
<Button variant="ghost" size="sm" onClick={() => toggleExpandTable(table)}>
{expandedTables[table] ? "Hide Columns" : "Show Columns"}
</Button>
</div>
{expandedTables[table] && (
<div className="mt-3 max-h-48 overflow-auto border rounded-md bg-background">
{schema[table].map((col) => (
<label
key={`${table}:${col.name}`}
className="flex items-center justify-between px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/50"
>
<div className="flex items-center gap-2 min-w-0">
<input
type="checkbox"
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
checked={(selectedColumns[table] ?? []).includes(col.name)}
onChange={() => toggleColumn(table, col.name)}
/>
<span className="text-sm truncate">{col.name}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground ml-2">{col.type}</span>
</label>
))}
</div>
)}
</div>
))}
</div>
</ScrollArea>
<div className="pt-6 flex justify-end">
<Button onClick={handleGenerate} disabled={selectedTables.length === 0}>
Generate Model
</Button>
</div>
</CardContent>
</Card>
</div>
) : (
<div className="h-full flex gap-6">
{/* Sidebar List */}
<Card className="w-64 flex flex-col h-full">
<CardHeader className="py-4 px-4 border-b">
<CardTitle className="text-sm font-medium">Models ({mdl?.models.length})</CardTitle>
</CardHeader>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{mdl?.models.map((model) => (
<div
key={model.name}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-muted cursor-pointer"
onClick={() => openModelDetail(model.name)}
>
<TableIcon className="w-4 h-4 text-muted-foreground" />
<span className="truncate">{model.name}</span>
</div>
))}
</div>
</ScrollArea>
</Card>
{/* Canvas Area (ReactFlow) */}
<div className="flex-1 overflow-hidden bg-muted/50 rounded-lg border relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes}
fitView
minZoom={0.1}
maxZoom={1.5}
attributionPosition="bottom-right"
>
<Background color="#cbd5e1" gap={20} size={1} />
<Controls />
</ReactFlow>
</div>
</div>
)}
</div>
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[1100px] max-h-[85vh] overflow-auto">
<DialogHeader>
<DialogTitle>{modelDetail?.model?.name ?? "Model Detail"}</DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="py-8 text-center text-muted-foreground">Loading model detail...</div>
) : !modelDetail ? (
<div className="py-8 text-center text-muted-foreground">No metadata available.</div>
) : (
<div className="space-y-6">
<div className="space-y-2">
<div className="text-base font-semibold">Columns Metadata</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelDetail.model.columns.map((col) => (
<TableRow key={col.name}>
<TableCell>{col.name}</TableCell>
<TableCell>{col.type}</TableCell>
<TableCell>{String(col.properties?.description ?? "-")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2">
<div className="text-base font-semibold">Relationships ({modelDetail.relationships.length})</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Models</TableHead>
<TableHead>Type</TableHead>
<TableHead>Condition</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelDetail.relationships.map((rel) => (
<TableRow key={rel.name}>
<TableCell>{rel.name}</TableCell>
<TableCell>{rel.models.join(" ↔ ")}</TableCell>
<TableCell>{rel.joinType}</TableCell>
<TableCell>{rel.condition}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2">
<div className="text-base font-semibold">Data Preview (Top 10)</div>
{modelDetail.preview_rows.length === 0 ? (
<div className="text-sm text-muted-foreground">No preview data.</div>
) : (
<Table>
<TableHeader>
<TableRow>
{Object.keys(modelDetail.preview_rows[0]).map((key) => (
<TableHead key={key}>{key}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{modelDetail.preview_rows.map((row, idx) => (
<TableRow key={idx}>
{Object.keys(modelDetail.preview_rows[0]).map((key) => (
<TableCell key={key}>{String(row[key] ?? "")}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Folder, Pencil, Trash2, Loader2, Database } from 'lucide-react';
import { useProjectStore, type Project } from '@/store/projectStore';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useNavigate } from 'react-router-dom';
export function Projects() {
const { t } = useTranslation();
const { projects, loading, fetchProjects, addProject, updateProject, deleteProject, setCurrentProject } = useProjectStore();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formData, setFormData] = useState({ name: '', description: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
const navigate = useNavigate();
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
const handleCreate = async () => {
if (!formData.name.trim()) return;
setIsSubmitting(true);
try {
await addProject(formData.name, formData.description);
setFormData({ name: '', description: '' });
setIsCreateDialogOpen(false);
} catch (error) {
console.error('Failed to create project:', error);
} finally {
setIsSubmitting(false);
}
};
const handleUpdate = async () => {
if (!editingProject || !formData.name.trim()) return;
setIsSubmitting(true);
try {
await updateProject(editingProject.id, formData.name, formData.description);
setEditingProject(null);
setFormData({ name: '', description: '' });
setIsEditDialogOpen(false);
} catch (error) {
console.error('Failed to update project:', error);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (id: number) => {
if (!window.confirm(t('confirmDeleteProject'))) return;
try {
await deleteProject(id);
} catch (error) {
console.error('Failed to delete project:', error);
}
};
const openEditDialog = (project: Project) => {
setEditingProject(project);
setFormData({ name: project.name, description: project.description || '' });
setIsEditDialogOpen(true);
};
const goToDataSources = (project: Project) => {
setCurrentProject(project);
navigate('/datasources');
};
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Folder className="h-5 w-5 text-blue-500" />{t('projectManagement')}</div>
<Button onClick={() => {
setFormData({ name: '', description: '' });
setIsCreateDialogOpen(true);
}} className="h-9 gap-2 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white rounded-md px-3">
<Plus className="h-4 w-4" />{t('newProject')}
</Button>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="max-w-5xl mx-auto space-y-6">
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle>{t('projectList')}</CardTitle>
<CardDescription>{t('manageProjectsDesc')}</CardDescription>
</CardHeader>
<CardContent>
{loading && projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin mb-4" />
<p>{t('loading')}</p>
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg border-border">
<Folder className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
<p className="text-muted-foreground">{t('noProjectsCreateOne')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('name')}</TableHead>
<TableHead>{t('description')}</TableHead>
<TableHead>{t('createdAt')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium">{project.name}</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">
{project.description || '-'}
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(project.created_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => goToDataSources(project)}
title={t('manageDataSources')}
>
<Database className="h-4 w-4 text-emerald-500" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(project)}
title={t('editProject')}
>
<Pencil className="h-4 w-4 text-blue-500" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(project.id)}
title={t('deleteProject')}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('newProject')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">{t('projectName')}</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('enterProjectName')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">{t('descriptionOptional')}</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder={t('enterProjectDescription')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>{t('cancel')}</Button>
<Button onClick={handleCreate} disabled={isSubmitting || !formData.name.trim()}>
{isSubmitting ? t('creating') : t('create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('editProject')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">{t('projectName')}</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('enterProjectName')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">{t('descriptionOptional')}</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder={t('enterProjectDescription')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>{t('cancel')}</Button>
<Button onClick={handleUpdate} disabled={isSubmitting || !formData.name.trim()}>
{isSubmitting ? t('saving') : t('save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Save, Loader2, Check } from "lucide-react";
import { api } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
const BUILTIN_AVATARS = [
"https://api.dicebear.com/7.x/bottts/svg?seed=Felix",
"https://api.dicebear.com/7.x/bottts/svg?seed=Aneka",
"https://api.dicebear.com/7.x/bottts/svg?seed=Tinkerbell",
"https://api.dicebear.com/7.x/bottts/svg?seed=Bella",
"https://api.dicebear.com/7.x/bottts/svg?seed=Buster",
"https://api.dicebear.com/7.x/bottts/svg?seed=Max",
"https://api.dicebear.com/7.x/adventurer/svg?seed=Leo",
"https://api.dicebear.com/7.x/adventurer/svg?seed=Oliver",
"https://api.dicebear.com/7.x/adventurer/svg?seed=Mia",
"https://api.dicebear.com/7.x/adventurer/svg?seed=Lily",
"https://api.dicebear.com/7.x/adventurer/svg?seed=Chloe",
"https://api.dicebear.com/7.x/adventurer/svg?seed=Simba"
];
export function Settings() {
const { t } = useTranslation();
const { user, updateUser } = useAuthStore();
const [email, setEmail] = useState('');
const [avatar, setAvatar] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
if (user) {
setEmail(user.email || '');
setAvatar(user.avatar || '');
}
}, [user]);
const isPasswordMismatch = password !== '' && confirmPassword !== '' && password !== confirmPassword;
const handleSave = async () => {
setError('');
setSuccess('');
if (isPasswordMismatch) {
setError(t('passwordsDoNotMatch'));
return;
}
setIsSaving(true);
try {
const updateData: any = {
email: email,
avatar: avatar || null
};
if (password) {
updateData.password = password;
}
if (user && user.id) {
const response = await api.put<any>(`/api/v1/users/${user.id}`, updateData);
let successMsg = t('personalSettingsSaved');
if (password) {
successMsg = t('personalSettingsAndPasswordSaved');
}
setSuccess(successMsg);
setPassword('');
setConfirmPassword('');
// Update global state with new email and avatar
updateUser({ email: response.email, avatar: response.avatar });
}
} catch (error: any) {
console.error("Failed to save settings", error);
setError(error.message || t('failedToSaveSettings'));
} finally {
setIsSaving(false);
}
};
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Save className="h-5 w-5 text-indigo-500" />
{t('personalSettings')}
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="grid gap-6 max-w-4xl mx-auto">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
{success && <div className="text-sm text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-md p-3">{success}</div>}
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle className="text-xl">{t('accountInfo')}</CardTitle>
<CardDescription>{t('modifyLoginEmailAndPassword')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('avatar', 'Avatar')}</Label>
<div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-12 gap-2 mt-2">
{BUILTIN_AVATARS.map((url) => (
<div
key={url}
className={`relative cursor-pointer rounded-full overflow-hidden border-2 transition-all ${
avatar === url ? 'border-indigo-500 scale-110 shadow-md' : 'border-transparent hover:border-indigo-200'
}`}
onClick={() => setAvatar(url)}
>
<img src={url} alt="avatar" className="w-full h-auto bg-muted/30" />
{avatar === url && (
<div className="absolute inset-0 bg-black/10 flex items-center justify-center">
<Check className="h-4 w-4 text-indigo-600 drop-shadow-sm" />
</div>
)}
</div>
))}
</div>
</div>
<div className="space-y-2 pt-2 border-t border-border">
<Label htmlFor="username">{t('username')}</Label>
<Input
id="username"
value={user?.username || ''}
disabled
className="bg-muted/50 text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">{t('usernameCannotBeModified')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('emailAddress')}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2 pt-4 border-t border-border">
<Label htmlFor="new-password">{t('newPassword')}</Label>
<Input
id="new-password"
type="password"
placeholder={t('leaveBlankIfNotModifying')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">{t('confirmNewPassword')}</Label>
<Input
id="confirm-password"
type="password"
placeholder={t('leaveBlankIfNotModifying')}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setError('');
}}
/>
{isPasswordMismatch && <p className="text-sm text-red-600">{t('passwordsDoNotMatch')}</p>}
</div>
</CardContent>
<CardFooter className="bg-muted/50/50 border-t border-border pt-6">
<Button onClick={handleSave} className="ml-auto bg-indigo-600 hover:bg-indigo-700 text-primary-foreground" disabled={isSaving || isPasswordMismatch}>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{t('saveSettings')}
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Trash2, Loader2, Bot, Plus, Pencil } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { subagentApi, type Subagent } from "@/api/subagents";
import { useProjectStore } from "@/store/projectStore";
import { api } from "@/lib/api";
interface ModelConfig {
id: string;
name: string;
model: string;
provider: string;
}
export function Subagents() {
const { t } = useTranslation();
const { projectId: routeProjectId } = useParams<{ projectId: string }>();
const { currentProject } = useProjectStore();
// Use projectId from route, or fallback to currentProject
const projectId = routeProjectId || currentProject?.id?.toString();
const [subagents, setSubagents] = useState<Subagent[]>([]);
const [availableModels, setAvailableModels] = useState<ModelConfig[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSubagent, setEditingSubagent] = useState<Subagent | null>(null);
const [newSubagent, setNewSubagent] = useState<Partial<Subagent>>({
name: '',
description: '',
model: '',
instructions: '',
status: 'active'
});
const fetchInitialData = async () => {
if (!projectId) return;
setIsLoading(true);
try {
const [subagentsData, modelsData] = await Promise.all([
subagentApi.list(projectId),
api.get<ModelConfig[]>('/api/v1/llm')
]);
setSubagents(subagentsData || []);
setAvailableModels(modelsData || []);
} catch (error) {
console.error("Failed to fetch initial data", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (projectId) {
fetchInitialData();
}
}, [projectId]);
const getModelDisplay = (value?: string) => {
if (!value) return '-';
const matched = availableModels.find((m) => m.id === value || m.model === value);
if (!matched) return value;
const label = matched.name || matched.model;
return `${label} (${matched.provider})`;
};
const handleSaveSubagent = async () => {
if (!projectId) return;
if (newSubagent.name && newSubagent.model) {
try {
if (editingSubagent && editingSubagent.id) {
await subagentApi.update(projectId, editingSubagent.id, newSubagent);
} else {
const payload = {
...newSubagent,
instructions: newSubagent.instructions || ''
};
await subagentApi.create(projectId, payload);
}
await fetchInitialData();
setNewSubagent({ name: '', description: '', model: '', instructions: '', status: 'active' });
setEditingSubagent(null);
setIsDialogOpen(false);
} catch (error) {
console.error("Failed to save subagent", error);
alert(t('saveFailed'));
}
} else {
alert(t('fillRequiredFields'));
}
};
const handleEditSubagent = (subagent: Subagent) => {
const matched = availableModels.find((m) => m.id === subagent.model || m.model === subagent.model);
setEditingSubagent(subagent);
setNewSubagent({
...subagent,
model: matched?.id || subagent.model
});
setIsDialogOpen(true);
};
const handleDeleteSubagent = async (id: string) => {
if (!projectId) return;
if (!window.confirm(t('confirmDeleteSubagent'))) return;
try {
await subagentApi.delete(projectId, id);
setSubagents(subagents.filter(s => s.id !== id));
} catch (error) {
console.error("Failed to delete subagent", error);
}
};
if (!projectId) {
return (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground gap-4">
<Bot className="h-12 w-12 text-muted-foreground/30" />
<p>{t('selectProjectToManageSubagents', 'Please select a project to manage subagents')}</p>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background shrink-0">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Bot className="h-5 w-5 text-indigo-500" />
{t('subagentManagement', 'Subagent Management')}
</div>
<Button
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
onClick={() => {
setEditingSubagent(null);
setNewSubagent({ name: '', description: '', model: '', instructions: '', status: 'active' });
setIsDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />{t('createSubagent', 'New Subagent')}
</Button>
</div>
<div className="flex-1 overflow-auto p-4 md:p-8 bg-muted/50/30">
<div className="bg-background rounded-xl border border-border shadow-sm overflow-hidden min-w-[800px] lg:min-w-0">
<Table className="table-fixed w-full">
<TableHeader className="bg-muted/50/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[25%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('name')}</TableHead>
<TableHead className="w-[25%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('modelName', 'Model')}</TableHead>
<TableHead className="w-[35%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('description')}</TableHead>
<TableHead className="w-[15%] font-semibold text-foreground/80 py-3 px-4 text-sm text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="py-24 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
</div>
</TableCell>
</TableRow>
) : (
<>
{subagents.map((subagent) => (
<TableRow key={subagent.id} className="group hover:bg-muted/50/50 transition-colors border-border">
<TableCell className="py-4 px-4 overflow-hidden">
<h3 className="font-bold text-foreground text-sm md:text-base truncate flex-1" title={subagent.name}>
{subagent.name}
</h3>
</TableCell>
<TableCell className="py-4 px-4 text-muted-foreground text-sm truncate" title={getModelDisplay(subagent.model)}>
{getModelDisplay(subagent.model)}
</TableCell>
<TableCell className="py-4 px-4 text-muted-foreground text-sm truncate" title={subagent.description}>
{subagent.description || '-'}
</TableCell>
<TableCell className="py-4 px-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-indigo-600 hover:bg-indigo-50 rounded-md transition-all shrink-0"
onClick={() => handleEditSubagent(subagent)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-rose-600 hover:bg-rose-50 rounded-md transition-all shrink-0"
onClick={() => handleDeleteSubagent(subagent.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{subagents.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="py-24 text-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<div className="p-4 bg-muted/50 rounded-2xl">
<Bot className="h-10 w-10 opacity-20" />
</div>
<p className="text-sm">{t('noSubagents', 'No subagents configured')}</p>
</div>
</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
</Table>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingSubagent(null);
setNewSubagent({ name: '', description: '', model: '', instructions: '', status: 'active' });
}
}}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col rounded-2xl p-0 overflow-hidden">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-xl font-bold text-foreground">
{editingSubagent ? t('editSubagent', 'Edit Subagent') : t('addSubagent', 'Add Subagent')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-2">
<div className="grid gap-5">
<div className="grid gap-1.5">
<Label htmlFor="name" className="text-muted-foreground font-medium text-sm">{t('name')} *</Label>
<Input
id="name"
placeholder={t('subagentName', 'Subagent Name')}
value={newSubagent.name || ''}
onChange={(e) => setNewSubagent({...newSubagent, name: e.target.value})}
className="rounded-lg border-border h-10"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="model" className="text-muted-foreground font-medium text-sm">{t('modelName', 'Model')} *</Label>
<Select
value={newSubagent.model || ''}
onValueChange={(v) => setNewSubagent({...newSubagent, model: v || undefined})}
>
<SelectTrigger className="w-full h-10 border-border rounded-lg">
<SelectValue placeholder={t('selectModel', 'Select a model')}>
{newSubagent.model ? getModelDisplay(newSubagent.model) : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.model} <span className="text-xs text-muted-foreground ml-1">({m.provider})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="description" className="text-muted-foreground font-medium text-sm">{t('description')}</Label>
<Textarea
id="description"
placeholder={t('descriptionOptional')}
value={newSubagent.description || ''}
onChange={(e) => setNewSubagent({...newSubagent, description: e.target.value})}
className="rounded-lg border-border min-h-[80px] py-2 text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="instructions" className="text-muted-foreground font-medium text-sm">{t('instructions', 'System Instructions')}</Label>
<Textarea
id="instructions"
value={newSubagent.instructions || ''}
onChange={(e) => setNewSubagent({...newSubagent, instructions: e.target.value})}
className="rounded-lg border-border font-mono text-xs min-h-[160px] py-3 bg-muted/50"
placeholder={t('systemInstructionsPlaceholder', 'You are a helpful AI assistant...')}
/>
</div>
</div>
</div>
<DialogFooter className="p-6 pt-2">
<Button onClick={handleSaveSubagent} className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground rounded-lg px-6 h-10 w-full">
{t('save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { useState, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Pencil, Trash2, Plus, User as UserIcon, Users as UsersIcon, Loader2 } from "lucide-react";
import { api } from "@/lib/api";
interface User {
id: number;
username: string;
email: string;
avatar?: string | null;
is_active: boolean;
is_admin: boolean;
created_at: string;
}
export function Users() {
const { t } = useTranslation();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
is_active: true,
is_admin: false,
});
const [error, setError] = useState("");
const fetchUsers = async () => {
try {
setIsLoading(true);
const data = await api.get<User[]>("/api/v1/users");
setUsers(data);
} catch (err) {
console.error("Failed to fetch users", err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleOpenDialog = (user?: User) => {
setError("");
if (user) {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: "", // Don't show password
is_active: user.is_active,
is_admin: user.is_admin,
});
} else {
setEditingUser(null);
setFormData({
username: "",
email: "",
password: "",
is_active: true,
is_admin: false,
});
}
setIsDialogOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
if (editingUser) {
// Update
await api.put(`/api/v1/users/${editingUser.id}`, {
username: formData.username,
email: formData.email,
is_active: formData.is_active,
is_admin: formData.is_admin,
});
} else {
// Create
if (!formData.password) {
setError(t('newUserMustHavePassword'));
return;
}
await api.post("/api/v1/users", formData);
}
setIsDialogOpen(false);
fetchUsers();
} catch (err: any) {
setError(err.message || t('anErrorOccurred'));
}
};
const handleDelete = async (id: number) => {
if (window.confirm(t('confirmDeleteUser'))) {
try {
await api.delete(`/api/v1/users/${id}`);
fetchUsers();
} catch (err) {
console.error("Failed to delete user", err);
}
}
};
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<UsersIcon className="h-5 w-5 text-indigo-500" />
{t('userManagement')}
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger className="inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white rounded-md px-3" onClick={() => handleOpenDialog()}>
<Plus className="h-4 w-4" />
{t('addUser')}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{editingUser ? t('editUser') : t('addNewUser')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{error && <div className="text-red-500 text-sm">{error}</div>}
<div className="grid gap-2">
<Label htmlFor="username">{t('username')}</Label>
<Input
id="username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">{t('email')}</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
{!editingUser && (
<div className="grid gap-2">
<Label htmlFor="password">{t('password')}</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor="is_active">{t('activeStatus')}</Label>
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="is_admin">{t('adminPrivileges')}</Label>
<Switch
id="is_admin"
checked={formData.is_admin}
onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
{t('cancel')}
</Button>
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-primary-foreground">
{t('save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="bg-background rounded-xl border border-border shadow-sm overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('id', 'ID')}</TableHead>
<TableHead>{t('username', 'Username')}</TableHead>
<TableHead>{t('email', 'Email')}</TableHead>
<TableHead>{t('status', 'Status')}</TableHead>
<TableHead>{t('role', 'Role')}</TableHead>
<TableHead>{t('createdAt', 'Created At')}</TableHead>
<TableHead className="text-right">{t('actions', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center h-24 text-muted-foreground">
{t('noUserData', 'No User Data')}
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{user.avatar ? (
<img src={user.avatar} alt="avatar" className="w-6 h-6 rounded-full object-cover border border-border" />
) : (
<div className="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 border border-indigo-200">
<UserIcon className="h-3 w-3" />
</div>
)}
{user.username}
</div>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground'}`}>
{user.is_active ? t('normal') : t('disabled')}
</span>
</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_admin ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>
{user.is_admin ? t('admin') : t('regularUser')}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-indigo-600"
onClick={() => handleOpenDialog(user)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-red-600"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
import { api } from "@/lib/api";
export function VerifyEmail() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [errorMessage, setErrorMessage] = useState("");
const hasAttempted = useRef(false);
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setStatus("error");
setErrorMessage(t("verifyEmailFailed"));
return;
}
if (hasAttempted.current) return;
hasAttempted.current = true;
const verifyToken = async () => {
try {
await api.get(`/api/v1/auth/verify-email?token=${encodeURIComponent(token)}`);
setStatus("success");
} catch (err: any) {
setStatus("error");
setErrorMessage(err.message || t("verifyEmailFailed"));
}
};
verifyToken();
}, [searchParams, t]);
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-muted/50 px-4">
<div className="w-full max-w-md bg-background rounded-2xl shadow-xl border border-border p-8 text-center">
<div className="mb-6 flex justify-center">
{status === "loading" && <Loader2 className="h-16 w-16 text-indigo-600 animate-spin" />}
{status === "success" && <CheckCircle2 className="h-16 w-16 text-emerald-500" />}
{status === "error" && <XCircle className="h-16 w-16 text-red-500" />}
</div>
<h2 className="text-2xl font-bold text-foreground/90 mb-4">
{t("verifyEmailTitle")}
</h2>
<div className="mb-8 text-muted-foreground">
{status === "loading" && <p>{t("verifyingEmail")}</p>}
{status === "success" && <p className="text-emerald-600 font-medium">{t("verifyEmailSuccess")}</p>}
{status === "error" && <p className="text-red-600 font-medium">{errorMessage}</p>}
</div>
<Button
onClick={() => navigate("/login")}
className="w-full h-11 bg-indigo-600 hover:bg-indigo-700 text-primary-foreground font-medium text-base rounded-xl transition-all shadow-md"
>
{t("goToLogin")}
</Button>
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Save, Loader2, Globe } from "lucide-react";
import { api } from "@/lib/api";
interface WebSearchConfig {
provider: string;
api_key?: string;
base_url?: string;
max_results: number;
}
export function WebSearchConfig() {
const { t } = useTranslation();
const [config, setConfig] = useState<WebSearchConfig>({ provider: 'duckduckgo', max_results: 5 });
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const fetchConfig = async () => {
setIsLoading(true);
try {
const data = await api.get<WebSearchConfig>('/api/v1/web-search/config');
setConfig({
provider: data.provider || 'duckduckgo',
api_key: data.api_key || '',
base_url: data.base_url || '',
max_results: data.max_results || 5
});
} catch (err: unknown) {
console.error("Failed to load web search config", err);
setError(t('failedToLoadConfig', 'Failed to load configuration'));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = async () => {
setError('');
setSuccess('');
setIsSaving(true);
try {
await api.put('/api/v1/web-search/config', config);
setSuccess(t('configSaved', 'Configuration saved successfully. Note: Active agents may require a restart to pick up the new configuration.'));
} catch (err: unknown) {
console.error("Failed to save web search config", err);
const errorMessage = err instanceof Error ? err.message : t('failedToSaveConfig', 'Failed to save configuration');
setError(errorMessage);
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
const needsApiKey = ['brave', 'tavily', 'jina'].includes(config.provider);
const needsBaseUrl = config.provider === 'searxng';
return (
<div className="flex-1 flex flex-col h-full bg-muted/50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-border bg-background">
<div className="flex items-center gap-2 text-foreground/80 font-medium">
<Globe className="h-5 w-5 text-indigo-500" />
{t('webSearchConfig', 'Web Search Configuration')}
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="grid gap-6 max-w-4xl mx-auto">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
{success && <div className="text-sm text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-md p-3">{success}</div>}
<Card className="border-border shadow-sm">
<CardHeader>
<CardTitle className="text-xl">{t('webSearchConfig', 'Web Search Configuration')}</CardTitle>
<CardDescription>{t('configureWebSearchProvider', 'Configure the default web search provider and settings for the AI agent.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('provider', 'Provider')}</Label>
<Select
value={config.provider}
onValueChange={(val) => { if (val) setConfig({ ...config, provider: val }) }}
>
<SelectTrigger>
<SelectValue placeholder={t('selectProvider', 'Select a provider')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="duckduckgo">DuckDuckGo (Free, No API Key required)</SelectItem>
<SelectItem value="brave">Brave Search</SelectItem>
<SelectItem value="tavily">Tavily</SelectItem>
<SelectItem value="jina">Jina Reader</SelectItem>
<SelectItem value="searxng">SearXNG</SelectItem>
</SelectContent>
</Select>
</div>
{needsApiKey && (
<div className="space-y-2">
<Label>{t('apiKey', 'API Key')}</Label>
<Input
type="password"
placeholder={t('enterApiKey', 'Enter API Key')}
value={config.api_key || ''}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
/>
<p className="text-xs text-muted-foreground">{t('apiKeyRequiredFor', 'An API Key is required for {{provider}}', { provider: config.provider })}</p>
</div>
)}
{needsBaseUrl && (
<div className="space-y-2">
<Label>{t('baseUrl', 'Base URL')}</Label>
<Input
placeholder="e.g. http://localhost:8080"
value={config.base_url || ''}
onChange={(e) => setConfig({ ...config, base_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">{t('baseUrlRequiredFor', 'A Base URL is required for {{provider}}', { provider: config.provider })}</p>
</div>
)}
<div className="space-y-2">
<Label>{t('maxResults', 'Max Results')}</Label>
<Input
type="number"
min={1}
max={20}
value={config.max_results}
onChange={(e) => setConfig({ ...config, max_results: parseInt(e.target.value) || 5 })}
/>
<p className="text-xs text-muted-foreground">{t('maxResultsDescription', 'Maximum number of search results to return (1-20)')}</p>
</div>
</CardContent>
<CardFooter className="bg-muted/50/50 border-t border-border pt-6">
<Button onClick={handleSave} className="ml-auto bg-indigo-600 hover:bg-indigo-700 text-primary-foreground" disabled={isSaving}>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
{t('saveSettings', 'Save Settings')}
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand';
export interface User {
id: number;
username: string;
email: string;
avatar?: string | null;
is_admin: boolean;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (user: User, token: string) => void;
updateUser: (user: Partial<User>) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: JSON.parse(localStorage.getItem('user') || 'null'),
token: localStorage.getItem('token'),
isAuthenticated: !!localStorage.getItem('token'),
login: (user, token) => {
localStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('token', token);
set({ user, token, isAuthenticated: true });
},
updateUser: (updatedUser) => set((state) => {
const user = state.user ? { ...state.user, ...updatedUser } : null;
if (user) {
localStorage.setItem('user', JSON.stringify(user));
}
return { user };
}),
logout: () => {
localStorage.removeItem('user');
localStorage.removeItem('token');
set({ user: null, token: null, isAuthenticated: false });
},
}));
+193
View File
@@ -0,0 +1,193 @@
import { create } from 'zustand';
import type { ChartSpec } from './visualizationStore';
type ChartRow = Record<string, unknown>;
type GridLayout = { i: string; x: number; y: number; w: number; h: number };
export interface ChartConfig {
id: string;
title: string;
type: 'bar' | 'line' | 'table';
data: ChartRow[];
sql: string;
chartSpec?: ChartSpec | null;
layout: GridLayout;
}
export interface DashboardConfig {
id: string;
name: string;
titleStyle?: {
fontSize?: string;
fontWeight?: string;
color?: string;
textAlign?: 'left' | 'center' | 'right';
fontStyle?: string;
textDecoration?: string;
};
createdAt: number;
charts: ChartConfig[];
}
interface DashboardState {
dashboards: DashboardConfig[];
activeDashboardId: string | null;
loadDashboards: (projectId: number) => void;
createDashboard: (name: string, projectId: number) => string;
deleteDashboard: (id: string, projectId: number) => void;
renameDashboard: (id: string, newName: string, projectId: number) => void;
updateDashboardTitleStyle: (id: string, style: DashboardConfig['titleStyle'], projectId: number) => void;
setActiveDashboard: (id: string | null) => void;
addChart: (chart: Omit<ChartConfig, 'layout'>, dashboardId: string, projectId: number) => void;
removeChart: (chartId: string, dashboardId: string, projectId: number) => void;
updateLayout: (layouts: GridLayout[], dashboardId: string, projectId: number) => void;
}
const DASHBOARD_STORAGE_KEY_PREFIX = 'dashboards_v2_project_';
function getStorageKey(projectId: number) {
return `${DASHBOARD_STORAGE_KEY_PREFIX}${projectId}`;
}
function loadDashboardsFromStorage(projectId: number): DashboardConfig[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(getStorageKey(projectId));
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((d: any) => ({
...d,
charts: Array.isArray(d.charts) ? d.charts.map((item: any) => ({
...item,
layout: {
i: item.layout?.i || item.id,
x: Number.isFinite(item.layout?.x) ? item.layout.x : 0,
y: Number.isFinite(item.layout?.y) ? item.layout.y : 0,
w: Number.isFinite(item.layout?.w) ? item.layout.w : 4,
h: Number.isFinite(item.layout?.h) ? item.layout.h : 4,
},
})) : []
}));
}
}
// Migration from v1
const oldRaw = window.localStorage.getItem(`dashboard_charts_v1_project_${projectId}`);
if (oldRaw) {
const parsed = JSON.parse(oldRaw);
if (Array.isArray(parsed) && parsed.length > 0) {
const defaultDashboard: DashboardConfig = {
id: 'default',
name: 'Default Dashboard',
createdAt: Date.now(),
charts: parsed.map((item: any) => ({
...item,
layout: {
i: item.layout?.i || item.id,
x: Number.isFinite(item.layout?.x) ? item.layout.x : 0,
y: Number.isFinite(item.layout?.y) ? item.layout.y : 0,
w: Number.isFinite(item.layout?.w) ? item.layout.w : 4,
h: Number.isFinite(item.layout?.h) ? item.layout.h : 4,
},
})),
};
saveDashboardsToStorage([defaultDashboard], projectId);
return [defaultDashboard];
}
}
return [];
} catch {
return [];
}
}
function saveDashboardsToStorage(dashboards: DashboardConfig[], projectId: number) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(dashboards));
}
export const useDashboardStore = create<DashboardState>((set) => ({
dashboards: [],
activeDashboardId: null,
loadDashboards: (projectId) => {
const dashboards = loadDashboardsFromStorage(projectId);
set({ dashboards, activeDashboardId: dashboards.length > 0 ? dashboards[0].id : null });
},
createDashboard: (name, projectId) => {
const newId = Date.now().toString();
set((state) => {
const newDashboard: DashboardConfig = {
id: newId,
name,
createdAt: Date.now(),
charts: [],
};
const nextDashboards = [...state.dashboards, newDashboard];
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards, activeDashboardId: newId };
});
return newId;
},
deleteDashboard: (id, projectId) => set((state) => {
const nextDashboards = state.dashboards.filter((d) => d.id !== id);
saveDashboardsToStorage(nextDashboards, projectId);
return {
dashboards: nextDashboards,
activeDashboardId: state.activeDashboardId === id ? (nextDashboards.length > 0 ? nextDashboards[0].id : null) : state.activeDashboardId,
};
}),
renameDashboard: (id, newName, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => d.id === id ? { ...d, name: newName } : d);
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
updateDashboardTitleStyle: (id, style, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) =>
d.id === id ? { ...d, titleStyle: { ...d.titleStyle, ...style } } : d
);
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
setActiveDashboard: (id) => set({ activeDashboardId: id }),
addChart: (chart, dashboardId, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => {
if (d.id !== dashboardId) return d;
const colSize = 4;
const cols = 12 / colSize;
const index = d.charts.length;
const newLayout: GridLayout = {
i: chart.id,
x: (index % cols) * colSize,
y: Math.floor(index / cols) * 4,
w: colSize,
h: 4,
};
return { ...d, charts: [...d.charts, { ...chart, layout: newLayout }] };
});
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
removeChart: (chartId, dashboardId, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => {
if (d.id !== dashboardId) return d;
return { ...d, charts: d.charts.filter((c) => c.id !== chartId) };
});
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
updateLayout: (layouts, dashboardId, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => {
if (d.id !== dashboardId) return d;
return {
...d,
charts: d.charts.map((chart) => {
const layout = layouts.find((l) => l.i === chart.id);
return layout ? { ...chart, layout } : chart;
})
};
});
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
}));
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand';
import { api } from '@/lib/api';
interface MCPServerStatus {
status?: string;
}
interface MCPHealthState {
hasMcpError: boolean;
currentProjectId: number | null;
startPolling: (projectId: number | null) => void;
stopPolling: () => void;
refresh: (projectId?: number | null) => Promise<void>;
}
let pollingTimer: ReturnType<typeof setInterval> | null = null;
export const useMcpHealthStore = create<MCPHealthState>((set, get) => ({
hasMcpError: false,
currentProjectId: null,
refresh: async (projectIdArg?: number | null) => {
const projectId = projectIdArg ?? get().currentProjectId;
if (!projectId) {
set({ hasMcpError: false, currentProjectId: null });
return;
}
try {
const data = await api.get<MCPServerStatus[]>(`/api/v1/mcp?project_id=${projectId}`);
const hasError = data.some((mcp) => Boolean(mcp.status && mcp.status.startsWith('error')));
set({ hasMcpError: hasError, currentProjectId: projectId });
} catch (error) {
console.error('Failed to check MCP health', error);
set({ hasMcpError: true, currentProjectId: projectId });
}
},
startPolling: (projectId: number | null) => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
if (!projectId) {
set({ hasMcpError: false, currentProjectId: null });
return;
}
set({ currentProjectId: projectId });
void get().refresh(projectId);
pollingTimer = setInterval(() => {
void get().refresh(projectId);
}, 60000);
},
stopPolling: () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
},
}));
+106
View File
@@ -0,0 +1,106 @@
import { create } from 'zustand';
import { api } from '@/lib/api';
export interface Project {
id: number;
name: string;
description?: string;
owner_id: number;
created_at: string;
updated_at: string;
}
interface ProjectState {
projects: Project[];
currentProject: Project | null;
loading: boolean;
error: string | null;
fetchProjects: () => Promise<void>;
setCurrentProject: (project: Project) => void;
addProject: (name: string, description?: string) => Promise<Project>;
updateProject: (id: number, name: string, description?: string) => Promise<Project>;
deleteProject: (id: number) => Promise<void>;
}
export const useProjectStore = create<ProjectState>((set, get) => ({
projects: [],
currentProject: JSON.parse(localStorage.getItem('currentProject') || 'null'),
loading: false,
error: null,
fetchProjects: async () => {
set({ loading: true, error: null });
try {
const projects = await api.get<Project[]>('/api/v1/projects');
set({ projects, loading: false });
// Set current project if not set or not in list
const current = get().currentProject;
if (projects.length > 0) {
if (!current || !projects.find((p: Project) => p.id === current.id)) {
get().setCurrentProject(projects[0]);
}
} else {
set({ currentProject: null });
localStorage.removeItem('currentProject');
}
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
setCurrentProject: (project: Project) => {
localStorage.setItem('currentProject', JSON.stringify(project));
set({ currentProject: project });
},
addProject: async (name: string, description?: string) => {
try {
const newProject = await api.post<Project>('/api/v1/projects', { name, description });
set((state) => ({ projects: [...state.projects, newProject] }));
if (!get().currentProject) {
get().setCurrentProject(newProject);
}
return newProject;
} catch (error: any) {
throw new Error(error.message || 'Failed to create project');
}
},
updateProject: async (id: number, name: string, description?: string) => {
try {
const updatedProject = await api.put<Project>(`/api/v1/projects/${id}`, { name, description });
set((state) => ({
projects: state.projects.map((p) => (p.id === id ? updatedProject : p)),
currentProject: state.currentProject?.id === id ? updatedProject : state.currentProject,
}));
if (get().currentProject?.id === id) {
localStorage.setItem('currentProject', JSON.stringify(updatedProject));
}
return updatedProject;
} catch (error: any) {
throw new Error(error.message || 'Failed to update project');
}
},
deleteProject: async (id: number) => {
try {
await api.delete(`/api/v1/projects/${id}`);
set((state) => {
const projects = state.projects.filter((p) => p.id !== id);
let currentProject = state.currentProject;
if (currentProject?.id === id) {
currentProject = projects.length > 0 ? projects[0] : null;
if (currentProject) {
localStorage.setItem('currentProject', JSON.stringify(currentProject));
} else {
localStorage.removeItem('currentProject');
}
}
return { projects, currentProject };
});
} catch (error: any) {
throw new Error(error.message || 'Failed to delete project');
}
},
}));
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark';
interface ThemeState {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () => set((state) => {
const newTheme = state.theme === 'light' ? 'dark' : 'light';
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return { theme: newTheme };
}),
setTheme: (theme: Theme) => set(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return { theme };
}),
}),
{
name: 'theme-storage',
}
)
);
@@ -0,0 +1,50 @@
import { create } from 'zustand';
export interface ChartSpec {
$schema?: string;
title?: string;
description?: string;
mark?: string | { type?: string; [key: string]: unknown };
encoding?: Record<string, unknown>;
transform?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
export interface ChartInfo {
canVisualize: boolean;
reasoning?: string;
chartType?: string;
description?: string;
}
export interface VisualizationState {
currentData: any[] | null;
currentSQL: string | null;
currentChartSpec: ChartSpec | null;
currentChartInfo: ChartInfo | null;
isLoading: boolean;
error: string | null;
setVisualization: (data: any[], sql: string, chartSpec?: ChartSpec | null, chartInfo?: ChartInfo | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearVisualization: () => void;
}
export const useVisualizationStore = create<VisualizationState>((set) => ({
currentData: null,
currentSQL: null,
currentChartSpec: null,
currentChartInfo: null,
isLoading: false,
error: null,
setVisualization: (data, sql, chartSpec = null, chartInfo = null) => set({
currentData: data,
currentSQL: sql,
currentChartSpec: chartSpec,
currentChartInfo: chartInfo,
error: null,
}),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error, isLoading: false }),
clearVisualization: () => set({ currentData: null, currentSQL: null, currentChartSpec: null, currentChartInfo: null, error: null }),
}));
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import path from "path"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/nanobot': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/reports': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})