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
|
||||
RUN apk add --no-cache bash
|
||||
# Multi-stage build:
|
||||
# 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
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
ARG VITE_DIALECTIC_API_BASE=/api
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"name": "dialectic-frontend",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"private": true
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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