feat(frontend): v2 rewrite — Vite + React + TS readonly SPA

Replaces the v1 CRA app (which targeted the obsolete Python Dialectic
backend) with a fresh Vite + React 18 + TypeScript scaffold that talks
to Dialectic.Backend Go v2.

Pages (all readonly — propose/signup/post are agent-only by design):
  - /                       TopicList — filter by status, paginated
  - /topics/:id             TopicDetail — meta + camps + transcript
                            (polling every 8s)
  - /topics/:id/verdict     Verdict permalink (shareable)
  - /agents/:id             AgentActivity — admin diagnostics card

Stack:
  - Vite 5 + React 18 + react-router-dom 6
  - Pure ESM, NodeNext-style imports, .tsx
  - Style: ~/STYLE.md tokens (IBM Plex Mono + Major Mono Display +
    --acid #d8ff3e on --ink #080a0d, with subtle blueprint grid wash)

Auth:
  - v1 dev-bypass only — VITE_OIDC_DEV_BYPASS auto-attaches
    x-dev-bypass header. Real Keycloak OIDC redirect ships as v2.
  - Admin endpoints (x-dialectic-admin-key) prompt on first visit
    and store key in localStorage. Never baked into bundle. Never
    sent to non-admin endpoints.

Backend pairing:
  - Dialectic.Backend@0b16b52 adds GET /api/admin/agents/{id} for the
    AgentActivity page. AgentActivity calls it via the admin-key
    branch in api.ts.

Deploy:
  - Multi-stage Dockerfile (node:22-alpine build → nginx:1.27-alpine
    serve). nginx.conf reverse-proxies /api/ → dialectic-backend:8090
    so the browser sees one origin (no CORS).

Reuses the existing hzhang/Dialectic.Frontend repo — old CRA contents
nuked in this commit. History preserved on master.
This commit is contained in:
h z
2026-05-24 00:15:35 +01:00
parent f7c4ed9e3b
commit 3dbb5abaf6
38 changed files with 3497 additions and 2826 deletions

View File

@@ -1,211 +0,0 @@
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;

54
src/App.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { NavLink, Route, Routes } from 'react-router-dom';
import { useAuth } from './auth';
import { TopicListPage } from './pages/TopicList';
import { TopicDetailPage } from './pages/TopicDetail';
import { VerdictPage } from './pages/Verdict';
import { AgentActivityPage } from './pages/AgentActivity';
import { NotFoundPage } from './pages/NotFound';
import './styles/app.css';
export function App() {
const { user, devBypass } = useAuth();
return (
<div className="app">
<header className="app-header">
<div className="app-header-inner">
<a href="/" className="app-brand brand-d">
dialectic
</a>
<nav className="app-nav">
<NavLink to="/" end>
topics
</NavLink>
<NavLink to="/agents/recruiter">agents</NavLink>
</nav>
<div className="app-user">
{user ? (
<span className="eyebrow">{user.label}</span>
) : (
<span className="eyebrow">readonly</span>
)}
</div>
</div>
{devBypass && (
<div className="app-dev-banner">
DEV BYPASS ACTIVE auto-attaching <code>x-dev-bypass</code> on
every request; do not run this build in production
</div>
)}
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<TopicListPage />} />
<Route path="/topics/:id" element={<TopicDetailPage />} />
<Route path="/topics/:id/verdict" element={<VerdictPage />} />
<Route path="/agents/:id" element={<AgentActivityPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<footer className="app-footer">
<span className="eyebrow">hangman lab · dialectic v2</span>
</footer>
</div>
);
}

125
src/api.ts Normal file
View File

@@ -0,0 +1,125 @@
// Thin fetch wrapper for the Dialectic backend.
//
// Auth: this SPA targets human operators / observers. v1 auth model
// is dev-bypass only — `x-dev-bypass: <token>` is auto-attached when
// VITE_OIDC_DEV_BYPASS is set at build time. Real OIDC + Keycloak
// redirect ships as v2; dev-bypass covers the entire MVP scope.
//
// Backend base: configurable via VITE_DIALECTIC_API_BASE (default
// '/api', which works both behind the vite dev proxy and behind nginx
// in prod).
//
// Admin endpoints (under /api/admin/*) need a separate header
// `x-dialectic-admin-key`. The frontend reads it from
// localStorage['dialectic-admin-key'] (set via a one-liner in the
// AgentActivity page) so it never gets baked into the bundle.
import type {
Argument,
AgentSummary,
Topic,
TopicDetail,
TopicStatus,
Verdict,
Visibility,
} from './types';
const API_BASE = import.meta.env.VITE_DIALECTIC_API_BASE ?? '/api';
const DEV_BYPASS = import.meta.env.VITE_OIDC_DEV_BYPASS ?? '';
class HttpError extends Error {
constructor(public status: number, public body: string) {
super(`HTTP ${status}: ${body.slice(0, 200)}`);
}
}
async function request<T>(
method: string,
path: string,
opts: { body?: unknown; admin?: boolean; signal?: AbortSignal } = {},
): Promise<T> {
const headers: Record<string, string> = {};
if (DEV_BYPASS) headers['x-dev-bypass'] = DEV_BYPASS;
if (opts.admin) {
const adminKey = localStorage.getItem('dialectic-admin-key') ?? '';
if (adminKey) headers['x-dialectic-admin-key'] = adminKey;
}
if (opts.body !== undefined) headers['content-type'] = 'application/json';
const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new HttpError(res.status, text);
}
// Some endpoints (e.g. PATCH success) return empty body.
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
}
// --------------------------------------------------------------------
// Topics
export function listTopics(filter: {
status?: TopicStatus;
visibility?: Visibility;
limit?: number;
offset?: number;
signal?: AbortSignal;
}): Promise<{ topics: Topic[]; count: number }> {
const qs = new URLSearchParams();
if (filter.status) qs.set('status', filter.status);
if (filter.visibility) qs.set('visibility', filter.visibility);
if (filter.limit) qs.set('limit', String(filter.limit));
if (filter.offset) qs.set('offset', String(filter.offset));
const suffix = qs.toString() ? `?${qs}` : '';
return request('GET', `/topics${suffix}`, { signal: filter.signal });
}
export function getTopic(
id: string,
signal?: AbortSignal,
): Promise<TopicDetail> {
return request('GET', `/topics/${encodeURIComponent(id)}`, { signal });
}
export function listArguments(
topicId: string,
signal?: AbortSignal,
): Promise<{ arguments: Argument[]; count: number }> {
return request('GET', `/topics/${encodeURIComponent(topicId)}/arguments`, {
signal,
});
}
export function getVerdict(
topicId: string,
signal?: AbortSignal,
): Promise<Verdict | null> {
// 404 is "no verdict yet" — surface as null rather than throw.
return request<Verdict>('GET', `/topics/${encodeURIComponent(topicId)}/verdict`, {
signal,
}).catch((e) => {
if (e instanceof HttpError && e.status === 404) return null;
throw e;
});
}
// --------------------------------------------------------------------
// Admin
export function getAgentSummary(
agentId: string,
signal?: AbortSignal,
): Promise<AgentSummary> {
return request('GET', `/admin/agents/${encodeURIComponent(agentId)}`, {
admin: true,
signal,
});
}
export { HttpError };

