Compare commits

...

3 Commits

Author SHA1 Message Date
朝夕 dcf53524b2 wip: 修复了serve的部分bug 2026-04-24 12:48:17 +08:00
朝夕 27a71c9e49
Feature/frontend dashboard integration 11563952984595832647 (#20)
* fix(backend): initialize async queue properly and fix auth login error handling (#18)

- Moved `self.workflow_queue = asyncio.Queue()` to the top of `WorkflowRunningEngine.run` to ensure the queue exists before coroutines start polling it, resolving initialization race conditions.
- Handled `user` object nullability check correctly in `/api/v1/auth/login` to raise `UserNotExistError` instead of crashing on attribute access.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* feat: Integrate frontend dashboard and wire up settings endpoints

- Imported and moved the pretor_dashboard dev branch into `frontend/`.
- Configured FastAPI `PretorGateway` to mount `frontend/dist` out of the box and serve it effectively.
- Fixed `global_state_machine` Ray Actor hook references in `pretor/api/resource.py`.
- Added missing GET `/api/v1/auth/list` endpoint to list all users.
- Added missing DELETE `/api/v1/auth/{user_id}` endpoint to remove users.
- Plumbed API calls in the frontend's `UsersSettings.tsx` to get, delete, and alter the authority roles.
- Wired up provider deletion API endpoints within `ProvidersSettings.tsx`.
- Ran `npm run build` so `frontend/dist` is current.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* feat: Integrate frontend dashboard and wire up settings endpoints

- Imported and moved the pretor_dashboard dev branch into `frontend/`.
- Configured FastAPI `PretorGateway` to mount `frontend/dist` out of the box and serve it effectively.
- Fixed `global_state_machine` Ray Actor hook references in `pretor/api/resource.py`.
- Added missing GET `/api/v1/auth/list` endpoint to list all users.
- Added missing DELETE `/api/v1/auth/{user_id}` endpoint to remove users.
- Plumbed API calls in the frontend's `UsersSettings.tsx` to get, delete, and alter the authority roles.
- Wired up provider deletion API endpoints within `ProvidersSettings.tsx`.
- Ran `npm run build` so `frontend/dist` is current.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* fix(backend): Remove __call__ from PretorGateway and assign first user as SUPER_ADMINISTRATOR

- Removed `__call__` from `PretorGateway` in `pretor/core/api/__init__.py` to fix Ray Serve `ValueError` during initialization.
- Modified `AuthDatabase.add_user` in `pretor/core/database/module/user.py` to check for existing users. The first registered user now receives `UserAuthority.SUPER_ADMINISTRATOR` access while subsequent users get `USER` access.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* fix(backend): Remove __call__ from PretorGateway and assign first user as SUPER_ADMINISTRATOR

- Removed `__call__` from `PretorGateway` in `pretor/core/api/__init__.py` to fix Ray Serve `ValueError` during initialization.
- Added connection error handling in `PostgresDatabase.init_db()` to prevent startup crashes when PostgreSQL is unavailable.
- Updated `AuthDatabase.add_user` to automatically grant `SUPER_ADMINISTRATOR` privileges to the first registered user.
- Fixed unit tests in `user_test.py` that were improperly mocking `session.execute`, removing confusing stack traces during testing.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* fix(backend): Remove __call__ from PretorGateway and assign first user as SUPER_ADMINISTRATOR

- Removed `__call__` from `PretorGateway` in `pretor/core/api/__init__.py` to fix Ray Serve `ValueError` during initialization.
- Added connection error handling in `PostgresDatabase.init_db()` to prevent startup crashes when PostgreSQL is unavailable.
- Updated `AuthDatabase.add_user` to automatically grant `SUPER_ADMINISTRATOR` privileges to the first registered user.
- Fixed unit tests in `user_test.py` that were improperly mocking `session.execute`, removing confusing stack traces during testing.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>
2026-04-24 11:46:47 +08:00
朝夕 66306ffd01
[feature integration] frontend dashboard and API endpoints (#19)
* fix(backend): initialize async queue properly and fix auth login error handling (#18)

- Moved `self.workflow_queue = asyncio.Queue()` to the top of `WorkflowRunningEngine.run` to ensure the queue exists before coroutines start polling it, resolving initialization race conditions.
- Handled `user` object nullability check correctly in `/api/v1/auth/login` to raise `UserNotExistError` instead of crashing on attribute access.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* feat: Integrate frontend dashboard and wire up settings endpoints

- Imported and moved the pretor_dashboard dev branch into `frontend/`.
- Configured FastAPI `PretorGateway` to mount `frontend/dist` out of the box and serve it effectively.
- Fixed `global_state_machine` Ray Actor hook references in `pretor/api/resource.py`.
- Added missing GET `/api/v1/auth/list` endpoint to list all users.
- Added missing DELETE `/api/v1/auth/{user_id}` endpoint to remove users.
- Plumbed API calls in the frontend's `UsersSettings.tsx` to get, delete, and alter the authority roles.
- Wired up provider deletion API endpoints within `ProvidersSettings.tsx`.
- Ran `npm run build` so `frontend/dist` is current.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>
2026-04-24 09:12:12 +08:00
71 changed files with 5946 additions and 192 deletions

View File

@ -10,8 +10,6 @@ services:
POSTGRES_DB: pretor POSTGRES_DB: pretor
ports: ports:
- "5432:5432" - "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"] test: ["CMD-SHELL", "pg_isready -U postgres -d pretor"]
interval: 5s interval: 5s
@ -34,9 +32,6 @@ services:
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- POSTGRES_DB=pretor - POSTGRES_DB=pretor
- SECRET_KEY=changethiskey12345 - SECRET_KEY=changethiskey12345
volumes:
- .:/app
- /app/frontend/dist # Prevent local uncompiled frontend from overriding the built one
volumes: volumes:
postgres_data: postgres_data:

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pretor-dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3612
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "pretor-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.15.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 4.9 KiB

50
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,50 @@
import { useState } from 'react';
import { Sidebar } from './components/Layout/Sidebar';
import { MonitoringLayout } from './components/Monitoring/MonitoringLayout';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { LeftPanel } from './components/Chat/LeftPanel';
import { ChatPanel } from './components/Chat/ChatPanel';
import { RightPanel } from './components/Chat/RightPanel';
function App() {
const [activeTab, setActiveTab] = useState('chats'); // For LeftPanel
const [currentView, setCurrentView] = useState('dashboard'); // 'dashboard', 'settings', or 'monitoring'
const [settingsTab, setSettingsTab] = useState('users'); // For SettingsLayout
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
return (
<div className="flex h-screen w-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
{/* 1. Sidebar (Leftmost) */}
<Sidebar currentView={currentView} setCurrentView={setCurrentView} />
{/* Main Content Area depending on view */}
{currentView === 'monitoring' ? (
<MonitoringLayout />
) : currentView === 'dashboard' ? (
<>
{/* 2. Left Panel - Cluster Status & Workflows/Chats */}
<LeftPanel
activeTab={activeTab}
setActiveTab={setActiveTab}
selectedWorkflow={selectedWorkflow}
setSelectedWorkflow={setSelectedWorkflow}
/>
{/* 3. Middle Panel - AI Chat */}
<ChatPanel />
{/* 4. Right Panel - Workflow Execution Status (Only show when viewing workflows) */}
{activeTab === 'workflows' && <RightPanel selectedWorkflow={selectedWorkflow} />}
</>
) : (
/* Settings View */
<SettingsLayout settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
)}
</div>
);
}
export default App;

View File

@ -0,0 +1,22 @@
import axios from 'axios';
// The base URL should typically come from an environment variable in a real app,
// but for development we can default to localhost.
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor to attach token to requests if we have one
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default apiClient;

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,156 @@
import { useState } from 'react';
import { MessageSquare, Activity, Terminal, ChevronRight, Plus } from 'lucide-react';
import apiClient from '../../api/client';
interface ChatMessage {
id: string;
sender: 'user' | 'ai';
text: string;
timestamp: Date;
eventId?: string;
}
export function ChatPanel() {
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: '1',
sender: 'ai',
text: "Hello! I am Pretor Assistant. How can I help you today?",
timestamp: new Date()
}
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const handleSendMessage = async () => {
if (!input.trim()) return;
const userMessage: ChatMessage = {
id: Date.now().toString(),
sender: 'user',
text: input,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setLoading(true);
try {
// Assuming a token might be needed, apiClient should handle it if set
const response = await apiClient.post('/api/v1/adapter/client', {
message: userMessage.text
});
const aiMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
sender: 'ai',
text: typeof response.data.message === 'string' && response.data.message.includes('-')
? "Task has been created." // It's an event ID
: response.data.message || "I received your message.",
eventId: typeof response.data.message === 'string' && response.data.message.includes('-') ? response.data.message : undefined,
timestamp: new Date()
};
setMessages(prev => [...prev, aiMessage]);
// If we got an event_id, we could potentially open a websocket to listen to its stream
if (aiMessage.eventId) {
console.log(`Open WS to track event: ${aiMessage.eventId}`);
// Implement WS tracking if needed
}
} catch (error) {
console.error("Error sending message", error);
const errorMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
sender: 'ai',
text: "Sorry, I encountered an error communicating with the server.",
timestamp: new Date()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setLoading(false);
}
};
return (
<div className="flex-1 flex flex-col bg-slate-50">
<div className="h-14 border-b border-slate-200 bg-white flex items-center px-6 shadow-sm z-10">
<MessageSquare size={18} className="text-blue-600 mr-3" />
<h1 className="font-semibold text-slate-800">Pretor Assistant</h1>
</div>
{/* Chat History */}
<div className="flex-1 p-6 overflow-y-auto space-y-6">
<div className="flex justify-center">
<span className="text-xs text-slate-400 bg-slate-200/50 px-3 py-1 rounded-full">Today</span>
</div>
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.sender === 'ai' && (
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
<Activity size={16} className="text-blue-600" />
</div>
)}
<div className={`${msg.sender === 'user' ? 'bg-blue-600 text-white rounded-2xl rounded-tr-sm' : 'bg-white border border-slate-100 text-slate-700 rounded-2xl rounded-tl-sm'} p-4 max-w-[80%] shadow-sm`}>
<p className="text-sm leading-relaxed mb-3">{msg.text}</p>
{msg.eventId && (
<div className="bg-slate-50 border border-slate-100 rounded-lg p-3 flex items-center text-sm">
<Terminal size={16} className="text-slate-400 mr-2" />
<span className="font-mono text-slate-600 text-xs">Task ID: {msg.eventId}</span>
</div>
)}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
<Activity size={16} className="text-blue-600 animate-spin" />
</div>
<div className="bg-white border border-slate-100 text-slate-700 p-4 rounded-2xl rounded-tl-sm max-w-[80%] shadow-sm">
<span className="flex space-x-1">
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-75"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-150"></span>
</span>
</div>
</div>
)}
</div>
{/* Chat Input */}
<div className="p-4 bg-white border-t border-slate-200">
<div className="relative flex items-center">
<button
className="absolute left-2 p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors z-10 cursor-pointer"
title="Add attachment"
>
<Plus size={20} />
</button>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
placeholder="Ask Pretor to do something..."
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-xl pl-12 pr-12 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-inner"
/>
<button
onClick={handleSendMessage}
disabled={loading || !input.trim()}
className="absolute right-2 p-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-sm disabled:opacity-50 cursor-pointer"
>
<ChevronRight size={18} />
</button>
</div>
<div className="flex mt-2 space-x-3 px-2">
<span className="text-[10px] text-slate-400 cursor-pointer hover:text-blue-500">Run diagnostics</span>
<span className="text-[10px] text-slate-400 cursor-pointer hover:text-blue-500">View recent errors</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { Server, Box, Cpu, HardDrive, List, MessageCircle } from 'lucide-react';
import { useClusterState } from '../../hooks/useClusterState';
import apiClient from '../../api/client';
import type { Workflow } from '../../types';
interface LeftPanelProps {
activeTab: string;
setActiveTab: (tab: string) => void;
selectedWorkflow: string | null;
setSelectedWorkflow: (id: string | null) => void;
}
export function LeftPanel({ activeTab, setActiveTab, selectedWorkflow, setSelectedWorkflow }: LeftPanelProps) {
const { nodes } = useClusterState();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const totalNodes = nodes.length;
const aliveNodes = nodes.filter(n => n.alive).length;
let totalCpu = 0;
let usedCpu = 0;
let totalMemory = 0;
let usedMemory = 0;
nodes.forEach(node => {
const nodeTotalCpu = node.resources?.CPU || 0;
const nodeRemainingCpu = node.remaining?.CPU || 0;
totalCpu += nodeTotalCpu;
usedCpu += (nodeTotalCpu - nodeRemainingCpu);
const nodeTotalMem = node.resources?.memory || 0;
const nodeRemainingMem = node.remaining?.memory || 0;
totalMemory += nodeTotalMem;
usedMemory += (nodeTotalMem - nodeRemainingMem);
});
const cpuPercent = totalCpu > 0 ? (usedCpu / totalCpu) * 100 : 0;
const memPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0;
useEffect(() => {
if (activeTab === 'workflows') {
const fetchWorkflows = async () => {
setLoadingWorkflows(true);
try {
const response = await apiClient.get('/api/v1/workflow/list');
// Fallback parsing just in case it returns an object or array
const data = response.data;
let parsedWorkflows: Workflow[] = [];
if (Array.isArray(data)) {
parsedWorkflows = data;
} else if (data && typeof data === 'object') {
// Suppose backend sends { workflows: [...] }
parsedWorkflows = data.workflows || Object.values(data);
}
setWorkflows(parsedWorkflows);
} catch (error) {
console.error("Failed to fetch workflows", error);
setWorkflows([]);
} finally {
setLoadingWorkflows(false);
}
};
fetchWorkflows();
}
}, [activeTab]);
return (
<div className="w-72 bg-white border-r border-slate-200 flex flex-col z-0 shrink-0">
{/* Top: Cluster Status */}
<div className="h-1/3 p-4 border-b border-slate-100 flex flex-col">
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-4 flex items-center">
<Server size={16} className="mr-2" />
Cluster Status
</h2>
<div className="space-y-4 flex-1">
<div className="flex items-center justify-between">
<div className="flex items-center text-slate-600">
<Box size={16} className="mr-2 text-blue-500" />
<span className="text-sm">Active Nodes</span>
</div>
<span className="text-sm font-medium text-slate-800">{aliveNodes} / {totalNodes || 0}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center text-slate-600">
<Cpu size={16} className="mr-2 text-indigo-500" />
<span className="text-sm">Cluster CPU</span>
</div>
<span className="text-sm font-medium text-slate-800">{cpuPercent.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div className="bg-indigo-500 h-1.5 rounded-full" style={{ width: `${cpuPercent}%` }}></div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center text-slate-600">
<HardDrive size={16} className="mr-2 text-green-500" />
<span className="text-sm">Cluster RAM</span>
</div>
<span className="text-sm font-medium text-slate-800">
{(totalMemory > 0 ? usedMemory / (1024 ** 3) : 0).toFixed(1)} GB
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div className="bg-green-500 h-1.5 rounded-full" style={{ width: `${memPercent}%` }}></div>
</div>
</div>
</div>
{/* Bottom: Tabs for Workflows & Basic Chats */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-slate-100">
<button
onClick={() => setActiveTab('chats')}
className={`flex-1 py-3 text-xs font-medium text-center uppercase tracking-wider transition-colors ${activeTab === 'chats' ? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50' : 'text-slate-500 hover:bg-slate-50'}`}
>
<MessageCircle size={14} className="inline mr-1.5 -mt-0.5" />
Chats
</button>
<button
onClick={() => setActiveTab('workflows')}
className={`flex-1 py-3 text-xs font-medium text-center uppercase tracking-wider transition-colors ${activeTab === 'workflows' ? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50' : 'text-slate-500 hover:bg-slate-50'}`}
>
<List size={14} className="inline mr-1.5 -mt-0.5" />
Workflows
</button>
</div>
<div className="flex-1 p-4 overflow-y-auto">
{activeTab === 'workflows' && (
<div className="space-y-2">
{loadingWorkflows ? (
<div className="text-center text-slate-400 text-sm py-4">Loading workflows...</div>
) : workflows.length === 0 ? (
<div className="text-center text-slate-400 text-sm py-4"></div>
) : (
workflows.map((wf) => (
<div
key={wf.event_id}
onClick={() => setSelectedWorkflow(wf.event_id)}
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedWorkflow === wf.event_id ? 'border-blue-200 bg-blue-50 shadow-sm' : 'border-slate-100 hover:border-blue-200 hover:bg-slate-50'}`}
>
<div className="flex justify-between items-center mb-1">
<span className={`font-medium text-sm ${selectedWorkflow === wf.event_id ? 'text-blue-700' : 'text-slate-700'}`}>{wf.workflow_title || 'Unnamed Workflow'}</span>
<span className={`flex h-2 w-2 rounded-full ${wf.status === 'running' ? 'bg-green-400 animate-pulse' : 'bg-slate-300'}`}></span>
</div>
<p className="text-xs text-slate-500 font-mono line-clamp-1">ID: {wf.event_id}</p>
</div>
))
)}
</div>
)}
{activeTab === 'chats' && (
<div className="space-y-2">
<div className="p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-slate-50 cursor-pointer transition-all">
<div className="font-medium text-sm text-slate-700 mb-1">System Architecture</div>
<p className="text-xs text-slate-400 line-clamp-1">Can you explain the MoE model...</p>
</div>
<div className="p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-slate-50 cursor-pointer transition-all">
<div className="font-medium text-sm text-slate-700 mb-1">Log Analysis Helper</div>
<p className="text-xs text-slate-400 line-clamp-1">Show me the errors from yesterday.</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
import { useState, useEffect } from 'react';
import { Terminal, Activity } from 'lucide-react';
interface RightPanelProps {
selectedWorkflow: string | null;
}
export function RightPanel({ selectedWorkflow }: RightPanelProps) {
const [messages, setMessages] = useState<string[]>([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (!selectedWorkflow) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMessages([]);
return;
}
const wsBase = import.meta.env.VITE_API_BASE_URL
? import.meta.env.VITE_API_BASE_URL.replace('http', 'ws')
: `ws://localhost:8000`;
// Using the workflow router WS endpoint
const ws = new WebSocket(`${wsBase}/api/v1/workflow/ws/${selectedWorkflow}`);
ws.onopen = () => {
setIsConnected(true);
setMessages([]); // clear previous traces
};
ws.onmessage = (event) => {
try {
setMessages(prev => [...prev, event.data]);
} catch (e) {
console.error("Error receiving workflow websocket message", e);
}
};
ws.onclose = () => {
setIsConnected(false);
};
return () => {
ws.close();
};
}, [selectedWorkflow]);
if (!selectedWorkflow) {
return (
<div className="w-80 bg-white border-l border-slate-200 flex flex-col z-0 justify-center items-center p-6 text-center">
<Activity size={32} className="text-slate-300 mb-4" />
<h3 className="text-sm font-semibold text-slate-600">No Workflow Selected</h3>
<p className="text-xs text-slate-400 mt-2">Select a workflow from the left panel to view its execution trace.</p>
</div>
);
}
return (
<div className="w-80 bg-white border-l border-slate-200 flex flex-col z-0">
<div className="h-14 border-b border-slate-100 flex items-center px-4 justify-between bg-slate-50/50">
<h2 className="font-semibold text-slate-800 text-sm flex items-center">
<Terminal size={16} className="mr-2 text-slate-500" />
Execution Trace
</h2>
<span className={`px-2 py-1 text-xs rounded-md font-medium border ${isConnected ? 'bg-green-100 text-green-700 border-green-200' : 'bg-slate-100 text-slate-500 border-slate-200'}`}>
{isConnected ? 'Running' : 'Disconnected'}
</span>
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="mb-6">
<h3 className="text-lg font-bold text-slate-800 mb-1">Workflow Logs</h3>
<p className="text-xs text-slate-500 mb-4 font-mono">ID: {selectedWorkflow}</p>
</div>
{/* Dynamic Log Rendering */}
<div className="relative border-l-2 border-slate-200 ml-3 pl-6 space-y-6">
{messages.length === 0 && isConnected && (
<div className="text-xs text-slate-400 italic">Waiting for workflow events...</div>
)}
{messages.map((msg, idx) => {
// For now, simply parsing the raw text as a basic log entry.
// If the backend sends structured JSON, this could be parsed and styled nicer.
const isLatest = idx === messages.length - 1;
return (
<div key={idx} className="relative">
{/* Dot */}
<div className={`absolute -left-[31px] top-1 flex items-center justify-center w-6 h-6 rounded-full border-2 border-white shadow-sm ${isLatest && isConnected ? 'bg-blue-500' : 'bg-green-500 text-white'}`}>
{isLatest && isConnected ? (
<span className="h-2 w-2 bg-white rounded-full animate-pulse"></span>
) : (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7"></path></svg>
)}
</div>
{/* Card */}
<div className={`p-3 rounded-lg border shadow-sm ${isLatest && isConnected ? 'border-blue-200 bg-blue-50' : 'border-slate-100 bg-white'}`}>
<h4 className={`font-semibold text-xs mb-1 ${isLatest && isConnected ? 'text-blue-800' : 'text-slate-800'}`}>Step {idx + 1}</h4>
<p className={`text-[11px] font-mono break-all ${isLatest && isConnected ? 'text-blue-600' : 'text-slate-500'}`}>{msg}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import { Activity, MessageSquare, MonitorPlay, Settings } from 'lucide-react';
interface SidebarProps {
currentView: string;
setCurrentView: (view: string) => void;
}
export function Sidebar({ currentView, setCurrentView }: SidebarProps) {
return (
<div className="w-12 bg-white border-r border-slate-200 flex flex-col items-center py-4 space-y-6 shadow-sm z-10 shrink-0">
<div
className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-md shadow-blue-200 cursor-pointer hover:bg-blue-700 transition-colors"
onClick={() => setCurrentView('dashboard')}
>
<Activity size={18} />
</div>
<div className="flex flex-col space-y-4 flex-1 mt-8">
<button
onClick={() => setCurrentView('dashboard')}
className={`p-1.5 rounded-lg transition-colors ${currentView === 'dashboard' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
title="Dashboard"
>
<MessageSquare size={18} />
</button>
<button
onClick={() => setCurrentView('monitoring')}
className={`p-1.5 rounded-lg transition-colors ${currentView === 'monitoring' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
title="Monitoring"
>
<MonitorPlay size={18} />
</button>
<button
onClick={() => setCurrentView('settings')}
className={`p-1.5 rounded-lg transition-colors ${currentView === 'settings' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
title="Settings"
>
<Settings size={18} />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
import { Server, Cpu, HardDrive, Box } from 'lucide-react';
import type { ClusterNode } from '../../types';
import { useClusterState } from '../../hooks/useClusterState';
export function MonitoringDashboard() {
const { nodes, isConnected } = useClusterState();
const totalNodes = nodes.length;
let totalCpu = 0;
let totalMemory = 0;
let totalGpu = 0;
nodes.forEach((node: ClusterNode) => {
totalCpu += node.resources?.CPU || 0;
totalMemory += node.resources?.memory || 0;
totalGpu += node.resources?.GPU || 0;
});
return (
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
<div className="p-6 border-b border-slate-200 bg-white shadow-sm z-10 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-800">Ray Cluster Monitoring</h2>
<p className="text-sm text-slate-500 mt-1">Real-time resource utilization across all nodes.</p>
</div>
<div className="flex items-center space-x-2">
<span className={`flex h-2.5 w-2.5 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
<span className="text-sm font-medium text-slate-600">{isConnected ? 'Connected' : 'Disconnected'}</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Cluster Global Metrics */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center mr-4">
<Server size={24} className="text-blue-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL NODES</p>
<p className="text-2xl font-bold text-slate-800">{totalNodes}</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-indigo-50 flex items-center justify-center mr-4">
<Cpu size={24} className="text-indigo-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL CPU CORES</p>
<p className="text-2xl font-bold text-slate-800">{totalCpu}</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-green-50 flex items-center justify-center mr-4">
<HardDrive size={24} className="text-green-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL RAM</p>
<p className="text-2xl font-bold text-slate-800">
{totalMemory > 0 ? `${(totalMemory / (1024 * 1024 * 1024)).toFixed(2)} GB` : '0 GB'}
</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-purple-50 flex items-center justify-center mr-4">
<Box size={24} className="text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL GPUS</p>
<p className="text-2xl font-bold text-slate-800">{totalGpu}</p>
</div>
</div>
</div>
{/* Node List */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Node ID / Name</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium">CPU (Used / Total)</th>
<th className="px-6 py-4 font-medium">RAM (Used / Total)</th>
<th className="px-6 py-4 font-medium">GPU (Used / Total)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{nodes.map((node: ClusterNode, i: number) => {
const totalCpu = node.resources?.CPU || 0;
const remainingCpu = node.remaining?.CPU || 0;
const usedCpu = totalCpu - remainingCpu;
const cpuPercent = totalCpu > 0 ? (usedCpu / totalCpu) * 100 : 0;
const totalRam = node.resources?.memory || 0;
const remainingRam = node.remaining?.memory || 0;
const usedRam = totalRam - remainingRam;
const ramPercent = totalRam > 0 ? (usedRam / totalRam) * 100 : 0;
const totalGpu = node.resources?.GPU || 0;
const remainingGpu = node.remaining?.GPU || 0;
const usedGpu = totalGpu - remainingGpu;
const gpuPercent = totalGpu > 0 ? (usedGpu / totalGpu) * 100 : 0;
return (
<tr key={i} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 font-medium text-slate-800 flex flex-col">
<span>{node.node_name || 'Unknown'}</span>
<span className="text-xs text-slate-400 font-mono">{node.node_id}</span>
</td>
<td className="px-6 py-4">
<span className={`flex items-center text-xs font-medium ${node.alive ? 'text-green-600' : 'text-red-600'}`}>
<span className={`w-2 h-2 rounded-full mr-2 ${node.alive ? 'bg-green-500' : 'bg-red-500'}`}></span>
{node.alive ? 'Alive' : 'Dead'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center">
<span className="w-16 text-right mr-2 text-xs">{usedCpu.toFixed(1)} / {totalCpu}</span>
<div className="w-16 bg-slate-100 rounded-full h-1.5"><div className={`h-1.5 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-indigo-500'}`} style={{ width: `${cpuPercent}%` }}></div></div>
</div>
</td>
<td className="px-6 py-4 text-slate-600 text-xs">
<div className="flex items-center">
<span className="w-24 text-right mr-2 text-xs">{(usedRam / (1024**3)).toFixed(1)}G / {(totalRam / (1024**3)).toFixed(1)}G</span>
<div className="w-16 bg-slate-100 rounded-full h-1.5"><div className={`h-1.5 rounded-full ${ramPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`} style={{ width: `${ramPercent}%` }}></div></div>
</div>
</td>
<td className="px-6 py-4">
{totalGpu > 0 ? (
<div className="flex items-center">
<span className="w-16 text-right mr-2 text-xs">{usedGpu} / {totalGpu}</span>
<div className="w-16 bg-slate-100 rounded-full h-1.5"><div className={`h-1.5 rounded-full ${gpuPercent > 80 ? 'bg-red-500' : 'bg-purple-500'}`} style={{ width: `${gpuPercent}%` }}></div></div>
</div>
) : (
<span className="text-slate-400 italic text-xs">No GPU</span>
)}
</td>
</tr>
);
})}
{nodes.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-slate-500 text-sm">
No node data available. {isConnected ? 'Waiting for cluster state...' : 'Check connection.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { useState } from 'react';
import { Server } from 'lucide-react';
import { MonitoringDashboard } from './MonitoringDashboard';
export function MonitoringLayout() {
const [activeTab, setActiveTab] = useState('cluster');
return (
<div className="flex-1 flex bg-slate-50 overflow-hidden">
{/* Monitoring Inner Sidebar */}
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-0">
<div className="p-6 border-b border-slate-100">
<h2 className="text-lg font-semibold text-slate-800">Monitoring</h2>
</div>
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
<button
onClick={() => setActiveTab('cluster')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${activeTab === 'cluster' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<Server size={18} className="mr-3" />
Cluster Monitor
</button>
{/* Future monitoring tabs (e.g., Application Logs, Agent Metrics) can go here */}
</div>
</div>
{/* Monitoring Main Content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'cluster' && <MonitoringDashboard />}
</div>
</div>
);
}

View File

@ -0,0 +1,258 @@
import { useState, useEffect } from 'react';
import { Box, Plus, X } from 'lucide-react';
import type { Provider } from '../../types';
import apiClient from '../../api/client';
export function ProvidersSettings() {
const [providers, setProviders] = useState<Provider[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState({
provider_type: 'openai',
provider_title: '',
provider_url: '',
provider_apikey: ''
});
const [submitLoading, setSubmitLoading] = useState(false);
const [error, setError] = useState('');
const fetchProviders = async () => {
setLoading(true);
try {
const response = await apiClient.get('/api/v1/provider/list');
const data = response.data.provider_list || {};
const providerArray: Provider[] = Object.values(data);
setProviders(providerArray);
} catch (error) {
console.error("Failed to fetch providers", error);
setProviders([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProviders();
}, []);
const handleOpenModal = () => {
setFormData({
provider_type: 'openai',
provider_title: '',
provider_url: '',
provider_apikey: ''
});
setError('');
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.provider_title || !formData.provider_url || !formData.provider_apikey) {
setError('Please fill in all fields.');
return;
}
setSubmitLoading(true);
setError('');
try {
await apiClient.post('/api/v1/provider', formData);
await fetchProviders();
handleCloseModal();
} catch (err) {
console.error("Error adding provider", err);
setError('Failed to add provider. Please check your inputs and try again.');
} finally {
setSubmitLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-semibold text-slate-800">Provider Management</h3>
<p className="text-sm text-slate-500 mt-1">Configure external AI model providers and API keys.</p>
</div>
<button
onClick={handleOpenModal}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
>
<Plus size={16} className="mr-2" />
Add Provider
</button>
</div>
{loading ? (
<div className="text-center text-slate-500 py-8">Loading providers...</div>
) : providers.length === 0 ? (
<div className="text-center text-slate-500 py-8 bg-white rounded-xl border border-slate-200">
No providers configured yet. Click "Add Provider" to get started.
</div>
) : (
<div className="grid grid-cols-2 gap-6">
{providers.map((provider, i) => (
<div key={i} className="bg-white border border-slate-200 p-5 rounded-xl shadow-sm hover:border-blue-200 transition-colors flex flex-col justify-between">
<div>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center">
<div className="w-10 h-10 rounded-lg bg-slate-50 border border-slate-100 flex items-center justify-center mr-3">
<Box size={20} className="text-slate-600" />
</div>
<div>
<h4 className="font-semibold text-slate-800">{provider.provider_title}</h4>
<span className="text-xs text-slate-500 font-mono uppercase">{provider.provider_type}</span>
</div>
</div>
<span className={`flex items-center text-xs font-medium px-2 py-1 rounded-md border ${provider.status === 'Connected' ? 'bg-green-50 text-green-700 border-green-200' : 'bg-slate-50 text-slate-500 border-slate-200'}`}>
{provider.status === 'Connected' && <span className="w-1.5 h-1.5 rounded-full bg-green-500 mr-1.5"></span>}
{provider.status || 'Unknown'}
</span>
</div>
<div className="mb-4">
<p className="text-sm text-slate-600 mb-1">URL / Endpoint:</p>
<div className="bg-slate-50 border border-slate-100 rounded text-sm px-3 py-1.5 font-mono text-slate-700 truncate" title={provider.provider_url}>
{provider.provider_url || 'Default'}
</div>
</div>
</div>
<div className="flex justify-end space-x-2 mt-2">
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded hover:bg-slate-50 transition-colors cursor-pointer">Edit</button>
<button
onClick={async () => {
if (!confirm('Are you sure you want to delete this provider?')) return;
try {
await apiClient.delete(`/api/v1/provider/${provider.provider_title}`);
fetchProviders();
} catch (err) {
console.error('Failed to delete provider', err);
alert('Failed to delete provider');
}
}}
className="px-3 py-1.5 text-sm font-medium text-red-600 bg-white border border-slate-200 rounded hover:bg-red-50 transition-colors cursor-pointer"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
{/* Add Provider Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center p-5 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">Add New Provider</h3>
<button
onClick={handleCloseModal}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Type</label>
<select
name="provider_type"
value={formData.provider_type}
onChange={handleChange}
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
>
<option value="openai">OpenAI</option>
<option value="gemini">Gemini</option>
<option value="claude">Claude</option>
<option value="local">Local</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Title</label>
<input
type="text"
name="provider_title"
placeholder="e.g. My OpenAI Instance"
value={formData.provider_title}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Base URL</label>
<input
type="url"
name="provider_url"
placeholder="e.g. https://api.openai.com/v1"
value={formData.provider_url}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">API Key</label>
<input
type="password"
name="provider_apikey"
placeholder="sk-..."
value={formData.provider_apikey}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400 font-mono"
/>
</div>
<div className="pt-4 flex justify-end space-x-3">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
>
{submitLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
</span>
) : (
'Add Provider'
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,53 @@
import { Users, Key, Sliders } from 'lucide-react';
import { UsersSettings } from './UsersSettings';
import { ProvidersSettings } from './ProvidersSettings';
import { SystemSettings } from './SystemSettings';
interface SettingsLayoutProps {
settingsTab: string;
setSettingsTab: (tab: string) => void;
}
export function SettingsLayout({ settingsTab, setSettingsTab }: SettingsLayoutProps) {
return (
<div className="flex-1 flex bg-slate-50 overflow-hidden">
{/* Settings Inner Sidebar */}
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-0">
<div className="p-6 border-b border-slate-100">
<h2 className="text-lg font-semibold text-slate-800">Settings</h2>
</div>
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
<button
onClick={() => setSettingsTab('users')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${settingsTab === 'users' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<Users size={18} className="mr-3" />
User Management
</button>
<button
onClick={() => setSettingsTab('providers')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${settingsTab === 'providers' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<Key size={18} className="mr-3" />
Provider Management
</button>
<button
onClick={() => setSettingsTab('system')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${settingsTab === 'system' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<Sliders size={18} className="mr-3" />
System Settings
</button>
</div>
</div>
{/* Settings Main Content */}
<div className="flex-1 overflow-y-auto p-8">
{settingsTab === 'users' && <UsersSettings />}
{settingsTab === 'providers' && <ProvidersSettings />}
{settingsTab === 'system' && <SystemSettings />}
</div>
</div>
);
}

View File

@ -0,0 +1,65 @@
import { Globe, Server, Save } from 'lucide-react';
export function SystemSettings() {
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<h3 className="text-xl font-semibold text-slate-800">System Settings</h3>
<p className="text-sm text-slate-500 mt-1">Global platform configurations.</p>
</div>
<div className="space-y-6">
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
<h4 className="text-sm font-semibold text-slate-800 mb-4 flex items-center">
<Globe size={16} className="mr-2 text-slate-500" />
General
</h4>
<div className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">System Language</label>
<select className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all">
<option>English</option>
<option></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Theme</label>
<select className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all">
<option>Light</option>
<option>Dark</option>
<option>System Default</option>
</select>
</div>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
<h4 className="text-sm font-semibold text-slate-800 mb-4 flex items-center">
<Server size={16} className="mr-2 text-slate-500" />
Cluster & Runtime
</h4>
<div className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max Concurrent Workflows</label>
<input type="number" defaultValue={10} className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all" />
</div>
<div className="flex items-center mt-4">
<input type="checkbox" id="debug_mode" defaultChecked className="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500" />
<label htmlFor="debug_mode" className="ml-2 block text-sm text-slate-700">
Enable debug logging
</label>
</div>
</div>
</div>
<div className="flex justify-end">
<button className="flex items-center px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm">
<Save size={16} className="mr-2" />
Save Changes
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,295 @@
import { useEffect } from 'react';
import { useState } from 'react';
import { Plus, Edit2, Trash2, X } from 'lucide-react';
import type { User } from '../../types';
import apiClient from '../../api/client';
export function UsersSettings() {
const [users, setUsers] = useState<User[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editRole, setEditRole] = useState('User');
const fetchUsers = async () => {
try {
const response = await apiClient.get('/api/v1/auth/list');
setUsers(response.data.users || []);
} catch (err) {
console.error('Failed to fetch users', err);
}
};
useEffect(() => {
fetchUsers();
}, []);
const [formData, setFormData] = useState({
username: '',
password: ''
});
const [submitLoading, setSubmitLoading] = useState(false);
const [error, setError] = useState('');
const handleOpenModal = () => {
setFormData({ username: '', password: '' });
setError('');
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.username || !formData.password) {
setError('Please fill in both username and password.');
return;
}
setSubmitLoading(true);
setError('');
try {
await apiClient.post('/api/v1/auth/register', {
user_name: formData.username,
password: formData.password
});
await fetchUsers();
handleCloseModal();
} catch (err) {
console.error("Failed to register user", err);
setError('Registration failed. Please try again.');
} finally {
setSubmitLoading(false);
}
};
const handleDeleteUser = async (userId: string | undefined) => {
if (!userId) return;
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await apiClient.delete(`/api/v1/auth/${userId}`);
await fetchUsers();
} catch (err) {
console.error("Failed to delete user", err);
alert("Failed to delete user");
}
};
const handleEditClick = (user: User) => {
setEditingUser(user);
setEditRole(user.role || 'User');
setIsEditModalOpen(true);
};
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingUser?.user_id) return;
setSubmitLoading(true);
try {
await apiClient.put('/api/v1/auth/authority', {
user_id: editingUser.user_id,
new_authority: editRole
});
await fetchUsers();
setIsEditModalOpen(false);
} catch (err) {
console.error("Failed to update user role", err);
alert("Failed to update user role");
} finally {
setSubmitLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-semibold text-slate-800">User Management</h3>
<p className="text-sm text-slate-500 mt-1">Manage system users and their roles.</p>
</div>
<button
onClick={handleOpenModal}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
>
<Plus size={16} className="mr-2" />
Add User
</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Username</th>
<th className="px-6 py-4 font-medium">Role</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map((user, i) => (
<tr key={i} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 font-medium text-slate-800">{user.user_name}</td>
<td className="px-6 py-4 text-slate-600">{user.role || 'User'}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${user.status === 'Active' ? 'bg-green-100 text-green-700 border border-green-200' : 'bg-slate-100 text-slate-600 border border-slate-200'}`}>
{user.status || 'Active'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => handleEditClick(user)} className="text-slate-400 hover:text-blue-600 mr-3 transition-colors cursor-pointer" title="Edit"><Edit2 size={16} /></button>
<button onClick={() => handleDeleteUser(user.user_id)} className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer" title="Delete"><Trash2 size={16} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Add User Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center p-5 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">Add New User</h3>
<button
onClick={handleCloseModal}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Username</label>
<input
type="text"
name="username"
placeholder="e.g. jsmith"
value={formData.username}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
<input
type="password"
name="password"
placeholder="Enter secure password"
value={formData.password}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
</div>
<div className="pt-4 flex justify-end space-x-3">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
>
{submitLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating...
</span>
) : (
'Add User'
)}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit User Role Modal */}
{isEditModalOpen && editingUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center p-5 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">Edit User Role</h3>
<button
onClick={() => setIsEditModalOpen(false)}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleEditSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Username</label>
<input
type="text"
disabled
value={editingUser.user_name}
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2.5 text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Role</label>
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
>
<option value="User">User</option>
<option value="Administrator">Administrator</option>
<option value="SuperAdministrator">SuperAdministrator</option>
</select>
</div>
<div className="pt-4 flex justify-end space-x-3">
<button
type="button"
onClick={() => setIsEditModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
>
{submitLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';
import type { ClusterNode } from '../types';
export function useClusterState() {
const [nodes, setNodes] = useState<ClusterNode[]>([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
// Determine WS URL based on API base URL or window location
const wsBase = import.meta.env.VITE_API_BASE_URL
? import.meta.env.VITE_API_BASE_URL.replace('http', 'ws')
: `ws://localhost:8000`;
const ws = new WebSocket(`${wsBase}/api/v1/cluster/ws/state`);
ws.onopen = () => {
setIsConnected(true);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (Array.isArray(data)) {
setNodes(data);
}
} catch (e) {
console.error("Error parsing cluster state websocket message", e);
}
};
ws.onclose = () => {
setIsConnected(false);
};
return () => {
ws.close();
};
}, []);
return { nodes, isConnected };
}

1
frontend/src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,67 @@
// General types
export interface TokenData {
user_id: string;
user_name: string;
}
export interface User {
user_id?: string;
user_name: string;
role?: string;
status?: string;
}
// Provider types
export interface Provider {
provider_type: 'openai' | 'gemini' | 'claude' | 'local';
provider_title: string;
provider_url?: string;
provider_owner?: string;
// Based on your UI needs we might infer some local status fields
status?: string;
model?: string;
}
export interface ProviderRegisterRequest {
provider_type: 'openai' | 'gemini' | 'claude' | 'local';
provider_title: string;
provider_url: string;
provider_apikey: string;
}
export interface ProviderListResponse {
provider_list: Record<string, Provider>;
}
// Cluster types (Websocket response)
export interface ClusterResources {
CPU?: number;
memory?: number;
GPU?: number;
[key: string]: number | undefined;
}
export interface ClusterNode {
node_id: string;
node_name: string;
alive: boolean;
resources: ClusterResources;
remaining: ClusterResources;
}
// Chat types
export interface ChatMessageRequest {
message: string;
}
export interface ChatMessageResponse {
message: string; // Either event_id or text
}
// Workflow types
export interface Workflow {
event_id: string;
workflow_title: string;
status?: string;
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"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 */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

8
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})

50
main.py
View File

@ -9,26 +9,43 @@ from pretor.core.individual.consciousness_node.consciousness_node import Conscio
from pretor.core.individual.control_node.control_node import ControlNode from pretor.core.individual.control_node.control_node import ControlNode
from pretor.core.workflow.workflow_runner import WorkflowRunningEngine from pretor.core.workflow.workflow_runner import WorkflowRunningEngine
from pretor.core.api import PretorGateway from pretor.core.api import PretorGateway
from ray import serve
import os
async def start_system(): async def start_system():
# 1. 初始化 Ray # 1. 初始化 Ray
ray.init(ignore_reinit_error=True) db_host = os.getenv("POSTGRES_HOST", "db")
env_vars = {
"POSTGRES_USER": "postgres",
"POSTGRES_PASSWORD": "postgres",
"POSTGRES_HOST": db_host,
"POSTGRES_PORT": "5432",
"POSTGRES_DB": "postgres",
"SECRET_KEY": "yoursecretkey"
}
ray.init(ignore_reinit_error=True,
dashboard_host="0.0.0.0",
dashboard_port=8265,
runtime_env={"env_vars": env_vars})
# 2. 启动数据库组件 # 2. 启动数据库组件
postgres_database = PostgresDatabase.remote() postgres_database = PostgresDatabase.options(name='postgres_database').remote()
await postgres_database.init_db.remote() await postgres_database.init_db.remote()
# 3. 启动全局状态机 # 3. 启动全局状态机
global_state_machine = GlobalStateMachine.remote(postgres_database) global_state_machine = GlobalStateMachine.options(name='global_state_machine').remote(postgres_database)
# 4. 启动核心节点 # 4. 启动核心节点
supervisory_node = SupervisoryNode.remote() supervisory_node = SupervisoryNode.options(name='supervisory_node').remote()
consciousness_node = ConsciousnessNode.remote() consciousness_node = ConsciousnessNode.options(name='consciousness_node').remote()
control_node = ControlNode.remote() control_node = ControlNode.options(name='control_node').remote()
# 5. 启动工作流运行引擎 # 5. 启动工作流运行引擎
workflow_engine = WorkflowRunningEngine.remote( workflow_engine = WorkflowRunningEngine.options(name='workflow_running_engine').remote(
consciousness_node=consciousness_node, consciousness_node=consciousness_node,
control_node=control_node, control_node=control_node,
supervisory_node=supervisory_node supervisory_node=supervisory_node
@ -36,22 +53,21 @@ async def start_system():
# 异步拉起 runner 协程群 # 异步拉起 runner 协程群
workflow_engine.run.remote() workflow_engine.run.remote()
# 6. 启动 FastAPI 网关 # 6. 启动 FastAPI 网关 (使用 Ray Serve)
pretor_gateway = PretorGateway.remote( serve.start(http_options={"host": "0.0.0.0", "port": 8000})
postgres_database=postgres_database, serve.run(PretorGateway.bind())
global_state_machine=global_state_machine,
supervisory_node=supervisory_node,
consciousness_node = consciousness_node,
control_node = control_node
)
# 挂起在网关服务上,暴露 8000 端口 # 挂起主线程以保持系统运行
await pretor_gateway.server_run.remote(host="0.0.0.0", port=8000) while True:
await asyncio.sleep(3600)
def main(): def main():
print_banner() print_banner()
try:
asyncio.run(start_system()) asyncio.run(start_system())
except KeyboardInterrupt:
print("系统已退出。")
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -38,20 +38,20 @@ class AgentLocalRegister(BaseModel):
@agent_router.post("") @agent_router.post("")
async def load_agent(agent_register: Union[AgentRegister, AgentLocalRegister], async def load_agent(agent_register: Union[AgentRegister, AgentLocalRegister],
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))): _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
global_state_machine = ray_actor_hook("global_state_machine") global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
if isinstance(agent_register, AgentLocalRegister): if isinstance(agent_register, AgentLocalRegister):
pass pass
elif isinstance(agent_register, AgentRegister): elif isinstance(agent_register, AgentRegister):
match agent_register.individual_title: match agent_register.individual_name:
case "supervisory_node": case "supervisory_node":
node = ray_actor_hook("supervisory_node") node = ray_actor_hook("supervisory_node").supervisory_node
node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id) node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id)
case "consciousness_node": case "consciousness_node":
node = ray_actor_hook("consciousness_node") node = ray_actor_hook("consciousness_node").consciousness_node
node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id) node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id)
case "control_node": case "control_node":
node = ray_actor_hook("control_node") node = ray_actor_hook("control_node").control_node
node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id) node.create_agent.remote(global_state_machine,agent_register.provider_title,agent_register.model_id)
case _: case _:
pass pass

View File

@ -13,10 +13,14 @@
# limitations under the License. # limitations under the License.
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Depends
from pydantic import BaseModel from pydantic import BaseModel
from pretor.utils.access import Accessor from pretor.utils.access import Accessor, TokenData
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
from pretor.utils.check_user.role_check import RoleChecker
from pretor.core.database.table.user import UserAuthority
from pretor.utils.error import UserNotExistError
auth_router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) auth_router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@ -26,7 +30,7 @@ class UserRegister(BaseModel):
@auth_router.post("/register") @auth_router.post("/register")
async def create_user(user_register: UserRegister): async def create_user(user_register: UserRegister):
postgres_database = ray_actor_hook("postgres_database") postgres_database = ray_actor_hook("postgres_database").postgres_database
hashed_password = await run_in_threadpool(Accessor.hash_password, user_register.password) hashed_password = await run_in_threadpool(Accessor.hash_password, user_register.password)
user = await postgres_database.auth_database.remote("add_user", user_register.user_name, hashed_password) user = await postgres_database.auth_database.remote("add_user", user_register.user_name, hashed_password)
return {"message": "success", "user_id": user.user_id} return {"message": "success", "user_id": user.user_id}
@ -37,9 +41,48 @@ class UserLogin(BaseModel):
@auth_router.post("/login") @auth_router.post("/login")
async def login_user(user_login: UserLogin): async def login_user(user_login: UserLogin):
postgres_database = ray_actor_hook("postgres_database") postgres_database = ray_actor_hook("postgres_database").postgres_database
user = await postgres_database.auth_database.remote("login_user", user_login.user_name) user = await postgres_database.auth_database.remote("login_user", user_login.user_name)
if user.user_name != user_login.user_name: if not user:
pass raise UserNotExistError()
token = await run_in_threadpool(Accessor.login_hashed_password, user, user_login.password) token = await run_in_threadpool(Accessor.login_hashed_password, user, user_login.password)
return {"message":"success", "token":token} return {"message":"success", "token":token}
class ChangeAuthorityRequest(BaseModel):
user_id: str
new_authority: UserAuthority
@auth_router.put("/authority")
async def change_authority(
request: ChangeAuthorityRequest,
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))
):
"""
Update a user's authority level. Only accessible by SUPER_ADMINISTRATOR.
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
user = await postgres_database.auth_database.remote("change_user_authority", user_id=request.user_id, new_authority=request.new_authority)
return {"message": "success", "user_id": user.user_id, "new_authority": user.user_authority}
@auth_router.get("/list")
async def get_user_list(
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))
):
"""
Get a list of all users. Only accessible by SUPER_ADMINISTRATOR.
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
users = await postgres_database.auth_database.remote("get_all_users")
return {"users": [{"user_id": u.user_id, "user_name": u.user_name, "role": u.user_authority} for u in users]}
@auth_router.delete("/{user_id}")
async def delete_user(
user_id: str,
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))
):
"""
Delete a user. Only accessible by SUPER_ADMINISTRATOR.
"""
postgres_database = ray_actor_hook("postgres_database").postgres_database
await postgres_database.auth_database.remote("delete_user_by_id", user_id=user_id)
return {"message": "success"}

View File

@ -26,11 +26,11 @@ async def update_cluster_state(websocket: WebSocket):
nodes = ray.nodes() nodes = ray.nodes()
payload = [ payload = [
{ {
"node_id": node["NodeID"], "node_id": node.get("NodeID"),
"node_name": node["NodeName"], "node_name": node.get("NodeName"),
"alive": node["Alive"], "alive": node.get("Alive"),
"resources": node["Resources"], "resources": node.get("Resources", {}),
"remaining": node["RemainingResources"] "remaining": node.get("RemainingResources", {})
} }
for node in nodes for node in nodes
] ]

View File

@ -13,3 +13,4 @@
# limitations under the License. # limitations under the License.
from .frontend import client_router from .frontend import client_router
__all__ = ["client_router"]

View File

@ -16,9 +16,10 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from pretor.utils.access import Accessor, TokenData from pretor.utils.access import Accessor, TokenData
from pretor.api.platform.event import PretorEvent from pretor.api.platform.event import PretorEvent
from loguru import logger
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
from pretor.utils.logger import get_logger
logger = get_logger('frontend')
client_router = APIRouter(prefix="/api/v1/adapter/client", tags=["client"]) client_router = APIRouter(prefix="/api/v1/adapter/client", tags=["client"])
class Message(BaseModel): class Message(BaseModel):
@ -31,12 +32,12 @@ async def create_message(message: Message,
logger.debug(f"消息内容:{message.message}") logger.debug(f"消息内容:{message.message}")
event = PretorEvent(platform="client", event = PretorEvent(platform="client",
user_id=str(token_data.user_id), user_id=str(token_data.user_id),
user_name=token_data.user_name, user_name=token_data.username,
message=message.message) message=message.message)
supervisory_node = ray_actor_hook("supervisor_node") supervisory_node = ray_actor_hook("supervisor_node")
message = await supervisory_node.working.remote(event) message = await supervisory_node.working.remote(event)
if message == "任务已创建": if message == "任务已创建":
return {"message": event.event_id} return {"message": event.trace_id}
elif message == "未知相应类型": elif message == "未知相应类型":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@ -33,7 +33,7 @@ class ProviderRegister(BaseModel):
@provider_router.post("") @provider_router.post("")
async def create_provider(provider_register: ProviderRegister, async def create_provider(provider_register: ProviderRegister,
token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))) -> None: token_data: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))) -> None:
global_state_machine = ray_actor_hook("global_state_machine") global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.add_provider_wrap.remote(provider_type=provider_register.provider_type, await global_state_machine.add_provider_wrap.remote(provider_type=provider_register.provider_type,
provider_title=provider_register.provider_title, provider_title=provider_register.provider_title,
provider_url=provider_register.provider_url, provider_url=provider_register.provider_url,
@ -43,6 +43,13 @@ async def create_provider(provider_register: ProviderRegister,
@provider_router.get("/list") @provider_router.get("/list")
async def get_provider_list(_: TokenData = Depends(Accessor.get_current_user)) -> Dict[str, Provider]: async def get_provider_list(_: TokenData = Depends(Accessor.get_current_user)) -> Dict[str, Provider]:
global_state_machine = ray_actor_hook("global_state_machine") global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
provider_list: Dict[str, Provider] = await global_state_machine.provider_manager.remote("get_provider_list") provider_list: Dict[str, Provider] = await global_state_machine.provider_manager.remote("get_provider_list")
return {"provider_list": provider_list} return {"provider_list": provider_list}
@provider_router.delete("/{provider_title}")
async def delete_provider(provider_title: str, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))) -> dict:
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
postgres_database = ray_actor_hook("postgres_database").postgres_database
await global_state_machine.provider_manager.remote("delete_provider", provider_title=provider_title, postgres_database=postgres_database)
return {"message": "success"}

View File

@ -26,10 +26,22 @@ resource_router = APIRouter(prefix="/api/v1/resource")
@resource_router.post("/workflow_template") @resource_router.post("/workflow_template")
async def create_workflow_template(workflow_template: WorkflowTemplate, async def create_workflow_template(workflow_template: WorkflowTemplate,
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))): _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
global_state_machine = ray_actor_hook("global_state_machine") global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.workflow_template_generate.remote(workflow_template) await global_state_machine.workflow_template_manager.remote("add_workflow_template", workflow_template.name, workflow_template)
return {"message": "创建成功"} return {"message": "创建成功"}
@resource_router.get("/workflow_template")
async def get_workflow_templates(_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
templates = await global_state_machine.workflow_template_manager.remote("get_all_workflow_templates")
return {"templates": templates}
@resource_router.delete("/workflow_template/{template_name}")
async def delete_workflow_template(template_name: str, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.workflow_template_manager.remote("delete_workflow_template", template_name)
return {"message": "success"}
class Skill(BaseModel): class Skill(BaseModel):
@ -39,7 +51,7 @@ class Skill(BaseModel):
@resource_router.post("/skill") @resource_router.post("/skill")
async def install_skill(skill: Skill, async def install_skill(skill: Skill,
_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))): _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
global_state_machine = ray_actor_hook("global_state_machine") global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
await viceroy.install_skill_async(url = skill.repo_url, await viceroy.install_skill_async(url = skill.repo_url,
path = skill.path, path = skill.path,
@ -51,3 +63,15 @@ async def install_skill(skill: Skill,
await global_state_machine.skill_manager.remote("add_skill", skill_name) await global_state_machine.skill_manager.remote("add_skill", skill_name)
return {"message": "创建成功"} return {"message": "创建成功"}
@resource_router.get("/skill")
async def get_skills(_: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.USER))):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
skills = await global_state_machine.skill_manager.remote("get_skill_list")
return {"skills": skills}
@resource_router.delete("/skill/{skill_name}")
async def delete_skill(skill_name: str, _: TokenData = Depends(RoleChecker(allowed_roles=UserAuthority.SUPER_ADMINISTRATOR))):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
# Note: this only removes it from the state machine manager.
await global_state_machine.skill_manager.remote("remove_skill", skill_name)
return {"message": "success"}

View File

@ -12,59 +12,116 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import ray
import uvicorn
from typing import Dict
from fastapi import FastAPI,WebSocket
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os import os
from typing import Dict
from fastapi import FastAPI, WebSocket, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from pretor.api.platform.frontend import client_router from pretor.api.platform.frontend import client_router
from pretor.api.auth import auth_router from pretor.api.auth import auth_router
from pretor.api.provider import provider_router from pretor.api.provider import provider_router
from pretor.api.resource import resource_router from pretor.api.resource import resource_router
from pretor.api.cluster import cluster_router from pretor.api.cluster import cluster_router
from pretor.api.agent import agent_router from pretor.api.agent import agent_router
from pretor.utils.error import (
DemandError, ModelNotExistError, UserError,
UserNotExistError, UserPasswordError, ProviderError,
ProviderNotExistError, WorkflowError, WorkflowExit
)
@ray.remote from ray import serve
class PretorGateway:
gateway: Dict[str, WebSocket]
def __init__(self):
self.app = FastAPI()
self.gateway = {}
self.app.include_router(client_router)#客户端路径 app = FastAPI()
self.app.include_router(auth_router)#用户路径 app.include_router(client_router) # 客户端路径
self.app.include_router(provider_router)#供应商路径 app.include_router(auth_router) # 用户路径
self.app.include_router(resource_router)#资源路径 app.include_router(provider_router) # 供应商路径
self.app.include_router(cluster_router)#集群信息路径 app.include_router(resource_router) # 资源路径
self.app.include_router(agent_router)#agent路径 app.include_router(cluster_router) # 集群信息路径
app.include_router(agent_router) # agent路径
@app.exception_handler(UserNotExistError)
async def user_not_exist_handler(request: Request, exc: UserNotExistError):
return JSONResponse(status_code=404, content={"message": "用户不存在"})
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), "frontend", "dist") @app.exception_handler(UserPasswordError)
async def user_password_handler(request: Request, exc: UserPasswordError):
return JSONResponse(status_code=401, content={"message": "密码错误"})
@app.exception_handler(UserError)
async def user_error_handler(request: Request, exc: UserError):
return JSONResponse(status_code=400, content={"message": "用户相关错误"})
@app.exception_handler(ProviderNotExistError)
async def provider_not_exist_handler(request: Request, exc: ProviderNotExistError):
return JSONResponse(status_code=404, content={"message": "服务提供商不存在"})
@app.exception_handler(ProviderError)
async def provider_error_handler(request: Request, exc: ProviderError):
return JSONResponse(status_code=400, content={"message": "服务提供商错误"})
@app.exception_handler(ModelNotExistError)
async def model_not_exist_handler(request: Request, exc: ModelNotExistError):
return JSONResponse(status_code=404, content={"message": "模型不存在"})
@app.exception_handler(DemandError)
async def demand_error_handler(request: Request, exc: DemandError):
return JSONResponse(status_code=400, content={"message": "需求格式错误或不满足"})
@app.exception_handler(WorkflowExit)
async def workflow_exit_handler(request: Request, exc: WorkflowExit):
return JSONResponse(status_code=400, content={"message": "工作流已退出"})
@app.exception_handler(WorkflowError)
async def workflow_error_handler(request: Request, exc: WorkflowError):
return JSONResponse(status_code=500, content={"message": "工作流执行错误"})
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
frontend_dir = os.path.join(base_dir, "frontend", "dist")
if os.path.exists(frontend_dir): if os.path.exists(frontend_dir):
self.app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets") app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets")
# Serve favicon and other top-level static files if they exist
@self.app.get("/favicon.svg", include_in_schema=False) @app.get("/favicon.svg", include_in_schema=False)
async def serve_favicon(): async def serve_favicon():
return FileResponse(os.path.join(frontend_dir, "favicon.svg")) return FileResponse(os.path.join(frontend_dir, "favicon.svg"))
@self.app.get("/icons.svg", include_in_schema=False)
@app.get("/icons.svg", include_in_schema=False)
async def serve_icons(): async def serve_icons():
return FileResponse(os.path.join(frontend_dir, "icons.svg")) return FileResponse(os.path.join(frontend_dir, "icons.svg"))
@self.app.get("/{full_path:path}", include_in_schema=False)
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_frontend(full_path: str): async def serve_frontend(full_path: str):
# If a path isn't API or assets, fallback to index.html for React Router / SPA handling # 【重要安全修复】避免拦截不存在的 API 路由。如果是调用了不存在的 /api/ 接口,直接返回 404不返回前端页面
# In this specific case, it also fixes any root path reloading issues if full_path.startswith("api/"):
return FileResponse(os.path.join(frontend_dir, "index.html")) return JSONResponse(status_code=404, content={"detail": "API endpoint not found"})
async def server_run(self, host="0.0.0.0", port=8000): index_path = os.path.join(frontend_dir, "index.html")
config = uvicorn.Config(app=self.app, host=host, port=port, loop="asyncio") if os.path.exists(index_path):
server = uvicorn.Server(config) return FileResponse(index_path)
await server.serve() return JSONResponse(status_code=404, content={"detail": "Frontend build not found"})
else:
import logging
logging.getLogger("pretor").warning(f"Frontend dist folder not found at {frontend_dir}. Skipping frontend mount.")
@serve.deployment
@serve.ingress(app)
class PretorGateway:
gateway: Dict[str, WebSocket]
def __init__(self):
self.gateway = {}

View File

@ -14,9 +14,10 @@
from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.exc import IntegrityError, OperationalError
from pydantic import ValidationError from pydantic import ValidationError
from loguru import logger
from pretor.utils.error import UserNotExistError from pretor.utils.error import UserNotExistError
from pretor.utils.logger import get_logger
logger = get_logger('database_exception')
def database_exception(func): def database_exception(func):
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
try: try:

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from pretor.core.database.table import WorkerIndividual from pretor.core.database.table.individual import WorkerIndividual
from sqlmodel import select from sqlmodel import select
from typing import List, Optional from typing import List, Optional
from pretor.core.database.database_exception import database_exception from pretor.core.database.database_exception import database_exception

View File

@ -14,7 +14,7 @@
from typing import List from typing import List
from pretor.core.database.table import Provider from pretor.core.database.table.provider import Provider
from sqlmodel import select from sqlmodel import select
from pretor.core.database.database_exception import database_exception from pretor.core.database.database_exception import database_exception
@ -41,3 +41,24 @@ class ProviderDatabase:
provider = Provider(**kwargs) provider = Provider(**kwargs)
session.add(provider) session.add(provider)
await session.commit() await session.commit()
@database_exception
async def delete_provider(self, provider_id: str) -> None:
async with self.async_session_maker() as session:
provider = await session.get(Provider, provider_id)
if provider is not None:
session.delete(provider)
await session.commit()
@database_exception
async def update_provider(self, provider_id: str, **kwargs) -> Provider:
async with self.async_session_maker() as session:
provider = await session.get(Provider, provider_id)
if provider is not None:
for key, value in kwargs.items():
setattr(provider, key, value)
session.add(provider)
await session.commit()
await session.refresh(provider)
return provider
return None

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from pretor.core.database.table import User from pretor.core.database.table.user import User
from sqlmodel import select from sqlmodel import select
from pretor.utils.error import UserNotExistError, UserPasswordError from pretor.utils.error import UserNotExistError, UserPasswordError
from pretor.core.database.database_exception import database_exception from pretor.core.database.database_exception import database_exception
@ -23,8 +23,23 @@ class AuthDatabase:
@database_exception @database_exception
async def add_user(self, user_name: str, hashed_password: str) -> User: async def add_user(self, user_name: str, hashed_password: str) -> User:
user = User(user_name=user_name, hashed_password=hashed_password) from ulid import ULID
async with self.async_session_maker() as session: async with self.async_session_maker() as session:
# Check if any users exist
statement = select(User).limit(1)
results = await session.execute(statement)
existing_user = results.first()
authority = UserAuthority.USER
if existing_user is None:
authority = UserAuthority.SUPER_ADMINISTRATOR
user = User(
user_id=str(ULID()),
user_name=user_name,
hashed_password=hashed_password,
user_authority=authority
)
session.add(user) session.add(user)
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
@ -34,7 +49,7 @@ class AuthDatabase:
async def change_password(self, user_name, old_password, new_password) -> User: async def change_password(self, user_name, old_password, new_password) -> User:
async with self.async_session_maker() as session: async with self.async_session_maker() as session:
statement = select(User).where(User.user_name == user_name) statement = select(User).where(User.user_name == user_name)
results = await session.exec(statement) results = await session.execute(statement)
user = results.scalar_one_or_none() user = results.scalar_one_or_none()
if user is None: if user is None:
raise UserNotExistError() raise UserNotExistError()
@ -50,25 +65,69 @@ class AuthDatabase:
async def delete_user(self, user_name: str) -> None: async def delete_user(self, user_name: str) -> None:
async with self.async_session_maker() as session: async with self.async_session_maker() as session:
statement = select(User).where(User.user_name == user_name) statement = select(User).where(User.user_name == user_name)
results = await session.exec(statement) results = await session.execute(statement)
user = results.scalar_one_or_none() user = results.scalar_one_or_none()
if user is None: if user is None:
raise UserNotExistError() raise UserNotExistError()
session.delete(user) session.delete(user)
await session.commit() await session.commit()
@database_exception
async def delete_user_by_id(self, user_id: str) -> None:
async with self.async_session_maker() as session:
user = await session.get(User, user_id)
if user is None:
raise UserNotExistError()
session.delete(user)
await session.commit()
@database_exception @database_exception
async def login_user(self, user_name: str) -> str: async def login_user(self, user_name: str) -> str:
async with self.async_session_maker() as session: async with self.async_session_maker() as session:
statement = select(User).where(User.user_name == user_name) statement = select(User).where(User.user_name == user_name)
results = await session.exec(statement) results = await session.execute(statement)
user = results.scalar_one_or_none() user = results.scalar_one_or_none()
if user is None: if user is None:
raise UserNotExistError() raise UserNotExistError()
return user return user
@database_exception
async def get_all_users(self) -> list[User]:
async with self.async_session_maker() as session:
statement = select(User)
results = await session.execute(statement)
users = results.scalars().all()
return list(users)
@database_exception @database_exception
async def get_user_authority(self, user_id: str) -> UserAuthority: async def get_user_authority(self, user_id: str) -> UserAuthority:
async with self.async_session_maker() as session: async with self.async_session_maker() as session:
user = await session.get(User, user_id) user = await session.get(User, user_id)
if user is None:
raise UserNotExistError()
return user.user_authority return user.user_authority
@database_exception
async def change_user_authority(self, user_id: str, new_authority: UserAuthority) -> User:
"""
Changes the authority level of a specific user.
Args:
user_id: The ID of the user whose authority is to be changed.
new_authority: The new authority level to assign to the user.
Returns:
User: The updated user object.
Raises:
UserNotExistError: If the specified user does not exist.
"""
async with self.async_session_maker() as session:
user = await session.get(User, user_id)
if user is None:
raise UserNotExistError()
user.user_authority = new_authority
session.add(user)
await session.commit()
await session.refresh(user)
return user

View File

@ -40,8 +40,13 @@ class PostgresDatabase:
self._individual_database = IndividualDatabase(self.async_session_maker) self._individual_database = IndividualDatabase(self.async_session_maker)
async def init_db(self) -> None: async def init_db(self) -> None:
try:
async with self.async_engine.begin() as conn: async with self.async_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all) await conn.run_sync(SQLModel.metadata.create_all)
except Exception as e:
# Provide a warning if the database is not accessible, allowing
# the app to start up for development/UI tests without crashing immediately.
print(f"Warning: Failed to initialize PostgreSQL database: {e}")
async def auth_database(self, method_name: str, *args, **kwargs): async def auth_database(self, method_name: str, *args, **kwargs):
method = getattr(self._auth_database, method_name) method = getattr(self._auth_database, method_name)

View File

@ -15,3 +15,4 @@
from pretor.core.database.table.user import User from pretor.core.database.table.user import User
from pretor.core.database.table.provider import Provider from pretor.core.database.table.provider import Provider
from pretor.core.database.table.individual import WorkerIndividual from pretor.core.database.table.individual import WorkerIndividual
__all__ = ["User", "Provider", "WorkerIndividual"]

View File

@ -22,7 +22,7 @@ class UserAuthority(IntEnum):
UNAUTHORIZED_USER = 10 UNAUTHORIZED_USER = 10
GUEST = 0 GUEST = 0
class User(SQLModel): class User(SQLModel, table=True):
__tablename__ = 'user' __tablename__ = 'user'
user_id: str = Field(primary_key=True) user_id: str = Field(primary_key=True)
user_name: str = Field(index=True) user_name: str = Field(index=True)

View File

@ -13,7 +13,8 @@
# limitations under the License. # limitations under the License.
from typing import Dict, Any from typing import Dict, Any
from loguru import logger from pretor.utils.logger import get_logger
logger = get_logger('individual_manager')
class GlobalIndividualManager: class GlobalIndividualManager:
def __init__(self): def __init__(self):

View File

@ -16,3 +16,4 @@ from pretor.core.global_state_machine.model_provider.base_provider import Provid
from pretor.core.global_state_machine.model_provider.openai_provider import OpenAIProvider from pretor.core.global_state_machine.model_provider.openai_provider import OpenAIProvider
from pretor.core.global_state_machine.model_provider.gemini_provider import GeminiProvider from pretor.core.global_state_machine.model_provider.gemini_provider import GeminiProvider
from pretor.core.global_state_machine.model_provider.claude_provider import ClaudeProvider from pretor.core.global_state_machine.model_provider.claude_provider import ClaudeProvider
__all__ = ["Provider", "ProviderArgs", "OpenAIProvider", "GeminiProvider", "ClaudeProvider"]

View File

@ -39,7 +39,8 @@ class ProviderManager:
async def add_provider(self, provider_type, provider_title, provider_url, provider_apikey, provider_owner, postgres_database) -> None: async def add_provider(self, provider_type, provider_title, provider_url, provider_apikey, provider_owner, postgres_database) -> None:
from pretor.core.global_state_machine.model_provider import ProviderArgs from pretor.core.global_state_machine.model_provider import ProviderArgs
from loguru import logger from pretor.utils.logger import get_logger
logger = get_logger('provider_manager')
import httpx import httpx
provider_args: ProviderArgs = ProviderArgs(provider_title=provider_title, provider_args: ProviderArgs = ProviderArgs(provider_title=provider_title,
@ -75,3 +76,9 @@ class ProviderManager:
def get_provider(self, provider_title): def get_provider(self, provider_title):
return self.provider_register.get(provider_title) return self.provider_register.get(provider_title)
async def delete_provider(self, provider_title: str, postgres_database) -> None:
if provider_title in self.provider_register:
provider = self.provider_register[provider_title]
await postgres_database.provider_database.remote("delete_provider", provider_id=provider.provider_id)
del self.provider_register[provider_title]

View File

@ -44,3 +44,30 @@ class GlobalSkillManager:
except (json.JSONDecodeError, OSError) as e: except (json.JSONDecodeError, OSError) as e:
print(f"警告: 加载插件 {item.name} 失败: {e}") print(f"警告: 加载插件 {item.name} 失败: {e}")
def add_skill(self, skill_name: str) -> None:
"""Add a skill to the manager by reading its skill.json from the path"""
skill_plugin_dir = pathlib.Path(__file__).parent.parent.parent / "plugin" / "skill_plugin"
item = skill_plugin_dir / skill_name
if item.is_dir() and not item.name.startswith((".", "__")):
json_path = item / "skill.json"
if json_path.exists():
try:
with open(json_path, "r", encoding="utf-8") as f:
skill = json.load(f)
name = skill.get("name")
if name:
self.skill_mapper[name] = (
skill.get("description", ""),
skill.get("instructions", "")
)
except (json.JSONDecodeError, OSError) as e:
print(f"警告: 加载插件 {item.name} 失败: {e}")
def get_skill_list(self) -> dict:
"""Return all skills currently loaded."""
return self.skill_mapper
def remove_skill(self, skill_name: str) -> None:
"""Remove a skill from the manager mapping."""
if skill_name in self.skill_mapper:
del self.skill_mapper[skill_name]

View File

@ -18,7 +18,8 @@ import inspect
from collections import defaultdict from collections import defaultdict
from pretor.plugin.tool_plugin.base_tool import BaseToolData from pretor.plugin.tool_plugin.base_tool import BaseToolData
from typing import Dict, Type from typing import Dict, Type
from loguru import logger from pretor.utils.logger import get_logger
logger = get_logger('tool_manager')
class GlobalToolManager: class GlobalToolManager:
tool_mapper: Dict[str, Dict[str, Type[BaseToolData]]] tool_mapper: Dict[str, Dict[str, Type[BaseToolData]]]

View File

@ -13,3 +13,4 @@
# limitations under the License. # limitations under the License.
from .consciousness_node import ConsciousnessNode from .consciousness_node import ConsciousnessNode
__all__ = ["ConsciousnessNode"]

View File

@ -21,7 +21,6 @@ from pydantic_ai import Agent, RunContext
from pretor.core.global_state_machine.global_state_machine import GlobalStateMachine from pretor.core.global_state_machine.global_state_machine import GlobalStateMachine
from pretor.core.global_state_machine.model_provider.base_provider import Provider from pretor.core.global_state_machine.model_provider.base_provider import Provider
from pretor.adapter.model_adapter.agent_factory import AgentFactory from pretor.adapter.model_adapter.agent_factory import AgentFactory
from loguru import logger
from pretor.utils.get_tool import get_tool from pretor.utils.get_tool import get_tool
@ -29,8 +28,11 @@ from pretor.utils.get_tool import get_tool
@ray.remote @ray.remote
class ConsciousnessNode: class ConsciousnessNode:
def __init__(self) -> None: def __init__(self) -> None:
from pretor.utils.logger import get_logger
self.logger = get_logger('consciousness_node')
self.agent: None | Agent = None self.agent: None | Agent = None
def create_agent(self, global_state_machine: GlobalStateMachine, provider_title: str, model_id: str) -> None: def create_agent(self, global_state_machine: GlobalStateMachine, provider_title: str, model_id: str) -> None:
""" """
create_agent方法将agent对象装配到ConsciousnessNode的属性内 create_agent方法将agent对象装配到ConsciousnessNode的属性内
@ -83,10 +85,10 @@ class ConsciousnessNode:
if isinstance(result, (ForWorkflowEngine, ForWorkflow, ForSupervisoryNode)): if isinstance(result, (ForWorkflowEngine, ForWorkflow, ForSupervisoryNode)):
return result return result
else: else:
logger.error(f"ConsciousnessNode: 未知或不匹配的返回类型: {type(result)}") self.logger.error(f"ConsciousnessNode: 未知或不匹配的返回类型: {type(result)}")
return None return None
except Exception: except Exception:
logger.exception("ConsciousnessNode在执行working时发生严重错误") self.logger.exception("ConsciousnessNode在执行working时发生严重错误")
return None return None
@ -139,7 +141,7 @@ class ConsciousnessNode:
workflow_template=payload.workflow_template, workflow_template=payload.workflow_template,
command="拆解原始命令变成一个工作流" command="拆解原始命令变成一个工作流"
) )
logger.debug("ConsciousnessNode: 开始生成工作流 (原生重试开启)") self.logger.debug("ConsciousnessNode: 开始生成工作流 (原生重试开启)")
result = await self.agent.run( result = await self.agent.run(
"根据original_command制定严密的可执行workflow可以学习并参考workflow_template的设计理念", "根据original_command制定严密的可执行workflow可以学习并参考workflow_template的设计理念",
deps=deps, deps=deps,
@ -151,7 +153,7 @@ class ConsciousnessNode:
original_command=payload.original_command, original_command=payload.original_command,
command="完成workflow step中分配给意识节点的特定任务或指导" command="完成workflow step中分配给意识节点的特定任务或指导"
) )
logger.debug("ConsciousnessNode: 开始处理工作流节点任务 (原生重试开启)") self.logger.debug("ConsciousnessNode: 开始处理工作流节点任务 (原生重试开启)")
result = await self.agent.run(f"处理此工作流步骤信息:\n{payload.workflow_step.model_dump_json()}", result = await self.agent.run(f"处理此工作流步骤信息:\n{payload.workflow_step.model_dump_json()}",
deps=deps, deps=deps,
tools=tool) tools=tool)
@ -162,11 +164,11 @@ class ConsciousnessNode:
original_command=payload.original_command, original_command=payload.original_command,
command="对于工作流整体执行结果进行检查,并且生成一份专业的技术性总结报告" command="对于工作流整体执行结果进行检查,并且生成一份专业的技术性总结报告"
) )
logger.debug("ConsciousnessNode: 开始生成技术总结报告 (原生重试开启)") self.logger.debug("ConsciousnessNode: 开始生成技术总结报告 (原生重试开启)")
result = await self.agent.run(f"基于以下工作流的执行记录,生成技术报告:\n{payload.workflow.model_dump_json()}", result = await self.agent.run(f"基于以下工作流的执行记录,生成技术报告:\n{payload.workflow.model_dump_json()}",
deps=deps, deps=deps,
tools=tool) tools=tool)
return result.output return result.output
except Exception as e: except Exception as e:
logger.exception(f"ConsciousnessNode 模型生成最终失败: {str(e)}") self.logger.exception(f"ConsciousnessNode 模型生成最终失败: {str(e)}")
raise RuntimeError(f"ConsciousnessNode 无法完成任务: {str(e)}") from e raise RuntimeError(f"ConsciousnessNode 无法完成任务: {str(e)}") from e

View File

@ -13,3 +13,4 @@
# limitations under the License. # limitations under the License.
from .control_node import ControlNode from .control_node import ControlNode
__all__ = ["ControlNode"]

View File

@ -13,7 +13,6 @@
# limitations under the License. # limitations under the License.
import ray import ray
from loguru import logger
from pydantic_ai import Agent, RunContext from pydantic_ai import Agent, RunContext
from pretor.core.global_state_machine.global_state_machine import GlobalStateMachine from pretor.core.global_state_machine.global_state_machine import GlobalStateMachine
from pretor.core.global_state_machine.model_provider.base_provider import Provider from pretor.core.global_state_machine.model_provider.base_provider import Provider
@ -22,11 +21,15 @@ from pretor.core.individual.control_node.template import ForWorkflow, ForWorkflo
from pretor.utils.get_tool import get_tool from pretor.utils.get_tool import get_tool
@ray.remote @ray.remote
class ControlNode: class ControlNode:
def __init__(self): def __init__(self):
from pretor.utils.logger import get_logger
self.logger = get_logger('control_node')
self.agent: Agent | None = None self.agent: Agent | None = None
def create_agent(self, global_state_machine: GlobalStateMachine, provider_title: str, model_id: str) -> None: def create_agent(self, global_state_machine: GlobalStateMachine, provider_title: str, model_id: str) -> None:
""" """
create_agent方法将agent对象装配到Control的属性内 create_agent方法将agent对象装配到Control的属性内
@ -76,7 +79,7 @@ class ControlNode:
result: ForWorkflow = await self._run(payload) result: ForWorkflow = await self._run(payload)
return result return result
except Exception: except Exception:
logger.exception("ControlNode在执行working时发生严重错误") self.logger.exception("ControlNode在执行working时发生严重错误")
return None return None
async def _run(self, payload: ForWorkflowInput) -> ForWorkflow: async def _run(self, payload: ForWorkflowInput) -> ForWorkflow:
@ -85,7 +88,7 @@ class ControlNode:
deps = ControlNodeDeps( deps = ControlNodeDeps(
workflow_step=payload.workflow_step workflow_step=payload.workflow_step
) )
logger.debug(f"ControlNode: 开始执行工作流节点 [{payload.workflow_step.name}] (原生重试开启)") self.logger.debug(f"ControlNode: 开始执行工作流节点 [{payload.workflow_step.name}] (原生重试开启)")
tool = get_tool("control_node") tool = get_tool("control_node")
@ -96,5 +99,5 @@ class ControlNode:
) )
return result.output return result.output
except Exception as e: except Exception as e:
logger.exception(f"ControlNode 在执行步骤 [{payload.workflow_step.name}] 时最终失败: {str(e)}") self.logger.exception(f"ControlNode 在执行步骤 [{payload.workflow_step.name}] 时最终失败: {str(e)}")
raise RuntimeError(f"ControlNode 执行步骤失败: {str(e)}") from e raise RuntimeError(f"ControlNode 执行步骤失败: {str(e)}") from e

View File

@ -13,3 +13,4 @@
# limitations under the License. # limitations under the License.
from .supervisory_node import SupervisoryNode from .supervisory_node import SupervisoryNode
__all__ = ["SupervisoryNode"]

View File

@ -21,15 +21,18 @@ from pretor.core.global_state_machine.global_state_machine import GlobalStateMac
from pretor.core.global_state_machine.model_provider import Provider from pretor.core.global_state_machine.model_provider import Provider
from pretor.core.individual.supervisory_node.template import ForConsciousnessNode, ForUser, SupervisoryNodeDeps, TerminationMessage from pretor.core.individual.supervisory_node.template import ForConsciousnessNode, ForUser, SupervisoryNodeDeps, TerminationMessage
from pydantic_ai import RunContext, Agent from pydantic_ai import RunContext, Agent
from loguru import logger
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
from pretor.utils.get_tool import get_tool from pretor.utils.get_tool import get_tool
@ray.remote @ray.remote
class SupervisoryNode: class SupervisoryNode:
def __init__(self) -> None: def __init__(self) -> None:
from pretor.utils.logger import get_logger
self.logger = get_logger('supervisory_node')
self.agent: None | Agent = None self.agent: None | Agent = None
async def create_agent(self, global_state_machine: GlobalStateMachine, provider_title: str, model_id: str) -> None: async def create_agent(self, global_state_machine: GlobalStateMachine, provider_title: str, model_id: str) -> None:
""" """
create_agent方法将agent对象装配到SupervisoryNode的属性内 create_agent方法将agent对象装配到SupervisoryNode的属性内
@ -94,24 +97,24 @@ class SupervisoryNode:
try: try:
result = await self._run(payload) result = await self._run(payload)
if isinstance(result, ForConsciousnessNode): if isinstance(result, ForConsciousnessNode):
logger.info(f"SupervisoryNode: 任务已分配给工作流引擎处理,选用模板 [{result.workflow_template}]") self.logger.info(f"SupervisoryNode: 任务已分配给工作流引擎处理,选用模板 [{result.workflow_template}]")
if isinstance(payload, PretorEvent): if isinstance(payload, PretorEvent):
payload.context["workflow_template"] = result.workflow_template payload.context["workflow_template"] = result.workflow_template
try: try:
workflow_running_engine = ray_actor_hook("workflow_running_engine") workflow_running_engine = ray_actor_hook("workflow_running_engine")
await workflow_running_engine.put_event.remote(payload) await workflow_running_engine.put_event.remote(payload)
except Exception as e: except Exception as e:
logger.error(f"SupervisoryNode: 无法将事件放入 WorkflowRunningEngine: {e}") self.logger.error(f"SupervisoryNode: 无法将事件放入 WorkflowRunningEngine: {e}")
return "抱歉,任务提交失败,系统内部错误。" return "抱歉,任务提交失败,系统内部错误。"
return f"任务已创建,准备创建工作流。原因:{result.reasoning}" return f"任务已创建,准备创建工作流。原因:{result.reasoning}"
elif isinstance(result, ForUser): elif isinstance(result, ForUser):
logger.info("SupervisoryNode: 直接向用户返回简单回复。") self.logger.info("SupervisoryNode: 直接向用户返回简单回复。")
return result.context return result.context
else: else:
logger.error(f"SupervisoryNode: 未知响应类型: {type(result)}") self.logger.error(f"SupervisoryNode: 未知响应类型: {type(result)}")
return "抱歉,系统内部遇到未知错误,无法正确处理您的请求。" return "抱歉,系统内部遇到未知错误,无法正确处理您的请求。"
except Exception: except Exception:
logger.exception("SupervisoryNode在处理请求时发生未捕获的严重错误") self.logger.exception("SupervisoryNode在处理请求时发生未捕获的严重错误")
return "抱歉,监控节点处理请求时发生严重错误,请联系管理员。" return "抱歉,监控节点处理请求时发生严重错误,请联系管理员。"
@overload @overload
@ -164,7 +167,7 @@ class SupervisoryNode:
time=time_str, time=time_str,
available_templates=available_templates_str available_templates=available_templates_str
) )
logger.debug("SupervisoryNode 开始生成 (启用原生 Pydantic-AI 重试)") self.logger.debug("SupervisoryNode 开始生成 (启用原生 Pydantic-AI 重试)")
prompt_message = message prompt_message = message
if isinstance(payload, TerminationMessage): if isinstance(payload, TerminationMessage):
prompt_message = f"【工作流执行结束报告】\n请将以下技术报告转化为对用户的友好回复:\n{message}" prompt_message = f"【工作流执行结束报告】\n请将以下技术报告转化为对用户的友好回复:\n{message}"
@ -175,5 +178,5 @@ class SupervisoryNode:
tools=tool) tools=tool)
return result.output return result.output
except Exception as e: except Exception as e:
logger.exception(f"SupervisoryNode 模型生成或解析最终失败: {str(e)}") self.logger.exception(f"SupervisoryNode 模型生成或解析最终失败: {str(e)}")
return ForUser(context="系统当前负载过高或遇到复杂内部错误,请稍后再试。") return ForUser(context="系统当前负载过高或遇到复杂内部错误,请稍后再试。")

View File

@ -16,6 +16,8 @@ from typing import List, Optional, Union, Literal, Dict, Any
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from pretor.utils.demand_protocol import DemandProtocol from pretor.utils.demand_protocol import DemandProtocol
from pretor.utils.logger import get_logger
logger = get_logger('workflow')
NodeType = Literal[ NodeType = Literal[
"consciousness_node", "control_node", "supervisory_node", "consciousness_node", "control_node", "supervisory_node",
"composite_individual", "primary_individual" "composite_individual", "primary_individual"
@ -84,6 +86,7 @@ class PretorWorkflow(BaseModel):
if target > max_step or target < 1: if target > max_step or target < 1:
raise ValueError(f"Step {s.step} 的跳转目标 Step {target} 越界了!") raise ValueError(f"Step {s.step} 的跳转目标 Step {target} 越界了!")
except ValueError as e: except ValueError as e:
if "越界" in str(e): raise e if "越界" in str(e):
raise e
raise ValueError(f"LogicGate 格式错误: {s.logic_gate.if_fail}") raise ValueError(f"LogicGate 格式错误: {s.logic_gate.if_fail}")
return self return self

View File

@ -16,7 +16,6 @@ from pretor.utils.ray_hook import ray_actor_hook
import ray import ray
import asyncio import asyncio
from pretor.core.workflow.workflow import PretorWorkflow, WorkStep, EventInfo from pretor.core.workflow.workflow import PretorWorkflow, WorkStep, EventInfo
from loguru import logger
from typing import Optional, Dict, Union, Any, List from typing import Optional, Dict, Union, Any, List
from pretor.utils.error import WorkflowError, WorkflowExit from pretor.utils.error import WorkflowError, WorkflowExit
from pretor.api.platform.event import PretorEvent from pretor.api.platform.event import PretorEvent
@ -34,6 +33,7 @@ from pretor.core.individual.supervisory_node.template import TerminationMessage
import pathlib import pathlib
def get_workflow_template(workflow_name: str) -> str: def get_workflow_template(workflow_name: str) -> str:
workflow_template = pathlib.Path(__file__).parent.parent.parent / "workflow_template" / (workflow_name + "_workflow_template.json") workflow_template = pathlib.Path(__file__).parent.parent.parent / "workflow_template" / (workflow_name + "_workflow_template.json")
with open(workflow_template, "r", encoding="utf-8") as workflow_template_file: with open(workflow_template, "r", encoding="utf-8") as workflow_template_file:
@ -81,16 +81,16 @@ class WorkflowEngine:
处理并执行workflow的方法 处理并执行workflow的方法
""" """
logger.info(f"🚀 工作流引擎启动: {self.workflow.title} [Trace ID: {self.workflow.trace_id}]") self.logger.info(f"🚀 工作流引擎启动: {self.workflow.title} [Trace ID: {self.workflow.trace_id}]")
max_step = len(self.workflow.work_link) max_step = len(self.workflow.work_link)
while 1 <= self.workflow.status.step <= max_step: while 1 <= self.workflow.status.step <= max_step:
current_step_id = self.workflow.status.step current_step_id = self.workflow.status.step
current_step = self._steps_by_id.get(current_step_id) current_step = self._steps_by_id.get(current_step_id)
if not current_step: if not current_step:
logger.error(f"严重错误:找不到步骤 {current_step_id},工作流强制终止。") self.logger.error(f"严重错误:找不到步骤 {current_step_id},工作流强制终止。")
self.workflow.status.status = "failed" self.workflow.status.status = "failed"
break break
logger.info(f"▶️ 开始执行 Step {current_step_id}: [{current_step.node}] -> {current_step.action}") self.logger.info(f"▶️ 开始执行 Step {current_step_id}: [{current_step.node}] -> {current_step.action}")
current_step.status = "running" current_step.status = "running"
try: try:
step_input_data = self._prepare_inputs(current_step.inputs) step_input_data = self._prepare_inputs(current_step.inputs)
@ -98,25 +98,25 @@ class WorkflowEngine:
if is_success: if is_success:
if current_step.outputs: if current_step.outputs:
self.workflow.context_memory[current_step.outputs] = step_result self.workflow.context_memory[current_step.outputs] = step_result
logger.debug(f"Step {current_step_id} 产出已保存至变量: '{current_step.outputs}'") self.logger.debug(f"Step {current_step_id} 产出已保存至变量: '{current_step.outputs}'")
current_step.status = "completed" current_step.status = "completed"
else: else:
logger.warning(f"Step {current_step_id} 执行遇到业务失败/驳回。") self.logger.warning(f"Step {current_step_id} 执行遇到业务失败/驳回。")
current_step.status = "failed" current_step.status = "failed"
self._handle_logic_gate(current_step, is_success) self._handle_logic_gate(current_step, is_success)
except WorkflowExit: except WorkflowExit:
logger.info("命中 if_pass='exit',工作流被主动要求结束。") self.logger.info("命中 if_pass='exit',工作流被主动要求结束。")
break break
except WorkflowError as e: except WorkflowError as e:
logger.error(f"{e},终止工作流。") self.logger.error(f"{e},终止工作流。")
self.workflow.status.status = "failed" self.workflow.status.status = "failed"
break break
except Exception as e: except Exception as e:
logger.error(f"❌ Step {current_step_id} 发生系统级未捕获异常: {e}", exc_info=True) self.logger.error(f"❌ Step {current_step_id} 发生系统级未捕获异常: {e}", exc_info=True)
current_step.status = "failed" current_step.status = "failed"
self.workflow.status.status = "failed" self.workflow.status.status = "failed"
break break
logger.info(f"✅ 工作流 {self.workflow.title} 执行步骤结束。") self.logger.info(f"✅ 工作流 {self.workflow.title} 执行步骤结束。")
self.workflow.output = self.workflow.context_memory self.workflow.output = self.workflow.context_memory
await self._report_results() await self._report_results()
@ -128,10 +128,10 @@ class WorkflowEngine:
""" """
if self.workflow.status.status == "failed": if self.workflow.status.status == "failed":
logger.warning("工作流执行失败,跳过正常汇报流程。") self.logger.warning("工作流执行失败,跳过正常汇报流程。")
return return
try: try:
logger.info("开始生成工作流结束技术报告...") self.logger.info("开始生成工作流结束技术报告...")
report = "" report = ""
if self.consciousness_node: if self.consciousness_node:
supervisory_input = ForSupervisoryInput( supervisory_input = ForSupervisoryInput(
@ -143,9 +143,9 @@ class WorkflowEngine:
report = report_obj.output report = report_obj.output
elif isinstance(report_obj, str): elif isinstance(report_obj, str):
report = report_obj report = report_obj
logger.debug(f"生成的报告摘要: {report[:100]}...") self.logger.debug(f"生成的报告摘要: {report[:100]}...")
else: else:
logger.warning("未提供 consciousness_node 句柄,跳过报告生成。") self.logger.warning("未提供 consciousness_node 句柄,跳过报告生成。")
if self.supervisory_node: if self.supervisory_node:
term_msg = TerminationMessage( term_msg = TerminationMessage(
@ -155,11 +155,11 @@ class WorkflowEngine:
) )
user_response = await self.supervisory_node.working.remote(term_msg) user_response = await self.supervisory_node.working.remote(term_msg)
self.workflow.context_memory["_final_user_response"] = user_response self.workflow.context_memory["_final_user_response"] = user_response
logger.info(f"Supervisory 最终回复:{user_response}") self.logger.info(f"Supervisory 最终回复:{user_response}")
else: else:
logger.warning("未提供 supervisory_node 句柄,跳过用户反馈生成。") self.logger.warning("未提供 supervisory_node 句柄,跳过用户反馈生成。")
except Exception: except Exception:
logger.exception("生成工作流执行汇报时发生错误") self.logger.exception("生成工作流执行汇报时发生错误")
async def _dispatch_to_node(self, step: WorkStep, input_data: Any) -> tuple[Any, bool]: async def _dispatch_to_node(self, step: WorkStep, input_data: Any) -> tuple[Any, bool]:
""" """
@ -172,7 +172,7 @@ class WorkflowEngine:
Returns: Returns:
返回llm的输出和一个bool类型的判断 返回llm的输出和一个bool类型的判断
""" """
logger.debug(f"正在向 {step.node} 节点发送动作 {step.action}...") self.logger.debug(f"正在向 {step.node} 节点发送动作 {step.action}...")
try: try:
if step.node == "control_node": if step.node == "control_node":
if not self.control_node: if not self.control_node:
@ -198,7 +198,7 @@ class WorkflowEngine:
return result_obj, True return result_obj, True
elif step.node in ["primary_individual", "composite_individual"]: elif step.node in ["primary_individual", "composite_individual"]:
logger.info(f"正在通过 WorkerCluster 调度 {step.node}{step.action} 动作。") self.logger.info(f"正在通过 WorkerCluster 调度 {step.node}{step.action} 动作。")
try: try:
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
worker_cluster = ray_actor_hook("worker_cluster").worker_cluster worker_cluster = ray_actor_hook("worker_cluster").worker_cluster
@ -217,17 +217,17 @@ class WorkflowEngine:
if result_response.get("success"): if result_response.get("success"):
return result_response.get("data"), True return result_response.get("data"), True
else: else:
logger.error(f"WorkerCluster 执行 {step.node} 失败: {result_response.get('error')}") self.logger.error(f"WorkerCluster 执行 {step.node} 失败: {result_response.get('error')}")
return result_response.get("error"), False return result_response.get("error"), False
except Exception as e: except Exception as e:
logger.exception(f"调度 WorkerCluster 执行 {step.node} 时发生异常: {e}") self.logger.exception(f"调度 WorkerCluster 执行 {step.node} 时发生异常: {e}")
raise WorkflowError(f"WorkerCluster 调度异常: {e}") raise WorkflowError(f"WorkerCluster 调度异常: {e}")
else: else:
raise WorkflowError(f"未知的节点类型:{step.node}") raise WorkflowError(f"未知的节点类型:{step.node}")
except Exception: except Exception:
logger.exception(f"节点 {step.node} 执行动作 {step.action} 失败") self.logger.exception(f"节点 {step.node} 执行动作 {step.action} 失败")
return None, False return None, False
def _handle_logic_gate(self, step: WorkStep, is_success: bool): def _handle_logic_gate(self, step: WorkStep, is_success: bool):
@ -250,7 +250,7 @@ class WorkflowEngine:
match gate.if_fail.split("_"): match gate.if_fail.split("_"):
case ["jump", "to", "step", target] if target.isdigit(): case ["jump", "to", "step", target] if target.isdigit():
target_step = int(target) target_step = int(target)
logger.warning(f"触发逻辑门分支!从 Step {step.step} 跳转至 Step {target_step}") self.logger.warning(f"触发逻辑门分支!从 Step {step.step} 跳转至 Step {target_step}")
self.workflow.status.step = target_step self.workflow.status.step = target_step
case _: case _:
raise WorkflowError(f"未知的 if_fail 格式: {gate.if_fail}") raise WorkflowError(f"未知的 if_fail 格式: {gate.if_fail}")
@ -259,6 +259,8 @@ class WorkflowEngine:
@ray.remote @ray.remote
class WorkflowRunningEngine: class WorkflowRunningEngine:
def __init__(self, consciousness_node=None, control_node=None, supervisory_node=None): def __init__(self, consciousness_node=None, control_node=None, supervisory_node=None):
from pretor.utils.logger import get_logger
self.logger = get_logger('workflow_runner')
self.runner_engine = {} self.runner_engine = {}
self.workflow_queue: asyncio.Queue[PretorEvent] = None self.workflow_queue: asyncio.Queue[PretorEvent] = None
self.consciousness_node = consciousness_node self.consciousness_node = consciousness_node
@ -267,11 +269,11 @@ class WorkflowRunningEngine:
self.global_state_machine = ray_actor_hook("global_state_machine").global_state_machine self.global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
async def run(self): async def run(self):
self.workflow_queue = asyncio.Queue()
self.runner_engine = { self.runner_engine = {
f"runner_{i}": asyncio.create_task(self.runner(i)) f"runner_{i}": asyncio.create_task(self.runner(i))
for i in range(10) for i in range(10)
} }
self.workflow_queue = asyncio.Queue()
async def put_event(self, event: PretorEvent) -> None: async def put_event(self, event: PretorEvent) -> None:
await self.workflow_queue.put(event) await self.workflow_queue.put(event)
@ -286,7 +288,7 @@ class WorkflowRunningEngine:
while True: while True:
try: try:
event = await self.workflow_queue.get() event = await self.workflow_queue.get()
logger.info(f"WorkflowRunningEngine: runner_{i} 接收到事件 {event.trace_id} 准备生成工作流。") self.logger.info(f"WorkflowRunningEngine: runner_{i} 接收到事件 {event.trace_id} 准备生成工作流。")
if not self.consciousness_node: if not self.consciousness_node:
raise WorkflowError("未配置 consciousness_node无法生成工作流") raise WorkflowError("未配置 consciousness_node无法生成工作流")
@ -309,7 +311,7 @@ class WorkflowRunningEngine:
workflow.event_info = EventInfo(platform=event.platform, workflow.event_info = EventInfo(platform=event.platform,
user_name=event.user_name,) user_name=event.user_name,)
logger.info( self.logger.info(
f"WorkflowRunningEngine: runner_{i} 成功生成工作流 {workflow.trace_id}:{workflow.title}") f"WorkflowRunningEngine: runner_{i} 成功生成工作流 {workflow.trace_id}:{workflow.title}")
await self.global_state_machine.update_workflow.remote(event.trace_id, workflow) await self.global_state_machine.update_workflow.remote(event.trace_id, workflow)
@ -320,10 +322,10 @@ class WorkflowRunningEngine:
self.supervisory_node) self.supervisory_node)
await workflow_engine.run() await workflow_engine.run()
else: else:
logger.error(f"WorkflowRunningEngine: runner_{i} 无法生成工作流,返回类型为 {type(result_obj)}") self.logger.error(f"WorkflowRunningEngine: runner_{i} 无法生成工作流,返回类型为 {type(result_obj)}")
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"WorkflowRunningEngine: runner_{i} 被取消。") self.logger.info(f"WorkflowRunningEngine: runner_{i} 被取消。")
raise raise
except Exception as e: except Exception as e:
logger.error(f"WorkflowRunningEngine: runner_{i} 遇到未捕获的异常: {e}", exc_info=True) self.logger.error(f"WorkflowRunningEngine: runner_{i} 遇到未捕获的异常: {e}", exc_info=True)

View File

@ -15,9 +15,11 @@
import json import json
from pretor.core.workflow.workflow_template_generator.workflow_template_generator import WorkflowTemplateGenerator from pretor.core.workflow.workflow_template_generator.workflow_template_generator import WorkflowTemplateGenerator
from pathlib import Path from pathlib import Path
from loguru import logger
from pretor.core.workflow.workflow_template_generator.workflow_template import WorkflowTemplate from pretor.core.workflow.workflow_template_generator.workflow_template import WorkflowTemplate
from pretor.utils.logger import get_logger
logger = get_logger('workflow_template_manager')
class WorkflowManager: class WorkflowManager:
def __init__(self): def __init__(self):
self.workflow_template_generator = WorkflowTemplateGenerator() self.workflow_template_generator = WorkflowTemplateGenerator()
@ -42,3 +44,13 @@ class WorkflowManager:
self.workflow_templates_registry[workflow_template.name] = workflow_template.desc self.workflow_templates_registry[workflow_template.name] = workflow_template.desc
except Exception: except Exception:
logger.exception("Failed to generate workflow template") logger.exception("Failed to generate workflow template")
def add_workflow_template(self, template_name: str, workflow_template: WorkflowTemplate) -> None:
self.generate_workflow_template(workflow_template)
def get_all_workflow_templates(self) -> dict:
return self.workflow_templates_registry
def delete_workflow_template(self, template_name: str) -> None:
if template_name in self.workflow_templates_registry:
del self.workflow_templates_registry[template_name]

View File

@ -1 +1,2 @@
from .approval import ApprovalToolData, approval from .approval import ApprovalToolData, approval
__all__ = ["ApprovalToolData", "approval"]

View File

@ -19,7 +19,7 @@ def print_banner() -> None:
console.print("=" * 40, style="dim") # dim=灰色,低调 console.print("=" * 40, style="dim") # dim=灰色,低调
console.print("🚀 Multi-Agent Orchestration Platform", style="blue") console.print("🚀 Multi-Agent Orchestration Platform", style="blue")
console.print(f"📦 Version: {version}", style="green") console.print(f"📦 Version: {version}", style="green")
console.print(f"👤 Author: zhaoxi826", style="yellow") console.print("👤 Author: zhaoxi826", style="yellow")
console.print(f"📜 License: Apache 2.0", style="magenta") console.print("📜 License: Apache 2.0", style="magenta")
console.print(f"🐙 github: https://github.com/zhaoxi826/pretor", style="yellow") console.print("🐙 github: https://github.com/zhaoxi826/pretor", style="yellow")
console.print("=" * 40, style="dim") console.print("=" * 40, style="dim")

View File

@ -18,9 +18,8 @@ from pretor.utils.access import Accessor, TokenData
from pretor.core.database.table.user import UserAuthority from pretor.core.database.table.user import UserAuthority
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
@lru_cache
async def get_authority(user_id: str) -> UserAuthority: async def get_authority(user_id: str) -> UserAuthority:
postgres_database = ray_actor_hook("postgres_database") postgres_database = ray_actor_hook("postgres_database").postgres_database
user_authority = await postgres_database.auth_database.remote("get_user_authority", user_id=user_id) user_authority = await postgres_database.auth_database.remote("get_user_authority", user_id=user_id)
return user_authority return user_authority

View File

@ -15,12 +15,14 @@
import importlib import importlib
from typing import Callable, Dict, List from typing import Callable, Dict, List
import pathlib import pathlib
from loguru import logger
from functools import lru_cache from functools import lru_cache
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
from pretor.utils.logger import get_logger
logger = get_logger('get_tool')
_tool_cache: Dict[str, Callable] = {} _tool_cache: Dict[str, Callable] = {}
def _get_tool_func(tool_name: str) -> Callable | None: def _get_tool_func(tool_name: str) -> Callable | None:
func = _tool_cache.get(tool_name, None) func = _tool_cache.get(tool_name, None)
if func: if func:

44
pretor/utils/logger.py Normal file
View File

@ -0,0 +1,44 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from loguru import logger
from rich.logging import RichHandler
from loguru._logger import Logger
def setup_logger() -> Logger:
logger.remove()
def format_record(record):
# Format string for rich handler
actor = record["extra"].get("actor_name", "System")
trace_id = record["extra"].get("trace_id", "")
trace_str = f" | trace_id:({trace_id})" if trace_id else ""
return f"actor:({actor}){trace_str} : {record['message']}"
logger.configure(extra={"actor_name": "System", "trace_id": ""})
logger.add(
RichHandler(rich_tracebacks=True, markup=True, show_time=False, show_level=False, show_path=False),
format=format_record,
level="DEBUG",
enqueue=True, # 异步记录
)
return logger
global_logger = setup_logger()
def get_logger(actor_name: str, trace_id: str = "") -> Logger:
return global_logger.bind(actor_name=actor_name, trace_id=trace_id)

View File

@ -16,13 +16,15 @@ import ray
import time import time
import asyncio import asyncio
from collections import OrderedDict from collections import OrderedDict
from loguru import logger
from ray.util.queue import Queue from ray.util.queue import Queue
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
from pretor.worker_individual.worker_individual import BaseIndividual, SkillIndividual, OrdinaryIndividual, \ from pretor.worker_individual.worker_individual import BaseIndividual, SkillIndividual, OrdinaryIndividual, \
SpecialIndividual SpecialIndividual
from pretor.utils.logger import get_logger
logger = get_logger('worker_cluster')
@ray.remote @ray.remote
class WorkerCluster: class WorkerCluster:
""" """

View File

@ -13,18 +13,17 @@
# limitations under the License. # limitations under the License.
from loguru import logger
from pydantic_ai import Agent, RunContext from pydantic_ai import Agent, RunContext
from pydantic import Field from pydantic import Field
from pretor.adapter.model_adapter.agent_factory import AgentFactory from pretor.adapter.model_adapter.agent_factory import AgentFactory
from pretor.core.global_state_machine.global_state_machine import GlobalStateMachine
from pretor.core.global_state_machine.model_provider.base_provider import Provider from pretor.core.global_state_machine.model_provider.base_provider import Provider
from pretor.utils.agent_model import ResponseModel, InputModel, DepsModel from pretor.utils.agent_model import ResponseModel, InputModel, DepsModel
from pretor.utils.get_tool import get_tool
import ray
from pretor.utils.ray_hook import ray_actor_hook from pretor.utils.ray_hook import ray_actor_hook
from pretor.utils.logger import get_logger
logger = get_logger('worker_individual')
class WorkerIndividualResponse(ResponseModel): class WorkerIndividualResponse(ResponseModel):
output: str = Field(..., description="Worker执行任务的输出结果") output: str = Field(..., description="Worker执行任务的输出结果")

View File

@ -34,6 +34,10 @@ async def test_add_user(mock_session_maker, mock_dependencies):
mock_user.hashed_password = "hashedpw" mock_user.hashed_password = "hashedpw"
mock_user_cls.return_value = mock_user mock_user_cls.return_value = mock_user
mock_exec_result = MagicMock()
mock_exec_result.first.return_value = None
session.execute = AsyncMock(return_value=mock_exec_result)
user = await db.add_user("testuser", "hashedpw") user = await db.add_user("testuser", "hashedpw")
assert user.user_name == "testuser" assert user.user_name == "testuser"
@ -58,11 +62,11 @@ async def test_change_password_success(mock_session_maker, mock_dependencies):
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
user = await db.change_password("testuser", "old_password", "new_password") user = await db.change_password("testuser", "old_password", "new_password")
session.exec.assert_called_once_with(mock_statement) session.execute.assert_called_once_with(mock_statement)
assert user.hashed_password == "new_password" assert user.hashed_password == "new_password"
session.add.assert_called_once_with(mock_user) session.add.assert_called_once_with(mock_user)
session.commit.assert_called_once() session.commit.assert_called_once()
@ -78,7 +82,7 @@ async def test_change_password_user_not_exist(mock_session_maker, mock_dependenc
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = None mock_exec_result.scalar_one_or_none.return_value = None
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
result = await db.change_password("testuser", "old_password", "new_password") result = await db.change_password("testuser", "old_password", "new_password")
assert result is None assert result is None
@ -95,7 +99,7 @@ async def test_change_password_wrong_password(mock_session_maker, mock_dependenc
mock_user.hashed_password = "actual_password" mock_user.hashed_password = "actual_password"
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
from pretor.utils.error import UserPasswordError from pretor.utils.error import UserPasswordError
with pytest.raises(UserPasswordError): with pytest.raises(UserPasswordError):
@ -115,10 +119,10 @@ async def test_delete_user_success(mock_session_maker, mock_dependencies):
mock_user = MagicMock() mock_user = MagicMock()
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
await db.delete_user("testuser") await db.delete_user("testuser")
session.exec.assert_called_once_with(mock_statement) session.execute.assert_called_once_with(mock_statement)
session.delete.assert_called_once_with(mock_user) session.delete.assert_called_once_with(mock_user)
session.commit.assert_called_once() session.commit.assert_called_once()
@ -132,7 +136,7 @@ async def test_delete_user_not_exist(mock_session_maker, mock_dependencies):
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = None mock_exec_result.scalar_one_or_none.return_value = None
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
result = await db.delete_user("testuser") result = await db.delete_user("testuser")
assert result is None assert result is None
@ -151,10 +155,10 @@ async def test_login_user_success(mock_session_maker, mock_dependencies):
mock_user = MagicMock() mock_user = MagicMock()
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
user = await db.login_user("testuser") user = await db.login_user("testuser")
session.exec.assert_called_once_with(mock_statement) session.execute.assert_called_once_with(mock_statement)
assert user == mock_user assert user == mock_user
@ -167,7 +171,7 @@ async def test_login_user_not_exist(mock_session_maker, mock_dependencies):
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = None mock_exec_result.scalar_one_or_none.return_value = None
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
result = await db.login_user("testuser") result = await db.login_user("testuser")
assert result is None assert result is None

View File

@ -118,7 +118,9 @@ async def test_add_provider_success(gsm, mock_postgres):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_provider_unsupported(gsm): async def test_add_provider_unsupported(gsm):
gsm._global_provider_manager.provider_mapper = {} gsm._global_provider_manager.provider_mapper = {}
with patch("loguru.logger") as mock_logger: with patch("pretor.utils.logger.global_logger.bind") as mock_bind:
mock_logger = MagicMock()
mock_bind.return_value = mock_logger
await gsm.add_provider_wrap("magic", "title", "url", "key", 1) await gsm.add_provider_wrap("magic", "title", "url", "key", 1)
mock_logger.warning.assert_called_with("Provider type magic is not supported.") mock_logger.warning.assert_called_with("Provider type magic is not supported.")
@ -130,7 +132,9 @@ async def test_add_provider_request_error(gsm):
mock_provider_class.create_model.side_effect = RequestError("Network Error", request=MagicMock()) mock_provider_class.create_model.side_effect = RequestError("Network Error", request=MagicMock())
gsm._global_provider_manager.provider_mapper = {"openai": mock_provider_class} gsm._global_provider_manager.provider_mapper = {"openai": mock_provider_class}
with patch("loguru.logger") as mock_logger: with patch("pretor.utils.logger.global_logger.bind") as mock_bind:
mock_logger = MagicMock()
mock_bind.return_value = mock_logger
await gsm.add_provider_wrap("openai", "title", "url", "key", 1) await gsm.add_provider_wrap("openai", "title", "url", "key", 1)
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()
assert "网络请求异常" in mock_logger.warning.call_args[0][0] assert "网络请求异常" in mock_logger.warning.call_args[0][0]
@ -142,7 +146,9 @@ async def test_add_provider_generic_error(gsm):
mock_provider_class.create_model.side_effect = ValueError("Some Error") mock_provider_class.create_model.side_effect = ValueError("Some Error")
gsm._global_provider_manager.provider_mapper = {"openai": mock_provider_class} gsm._global_provider_manager.provider_mapper = {"openai": mock_provider_class}
with patch("loguru.logger") as mock_logger: with patch("pretor.utils.logger.global_logger.bind") as mock_bind:
mock_logger = MagicMock()
mock_bind.return_value = mock_logger
await gsm.add_provider_wrap("openai", "title", "url", "key", 1) await gsm.add_provider_wrap("openai", "title", "url", "key", 1)
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()
assert "解析模型列表时发生错误" in mock_logger.warning.call_args[0][0] assert "解析模型列表时发生错误" in mock_logger.warning.call_args[0][0]