- auth.py: replace synchronous httpx.get (blocked event loop) with async httpx.AsyncClient; add key-rotation refresh on unknown kid - App.tsx: use onRedirectNavigate: false so Sign out clears only the local MSAL session without redirecting to Microsoft logout endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
242 lines
8.4 KiB
TypeScript
242 lines
8.4 KiB
TypeScript
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
|
|
import { useEffect, useState } from 'react';
|
|
import { useMsal } from '@azure/msal-react';
|
|
import { AuthProvider } from './auth/AuthProvider';
|
|
import api from './api/client';
|
|
import Dashboard from './pages/Dashboard';
|
|
import NewProject from './pages/NewProject';
|
|
import ProjectView from './pages/ProjectView';
|
|
import GmalBrowser from './pages/GmalBrowser';
|
|
import Help from './pages/Help';
|
|
import GmalEditor from './pages/GmalEditor';
|
|
import './App.css';
|
|
|
|
const navItems = [
|
|
{ path: '/', label: 'Projects' },
|
|
{ path: '/gmal', label: 'GMAL Browser' },
|
|
{ path: '/gmal-editor', label: 'GMAL Editor' },
|
|
{ path: '/help', label: 'Help' },
|
|
];
|
|
|
|
interface AiUsage {
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
total_cost_usd: number;
|
|
call_count: number;
|
|
}
|
|
|
|
interface DebugEntry {
|
|
timestamp: string;
|
|
model: string;
|
|
system_prompt: string;
|
|
user_message_length: number;
|
|
user_message_preview: string;
|
|
tools: string[];
|
|
tool_choice: any;
|
|
status: string;
|
|
stop_reason?: string;
|
|
response_parts?: Array<{ type: string; text?: string; tool?: string; input_preview?: string }>;
|
|
tool_results_count?: number;
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
cost_usd?: number;
|
|
error?: string;
|
|
}
|
|
|
|
function AiCostTracker() {
|
|
const [usage, setUsage] = useState<AiUsage | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadUsage();
|
|
const interval = setInterval(loadUsage, 5000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
async function loadUsage() {
|
|
try {
|
|
const res = await api.get('/ai/usage');
|
|
setUsage(res.data);
|
|
} catch {}
|
|
}
|
|
|
|
if (!usage) return null;
|
|
|
|
return (
|
|
<div className="ai-tracker">
|
|
<div className="ai-tracker-label">AI Cost</div>
|
|
<div className="ai-tracker-cost">${usage.total_cost_usd.toFixed(4)}</div>
|
|
<div className="ai-tracker-detail">
|
|
{usage.call_count} calls · {(usage.total_input_tokens / 1000).toFixed(1)}k in · {(usage.total_output_tokens / 1000).toFixed(1)}k out
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DebugPanel() {
|
|
const [open, setOpen] = useState(false);
|
|
const [entries, setEntries] = useState<DebugEntry[]>([]);
|
|
const [expanded, setExpanded] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
loadDebug();
|
|
const interval = setInterval(loadDebug, 3000);
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [open]);
|
|
|
|
async function loadDebug() {
|
|
try {
|
|
const res = await api.get('/ai/debug');
|
|
setEntries(res.data);
|
|
} catch {}
|
|
}
|
|
|
|
return (
|
|
<div className={`debug-panel ${open ? 'debug-open' : ''}`}>
|
|
<button className="debug-toggle" onClick={() => setOpen(!open)}>
|
|
{open ? 'Hide' : 'Show'} AI Debug ({entries.length})
|
|
</button>
|
|
{open && (
|
|
<div className="debug-content">
|
|
{entries.length === 0 ? (
|
|
<div className="debug-empty">No AI calls yet. Upload a document or run matching.</div>
|
|
) : (
|
|
[...entries].reverse().map((e, idx) => {
|
|
const realIdx = entries.length - 1 - idx;
|
|
const isExpanded = expanded === realIdx;
|
|
return (
|
|
<div key={realIdx} className={`debug-entry debug-${e.status}`}>
|
|
<div className="debug-entry-header" onClick={() => setExpanded(isExpanded ? null : realIdx)}>
|
|
<span className={`debug-status debug-status-${e.status}`}>
|
|
{e.status === 'success' ? 'OK' : 'ERR'}
|
|
</span>
|
|
<span className="debug-time">{new Date(e.timestamp + 'Z').toLocaleTimeString()}</span>
|
|
<span className="debug-tools">{e.tools.join(', ') || 'no tools'}</span>
|
|
{e.input_tokens != null && (
|
|
<span className="debug-tokens">
|
|
{e.input_tokens} in / {e.output_tokens} out · ${e.cost_usd?.toFixed(4)}
|
|
</span>
|
|
)}
|
|
<span className="debug-reason">{e.stop_reason || ''}</span>
|
|
<span className="debug-expand">{isExpanded ? '▼' : '▶'}</span>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="debug-entry-body">
|
|
<div className="debug-section">
|
|
<div className="debug-label">System Prompt</div>
|
|
<pre className="debug-pre">{e.system_prompt}</pre>
|
|
</div>
|
|
|
|
<div className="debug-section">
|
|
<div className="debug-label">User Message ({e.user_message_length.toLocaleString()} chars)</div>
|
|
<pre className="debug-pre">{e.user_message_preview}</pre>
|
|
</div>
|
|
|
|
{e.tool_choice && (
|
|
<div className="debug-section">
|
|
<div className="debug-label">Tool Choice</div>
|
|
<pre className="debug-pre">{JSON.stringify(e.tool_choice)}</pre>
|
|
</div>
|
|
)}
|
|
|
|
{e.response_parts && e.response_parts.length > 0 && (
|
|
<div className="debug-section">
|
|
<div className="debug-label">Response ({e.response_parts.length} parts)</div>
|
|
{e.response_parts.map((part, pi) => (
|
|
<div key={pi} className="debug-response-part">
|
|
<span className="debug-part-type">{part.type}</span>
|
|
{part.type === 'text' && <pre className="debug-pre">{part.text}</pre>}
|
|
{part.type === 'tool_use' && (
|
|
<div>
|
|
<div className="debug-tool-name">Tool: {part.tool}</div>
|
|
<pre className="debug-pre">{part.input_preview}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{e.error && (
|
|
<div className="debug-section">
|
|
<div className="debug-label">Error</div>
|
|
<pre className="debug-pre debug-error-text">{e.error}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NavBar() {
|
|
const location = useLocation();
|
|
const { instance, accounts } = useMsal();
|
|
const user = accounts[0];
|
|
|
|
function handleLogout() {
|
|
// Sign out from the app only — does not sign out of the Microsoft account
|
|
instance.logoutRedirect({ onRedirectNavigate: () => false });
|
|
}
|
|
|
|
return (
|
|
<nav className="nav">
|
|
<div className="nav-inner">
|
|
<Link to="/" className="logo">
|
|
<span className="logo-icon">S</span>
|
|
Scope Builder
|
|
</Link>
|
|
<div className="nav-links">
|
|
{navItems.map(item => (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
className={`nav-link ${location.pathname === item.path ? 'nav-link-active' : ''}`}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
<div className="nav-spacer" />
|
|
<AiCostTracker />
|
|
{user && (
|
|
<div className="nav-user">
|
|
<span className="nav-user-name">{user.name || user.username}</span>
|
|
<button className="nav-logout" onClick={handleLogout}>Sign out</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<BrowserRouter basename="/gsb">
|
|
<div className="app">
|
|
<NavBar />
|
|
<main className="main">
|
|
<Routes>
|
|
<Route path="/" element={<Dashboard />} />
|
|
<Route path="/new" element={<NewProject />} />
|
|
<Route path="/projects/:id/*" element={<ProjectView />} />
|
|
<Route path="/gmal" element={<GmalBrowser />} />
|
|
<Route path="/gmal-editor" element={<GmalEditor />} />
|
|
<Route path="/help" element={<Help />} />
|
|
</Routes>
|
|
</main>
|
|
<DebugPanel />
|
|
</div>
|
|
</BrowserRouter>
|
|
</AuthProvider>
|
|
);
|
|
}
|