Update 2026-05-13 16:43:53
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,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;"]
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+11793
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "配置保存成功。注意:活跃的智能体可能需要重启新会话才能应用新配置。"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) }),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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 };
|
||||
}),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user