init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/.idea/
|
||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM node:21-alpine
|
||||
RUN apk add --no-cache bash
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm config set registry https://registry.npmmirror.com/
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 3001
|
||||
CMD ["npm", "start"]
|
||||
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "Dialectic_Frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
18
public/index.html
Normal file
18
public/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Dialectic - 多模型辩论框架"
|
||||
/>
|
||||
<title>Dialectic</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
211
src/App.js
Normal file
211
src/App.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import './styles/App.css';
|
||||
import { getBackendHost } from './utils/api';
|
||||
import DebateConfiguration from './components/DebateConfiguration';
|
||||
import DebateDisplay from './components/DebateDisplay';
|
||||
import SessionsList from './components/SessionsList';
|
||||
import Settings from './components/Settings';
|
||||
import SetupWizard from './components/SetupWizard';
|
||||
import AuthProvider, {
|
||||
useAuth,
|
||||
OidcCallback,
|
||||
PopupCallback,
|
||||
SilentCallback,
|
||||
} from './components/AuthProvider';
|
||||
|
||||
const App = () => {
|
||||
const [appState, setAppState] = useState('loading');
|
||||
const [oidcConfig, setOidcConfig] = useState(null);
|
||||
const [envMode, setEnvMode] = useState('dev');
|
||||
|
||||
useEffect(() => {
|
||||
checkSetupStatus();
|
||||
|
||||
const handler = () => setAppState('setup');
|
||||
window.addEventListener('needs-setup', handler);
|
||||
return () => window.removeEventListener('needs-setup', handler);
|
||||
}, []);
|
||||
|
||||
const checkSetupStatus = async () => {
|
||||
try {
|
||||
const resp = await fetch(`${getBackendHost()}/api/setup/status`);
|
||||
if (resp.status === 503) {
|
||||
setAppState('setup');
|
||||
return;
|
||||
}
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data.env_mode) setEnvMode(data.env_mode);
|
||||
|
||||
// Build OIDC config from backend-provided Keycloak info
|
||||
if (data.keycloak && data.keycloak.authority) {
|
||||
const origin = window.location.origin;
|
||||
setOidcConfig({
|
||||
authority: data.keycloak.authority,
|
||||
client_id: data.keycloak.client_id,
|
||||
redirect_uri: `${origin}/callback`,
|
||||
post_logout_redirect_uri: origin,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email roles',
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.initialized || !data.db_configured) {
|
||||
setAppState('setup');
|
||||
return;
|
||||
}
|
||||
setAppState('ready');
|
||||
return;
|
||||
}
|
||||
setAppState('ready');
|
||||
} catch {
|
||||
setAppState('setup');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
setAppState('ready');
|
||||
};
|
||||
|
||||
const isProd = envMode === 'prod';
|
||||
|
||||
return (
|
||||
<AuthProvider oidcConfig={isProd ? oidcConfig : null}>
|
||||
<Routes>
|
||||
<Route path="/callback" element={<OidcCallback oidcConfig={oidcConfig} />} />
|
||||
<Route path="/popup_callback" element={<PopupCallback oidcConfig={oidcConfig} />} />
|
||||
<Route path="/silent_callback" element={<SilentCallback oidcConfig={oidcConfig} />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<MainContent
|
||||
appState={appState}
|
||||
isProd={isProd}
|
||||
onSetupComplete={handleSetupComplete}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const MainContent = ({ appState, isProd, onSetupComplete }) => {
|
||||
const auth = useAuth();
|
||||
const [currentView, setCurrentView] = useState('configuration');
|
||||
const [currentSessionId, setCurrentSessionId] = useState(null);
|
||||
|
||||
const handleCreateDebate = (sessionId) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
setCurrentView('debate');
|
||||
};
|
||||
|
||||
const handleViewSessions = () => setCurrentView('sessions');
|
||||
const handleViewSettings = () => setCurrentView('settings');
|
||||
|
||||
const handleBackToConfig = () => {
|
||||
setCurrentView('configuration');
|
||||
setCurrentSessionId(null);
|
||||
};
|
||||
|
||||
const handleLoadSession = (sessionId) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
setCurrentView('debate');
|
||||
};
|
||||
|
||||
// --- Loading ---
|
||||
if (appState === 'loading') {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="app-header">
|
||||
<h1>Dialectica - 多模型辩论框架</h1>
|
||||
<p>正在检查系统状态...</p>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Setup wizard ---
|
||||
if (appState === 'setup') {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="app-header">
|
||||
<h1>Dialectica - 多模型辩论框架</h1>
|
||||
<p>系统初始化配置</p>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<SetupWizard onSetupComplete={onSetupComplete} />
|
||||
</main>
|
||||
<footer className="app-footer">
|
||||
<p>Dialectica - 基于多模型的结构化辩论框架</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ready (dev, or prod guest/authenticated) ---
|
||||
const isGuest = isProd && !auth?.isAuthenticated;
|
||||
const username = auth?.user?.profile?.preferred_username;
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="app-header">
|
||||
<h1>Dialectica - 多模型辩论框架</h1>
|
||||
<p>让不同大语言模型就特定议题进行结构化辩论</p>
|
||||
{isProd && (
|
||||
<div className="header-auth">
|
||||
{auth?.isAuthenticated ? (
|
||||
<>
|
||||
<span className="header-username">{username}</span>
|
||||
<button className="header-logout-btn" onClick={auth.logout}>
|
||||
退出登录
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="header-logout-btn" onClick={auth?.login}>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{currentView === 'configuration' && (
|
||||
<DebateConfiguration
|
||||
onCreateDebate={handleCreateDebate}
|
||||
onViewSessions={handleViewSessions}
|
||||
onViewSettings={isGuest ? undefined : handleViewSettings}
|
||||
isGuest={isGuest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'debate' && (
|
||||
<DebateDisplay
|
||||
sessionId={currentSessionId}
|
||||
onBackToConfig={handleBackToConfig}
|
||||
isGuest={isGuest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'sessions' && (
|
||||
<SessionsList
|
||||
onLoadSession={handleLoadSession}
|
||||
onBackToConfig={handleBackToConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'settings' && (
|
||||
<Settings onBackToConfig={handleBackToConfig} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>Dialectica - 基于多模型的结构化辩论框架</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
156
src/components/AuthProvider.js
Normal file
156
src/components/AuthProvider.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
// Module-level token getter for use outside React (e.g. apiFetch)
|
||||
let _getTokenFn = () => null;
|
||||
export function getAccessTokenGlobal() {
|
||||
return _getTokenFn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a UserManager with consistent storage settings.
|
||||
* All components must use this to avoid storage mismatches.
|
||||
*/
|
||||
function createUserManager(oidcConfig) {
|
||||
return new UserManager({
|
||||
...oidcConfig,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC authentication provider.
|
||||
* Wraps children with auth context when ENV_MODE=prod.
|
||||
*/
|
||||
const AuthProvider = ({ oidcConfig, children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [manager, setManager] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oidcConfig || !oidcConfig.authority) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mgr = createUserManager(oidcConfig);
|
||||
setManager(mgr);
|
||||
|
||||
// Try to load existing session
|
||||
mgr.getUser().then((u) => {
|
||||
if (u && !u.expired) {
|
||||
setUser(u);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
mgr.events.addUserLoaded((u) => setUser(u));
|
||||
mgr.events.addUserUnloaded(() => setUser(null));
|
||||
mgr.events.addSilentRenewError(() => setUser(null));
|
||||
|
||||
return () => {
|
||||
mgr.events.removeUserLoaded(() => {});
|
||||
mgr.events.removeUserUnloaded(() => {});
|
||||
};
|
||||
}, [oidcConfig]);
|
||||
|
||||
// Keep global token getter in sync with current user
|
||||
useEffect(() => {
|
||||
_getTokenFn = () => user?.access_token || null;
|
||||
return () => { _getTokenFn = () => null; };
|
||||
}, [user]);
|
||||
|
||||
const login = useCallback(() => {
|
||||
if (manager) manager.signinRedirect();
|
||||
}, [manager]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (manager) manager.signoutRedirect();
|
||||
}, [manager]);
|
||||
|
||||
const getAccessToken = useCallback(() => {
|
||||
return user?.access_token || null;
|
||||
}, [user]);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated: !!user && !user.expired,
|
||||
login,
|
||||
logout,
|
||||
getAccessToken,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OIDC redirect callback.
|
||||
* Mount this component at /callback route.
|
||||
*/
|
||||
export const OidcCallback = ({ oidcConfig }) => {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oidcConfig) return;
|
||||
const mgr = createUserManager(oidcConfig);
|
||||
mgr.signinRedirectCallback()
|
||||
.then(() => {
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('OIDC callback error:', err);
|
||||
setError(err.message || String(err));
|
||||
});
|
||||
}, [oidcConfig]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<h3>登录失败</h3>
|
||||
<p style={{ color: '#e53e3e' }}>{error}</p>
|
||||
<a href="/" style={{ color: '#2575fc' }}>返回首页</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div style={{ padding: '2rem', textAlign: 'center' }}>登录中...</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OIDC popup callback.
|
||||
* Mount this component at /popup_callback route.
|
||||
*/
|
||||
export const PopupCallback = ({ oidcConfig }) => {
|
||||
useEffect(() => {
|
||||
if (!oidcConfig) return;
|
||||
const mgr = createUserManager(oidcConfig);
|
||||
mgr.signinPopupCallback().catch((err) => {
|
||||
console.error('OIDC popup callback error:', err);
|
||||
});
|
||||
}, [oidcConfig]);
|
||||
|
||||
return <div>登录中...</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OIDC silent renew callback.
|
||||
* Mount this component at /silent_callback route.
|
||||
*/
|
||||
export const SilentCallback = ({ oidcConfig }) => {
|
||||
useEffect(() => {
|
||||
if (!oidcConfig) return;
|
||||
const mgr = createUserManager(oidcConfig);
|
||||
mgr.signinSilentCallback().catch((err) => {
|
||||
console.error('OIDC silent callback error:', err);
|
||||
});
|
||||
}, [oidcConfig]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
496
src/components/DebateConfiguration.js
Normal file
496
src/components/DebateConfiguration.js
Normal file
@@ -0,0 +1,496 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './../styles/App.css';
|
||||
|
||||
const DebateConfiguration = ({ onCreateDebate, onViewSessions, onViewSettings, isGuest }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
topic: '',
|
||||
proProvider: 'openai',
|
||||
proModel: 'gpt-4',
|
||||
conProvider: 'claude',
|
||||
conModel: 'claude-3-opus',
|
||||
maxRounds: 5,
|
||||
maxTokens: 500,
|
||||
webSearchEnabled: false,
|
||||
webSearchMode: 'auto'
|
||||
});
|
||||
|
||||
const [availableModels, setAvailableModels] = useState({
|
||||
openai: [],
|
||||
claude: [],
|
||||
qwen: [],
|
||||
deepseek: []
|
||||
});
|
||||
|
||||
const [availableProviders, setAvailableProviders] = useState([
|
||||
{ provider: 'openai', display_name: 'OpenAI' },
|
||||
{ provider: 'claude', display_name: 'Claude' },
|
||||
{ provider: 'qwen', display_name: 'Qwen' },
|
||||
{ provider: 'deepseek', display_name: 'DeepSeek' }
|
||||
]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [modelsLoading, setModelsLoading] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
// Load available providers when component mounts
|
||||
loadAvailableProviders();
|
||||
}, []);
|
||||
|
||||
const loadAvailableProviders = async () => {
|
||||
try {
|
||||
// Get all API keys from backend to determine which providers are available
|
||||
const providers = ['openai', 'claude', 'qwen', 'deepseek'];
|
||||
const available = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8000/api-keys/${provider}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.api_key) {
|
||||
// For Qwen and DeepSeek, we can check if the key is valid
|
||||
if (provider === 'qwen' || provider === 'deepseek') {
|
||||
// For now, just check if key exists - we'll implement validation later if needed
|
||||
available.push({
|
||||
provider: provider,
|
||||
display_name: provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
});
|
||||
} else {
|
||||
// For OpenAI and Claude, we're skipping validation as requested
|
||||
available.push({
|
||||
provider: provider,
|
||||
display_name: provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If there's an error getting the API key, skip this provider
|
||||
console.error(`Error getting API key for ${provider}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (available.length > 0) {
|
||||
setAvailableProviders(available);
|
||||
|
||||
// Update form data to use valid providers
|
||||
if (!available.some(p => p.provider === formData.proProvider)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
proProvider: available[0]?.provider || prev.proProvider
|
||||
}));
|
||||
}
|
||||
|
||||
if (!available.some(p => p.provider === formData.conProvider)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
conProvider: available[0]?.provider || prev.conProvider
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// If no providers have API keys, show all providers but disable functionality
|
||||
setAvailableProviders([
|
||||
{ provider: 'openai', display_name: 'OpenAI' },
|
||||
{ provider: 'claude', display_name: 'Claude' },
|
||||
{ provider: 'qwen', display_name: 'Qwen' },
|
||||
{ provider: 'deepseek', display_name: 'DeepSeek' }
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading available providers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Update model options when provider changes
|
||||
if (formData.proProvider) {
|
||||
loadModelsForProvider(formData.proProvider);
|
||||
}
|
||||
if (formData.conProvider) {
|
||||
loadModelsForProvider(formData.conProvider);
|
||||
}
|
||||
}, [formData.proProvider, formData.conProvider]);
|
||||
|
||||
const loadModelsForProvider = async (provider) => {
|
||||
setModelsLoading(prev => ({ ...prev, [provider]: true }));
|
||||
try {
|
||||
// Special handling for Qwen - fetch directly from Qwen API
|
||||
if (provider === 'qwen') {
|
||||
try {
|
||||
// Call backend to get Qwen models (backend will fetch from Qwen API)
|
||||
const qwenResponse = await fetch(`http://localhost:8000/models/${provider}`);
|
||||
if (qwenResponse.ok) {
|
||||
const qwenData = await qwenResponse.json();
|
||||
console.log('Backend Qwen models response:', qwenData); // Debug log
|
||||
|
||||
const models = qwenData.models || [];
|
||||
console.log('Fetched Qwen models:', models); // Debug log
|
||||
|
||||
setAvailableModels(prev => ({
|
||||
...prev,
|
||||
[provider]: models
|
||||
}));
|
||||
|
||||
// Update selected model if current selection is not in the new list
|
||||
if (provider === formData.proProvider && models.length > 0) {
|
||||
const currentModelExists = models.some(model => model.model_identifier === formData.proModel);
|
||||
if (!currentModelExists) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
proModel: models[0].model_identifier
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === formData.conProvider && models.length > 0) {
|
||||
const currentModelExists = models.some(model => model.model_identifier === formData.conModel);
|
||||
if (!currentModelExists) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
conModel: models[0].model_identifier
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Error fetching models from backend for Qwen:', qwenResponse.status, await qwenResponse.text());
|
||||
// Use fallback models if API call fails
|
||||
const fallbackModels = [
|
||||
{ model_identifier: 'qwen3-max', display_name: 'Qwen3 Max' },
|
||||
{ model_identifier: 'qwen3-plus', display_name: 'Qwen3 Plus' },
|
||||
{ model_identifier: 'qwen3-flash', display_name: 'Qwen3 Flash' },
|
||||
{ model_identifier: 'qwen-max', display_name: 'Qwen Max' },
|
||||
{ model_identifier: 'qwen-plus', display_name: 'Qwen Plus' },
|
||||
{ model_identifier: 'qwen-turbo', display_name: 'Qwen Turbo' }
|
||||
];
|
||||
|
||||
setAvailableModels(prev => ({
|
||||
...prev,
|
||||
[provider]: fallbackModels
|
||||
}));
|
||||
|
||||
if (provider === formData.proProvider) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
proModel: fallbackModels[0].model_identifier
|
||||
}));
|
||||
}
|
||||
|
||||
if (provider === formData.conProvider) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
conModel: fallbackModels[0].model_identifier
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Network error when fetching Qwen models from backend:', error);
|
||||
// Use fallback models if network request fails
|
||||
const fallbackModels = [
|
||||
{ model_identifier: 'qwen-max', display_name: 'Qwen Max' },
|
||||
{ model_identifier: 'qwen-plus', display_name: 'Qwen Plus' },
|
||||
{ model_identifier: 'qwen-turbo', display_name: 'Qwen Turbo' }
|
||||
];
|
||||
|
||||
setAvailableModels(prev => ({
|
||||
...prev,
|
||||
[provider]: fallbackModels
|
||||
}));
|
||||
|
||||
if (provider === formData.proProvider) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
proModel: fallbackModels[0].model_identifier
|
||||
}));
|
||||
}
|
||||
|
||||
if (provider === formData.conProvider) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
conModel: fallbackModels[0].model_identifier
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other providers, use the backend API
|
||||
const response = await fetch(`http://localhost:8000/models/${provider}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableModels(prev => ({
|
||||
...prev,
|
||||
[provider]: data.models || []
|
||||
}));
|
||||
|
||||
// Update selected model if current selection is not in the new list
|
||||
if (provider === formData.proProvider && data.models && data.models.length > 0) {
|
||||
const currentModelExists = data.models.some(model => model.model_identifier === formData.proModel);
|
||||
if (!currentModelExists) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
proModel: data.models[0].model_identifier
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === formData.conProvider && data.models && data.models.length > 0) {
|
||||
const currentModelExists = data.models.some(model => model.model_identifier === formData.conModel);
|
||||
if (!currentModelExists) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
conModel: data.models[0].model_identifier
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading models for ${provider}:`, error);
|
||||
} finally {
|
||||
setModelsLoading(prev => ({ ...prev, [provider]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
// Validate inputs
|
||||
if (!formData.topic.trim()) {
|
||||
alert('请输入辩论主题');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create debate request object
|
||||
const debateRequest = {
|
||||
topic: formData.topic,
|
||||
participants: [
|
||||
{
|
||||
model_identifier: formData.proModel,
|
||||
provider: formData.proProvider,
|
||||
stance: "pro"
|
||||
},
|
||||
{
|
||||
model_identifier: formData.conModel,
|
||||
provider: formData.conProvider,
|
||||
stance: "con"
|
||||
}
|
||||
],
|
||||
constraints: {
|
||||
max_rounds: parseInt(formData.maxRounds),
|
||||
max_tokens_per_turn: parseInt(formData.maxTokens),
|
||||
web_search_enabled: formData.webSearchEnabled,
|
||||
web_search_mode: formData.webSearchMode
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/debate/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(debateRequest)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`创建辩论失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
alert(`辩论创建成功!会话ID: ${result.session_id}`);
|
||||
|
||||
// Pass the session ID to the parent component
|
||||
onCreateDebate(result.session_id);
|
||||
} catch (error) {
|
||||
console.error('Error creating debate:', error);
|
||||
alert(`创建辩论失败: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>辩论配置</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="topic" className="label">辩论主题:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="topic"
|
||||
name="topic"
|
||||
className="input"
|
||||
value={formData.topic}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="请输入辩论主题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">参与模型:</label>
|
||||
<div className="model-selection">
|
||||
<div className="model-item">
|
||||
<select
|
||||
className="select"
|
||||
name="proProvider"
|
||||
value={formData.proProvider}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<option key={provider.provider} value={provider.provider}>
|
||||
{provider.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{modelsLoading[formData.proProvider] ? (
|
||||
<div className="input">加载模型中...</div>
|
||||
) : (
|
||||
<select
|
||||
className="select"
|
||||
name="proModel"
|
||||
value={formData.proModel}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{availableModels[formData.proProvider]?.map((model) => (
|
||||
<option key={model.model_identifier} value={model.model_identifier}>
|
||||
{model.display_name || model.model_identifier}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<span className="stance-label">正方</span>
|
||||
</div>
|
||||
|
||||
<div className="model-item">
|
||||
<select
|
||||
className="select"
|
||||
name="conProvider"
|
||||
value={formData.conProvider}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<option key={provider.provider} value={provider.provider}>
|
||||
{provider.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{modelsLoading[formData.conProvider] ? (
|
||||
<div className="input">加载模型中...</div>
|
||||
) : (
|
||||
<select
|
||||
className="select"
|
||||
name="conModel"
|
||||
value={formData.conModel}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{availableModels[formData.conProvider]?.map((model) => (
|
||||
<option key={model.model_identifier} value={model.model_identifier}>
|
||||
{model.display_name || model.model_identifier}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<span className="stance-label">反方</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxRounds" className="label">最大轮数:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxRounds"
|
||||
name="maxRounds"
|
||||
className="input"
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.maxRounds}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxTokens" className="label">每轮最大Token数:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxTokens"
|
||||
name="maxTokens"
|
||||
className="input"
|
||||
min="100"
|
||||
max="2000"
|
||||
value={formData.maxTokens}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="webSearchEnabled"
|
||||
checked={formData.webSearchEnabled}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
启用网络搜索
|
||||
</label>
|
||||
{formData.webSearchEnabled && (
|
||||
<div className="search-mode-selector" style={{ marginTop: '0.5rem' }}>
|
||||
<label htmlFor="webSearchMode" className="label">搜索模式:</label>
|
||||
<select
|
||||
className="select"
|
||||
name="webSearchMode"
|
||||
id="webSearchMode"
|
||||
value={formData.webSearchMode}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="auto">自动搜索 (每轮自动检索)</option>
|
||||
<option value="tool">工具调用 (模型决定是否搜索)</option>
|
||||
<option value="both">两者结合</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`button create-debate-button`}
|
||||
disabled={isLoading || isGuest}
|
||||
>
|
||||
{isLoading ? '创建中...' : '创建辩论'}
|
||||
</button>
|
||||
{isGuest && (
|
||||
<p style={{ color: '#e53e3e', marginTop: '0.5rem', textAlign: 'center' }}>
|
||||
请先登录后再创建辩论
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className="button view-sessions-button" onClick={onViewSessions}>
|
||||
查看历史辩论
|
||||
</button>
|
||||
{onViewSettings && (
|
||||
<button className="button view-sessions-button" style={{ marginLeft: '1rem' }} onClick={onViewSettings}>
|
||||
设置 API 密钥
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebateConfiguration;
|
||||
316
src/components/DebateDisplay.js
Normal file
316
src/components/DebateDisplay.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './../styles/App.css';
|
||||
|
||||
const DebateDisplay = ({ sessionId, onBackToConfig, isGuest }) => {
|
||||
const [debateRounds, setDebateRounds] = useState([]);
|
||||
const [summary, setSummary] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [eventSource, setEventSource] = useState(null);
|
||||
const debateStreamRef = useRef(null);
|
||||
const debateCompletedRef = useRef(false);
|
||||
const [evidenceLibrary, setEvidenceLibrary] = useState([]);
|
||||
const [evidenceLibraryExpanded, setEvidenceLibraryExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId && !isStreaming) {
|
||||
startDebateStreaming(sessionId);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to bottom when new content is added
|
||||
if (debateStreamRef.current) {
|
||||
debateStreamRef.current.scrollTop = debateStreamRef.current.scrollHeight;
|
||||
}
|
||||
}, [debateRounds]);
|
||||
|
||||
const startDebateStreaming = (sessionId) => {
|
||||
setIsStreaming(true);
|
||||
debateCompletedRef.current = false;
|
||||
|
||||
// Kick off the debate on the backend (fire and forget — runs in background)
|
||||
fetch(`http://localhost:8000/debate/${sessionId}/start`, { method: 'POST' })
|
||||
.then(res => res.json())
|
||||
.then(result => console.log('Debate completed:', result))
|
||||
.catch(err => console.error('Debate start error:', err));
|
||||
|
||||
// Connect to SSE endpoint for real-time updates
|
||||
const es = new EventSource(`http://localhost:8000/debate/${sessionId}/stream`);
|
||||
setEventSource(es);
|
||||
|
||||
es.addEventListener('update', function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
handleUpdate(data);
|
||||
});
|
||||
|
||||
es.addEventListener('complete', function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
handleComplete(data);
|
||||
});
|
||||
|
||||
es.addEventListener('error', function(event) {
|
||||
// Check if event.data exists before parsing
|
||||
if (event.data) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleError(data.error);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse error event data:', e);
|
||||
handleError('Unknown error occurred');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = function(err) {
|
||||
// Server closes the connection after "complete" event — not a real error
|
||||
if (debateCompletedRef.current) {
|
||||
es.close();
|
||||
return;
|
||||
}
|
||||
console.error('SSE connection error:', err);
|
||||
es.close();
|
||||
setIsStreaming(false);
|
||||
setEventSource(null);
|
||||
};
|
||||
};
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
if (data.rounds) {
|
||||
setDebateRounds([...data.rounds]);
|
||||
}
|
||||
if (data.evidence_library) {
|
||||
setEvidenceLibrary([...data.evidence_library]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = (data) => {
|
||||
debateCompletedRef.current = true;
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
|
||||
// Show summary if available
|
||||
if (data.summary) {
|
||||
setSummary(data.summary);
|
||||
}
|
||||
if (data.evidence_library) {
|
||||
setEvidenceLibrary([...data.evidence_library]);
|
||||
}
|
||||
|
||||
alert('辩论已完成!');
|
||||
};
|
||||
|
||||
const handleError = (errorMsg) => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
|
||||
alert(`错误: ${errorMsg}`);
|
||||
};
|
||||
|
||||
const handleStopDebate = async () => {
|
||||
if (!sessionId) {
|
||||
alert('没有活动的辩论会话');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('确定要停止当前辩论吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8000/debate/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`停止辩论失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
alert(`辩论已停止: ${result.status}`);
|
||||
|
||||
// Close SSE connection if active
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error stopping debate:', error);
|
||||
alert(`停止辩论失败: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSummary = (summaryText) => {
|
||||
// Simple formatting - split by newlines and paragraphs
|
||||
return summaryText.split('\n').map((paragraph, index) => (
|
||||
<p key={index}>{paragraph}</p>
|
||||
));
|
||||
};
|
||||
|
||||
const [expandedEvidence, setExpandedEvidence] = useState({});
|
||||
|
||||
const toggleEvidence = (roundKey) => {
|
||||
setExpandedEvidence(prev => ({
|
||||
...prev,
|
||||
[roundKey]: !prev[roundKey]
|
||||
}));
|
||||
};
|
||||
|
||||
const renderSearchEvidence = (evidence, roundKey) => {
|
||||
if (!evidence || !evidence.results || evidence.results.length === 0) return null;
|
||||
const isExpanded = expandedEvidence[roundKey];
|
||||
|
||||
return (
|
||||
<div className="search-evidence">
|
||||
<div className="search-evidence-header" onClick={() => toggleEvidence(roundKey)}>
|
||||
<span className="search-evidence-icon">{isExpanded ? '▼' : '▶'}</span>
|
||||
<span className="search-evidence-title">网络搜索参考</span>
|
||||
<span className="search-evidence-badge">{evidence.results.length} 条结果</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="search-evidence-body">
|
||||
<div className="search-evidence-query">搜索词: "{evidence.query}"</div>
|
||||
{evidence.results.map((result, i) => (
|
||||
<div key={i} className="search-evidence-item">
|
||||
<a href={result.url} target="_blank" rel="noopener noreferrer" className="search-evidence-link">
|
||||
{result.title}
|
||||
</a>
|
||||
<p className="search-evidence-snippet">{result.snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEvidenceLibrary = () => {
|
||||
if (!evidenceLibrary || evidenceLibrary.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="evidence-library-panel">
|
||||
<div
|
||||
className="evidence-library-header"
|
||||
onClick={() => setEvidenceLibraryExpanded(!evidenceLibraryExpanded)}
|
||||
>
|
||||
<span className="evidence-library-icon">{evidenceLibraryExpanded ? '▼' : '▶'}</span>
|
||||
<span className="evidence-library-title">证据库</span>
|
||||
<span className="evidence-library-badge">{evidenceLibrary.length} 条来源</span>
|
||||
</div>
|
||||
{evidenceLibraryExpanded && (
|
||||
<div className="evidence-library-body">
|
||||
{evidenceLibrary.map((entry, i) => (
|
||||
<div key={i} className="evidence-library-entry">
|
||||
<div className="evidence-library-entry-header">
|
||||
<a href={entry.url} target="_blank" rel="noopener noreferrer" className="evidence-library-link">
|
||||
{entry.title}
|
||||
</a>
|
||||
{entry.score != null && (
|
||||
<span className="evidence-library-score">相关度: {(entry.score * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="evidence-library-snippet">{entry.snippet}</p>
|
||||
<div className="evidence-library-refs">
|
||||
{entry.references.map((ref, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={`evidence-library-ref-tag ${ref.stance === 'pro' ? 'ref-pro' : 'ref-con'}`}
|
||||
>
|
||||
第{ref.round_number}轮 · {ref.stance === 'pro' ? '正方' : '反方'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const addRoundToDisplay = (round, index) => {
|
||||
const stanceClass = round.stance === 'pro' ? 'pro-speaker' : 'con-speaker';
|
||||
const stanceText = round.stance === 'pro' ? '正方' : '反方';
|
||||
const stanceLabelClass = round.stance === 'pro' ? 'pro-stance' : 'con-stance';
|
||||
const roundKey = `${index}-${round.round_number}`;
|
||||
|
||||
return (
|
||||
<div key={`${index}-${round.round_number}-${round.speaker}`} className="round-container">
|
||||
<div className={`speaker-header ${stanceClass}`}>
|
||||
<div className="speaker-info">{round.speaker} ({round.round_number}轮)</div>
|
||||
<div className={`speaker-stance ${stanceLabelClass}`}>{stanceText}</div>
|
||||
</div>
|
||||
<div className="speaker-content">{round.content}</div>
|
||||
{renderSearchEvidence(round.search_evidence, roundKey)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>辩论进行中</h2>
|
||||
<div className="debate-controls">
|
||||
{!isGuest && (
|
||||
<>
|
||||
<button
|
||||
className="button start-debate-button"
|
||||
disabled={isStreaming}
|
||||
onClick={() => !isStreaming && startDebateStreaming(sessionId)}
|
||||
>
|
||||
{isStreaming ? '辩论进行中...' : '开始辩论'}
|
||||
</button>
|
||||
<button
|
||||
className="button stop-debate-button"
|
||||
onClick={handleStopDebate}
|
||||
>
|
||||
停止辩论
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="button back-button"
|
||||
onClick={onBackToConfig}
|
||||
>
|
||||
返回配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="debate-display">
|
||||
<div ref={debateStreamRef} id="debate-stream" className="debate-stream">
|
||||
{debateRounds.length > 0 ? (
|
||||
debateRounds.map((round, index) => addRoundToDisplay(round, index))
|
||||
) : (
|
||||
<p>辩论尚未开始,点击"开始辩论"按钮启动辩论...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderEvidenceLibrary()}
|
||||
|
||||
{summary && (
|
||||
<div id="debate-summary" className="debate-summary">
|
||||
<h3>辩论总结</h3>
|
||||
<div id="summary-content">
|
||||
{formatSummary(summary)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebateDisplay;
|
||||
95
src/components/SessionsList.js
Normal file
95
src/components/SessionsList.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './../styles/App.css';
|
||||
|
||||
const SessionsList = ({ onLoadSession, onBackToConfig }) => {
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('http://localhost:8000/sessions');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取会话列表失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setSessions(result.sessions || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Error fetching sessions:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSession = (sessionId) => {
|
||||
onLoadSession(sessionId);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>辩论会话列表</h2>
|
||||
<p>加载中...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>辩论会话列表</h2>
|
||||
<p>错误: {error}</p>
|
||||
<button className="button" onClick={fetchSessions}>重试</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>辩论会话列表</h2>
|
||||
<div id="sessions-list">
|
||||
{sessions.length === 0 ? (
|
||||
<p>暂无辩论会话记录</p>
|
||||
) : (
|
||||
sessions.map(session => (
|
||||
<div key={session.session_id} className="sessions-list-item">
|
||||
<div className="session-topic">{session.topic}</div>
|
||||
<div className="session-meta">
|
||||
<span>ID: {session.session_id.substring(0, 8)}...</span>
|
||||
<span>状态: {session.status}</span>
|
||||
<span>时间: {formatDate(session.created_at)}</span>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button
|
||||
className="button view-session-button"
|
||||
onClick={() => handleLoadSession(session.session_id)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className="button back-button" onClick={onBackToConfig}>
|
||||
返回配置
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionsList;
|
||||
352
src/components/Settings.js
Normal file
352
src/components/Settings.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getBackendHost } from '../utils/api';
|
||||
import '../styles/App.css';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'database', label: '数据库' },
|
||||
{ id: 'keycloak', label: 'Keycloak' },
|
||||
{ id: 'tls', label: '证书' },
|
||||
{ id: 'apikeys', label: 'API Keys' },
|
||||
];
|
||||
|
||||
const Settings = ({ onBackToConfig }) => {
|
||||
const [activeTab, setActiveTab] = useState('database');
|
||||
const backend = getBackendHost();
|
||||
|
||||
// ---- system config (db / kc / tls) ----
|
||||
const [dbConfig, setDbConfig] = useState({ host: '', port: 3306, user: '', password: '', database: '' });
|
||||
const [kcConfig, setKcConfig] = useState({ host: '', realm: '', client_id: '' });
|
||||
const [tlsConfig, setTlsConfig] = useState({ cert_path: '', key_path: '', force_https: false });
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [dbTestResult, setDbTestResult] = useState(null);
|
||||
const [dbTesting, setDbTesting] = useState(false);
|
||||
const [kcTestResult, setKcTestResult] = useState(null);
|
||||
const [kcTesting, setKcTesting] = useState(false);
|
||||
|
||||
// ---- API keys (existing logic) ----
|
||||
const [apiKeys, setApiKeys] = useState({ openai: '', claude: '', qwen: '', deepseek: '', tavily: '' });
|
||||
const [loading, setLoading] = useState({});
|
||||
const [saved, setSaved] = useState({});
|
||||
const [validationStatus, setValidationStatus] = useState({});
|
||||
|
||||
// ---- Load config on mount ----
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchApiKeys();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const resp = await fetch(`${backend}/api/setup/config`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data.database) setDbConfig(prev => ({ ...prev, ...data.database }));
|
||||
if (data.keycloak) setKcConfig(prev => ({ ...prev, ...data.keycloak }));
|
||||
if (data.tls) setTlsConfig(prev => ({ ...prev, ...data.tls }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
database: dbConfig,
|
||||
keycloak: kcConfig.host ? kcConfig : null,
|
||||
tls: tlsConfig.cert_path ? tlsConfig : null,
|
||||
};
|
||||
const resp = await fetch(`${backend}/api/setup/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
alert('配置已保存');
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert(`保存失败: ${err.detail || resp.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`保存失败: ${err.message}`);
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testDb = async () => {
|
||||
setDbTesting(true);
|
||||
setDbTestResult(null);
|
||||
try {
|
||||
const resp = await fetch(`${backend}/api/setup/test-db`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dbConfig),
|
||||
});
|
||||
setDbTestResult(await resp.json());
|
||||
} catch (err) {
|
||||
setDbTestResult({ success: false, message: err.message });
|
||||
} finally {
|
||||
setDbTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testKc = async () => {
|
||||
setKcTesting(true);
|
||||
setKcTestResult(null);
|
||||
try {
|
||||
const resp = await fetch(`${backend}/api/setup/test-keycloak`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(kcConfig),
|
||||
});
|
||||
setKcTestResult(await resp.json());
|
||||
} catch (err) {
|
||||
setKcTestResult({ success: false, message: err.message });
|
||||
} finally {
|
||||
setKcTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- API Key handlers (preserved from original) ----
|
||||
const fetchApiKeys = async () => {
|
||||
const providers = ['openai', 'claude', 'qwen', 'deepseek', 'tavily'];
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const response = await fetch(`${backend}/api-keys/${provider}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApiKeys(prev => ({ ...prev, [provider]: data.api_key || '' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${provider} API key:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (provider, value) => {
|
||||
setApiKeys(prev => ({ ...prev, [provider]: value }));
|
||||
};
|
||||
|
||||
const saveApiKey = async (provider) => {
|
||||
setLoading(prev => ({ ...prev, [provider]: true }));
|
||||
setSaved(prev => ({ ...prev, [provider]: false }));
|
||||
|
||||
if (!apiKeys[provider]) {
|
||||
setValidationStatus(prev => ({ ...prev, [provider]: { isValid: false, message: 'API key 不能为空' } }));
|
||||
setLoading(prev => ({ ...prev, [provider]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('api_key', apiKeys[provider]);
|
||||
const vResp = await fetch(`${backend}/validate-api-key/${provider}`, { method: 'POST', body: formData });
|
||||
if (vResp.ok) {
|
||||
const vResult = await vResp.json();
|
||||
if (!vResult.valid) {
|
||||
setValidationStatus(prev => ({ ...prev, [provider]: { isValid: false, message: vResult.message } }));
|
||||
setLoading(prev => ({ ...prev, [provider]: false }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setValidationStatus(prev => ({ ...prev, [provider]: { isValid: false, message: err.message } }));
|
||||
setLoading(prev => ({ ...prev, [provider]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Save
|
||||
try {
|
||||
const response = await fetch(`${backend}/api-keys/${provider}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `api_key=${encodeURIComponent(apiKeys[provider])}`,
|
||||
});
|
||||
if (response.ok) {
|
||||
setSaved(prev => ({ ...prev, [provider]: true }));
|
||||
setValidationStatus(prev => ({ ...prev, [provider]: { isValid: true, message: 'Valid' } }));
|
||||
setTimeout(() => setSaved(prev => ({ ...prev, [provider]: false })), 2000);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
setValidationStatus(prev => ({ ...prev, [provider]: { isValid: false, message: errorText } }));
|
||||
}
|
||||
} catch (error) {
|
||||
setValidationStatus(prev => ({ ...prev, [provider]: { isValid: false, message: error.message } }));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [provider]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAllApiKeys = async () => {
|
||||
for (const provider of Object.keys(apiKeys)) {
|
||||
await saveApiKey(provider);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Tab content renderers ----
|
||||
const renderDatabaseTab = () => (
|
||||
<div className="settings-tab-content">
|
||||
<div className="form-group">
|
||||
<label className="label">主机</label>
|
||||
<input className="input" value={dbConfig.host} onChange={(e) => setDbConfig(prev => ({ ...prev, host: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">端口</label>
|
||||
<input className="input" type="number" value={dbConfig.port} onChange={(e) => setDbConfig(prev => ({ ...prev, port: parseInt(e.target.value) || 0 }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">用户名</label>
|
||||
<input className="input" value={dbConfig.user} onChange={(e) => setDbConfig(prev => ({ ...prev, user: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">密码 {dbConfig.password === '********' && <span style={{ fontWeight: 'normal', color: '#718096', fontSize: '0.85rem' }}>(已保存,留空则保持不变)</span>}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={dbConfig.password === '********' ? '' : dbConfig.password}
|
||||
placeholder={dbConfig.password === '********' ? '已设置,输入新密码可覆盖' : '请输入密码'}
|
||||
onChange={(e) => setDbConfig(prev => ({ ...prev, password: e.target.value || '********' }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">数据库名</label>
|
||||
<input className="input" value={dbConfig.database} onChange={(e) => setDbConfig(prev => ({ ...prev, database: e.target.value }))} />
|
||||
</div>
|
||||
<div className="setup-test-row">
|
||||
<button className="button" onClick={testDb} disabled={dbTesting}>{dbTesting ? '测试中...' : '测试连接'}</button>
|
||||
{dbTestResult && (
|
||||
<span className={`setup-test-result ${dbTestResult.success ? 'success' : 'fail'}`}>
|
||||
{dbTestResult.success ? '\u2713 ' : '\u2717 '}{dbTestResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderKeycloakTab = () => (
|
||||
<div className="settings-tab-content">
|
||||
<div className="form-group">
|
||||
<label className="label">KC 地址</label>
|
||||
<input className="input" value={kcConfig.host} onChange={(e) => setKcConfig(prev => ({ ...prev, host: e.target.value }))} placeholder="https://login.example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Realm</label>
|
||||
<input className="input" value={kcConfig.realm} onChange={(e) => setKcConfig(prev => ({ ...prev, realm: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Client ID</label>
|
||||
<input className="input" value={kcConfig.client_id} onChange={(e) => setKcConfig(prev => ({ ...prev, client_id: e.target.value }))} />
|
||||
</div>
|
||||
<div className="setup-test-row">
|
||||
<button className="button" onClick={testKc} disabled={kcTesting || !kcConfig.host}>{kcTesting ? '测试中...' : '测试连通性'}</button>
|
||||
{kcTestResult && (
|
||||
<span className={`setup-test-result ${kcTestResult.success ? 'success' : 'fail'}`}>
|
||||
{kcTestResult.success ? '\u2713 ' : '\u2717 '}{kcTestResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTlsTab = () => (
|
||||
<div className="settings-tab-content">
|
||||
<div className="form-group">
|
||||
<label className="label">证书路径</label>
|
||||
<input className="input" value={tlsConfig.cert_path} onChange={(e) => setTlsConfig(prev => ({ ...prev, cert_path: e.target.value }))} placeholder="/etc/ssl/certs/cert.pem" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">私钥路径</label>
|
||||
<input className="input" value={tlsConfig.key_path} onChange={(e) => setTlsConfig(prev => ({ ...prev, key_path: e.target.value }))} placeholder="/etc/ssl/private/key.pem" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label checkbox-label">
|
||||
<input type="checkbox" checked={tlsConfig.force_https} onChange={(e) => setTlsConfig(prev => ({ ...prev, force_https: e.target.checked }))} />
|
||||
强制 HTTPS
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderApiKeysTab = () => (
|
||||
<div className="settings-tab-content">
|
||||
<div className="api-key-form">
|
||||
{Object.entries(apiKeys).map(([provider, value]) => (
|
||||
<div key={provider} className="api-key-input-group">
|
||||
<label htmlFor={`${provider}-api-key`} className="label">
|
||||
{provider.charAt(0).toUpperCase() + provider.slice(1)} API Key:
|
||||
</label>
|
||||
<div className="input-with-buttons">
|
||||
<input
|
||||
type="password"
|
||||
id={`${provider}-api-key`}
|
||||
className="input"
|
||||
value={value}
|
||||
onChange={(e) => handleApiKeyChange(provider, e.target.value)}
|
||||
placeholder={`Enter your ${provider} API key`}
|
||||
/>
|
||||
<button className="button" onClick={() => saveApiKey(provider)} disabled={loading[provider]}>
|
||||
{loading[provider] ? '验证中...' : '保存'}
|
||||
</button>
|
||||
{saved[provider] && <span className="save-status"> \u2713 已保存</span>}
|
||||
{validationStatus[provider] && (
|
||||
<span className={`validation-status ${validationStatus[provider].isValid ? 'valid' : 'invalid'}`}>
|
||||
{validationStatus[provider].isValid ? '\u2713 有效' : `\u2717 无效: ${validationStatus[provider].message}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-actions" style={{ marginTop: '1rem' }}>
|
||||
<button className="button create-debate-button" style={{ width: 'auto' }} onClick={handleSaveAllApiKeys}>
|
||||
保存全部 API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>系统设置</h2>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="settings-tabs">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'keycloak' && renderKeycloakTab()}
|
||||
{activeTab === 'tls' && renderTlsTab()}
|
||||
{activeTab === 'apikeys' && renderApiKeysTab()}
|
||||
|
||||
{/* Save / Back (for non-apikey tabs) */}
|
||||
{activeTab !== 'apikeys' && (
|
||||
<div className="settings-actions">
|
||||
<button className="button create-debate-button" style={{ width: 'auto' }} onClick={saveConfig} disabled={configSaving}>
|
||||
{configSaving ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-actions" style={{ marginTop: '1rem' }}>
|
||||
<button className="button back-button" onClick={onBackToConfig}>
|
||||
返回配置
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
287
src/components/SetupWizard.js
Normal file
287
src/components/SetupWizard.js
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useState } from 'react';
|
||||
import { getBackendHost } from '../utils/api';
|
||||
import '../styles/App.css';
|
||||
|
||||
const SetupWizard = ({ onSetupComplete }) => {
|
||||
const [step, setStep] = useState(1);
|
||||
const totalSteps = 3;
|
||||
|
||||
// --- Step 1: Database ---
|
||||
const [dbConfig, setDbConfig] = useState({
|
||||
host: 'db',
|
||||
port: 3306,
|
||||
user: 'dialectica',
|
||||
password: '',
|
||||
database: 'dialectica',
|
||||
});
|
||||
const [dbTestResult, setDbTestResult] = useState(null);
|
||||
const [dbTesting, setDbTesting] = useState(false);
|
||||
|
||||
// --- Step 2: Keycloak ---
|
||||
const [kcConfig, setKcConfig] = useState({
|
||||
host: '',
|
||||
realm: '',
|
||||
client_id: '',
|
||||
});
|
||||
const [kcTestResult, setKcTestResult] = useState(null);
|
||||
const [kcTesting, setKcTesting] = useState(false);
|
||||
|
||||
// --- Step 3: TLS ---
|
||||
const [tlsConfig, setTlsConfig] = useState({
|
||||
cert_path: '',
|
||||
key_path: '',
|
||||
force_https: false,
|
||||
});
|
||||
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
|
||||
const backend = getBackendHost();
|
||||
|
||||
// ---- handlers ----
|
||||
const handleDbChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setDbConfig((prev) => ({
|
||||
...prev,
|
||||
[name]: name === 'port' ? parseInt(value) || 0 : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKcChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setKcConfig((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleTlsChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setTlsConfig((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
// ---- test actions ----
|
||||
const testDb = async () => {
|
||||
setDbTesting(true);
|
||||
setDbTestResult(null);
|
||||
try {
|
||||
const resp = await fetch(`${backend}/api/setup/test-db`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dbConfig),
|
||||
});
|
||||
const data = await resp.json();
|
||||
setDbTestResult(data);
|
||||
} catch (err) {
|
||||
setDbTestResult({ success: false, message: err.message });
|
||||
} finally {
|
||||
setDbTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testKc = async () => {
|
||||
setKcTesting(true);
|
||||
setKcTestResult(null);
|
||||
try {
|
||||
const resp = await fetch(`${backend}/api/setup/test-keycloak`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(kcConfig),
|
||||
});
|
||||
const data = await resp.json();
|
||||
setKcTestResult(data);
|
||||
} catch (err) {
|
||||
setKcTestResult({ success: false, message: err.message });
|
||||
} finally {
|
||||
setKcTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- save & initialise ----
|
||||
const handleFinish = async () => {
|
||||
setInitializing(true);
|
||||
try {
|
||||
// Save config
|
||||
const payload = {
|
||||
database: dbConfig,
|
||||
keycloak: kcConfig.host ? kcConfig : null,
|
||||
tls: tlsConfig.cert_path ? tlsConfig : null,
|
||||
};
|
||||
const saveResp = await fetch(`${backend}/api/setup/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!saveResp.ok) {
|
||||
const err = await saveResp.json();
|
||||
alert(`保存配置失败: ${err.detail || saveResp.statusText}`);
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
const initResp = await fetch(`${backend}/api/setup/initialize`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!initResp.ok) {
|
||||
const err = await initResp.json();
|
||||
alert(`初始化失败: ${err.detail || initResp.statusText}`);
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onSetupComplete();
|
||||
} catch (err) {
|
||||
alert(`初始化失败: ${err.message}`);
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- render helpers ----
|
||||
const renderStepIndicator = () => (
|
||||
<div className="setup-steps">
|
||||
{['数据库', 'Keycloak', 'TLS 证书'].map((label, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`setup-step-indicator ${i + 1 === step ? 'active' : ''} ${i + 1 < step ? 'done' : ''}`}
|
||||
>
|
||||
<span className="setup-step-number">{i + 1 < step ? '\u2713' : i + 1}</span>
|
||||
<span className="setup-step-label">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDbStep = () => (
|
||||
<div className="setup-step-content">
|
||||
<h3>Step 1: 数据库配置</h3>
|
||||
<div className="form-group">
|
||||
<label className="label">主机</label>
|
||||
<input className="input" name="host" value={dbConfig.host} onChange={handleDbChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">端口</label>
|
||||
<input className="input" name="port" type="number" value={dbConfig.port} onChange={handleDbChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">用户名</label>
|
||||
<input className="input" name="user" value={dbConfig.user} onChange={handleDbChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">密码</label>
|
||||
<input className="input" name="password" type="password" value={dbConfig.password} onChange={handleDbChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">数据库名</label>
|
||||
<input className="input" name="database" value={dbConfig.database} onChange={handleDbChange} />
|
||||
</div>
|
||||
|
||||
<div className="setup-test-row">
|
||||
<button className="button" onClick={testDb} disabled={dbTesting}>
|
||||
{dbTesting ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
{dbTestResult && (
|
||||
<span className={`setup-test-result ${dbTestResult.success ? 'success' : 'fail'}`}>
|
||||
{dbTestResult.success ? '\u2713 ' : '\u2717 '}{dbTestResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderKcStep = () => (
|
||||
<div className="setup-step-content">
|
||||
<h3>Step 2: Keycloak 配置</h3>
|
||||
<p className="setup-hint">dev 模式下可跳过此步,直接点击"下一步"。</p>
|
||||
<div className="form-group">
|
||||
<label className="label">KC 地址</label>
|
||||
<input className="input" name="host" value={kcConfig.host} onChange={handleKcChange} placeholder="https://login.example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Realm</label>
|
||||
<input className="input" name="realm" value={kcConfig.realm} onChange={handleKcChange} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Client ID</label>
|
||||
<input className="input" name="client_id" value={kcConfig.client_id} onChange={handleKcChange} />
|
||||
</div>
|
||||
|
||||
<div className="setup-test-row">
|
||||
<button className="button" onClick={testKc} disabled={kcTesting || !kcConfig.host}>
|
||||
{kcTesting ? '测试中...' : '测试连通性'}
|
||||
</button>
|
||||
{kcTestResult && (
|
||||
<span className={`setup-test-result ${kcTestResult.success ? 'success' : 'fail'}`}>
|
||||
{kcTestResult.success ? '\u2713 ' : '\u2717 '}{kcTestResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTlsStep = () => (
|
||||
<div className="setup-step-content">
|
||||
<h3>Step 3: TLS 证书(可选)</h3>
|
||||
<p className="setup-hint">如不需要 HTTPS,可留空直接完成初始化。</p>
|
||||
<div className="form-group">
|
||||
<label className="label">证书路径</label>
|
||||
<input className="input" name="cert_path" value={tlsConfig.cert_path} onChange={handleTlsChange} placeholder="/etc/ssl/certs/cert.pem" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">私钥路径</label>
|
||||
<input className="input" name="key_path" value={tlsConfig.key_path} onChange={handleTlsChange} placeholder="/etc/ssl/private/key.pem" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label checkbox-label">
|
||||
<input type="checkbox" name="force_https" checked={tlsConfig.force_https} onChange={handleTlsChange} />
|
||||
强制 HTTPS
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="section setup-wizard">
|
||||
<h2>系统初始化</h2>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#718096' }}>
|
||||
首次部署,请完成以下配置以启动系统。
|
||||
</p>
|
||||
|
||||
{renderStepIndicator()}
|
||||
|
||||
{step === 1 && renderDbStep()}
|
||||
{step === 2 && renderKcStep()}
|
||||
{step === 3 && renderTlsStep()}
|
||||
|
||||
<div className="setup-nav">
|
||||
{step > 1 && (
|
||||
<button className="button back-button" onClick={() => setStep(step - 1)}>
|
||||
上一步
|
||||
</button>
|
||||
)}
|
||||
{step < totalSteps && (
|
||||
<button
|
||||
className="button create-debate-button"
|
||||
style={{ width: 'auto' }}
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={step === 1 && !dbConfig.host}
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
)}
|
||||
{step === totalSteps && (
|
||||
<button
|
||||
className="button create-debate-button"
|
||||
style={{ width: 'auto' }}
|
||||
onClick={handleFinish}
|
||||
disabled={initializing || !dbConfig.host}
|
||||
>
|
||||
{initializing ? '初始化中...' : '完成初始化'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupWizard;
|
||||
13
src/index.js
Normal file
13
src/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
792
src/styles/App.css
Normal file
792
src/styles/App.css
Normal file
@@ -0,0 +1,792 @@
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.App {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header styles */
|
||||
.app-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
margin-bottom: 2rem;
|
||||
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Header auth info (prod mode) */
|
||||
.header-auth {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-username {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-logout-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.header-logout-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Login prompt page */
|
||||
.login-prompt {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
max-width: 480px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.login-prompt h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.login-prompt p {
|
||||
color: #718096;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 0.85rem 2.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Main layout */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.input, .select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.input:focus, .select:focus {
|
||||
outline: none;
|
||||
border-color: #2575fc;
|
||||
box-shadow: 0 0 0 2px rgba(37, 117, 252, 0.2);
|
||||
}
|
||||
|
||||
.model-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.model-item {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stance-label {
|
||||
align-self: flex-start;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(37, 117, 252, 0.3);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.create-debate-button {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.1rem;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Debate section styles */
|
||||
.debate-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.start-debate-button {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
.stop-debate-button {
|
||||
background: linear-gradient(135deg, #ff416c 0%, #ff4b2b 100%);
|
||||
}
|
||||
|
||||
.view-sessions-button {
|
||||
background: linear-gradient(135deg, #4A00E0 0%, #8E2DE2 100%);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: linear-gradient(135deg, #4A00E0 0%, #8E2DE2 100%);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.debate-stream {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
min-height: 400px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.round-container {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
.round-container:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.speaker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.pro-speaker {
|
||||
background-color: #dbeafe;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.con-speaker {
|
||||
background-color: #ffe4e6;
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.speaker-info {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speaker-stance {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pro-stance {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.con-stance {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.speaker-content {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: white;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.debate-summary {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #f0f9ff;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #0ea5e9;
|
||||
}
|
||||
|
||||
.debate-summary h3 {
|
||||
color: #0ea5e9;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Sessions list styles */
|
||||
.sessions-list-item {
|
||||
padding: 1rem;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.session-topic {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.view-session-button {
|
||||
background: linear-gradient(135deg, #4A00E0 0%, #8E2DE2 100%);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Settings Page Styles */
|
||||
.settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.api-key-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.api-key-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.api-key-input-group label {
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.input-with-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-buttons .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-with-buttons .button {
|
||||
align-self: stretch;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
align-self: center;
|
||||
color: #38a169;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.validation-status {
|
||||
align-self: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.validation-status.valid {
|
||||
color: #38a169; /* Green for valid */
|
||||
}
|
||||
|
||||
.validation-status.invalid {
|
||||
color: #e53e3e; /* Red for invalid */
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Checkbox label */
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Search Evidence Styles */
|
||||
.search-evidence {
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.search-evidence-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #edf2f7;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.search-evidence-header:hover {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.search-evidence-icon {
|
||||
font-size: 0.7rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.search-evidence-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.search-evidence-badge {
|
||||
margin-left: auto;
|
||||
background-color: #bee3f8;
|
||||
color: #2b6cb0;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-evidence-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.search-evidence-query {
|
||||
font-size: 0.8rem;
|
||||
color: #718096;
|
||||
margin-bottom: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.search-evidence-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.search-evidence-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-evidence-link {
|
||||
color: #2b6cb0;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.search-evidence-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.search-evidence-snippet {
|
||||
font-size: 0.8rem;
|
||||
color: #4a5568;
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Evidence Library Styles */
|
||||
.evidence-library-panel {
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid #fbbf24;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #fffbeb;
|
||||
}
|
||||
|
||||
.evidence-library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #fef3c7;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.evidence-library-header:hover {
|
||||
background-color: #fde68a;
|
||||
}
|
||||
|
||||
.evidence-library-icon {
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.evidence-library-title {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.evidence-library-badge {
|
||||
margin-left: auto;
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.evidence-library-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.evidence-library-entry {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.evidence-library-entry:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.evidence-library-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-library-link {
|
||||
color: #b45309;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.evidence-library-link:hover {
|
||||
text-decoration: underline;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.evidence-library-score {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-library-snippet {
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-library-refs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.evidence-library-ref-tag {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.evidence-library-ref-tag.ref-pro {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.evidence-library-ref-tag.ref-con {
|
||||
background-color: #ffe4e6;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Setup Wizard Styles */
|
||||
.setup-wizard {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.setup-steps {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setup-step-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.setup-step-indicator.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setup-step-indicator.done {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.setup-step-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.setup-step-indicator.active .setup-step-number {
|
||||
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.setup-step-indicator.done .setup-step-number {
|
||||
background: #38a169;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.setup-step-label {
|
||||
font-size: 0.8rem;
|
||||
color: #4a5568;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-step-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
font-size: 0.9rem;
|
||||
color: #718096;
|
||||
margin-bottom: 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.setup-test-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.setup-test-result {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.setup-test-result.success {
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.setup-test-result.fail {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.setup-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 2rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Settings Tabs */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: #2575fc;
|
||||
border-bottom-color: #2575fc;
|
||||
}
|
||||
|
||||
.settings-tab-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Footer styles */
|
||||
.app-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0 1rem;
|
||||
color: #718096;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.App {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.model-selection {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.debate-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
55
src/utils/api.js
Normal file
55
src/utils/api.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Global API wrapper with 503 SERVICE_NOT_CONFIGURED interception.
|
||||
*
|
||||
* When the backend replies 503 with error_code "SERVICE_NOT_CONFIGURED",
|
||||
* a custom event "needs-setup" is dispatched on window so App.js can
|
||||
* switch to the SetupWizard view.
|
||||
*/
|
||||
|
||||
import { getAccessTokenGlobal } from '../components/AuthProvider';
|
||||
|
||||
// Backend host: use CRA env var REACT_APP_BACKEND_HOST, or default to localhost.
|
||||
let _backendHost = null;
|
||||
|
||||
export function getBackendHost() {
|
||||
if (_backendHost !== null) return _backendHost;
|
||||
_backendHost = process.env.REACT_APP_BACKEND_HOST || 'http://localhost:8000';
|
||||
return _backendHost;
|
||||
}
|
||||
|
||||
export function setBackendHost(host) {
|
||||
_backendHost = host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around fetch() that checks for the 503 "not configured"
|
||||
* response and fires a global event when detected.
|
||||
*/
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const url = path.startsWith('http') ? path : `${getBackendHost()}${path}`;
|
||||
|
||||
// Attach Authorization header when a token is available
|
||||
const token = getAccessTokenGlobal();
|
||||
if (token) {
|
||||
options.headers = { ...options.headers, Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
const resp = await fetch(url, options);
|
||||
|
||||
if (resp.status === 503) {
|
||||
// Clone so callers can still read the body if needed
|
||||
const cloned = resp.clone();
|
||||
try {
|
||||
const body = await cloned.json();
|
||||
if (body.error_code === 'SERVICE_NOT_CONFIGURED') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('needs-setup', { detail: body })
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
Reference in New Issue
Block a user