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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1 +1,7 @@
|
|||||||
/.idea/
|
node_modules
|
||||||
|
dist
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
|||||||
30
Dockerfile
30
Dockerfile
@@ -1,8 +1,26 @@
|
|||||||
FROM node:21-alpine
|
# Multi-stage build:
|
||||||
RUN apk add --no-cache bash
|
# 1. node:alpine — install + vite build → dist/
|
||||||
|
# 2. nginx:alpine — serve dist/ + reverse-proxy /api/ to the backend
|
||||||
|
#
|
||||||
|
# Build args:
|
||||||
|
# VITE_DIALECTIC_API_BASE — defaults to '/api' (same-origin via nginx)
|
||||||
|
# VITE_OIDC_DEV_BYPASS — set to a token string to bake dev-bypass
|
||||||
|
# into the bundle (DO NOT set in prod images)
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm ci --no-audit --no-fund
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3000
|
ARG VITE_DIALECTIC_API_BASE=/api
|
||||||
CMD ["npm", "start"]
|
ARG VITE_OIDC_DEV_BYPASS=
|
||||||
|
ENV VITE_DIALECTIC_API_BASE=$VITE_DIALECTIC_API_BASE
|
||||||
|
ENV VITE_OIDC_DEV_BYPASS=$VITE_OIDC_DEV_BYPASS
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS serve
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1/index.html >/dev/null || exit 1
|
||||||
|
|||||||
84
README.md
Normal file
84
README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Dialectic.Frontend (v2)
|
||||||
|
|
||||||
|
Operator + observer SPA for [Dialectic v2](https://git.hangman-lab.top/hzhang/Dialectic).
|
||||||
|
|
||||||
|
**Readonly v1 scope** — humans browse debates, watch live transcripts,
|
||||||
|
and read verdicts. Propose / signup / post-argument are agent-only and
|
||||||
|
stay that way (use OpenClaw plugin tools).
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
|
||||||
|
- Vite + React 18 + TypeScript
|
||||||
|
- react-router-dom for client-side routing
|
||||||
|
- Pure ESM (`"type": "module"`)
|
||||||
|
- Style: [Hangman Lab](~/STYLE.md) dark "blueprint" theme — IBM Plex
|
||||||
|
Mono + Major Mono Display + acid `#d8ff3e` accent
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `/` | Topic list, filterable by status |
|
||||||
|
| `/topics/:id` | Topic detail + live transcript (8s polling) |
|
||||||
|
| `/topics/:id/verdict` | Verdict permalink (shareable) |
|
||||||
|
| `/agents/:id` | Admin diagnostics: key state, counts, recent topics |
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
v1 is dev-bypass only. Set `VITE_OIDC_DEV_BYPASS=<token>` at build time
|
||||||
|
to auto-attach `x-dev-bypass` on every request. Real OIDC + Keycloak
|
||||||
|
redirect lands as v2.
|
||||||
|
|
||||||
|
Admin pages (`/agents/:id`) call the backend's
|
||||||
|
`x-dialectic-admin-key`-gated endpoints. The frontend prompts on first
|
||||||
|
visit and stores the key in `localStorage` — never sent to non-admin
|
||||||
|
endpoints, never baked into the bundle.
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173; proxies /api → http://localhost:8090
|
||||||
|
npm run build # static bundle in dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the backend somewhere reachable from your dev machine — for sim:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_DIALECTIC_BACKEND=http://dind-t3:8090 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build / deploy
|
||||||
|
|
||||||
|
Multi-stage Docker — `node:alpine` build → `nginx:alpine` serve. The
|
||||||
|
nginx config reverse-proxies `/api/` to the `dialectic-backend`
|
||||||
|
compose service so the browser sees a single origin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t dialectic-frontend:dev .
|
||||||
|
docker run -p 8080:80 --network <compose-net> dialectic-frontend:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
For sim/prod, the umbrella `Dialectic/docker-compose.yaml` wires this
|
||||||
|
service into the same network as the backend.
|
||||||
|
|
||||||
|
## Source layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.tsx # entry — mounts <App> wrapped in BrowserRouter + AuthProvider
|
||||||
|
App.tsx # router + shell (header / nav / footer)
|
||||||
|
api.ts # thin fetch client; env-configurable base + dev-bypass
|
||||||
|
auth.tsx # AuthProvider — v1 is dev-bypass surfacing only
|
||||||
|
types.ts # backend response types (kept in sync by hand)
|
||||||
|
util.ts # fmtTime / fmtRelative — tiny date helpers
|
||||||
|
pages/
|
||||||
|
TopicList.tsx
|
||||||
|
TopicDetail.tsx
|
||||||
|
Verdict.tsx
|
||||||
|
AgentActivity.tsx
|
||||||
|
NotFound.tsx
|
||||||
|
styles/
|
||||||
|
tokens.css # Hangman Lab tokens — keep in sync with STYLE.md
|
||||||
|
app.css # layout + per-page styles
|
||||||
|
```
|
||||||
22
index.html
Normal file
22
index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Dialectic — agent-driven debate platform on Hangman Lab" />
|
||||||
|
<title>Dialectic — Hangman Lab</title>
|
||||||
|
|
||||||
|
<!-- Hangman Lab fonts (per ~/STYLE.md) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
nginx.conf
Normal file
40
nginx.conf
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# nginx config for the Dialectic SPA.
|
||||||
|
# - Static SPA assets under /; falls back to /index.html for client-side
|
||||||
|
# router (BrowserRouter needs all unknown paths to serve the shell).
|
||||||
|
# - /api/ reverse-proxies to the dialectic-backend container. The compose
|
||||||
|
# service name + port is settable via DIALECTIC_BACKEND env at run time
|
||||||
|
# (default = http://dialectic-backend:8090). We inject it via envsubst
|
||||||
|
# at container start so the same image works for sim + prod.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Long-lived caching for hashed asset bundles.
|
||||||
|
location /assets/ {
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: any unknown path serves index.html so React Router
|
||||||
|
# can take over client-side.
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API reverse-proxy. Same-origin from the browser's POV so we sidestep
|
||||||
|
# CORS. nginx upstream must point to the dialectic-backend service.
|
||||||
|
# Substitution happens at container start via envsubst (see Dockerfile
|
||||||
|
# entrypoint pattern; for v1 we hard-code the compose default).
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://dialectic-backend:8090/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
}
|
||||||
1831
package-lock.json
generated
Normal file
1831
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
52
package.json
@@ -1,37 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "Dialectic_Frontend",
|
"name": "dialectic-frontend",
|
||||||
"version": "1.0.0",
|
"private": true,
|
||||||
"description": "",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"type": "module",
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"oidc-client-ts": "^3.0.1",
|
|
||||||
"react-router-dom": "^6.28.0",
|
|
||||||
"react-scripts": "5.0.1"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"dev": "vite",
|
||||||
"build": "react-scripts build",
|
"build": "tsc -b && vite build",
|
||||||
"test": "react-scripts test",
|
"preview": "vite preview --port 5173",
|
||||||
"eject": "react-scripts eject"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"dependencies": {
|
||||||
"extends": [
|
"react": "^18.3.1",
|
||||||
"react-app",
|
"react-dom": "^18.3.1",
|
||||||
"react-app/jest"
|
"react-router-dom": "^6.28.0"
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"devDependencies": {
|
||||||
"production": [
|
"@types/node": "^22.10.1",
|
||||||
">0.2%",
|
"@types/react": "^18.3.12",
|
||||||
"not dead",
|
"@types/react-dom": "^18.3.1",
|
||||||
"not op_mini all"
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
],
|
"typescript": "^5.6.3",
|
||||||
"development": [
|
"vite": "^5.4.11"
|
||||||
"last 1 chrome version",
|
}
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"private": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Dialectic - 多模型辩论框架"
|
|
||||||
/>
|
|
||||||
<title>Dialectic</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
211
src/App.js
211
src/App.js
@@ -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
54
src/App.tsx
Normal 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
125
src/api.ts
Normal 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
33
src/auth.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
13
src/index.js
13
src/index.js
@@ -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
16
src/main.tsx
Normal 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
132
src/pages/AgentActivity.tsx
Normal 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
10
src/pages/NotFound.tsx
Normal 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
184
src/pages/TopicDetail.tsx
Normal 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
109
src/pages/TopicList.tsx
Normal 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
78
src/pages/Verdict.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
331
src/styles/app.css
Normal 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
202
src/styles/tokens.css
Normal 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
85
src/types.ts
Normal 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
28
src/util.ts
Normal 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`;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
13
tsconfig.node.json
Normal file
13
tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/api.ts","./src/auth.tsx","./src/main.tsx","./src/types.ts","./src/util.ts","./src/pages/AgentActivity.tsx","./src/pages/NotFound.tsx","./src/pages/TopicDetail.tsx","./src/pages/TopicList.tsx","./src/pages/Verdict.tsx"],"version":"5.9.3"}
|
||||||
2
vite.config.d.ts
vendored
Normal file
2
vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
21
vite.config.js
Normal file
21
vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
// Dev server proxies /api → the dialectic-backend so the SPA can run
|
||||||
|
// without CORS knobs at dev time. In prod the SPA + backend sit behind
|
||||||
|
// the same hostname (nginx rewrites /api/ → backend container).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_DIALECTIC_BACKEND ?? 'http://localhost:8090',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
22
vite.config.ts
Normal file
22
vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// Dev server proxies /api → the dialectic-backend so the SPA can run
|
||||||
|
// without CORS knobs at dev time. In prod the SPA + backend sit behind
|
||||||
|
// the same hostname (nginx rewrites /api/ → backend container).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_DIALECTIC_BACKEND ?? 'http://localhost:8090',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user