33
src/auth.tsx Normal file
View File

@@ -0,0 +1,33 @@
// AuthProvider — minimal v1: dev-bypass only, no OIDC redirect yet.
//
// Real OIDC + Keycloak ships when the backend's OIDC middleware is
// fully wired (Phase 4 in DIALECTIC-V2-DESIGN.md). Until then this
// just surfaces whether dev-bypass is on so the UI can show a banner
// ("dev mode — running as operator") and route admin-gated pages.
import { createContext, useContext, type ReactNode } from 'react';
interface AuthCtx {
// Whether dev-bypass token is configured at build time.
devBypass: boolean;
// Display label for the "current user" — in dev-bypass mode this is
// the literal env value (a placeholder until real OIDC).
user: { id: string; label: string } | null;
}
const Ctx = createContext<AuthCtx>({
devBypass: false,
user: null,
});
export function AuthProvider({ children }: { children: ReactNode }) {
const devBypass = Boolean(import.meta.env.VITE_OIDC_DEV_BYPASS);
const user = devBypass
? { id: 'dev-operator', label: 'dev-operator (bypass)' }
: null;
return <Ctx.Provider value={{ devBypass, user }}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthCtx {
return useContext(Ctx);
}

View File

@@ -1,156 +0,0 @@
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

@@ -1,497 +0,0 @@
import React, { useState, useEffect } from 'react';
import './../styles/App.css';
import {getBackendHost} from "../utils/api";
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(`${getBackendHost()}/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

@@ -1,317 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import './../styles/App.css';
import {getBackendHost} from "../utils/api";
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(`${getBackendHost()}/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

@@ -1,96 +0,0 @@
import React, { useState, useEffect } from 'react';
import './../styles/App.css';
import {getBackendHost} from "../utils/api";
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(`${getBackendHost()}/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;

View File

@@ -1,352 +0,0 @@
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

@@ -1,287 +0,0 @@
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;

View File

@@ -1,13 +0,0 @@
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>
);

16
src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { AuthProvider } from './auth';
import './styles/tokens.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);

132
src/pages/AgentActivity.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getAgentSummary, HttpError } from '../api';
import type { AgentSummary } from '../types';
import { Link } from 'react-router-dom';
import { fmtRelative, fmtTime } from '../util';
export function AgentActivityPage() {
const { id } = useParams<{ id: string }>();
const [data, setData] = useState<AgentSummary | null>(null);
const [err, setErr] = useState<string | null>(null);
const [needsKey, setNeedsKey] = useState(false);
const [pendingKey, setPendingKey] = useState('');
useEffect(() => {
if (!id) return;
const ac = new AbortController();
getAgentSummary(id, ac.signal)
.then((d) => {
setData(d);
setErr(null);
setNeedsKey(false);
})
.catch((e) => {
if ((e as Error).name === 'AbortError') return;
if (e instanceof HttpError && (e.status === 401 || e.status === 403)) {
setNeedsKey(true);
setErr(null);
} else {
setErr((e as Error).message);
}
});
return () => ac.abort();
}, [id]);
function saveKey() {
if (!pendingKey.trim()) return;
localStorage.setItem('dialectic-admin-key', pendingKey.trim());
setPendingKey('');
window.location.reload();
}
if (!id) return <div className="err">missing agent id</div>;
return (
<div>
<span className="eyebrow">agent</span>
<h1>{id}</h1>
{needsKey && (
<div className="aa-admin-prompt">
<div>
this page calls an <strong>admin endpoint</strong> paste your
<code> x-dialectic-admin-key</code> (stored in localStorage; never
sent except to <code>/api/admin/*</code>).
</div>
<input
type="password"
value={pendingKey}
onChange={(e) => setPendingKey(e.target.value)}
placeholder="admin key…"
/>
<div className="row" style={{ marginTop: 8 }}>
<button onClick={saveKey}>save + reload</button>
<span className="muted">key never leaves this browser</span>
</div>
</div>
)}
{err && <div className="err">{err}</div>}
{data && (
<>
<div className="aa-summary-grid">
<div className="aa-card">
<div className="aa-card-value">
{data.key_provisioned ? 'yes' : 'no'}
</div>
<div className="aa-card-label">key provisioned</div>
</div>
<div className="aa-card">
<div className="aa-card-value">{data.signups_count}</div>
<div className="aa-card-label">signups</div>
</div>
<div className="aa-card">
<div className="aa-card-value">{data.arguments_count}</div>
<div className="aa-card-label">arguments</div>
</div>
<div className="aa-card">
<div className="aa-card-value">{data.verdicts_count}</div>
<div className="aa-card-label">verdicts</div>
</div>
</div>
<h2>recent topics</h2>
{data.recent_topics.length === 0 ? (
<div className="td-empty">no recent topics for this agent</div>
) : (
<table className="tl-table">
<thead>
<tr>
<th>topic</th>
<th>role</th>
<th>status</th>
<th>last action</th>
</tr>
</thead>
<tbody>
{data.recent_topics.map((t) => (
<tr key={t.topic_id}>
<td>
<Link to={`/topics/${t.topic_id}`}>{t.title}</Link>
</td>
<td>
<span className={`camp-${t.role}`}>{t.role}</span>
</td>
<td>
<span className={`pill pill-${t.status}`}>{t.status}</span>
</td>
<td title={fmtTime(t.last_action_at)}>
{fmtRelative(t.last_action_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
);
}

10
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
<div className="td-empty">
<h1 style={{ marginBottom: 16 }}>404</h1>
<p>no route. <Link to="/">back to topics</Link></p>
</div>
);
}

184
src/pages/TopicDetail.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getTopic, listArguments } from '../api';
import type { Argument, TopicDetail } from '../types';
import { fmtTime, fmtRelative } from '../util';
// Polling interval for the live transcript. 8s strikes a balance — fast
// enough that a new argument appears within an attentive viewer's
// attention span, slow enough that idle tabs don't hammer the backend.
const POLL_MS = 8000;
export function TopicDetailPage() {
const { id } = useParams<{ id: string }>();
const [topic, setTopic] = useState<TopicDetail | null>(null);
const [args, setArgs] = useState<Argument[]>([]);
const [err, setErr] = useState<string | null>(null);
const [lastTick, setLastTick] = useState<number>(0);
const stopped = useRef(false);
useEffect(() => {
if (!id) return;
stopped.current = false;
const ac = new AbortController();
async function tick() {
try {
const [t, a] = await Promise.all([
getTopic(id!, ac.signal),
listArguments(id!, ac.signal),
]);
if (stopped.current) return;
setTopic(t);
setArgs(a.arguments ?? []);
setLastTick(Date.now());
setErr(null);
} catch (e) {
const err = e as Error;
if (err.name !== 'AbortError') setErr(err.message);
}
}
tick();
const h = setInterval(tick, POLL_MS);
return () => {
stopped.current = true;
ac.abort();
clearInterval(h);
};
}, [id]);
if (!id) return <div className="err">missing topic id in url</div>;
if (err && !topic) return <div className="err">{err}</div>;
if (!topic) return <div className="muted">loading</div>;
const proCamp = topic.camps?.find((c) => c.camp === 'pro');
const conCamp = topic.camps?.find((c) => c.camp === 'con');
const judgeCamp = topic.camps?.find((c) => c.camp === 'judge');
return (
<div className="td">
<div>
<span className="eyebrow">
topic · <span className={`pill pill-${topic.status}`}>{topic.status}</span>
</span>
<h1>{topic.title}</h1>
<p className="td-summary">{topic.summary}</p>
</div>
<div className="panel">
<div className="panel-header">metadata</div>
<div className="td-meta">
<div>
<div className="label">visibility</div>
<div className="value">{topic.visibility}</div>
</div>
<div>
<div className="label">verdict schema</div>
<div className="value">{topic.verdict_schema_id}</div>
</div>
<div>
<div className="label">signup window</div>
<div className="value">
{fmtTime(topic.signup_open_at)} {fmtTime(topic.signup_close_at)}
</div>
</div>
<div>
<div className="label">debate window</div>
<div className="value">
{fmtTime(topic.debate_start_at)} {fmtTime(topic.debate_end_at)}
</div>
</div>
<div>
<div className="label">creator</div>
<div className="value">{topic.creator_user_id}</div>
</div>
{topic.cancelled_reason && (
<div>
<div className="label">cancelled reason</div>
<div className="value err" style={{ padding: '4px 8px' }}>
{topic.cancelled_reason}
</div>
</div>
)}
</div>
</div>
{topic.camps && topic.camps.length > 0 && (
<div className="panel">
<div className="panel-header">camps (allocated)</div>
<div className="td-camps">
<div className="td-camp">
<div className="td-camp-label camp-pro">pro</div>
<div className="td-camp-agent">
{proCamp ? (
<Link to={`/agents/${proCamp.agent_id}`}>{proCamp.agent_id}</Link>
) : (
<span className="muted"></span>
)}
</div>
</div>
<div className="td-camp">
<div className="td-camp-label camp-con">con</div>
<div className="td-camp-agent">
{conCamp ? (
<Link to={`/agents/${conCamp.agent_id}`}>{conCamp.agent_id}</Link>
) : (
<span className="muted"></span>
)}
</div>
</div>
<div className="td-camp">
<div className="td-camp-label camp-judge">judge</div>
<div className="td-camp-agent">
{judgeCamp ? (
<Link to={`/agents/${judgeCamp.agent_id}`}>{judgeCamp.agent_id}</Link>
) : (
<span className="muted"></span>
)}
</div>
</div>
</div>
</div>
)}
<div className="panel">
<div className="panel-header row">
<span>transcript ({args.length})</span>
<span className="spacer" />
<span className="muted">
polling every {POLL_MS / 1000}s · last refresh {fmtRelative(new Date(lastTick).toISOString())}
</span>
</div>
{args.length === 0 ? (
<div className="td-empty">no arguments posted yet</div>
) : (
args.map((a) => (
<div key={a.id} className={`td-arg td-arg-${a.camp}`}>
<div className="td-arg-meta row">
<span className={`camp-${a.camp}`}>{a.camp.toUpperCase()}</span>
<Link to={`/agents/${a.agent_id}`} className="muted">
{a.agent_id}
</Link>
<span className="spacer" />
<span className="muted" title={fmtTime(a.posted_at)}>
{fmtRelative(a.posted_at)}
</span>
</div>
<div className="td-arg-content">{a.content}</div>
</div>
))
)}
</div>
{topic.status === 'completed' && (
<div className="panel">
<div className="panel-header">verdict</div>
<Link to={`/topics/${topic.id}/verdict`}>view verdict + rationale </Link>
</div>
)}
{err && <div className="err">refresh error: {err}</div>}
</div>
);
}

109
src/pages/TopicList.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { listTopics } from '../api';
import type { Topic, TopicStatus } from '../types';
import { fmtTime, fmtRelative } from '../util';
const STATUS_OPTIONS: Array<TopicStatus | 'all'> = [
'all',
'signup_open',
'signup_closed',
'debating',
'completed',
'cancelled',
];
export function TopicListPage() {
const [status, setStatus] = useState<TopicStatus | 'all'>('all');
const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const ac = new AbortController();
setLoading(true);
setErr(null);
listTopics({
status: status === 'all' ? undefined : status,
limit: 100,
signal: ac.signal,
})
.then((r) => setTopics(r.topics))
.catch((e: Error) => {
if (e.name !== 'AbortError') setErr(e.message);
})
.finally(() => setLoading(false));
return () => ac.abort();
}, [status]);
return (
<div>
<span className="eyebrow">debates</span>
<h1>topics</h1>
<p className="muted" style={{ marginBottom: 20 }}>
Public + visible-to-you topics. Filter by lifecycle status. Click a row for the
live transcript.
</p>
<div className="tl-filters">
<label>
status
<select
value={status}
onChange={(e) => setStatus(e.target.value as TopicStatus | 'all')}
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<span className="spacer" />
<span className="muted">
{loading ? 'loading…' : `${topics.length} ${topics.length === 1 ? 'topic' : 'topics'}`}
</span>
</div>
{err && <div className="err">{err}</div>}
{!err && !loading && topics.length === 0 && (
<div className="td-empty">no topics match this filter</div>
)}
{topics.length > 0 && (
<table className="tl-table">
<thead>
<tr>
<th style={{ width: '60%' }}>topic</th>
<th>status</th>
<th>signup close</th>
<th>debate window</th>
</tr>
</thead>
<tbody>
{topics.map((t) => (
<tr key={t.id}>
<td>
<Link to={`/topics/${t.id}`} className="tl-row-title">
{t.title}
</Link>
<div className="tl-row-summary">{t.summary}</div>
</td>
<td>
<span className={`pill pill-${t.status}`}>{t.status}</span>
</td>
<td className="tl-row-time" title={fmtTime(t.signup_close_at)}>
{fmtRelative(t.signup_close_at)}
</td>
<td className="tl-row-time" title={`${fmtTime(t.debate_start_at)}${fmtTime(t.debate_end_at)}`}>
{fmtTime(t.debate_start_at)} {fmtTime(t.debate_end_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

78
src/pages/Verdict.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getTopic, getVerdict } from '../api';
import type { TopicDetail, Verdict } from '../types';
import { fmtTime } from '../util';
export function VerdictPage() {
const { id } = useParams<{ id: string }>();
const [topic, setTopic] = useState<TopicDetail | null>(null);
const [verdict, setVerdict] = useState<Verdict | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
const ac = new AbortController();
setLoading(true);
Promise.all([getTopic(id, ac.signal), getVerdict(id, ac.signal)])
.then(([t, v]) => {
setTopic(t);
setVerdict(v);
setErr(null);
})
.catch((e: Error) => {
if (e.name !== 'AbortError') setErr(e.message);
})
.finally(() => setLoading(false));
return () => ac.abort();
}, [id]);
if (!id) return <div className="err">missing topic id</div>;
if (loading) return <div className="muted">loading</div>;
if (err) return <div className="err">{err}</div>;
if (!topic) return <div className="err">topic not found</div>;
return (
<div className="td">
<div>
<span className="eyebrow">verdict permalink</span>
<h1>{topic.title}</h1>
<p className="td-summary">{topic.summary}</p>
<p className="muted" style={{ marginTop: 8 }}>
schema: <code>{topic.verdict_schema_id}</code>
{' · '}debate window: {fmtTime(topic.debate_start_at)} {fmtTime(topic.debate_end_at)}
{' · '}
<Link to={`/topics/${topic.id}`}>back to topic</Link>
</p>
</div>
{!verdict ? (
<div className="panel td-empty">
no verdict yet topic status is <span className={`pill pill-${topic.status}`}>{topic.status}</span>
{topic.status === 'debating' && ', judge has not submitted'}
{topic.status === 'cancelled' && ', topic was cancelled before a verdict could be reached'}
</div>
) : (
<>
<div className="panel">
<div className="panel-header">verdict (structured)</div>
<pre className="vd-json">{JSON.stringify(verdict.verdict, null, 2)}</pre>
<div className="row muted" style={{ marginTop: 8, fontSize: '0.85em' }}>
<span>judge: <Link to={`/agents/${verdict.judge_agent_id}`}>{verdict.judge_agent_id}</Link></span>
<span className="spacer" />
<span>produced: {fmtTime(verdict.produced_at)}</span>
{(verdict.tokens_input + verdict.tokens_output) > 0 && (
<span>tokens: {verdict.tokens_input} in / {verdict.tokens_output} out</span>
)}
</div>
</div>
<div className="panel">
<div className="panel-header">rationale</div>
<div className="vd-rationale">{verdict.rationale}</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,792 +0,0 @@
/* 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%;
}
}

331
src/styles/app.css Normal file
View File

@@ -0,0 +1,331 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
/* Subtle blueprint grid wash on the body, per ~/STYLE.md. */
background-image: radial-gradient(
1000px 560px at 78% -8%,
rgba(216, 255, 62, 0.06),
transparent 60%
),
repeating-linear-gradient(
0deg,
transparent 0 47px,
rgba(29, 39, 51, 0.35) 47px 48px
),
repeating-linear-gradient(
90deg,
transparent 0 47px,
rgba(29, 39, 51, 0.35) 47px 48px
);
}
.app-header {
background: var(--chrome);
border-bottom: 1px solid var(--line);
}
.app-header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 24px;
padding: 14px 24px;
}
.app-brand {
font-size: 1.6rem;
color: var(--acid);
text-shadow: 0 0 24px rgba(216, 255, 62, 0.25);
text-decoration: none;
border: none;
}
.app-brand:hover {
border: none;
}
.app-nav {
display: flex;
gap: 18px;
margin-left: 24px;
}
.app-nav a {
font-size: 0.85rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--dim);
border: none;
padding: 4px 0;
}
.app-nav a:hover {
color: var(--text);
border: none;
}
.app-nav a.active {
color: var(--acid);
border-bottom: 1px solid var(--acid);
}
.app-user {
margin-left: auto;
}
.app-dev-banner {
background: rgba(255, 90, 82, 0.1);
color: var(--danger);
border-top: 1px solid rgba(255, 90, 82, 0.4);
padding: 6px 24px;
font-size: 0.75rem;
letter-spacing: 0.12em;
text-align: center;
}
.app-dev-banner code {
background: rgba(255, 90, 82, 0.18);
padding: 0 4px;
border-radius: 2px;
}
.app-main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 24px;
}
.app-footer {
border-top: 1px solid var(--line);
background: var(--chrome);
padding: 12px 24px;
text-align: center;
}
/* ----- Topic list ----- */
.tl-filters {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
flex-wrap: wrap;
}
.tl-filters label {
display: flex;
align-items: center;
gap: 8px;
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.tl-table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--line);
}
.tl-table th,
.tl-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.tl-table th {
background: var(--panel-2);
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--dim);
font-weight: 500;
}
.tl-table tr:hover td {
background: var(--panel-3);
}
.tl-row-title {
font-weight: 500;
color: var(--text-strong);
}
.tl-row-summary {
color: var(--dim);
font-size: 0.85em;
margin-top: 4px;
max-width: 600px;
}
.tl-row-time {
color: var(--text-2);
font-size: 0.85em;
white-space: nowrap;
}
/* ----- Topic detail ----- */
.td {
display: grid;
gap: 16px;
grid-template-columns: 1fr;
}
.td-meta {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
}
.td-meta .label {
color: var(--dim);
font-size: 0.75rem;
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: 4px;
}
.td-meta .value {
color: var(--text);
font-size: 0.95em;
}
.td-summary {
color: var(--text-1);
line-height: 1.6;
}
.td-camps {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.td-camp {
background: var(--panel-2);
padding: 8px 12px;
border-radius: var(--radius);
border: 1px solid var(--line);
min-width: 140px;
}
.td-camp-label {
font-size: 0.75rem;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.td-camp-agent {
margin-top: 4px;
color: var(--text-strong);
}
.td-arg {
border-left: 3px solid var(--line);
padding: 12px 16px;
margin-bottom: 12px;
background: var(--panel);
}
.td-arg-pro {
border-left-color: var(--ok);
}
.td-arg-con {
border-left-color: var(--danger);
}
.td-arg-judge {
border-left-color: var(--acid);
}
.td-arg-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
font-size: 0.82em;
}
.td-arg-content {
white-space: pre-wrap;
color: var(--text);
font-size: 0.95em;
line-height: 1.6;
}
.td-empty {
padding: 32px;
text-align: center;
color: var(--dim);
}
/* ----- Verdict ----- */
.vd-rationale {
white-space: pre-wrap;
color: var(--text);
line-height: 1.7;
}
.vd-json {
background: var(--ink);
border: 1px solid var(--line);
padding: 12px;
border-radius: var(--radius);
font-size: 0.9em;
overflow-x: auto;
color: var(--acid);
}
/* ----- AgentActivity ----- */
.aa-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aa-card {
background: var(--panel);
border: 1px solid var(--line);
padding: 14px;
border-radius: var(--radius);
text-align: center;
}
.aa-card-value {
font-family: var(--font-d);
font-size: 2.4rem;
color: var(--acid);
text-transform: lowercase;
}
.aa-card-label {
margin-top: 4px;
font-size: 0.75rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--dim);
}
.aa-admin-prompt {
background: var(--panel);
border: 1px solid var(--warn);
padding: 14px;
margin-bottom: 16px;
border-radius: var(--radius);
}
.aa-admin-prompt input {
width: 100%;
margin-top: 8px;
font-family: var(--font-m);
}
/* shared util */
.row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.spacer {
flex: 1;
}
.muted {
color: var(--dim);
}
.err {
color: var(--danger);
background: rgba(255, 90, 82, 0.08);
border: 1px solid rgba(255, 90, 82, 0.4);
padding: 12px;
border-radius: var(--radius);
font-family: var(--font-m);
}
h1 {
font-family: var(--font-d);
text-transform: lowercase;
letter-spacing: 0.02em;
font-size: 2rem;
color: var(--text-strong);
margin-bottom: 6px;
font-weight: 400;
}
h2 {
font-size: 0.95rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-1);
margin: 16px 0 8px;
font-weight: 500;
}

202
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,202 @@
/* Hangman Lab design tokens — per ~/STYLE.md
* Dark "blueprint" surface, monospace everything, one sharp acid accent.
* If you change these, propagate to the Gitea theme too (single source of
* truth lives in STYLE.md). */
:root {
/* Surfaces */
--ink: #080a0d; /* page background */
--panel: #0f141b; /* cards / box body */
--panel-2: #11161d; /* card header / raised */
--panel-3: #141b24; /* hovered card */
--chrome: #0b0e12; /* navbar / footer */
--line: #1d2733; /* borders / dividers */
/* Text ramp */
--text: #cdd8e8; /* primary text */
--text-strong: #e9f0fa;
--text-1: #b6c2d4;
--text-2: #9aa8bd;
--dim: #647691; /* muted / eyebrow */
/* Accent — THE acid */
--acid: #d8ff3e;
--acid-dim: #bbe233;
--on-acid: #0c0f08;
--acid-wash: #d8ff3e14;
--acid-active: #d8ff3e1f;
/* Status / semantic */
--danger: #ff5a52;
--warn: #f5a623;
--ok: #5fe07a;
/* Fonts */
--font-d: "Major Mono Display", monospace;
--font-m: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* Layout */
--radius: 4px;
--shadow-card: 0 0 0 1px var(--line);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100vh;
background: var(--ink);
color: var(--text);
font-family: var(--font-m);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a {
color: var(--acid);
text-decoration: none;
border-bottom: 1px dashed transparent;
transition: border-color 120ms ease;
}
a:hover {
border-bottom-color: var(--acid);
}
button {
font-family: inherit;
font-size: inherit;
background: transparent;
color: var(--text);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 6px 12px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
button:hover {
background: var(--acid-wash);
border-color: var(--acid);
color: var(--acid);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
background: var(--ink);
color: var(--text);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 6px 10px;
}
input:focus,
select:focus,
textarea:focus {
outline: 1px solid var(--acid);
outline-offset: 1px;
border-color: var(--acid);
}
/* Selection — acid wash, ink ink */
::selection {
background: var(--acid);
color: var(--on-acid);
}
/* Scrollbar — dark + thin */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--ink);
}
::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background: var(--dim);
}
/* Brand mark (Major Mono Display) — lowercase always */
.brand-d {
font-family: var(--font-d);
text-transform: lowercase;
letter-spacing: 0.02em;
}
/* Eyebrow text — uppercase, wide spacing, dim */
.eyebrow {
font-size: 0.78rem;
letter-spacing: 0.42em;
text-transform: uppercase;
color: var(--dim);
display: inline-flex;
align-items: center;
gap: 8px;
}
.eyebrow::before {
content: "";
width: 8px;
height: 8px;
background: var(--acid);
border-radius: 50%;
animation: hlx-pulse 2s ease-in-out infinite;
}
@keyframes hlx-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
/* Card / panel */
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px;
}
.panel-header {
background: var(--panel-2);
border-bottom: 1px solid var(--line);
padding: 12px 16px;
margin: -16px -16px 16px;
border-radius: var(--radius) var(--radius) 0 0;
font-size: 0.85rem;
color: var(--text-1);
}
/* Status pill */
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--panel-2);
color: var(--text-1);
border: 1px solid var(--line);
}
.pill-debating { color: var(--acid); border-color: var(--acid); }
.pill-completed { color: var(--ok); border-color: var(--ok); }
.pill-cancelled { color: var(--danger); border-color: var(--danger); }
.pill-signup_open { color: var(--text-strong); }
.pill-signup_closed { color: var(--text-1); }
/* Camp tags */
.camp-pro { color: var(--ok); }
.camp-con { color: var(--danger); }
.camp-judge { color: var(--acid); }

85
src/types.ts Normal file
View File

@@ -0,0 +1,85 @@
// Mirrors the Dialectic.Backend models — kept in sync by hand. If a
// field is added on the backend (models/topic.go / store responses),
// also add it here so the UI can use it.
export type TopicStatus =
| 'created'
| 'signup_open'
| 'signup_closed'
| 'debating'
| 'completed'
| 'cancelled';
export type Visibility = 'public' | 'private';
export type Camp = 'pro' | 'con' | 'judge';
export interface Topic {
id: string;
title: string;
summary: string;
visibility: Visibility;
verdict_schema_id: string;
status: TopicStatus;
signup_open_at: string;
signup_close_at: string;
debate_start_at: string;
debate_end_at: string;
creator_user_id: string;
visibility_changed_by?: string | null;
visibility_changed_at?: string | null;
cancelled_reason?: string | null;
created_at: string;
updated_at: string;
}
export interface CampAllocation {
id: string;
topic_id: string;
camp: Camp;
agent_id: string;
allocated_at: string;
}
// GET /api/topics/{id} returns the full Topic spread + `camps` sibling.
export interface TopicDetail extends Topic {
camps: CampAllocation[] | null;
}
export interface Argument {
id: string;
topic_id: string;
round_id: string;
camp: Camp;
agent_id: string;
content: string;
posted_at: string;
}
export interface Verdict {
id: string;
topic_id: string;
judge_agent_id: string;
// verdict shape depends on the topic's verdict_schema_id; UI shows raw JSON.
verdict: Record<string, unknown>;
rationale: string;
tokens_input: number;
tokens_output: number;
produced_at: string;
}
// Admin endpoint (next commit) shape — declare now so frontend compiles.
export interface AgentSummary {
agent_id: string;
key_provisioned: boolean;
signups_count: number;
arguments_count: number;
verdicts_count: number;
recent_topics: Array<{
topic_id: string;
title: string;
status: TopicStatus;
role: Camp | 'volunteer';
last_action_at: string;
}>;
}

28
src/util.ts Normal file
View File

@@ -0,0 +1,28 @@
// Tiny date helper — show RFC3339 timestamps in a compact "UTC" form.
// Avoids pulling in a full Date/intl library; UI is operator-facing
// (not end-user-localized) so UTC is the safer default.
export function fmtTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`
);
}
export function fmtRelative(iso: string | null | undefined): string {
if (!iso) return '—';
const ms = Date.now() - new Date(iso).getTime();
if (isNaN(ms)) return iso;
const abs = Math.abs(ms);
const sec = Math.round(abs / 1000);
if (sec < 60) return ms >= 0 ? `${sec}s ago` : `in ${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return ms >= 0 ? `${min}m ago` : `in ${min}m`;
const hr = Math.round(min / 60);
if (hr < 24) return ms >= 0 ? `${hr}h ago` : `in ${hr}h`;
const day = Math.round(hr / 24);
return ms >= 0 ? `${day}d ago` : `in ${day}d`;
}

View File

@@ -1,48 +0,0 @@
/**
* 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';
let _backendHost = null;
export function getBackendHost() {
if (_backendHost !== null) return _backendHost;
_backendHost = process.env.REACT_APP_DIALECTIC_BACKEND_HOST || 'http://localhost:8000';
return _backendHost;
}
export function setBackendHost(host) {
_backendHost = host;
}
export async function apiFetch(path, options = {}) {
const url = path.startsWith('http') ? path : `${getBackendHost()}${path}`;
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;
}