commit 29ae2ba1f4b1d7c1222c4da69290d2c3865d3c6c Author: hzhang Date: Fri Feb 13 03:42:04 2026 +0000 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85e7c1d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..952edce --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..04bf26e --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +console.log('Happy developing ✨') diff --git a/package.json b/package.json new file mode 100644 index 0000000..f735629 --- /dev/null +++ b/package.json @@ -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 +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4ae42e8 --- /dev/null +++ b/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Dialectic + + + +
+ + \ No newline at end of file diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..1b8463a --- /dev/null +++ b/src/App.js @@ -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 ( + + + } /> + } /> + } /> + + } + /> + + + ); +}; + +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 ( +
+
+

Dialectica - 多模型辩论框架

+

正在检查系统状态...

+
+
+ ); + } + + // --- Setup wizard --- + if (appState === 'setup') { + return ( +
+
+

Dialectica - 多模型辩论框架

+

系统初始化配置

+
+
+ +
+
+

Dialectica - 基于多模型的结构化辩论框架

+
+
+ ); + } + + // --- Ready (dev, or prod guest/authenticated) --- + const isGuest = isProd && !auth?.isAuthenticated; + const username = auth?.user?.profile?.preferred_username; + + return ( +
+
+

Dialectica - 多模型辩论框架

+

让不同大语言模型就特定议题进行结构化辩论

+ {isProd && ( +
+ {auth?.isAuthenticated ? ( + <> + {username} + + + ) : ( + + )} +
+ )} +
+ +
+ {currentView === 'configuration' && ( + + )} + + {currentView === 'debate' && ( + + )} + + {currentView === 'sessions' && ( + + )} + + {currentView === 'settings' && ( + + )} +
+ +
+

Dialectica - 基于多模型的结构化辩论框架

