This commit is contained in:
h z
2026-02-13 03:42:04 +00:00
commit 29ae2ba1f4
15 changed files with 2840 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/.idea/

9
Dockerfile Normal file
View 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"]

1
index.js Normal file
View File

@@ -0,0 +1 @@
console.log('Happy developing ✨')

38
package.json Normal file
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View 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
View 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
View 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
View 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;
}