+
+
+ ); +}; + +export default App; diff --git a/src/components/AuthProvider.js b/src/components/AuthProvider.js new file mode 100644 index 0000000..eef9212 --- /dev/null +++ b/src/components/AuthProvider.js @@ -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 {children}; +}; + +/** + * 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 ( +
+

登录失败

+

{error}

+ 返回首页 +
+ ); + } + + return
登录中...
; +}; + +/** + * 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
登录中...
; +}; + +/** + * 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; diff --git a/src/components/DebateConfiguration.js b/src/components/DebateConfiguration.js new file mode 100644 index 0000000..187d6c0 --- /dev/null +++ b/src/components/DebateConfiguration.js @@ -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 ( +
+

辩论配置

+
+
+ + +
+ +
+ +
+
+ + + {modelsLoading[formData.proProvider] ? ( +
加载模型中...
+ ) : ( + + )} + + 正方 +
+ +
+ + + {modelsLoading[formData.conProvider] ? ( +
加载模型中...
+ ) : ( + + )} + + 反方 +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + {formData.webSearchEnabled && ( +
+ + +
+ )} +
+ + + {isGuest && ( +

+ 请先登录后再创建辩论 +

+ )} +
+ +
+ + {onViewSettings && ( + + )} +
+
+ ); +}; + +export default DebateConfiguration; \ No newline at end of file diff --git a/src/components/DebateDisplay.js b/src/components/DebateDisplay.js new file mode 100644 index 0000000..ec84e20 --- /dev/null +++ b/src/components/DebateDisplay.js @@ -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) => ( +

{paragraph}

+ )); + }; + + 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 ( +
+
toggleEvidence(roundKey)}> + {isExpanded ? '▼' : '▶'} + 网络搜索参考 + {evidence.results.length} 条结果 +
+ {isExpanded && ( +
+
搜索词: "{evidence.query}"
+ {evidence.results.map((result, i) => ( +
+ + {result.title} + +

{result.snippet}

+
+ ))} +
+ )} +
+ ); + }; + + const renderEvidenceLibrary = () => { + if (!evidenceLibrary || evidenceLibrary.length === 0) return null; + + return ( +
+
setEvidenceLibraryExpanded(!evidenceLibraryExpanded)} + > + {evidenceLibraryExpanded ? '▼' : '▶'} + 证据库 + {evidenceLibrary.length} 条来源 +
+ {evidenceLibraryExpanded && ( +
+ {evidenceLibrary.map((entry, i) => ( +
+
+ + {entry.title} + + {entry.score != null && ( + 相关度: {(entry.score * 100).toFixed(0)}% + )} +
+

{entry.snippet}

+
+ {entry.references.map((ref, j) => ( + + 第{ref.round_number}轮 · {ref.stance === 'pro' ? '正方' : '反方'} + + ))} +
+
+ ))} +
+ )} +
+ ); + }; + + 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 ( +
+
+
{round.speaker} ({round.round_number}轮)
+
{stanceText}
+
+
{round.content}
+ {renderSearchEvidence(round.search_evidence, roundKey)} +
+ ); + }; + + return ( +
+

辩论进行中

+
+ {!isGuest && ( + <> + + + + )} + +
+ +
+
+ {debateRounds.length > 0 ? ( + debateRounds.map((round, index) => addRoundToDisplay(round, index)) + ) : ( +

辩论尚未开始,点击"开始辩论"按钮启动辩论...

+ )} +
+ + {renderEvidenceLibrary()} + + {summary && ( +
+

辩论总结

+
+ {formatSummary(summary)} +
+
+ )} +
+
+ ); +}; + +export default DebateDisplay; \ No newline at end of file diff --git a/src/components/SessionsList.js b/src/components/SessionsList.js new file mode 100644 index 0000000..662109b --- /dev/null +++ b/src/components/SessionsList.js @@ -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 ( +
+

辩论会话列表

+

加载中...

+
+ ); + } + + if (error) { + return ( +
+

辩论会话列表

+

错误: {error}

+ +
+ ); + } + + return ( +
+

辩论会话列表

+
+ {sessions.length === 0 ? ( +

暂无辩论会话记录

+ ) : ( + sessions.map(session => ( +
+
{session.topic}
+
+ ID: {session.session_id.substring(0, 8)}... + 状态: {session.status} + 时间: {formatDate(session.created_at)} +
+
+ +
+
+ )) + )} +
+
+ +
+
+ ); +}; + +export default SessionsList; \ No newline at end of file diff --git a/src/components/Settings.js b/src/components/Settings.js new file mode 100644 index 0000000..e990e1c --- /dev/null +++ b/src/components/Settings.js @@ -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 = () => ( +
+
+ + setDbConfig(prev => ({ ...prev, host: e.target.value }))} /> +
+
+ + setDbConfig(prev => ({ ...prev, port: parseInt(e.target.value) || 0 }))} /> +
+
+ + setDbConfig(prev => ({ ...prev, user: e.target.value }))} /> +
+
+ + setDbConfig(prev => ({ ...prev, password: e.target.value || '********' }))} + /> +
+
+ + setDbConfig(prev => ({ ...prev, database: e.target.value }))} /> +
+
+ + {dbTestResult && ( + + {dbTestResult.success ? '\u2713 ' : '\u2717 '}{dbTestResult.message} + + )} +
+
+ ); + + const renderKeycloakTab = () => ( +
+
+ + setKcConfig(prev => ({ ...prev, host: e.target.value }))} placeholder="https://login.example.com" /> +
+
+ + setKcConfig(prev => ({ ...prev, realm: e.target.value }))} /> +
+
+ + setKcConfig(prev => ({ ...prev, client_id: e.target.value }))} /> +
+
+ + {kcTestResult && ( + + {kcTestResult.success ? '\u2713 ' : '\u2717 '}{kcTestResult.message} + + )} +
+
+ ); + + const renderTlsTab = () => ( +
+
+ + setTlsConfig(prev => ({ ...prev, cert_path: e.target.value }))} placeholder="/etc/ssl/certs/cert.pem" /> +
+
+ + setTlsConfig(prev => ({ ...prev, key_path: e.target.value }))} placeholder="/etc/ssl/private/key.pem" /> +
+
+ +
+
+ ); + + const renderApiKeysTab = () => ( +
+
+ {Object.entries(apiKeys).map(([provider, value]) => ( +
+ +
+ handleApiKeyChange(provider, e.target.value)} + placeholder={`Enter your ${provider} API key`} + /> + + {saved[provider] && \u2713 已保存} + {validationStatus[provider] && ( + + {validationStatus[provider].isValid ? '\u2713 有效' : `\u2717 无效: ${validationStatus[provider].message}`} + + )} +
+
+ ))} +
+
+ +
+
+ ); + + return ( +
+

系统设置

+ + {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === 'database' && renderDatabaseTab()} + {activeTab === 'keycloak' && renderKeycloakTab()} + {activeTab === 'tls' && renderTlsTab()} + {activeTab === 'apikeys' && renderApiKeysTab()} + + {/* Save / Back (for non-apikey tabs) */} + {activeTab !== 'apikeys' && ( +
+ +
+ )} + +
+ +
+
+ ); +}; + +export default Settings; diff --git a/src/components/SetupWizard.js b/src/components/SetupWizard.js new file mode 100644 index 0000000..dc8011a --- /dev/null +++ b/src/components/SetupWizard.js @@ -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 = () => ( +
+ {['数据库', 'Keycloak', 'TLS 证书'].map((label, i) => ( +
+ {i + 1 < step ? '\u2713' : i + 1} + {label} +
+ ))} +
+ ); + + const renderDbStep = () => ( +
+

Step 1: 数据库配置

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {dbTestResult && ( + + {dbTestResult.success ? '\u2713 ' : '\u2717 '}{dbTestResult.message} + + )} +
+
+ ); + + const renderKcStep = () => ( +
+

Step 2: Keycloak 配置

+

dev 模式下可跳过此步,直接点击"下一步"。

+
+ + +
+
+ + +
+
+ + +
+ +
+ + {kcTestResult && ( + + {kcTestResult.success ? '\u2713 ' : '\u2717 '}{kcTestResult.message} + + )} +
+
+ ); + + const renderTlsStep = () => ( +
+

Step 3: TLS 证书(可选)

+

如不需要 HTTPS,可留空直接完成初始化。

+
+ + +
+
+ + +
+
+ +
+
+ ); + + return ( +
+

系统初始化

+

+ 首次部署,请完成以下配置以启动系统。 +

+ + {renderStepIndicator()} + + {step === 1 && renderDbStep()} + {step === 2 && renderKcStep()} + {step === 3 && renderTlsStep()} + +
+ {step > 1 && ( + + )} + {step < totalSteps && ( + + )} + {step === totalSteps && ( + + )} +
+
+ ); +}; + +export default SetupWizard; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..bb5095e --- /dev/null +++ b/src/index.js @@ -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( + + + + + +); \ No newline at end of file diff --git a/src/styles/App.css b/src/styles/App.css new file mode 100644 index 0000000..2dda47d --- /dev/null +++ b/src/styles/App.css @@ -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%; + } +} \ No newline at end of file diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..e93c237 --- /dev/null +++ b/src/utils/api.js @@ -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; +}