update for mcp

This commit is contained in:
wangwei
2026-08-06 11:08:46 +08:00
parent 31bbf80aeb
commit b2feaeddb4
40 changed files with 1986 additions and 202 deletions
+33
View File
@@ -52,6 +52,39 @@ export interface AnalysisSSEMessage {
text?: string;
}
export interface PerceptionNotification {
id: number;
event_id: string;
kind: 'new' | 'changed';
title: string;
impact_level: string | null;
summary: string | null;
created_at: string;
read: boolean;
}
export interface NotificationListResponse {
items: PerceptionNotification[];
unread_count: number;
}
/** Broadcast feed shared by every logged-in user; read state is per-caller. */
export async function getNotifications(limit = 20): Promise<NotificationListResponse> {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/notifications?limit=${limit}`, { headers: authHeader() });
if (!res.ok) throw new Error(`notifications failed: ${res.status}`);
return res.json() as Promise<NotificationListResponse>;
}
/** Marks every currently-unread notification read for the calling user. */
export async function markNotificationsRead(): Promise<{ marked: number }> {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/notifications/read`, {
method: 'POST',
headers: authHeader(),
});
if (!res.ok) throw new Error(`mark read failed: ${res.status}`);
return res.json() as Promise<{ marked: number }>;
}
export async function getPerceptionStats(): Promise<PerceptionStats> {
const res = await fetch(`${PERCEPTION_API_BASE}/perception/stats`, { headers: authHeader() });
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
+22 -1
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard, Radio, Monitor, FileText,
@@ -6,6 +7,12 @@ import {
import { useTheme } from '../../contexts/ThemeContext';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';
import { getNotifications } from '../../api/perception';
// How often the sidebar re-checks the unread count. A plain UI refresh
// cadence, not an infrastructure setting — unlike the crawl interval, this
// never needs to be tuned per deployment.
const UNREAD_POLL_MS = 60_000;
interface NavItem {
to: string;
@@ -47,10 +54,24 @@ export function Sidebar() {
const { theme, toggleTheme } = useTheme();
const { user, logout } = useAuth();
const { lang, t, toggleLang } = useLanguage();
const [unreadSignals, setUnreadSignals] = useState(0);
// Sidebar only mounts inside RequireAuth, so a token always exists here.
// Polling (not push) keeps this simple — at one crawl every 6 hours, a
// 60s badge refresh is more than fast enough to feel current.
useEffect(() => {
let cancelled = false;
function poll() {
getNotifications().then(r => { if (!cancelled) setUnreadSignals(r.unread_count); }).catch(() => {});
}
poll();
const timer = setInterval(poll, UNREAD_POLL_MS);
return () => { cancelled = true; clearInterval(timer); };
}, []);
const mainNav: NavItem[] = [
{ to: '/', icon: <LayoutDashboard size={16} />, label: t.nav.overview },
{ to: '/signals', icon: <Radio size={16} />, label: t.nav.signals },
{ to: '/signals', icon: <Radio size={16} />, label: t.nav.signals, badge: unreadSignals },
{ to: '/status', icon: <Monitor size={16} />, label: t.nav.status },
];
+1 -16
View File
@@ -12,6 +12,7 @@
*/
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { COMPLIANCE_INIT } from './pageStateDefaults';
// ── RagChat types ─────────────────────────────────────────────────────────────
@@ -122,22 +123,6 @@ export interface ComplianceState {
conflicts: ComplianceConflict[];
}
const COMPLIANCE_INIT: ComplianceState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
analysisId: null,
isReadOnly: false,
activeFindingId: null,
progress: null,
conflicts: [],
};
// ── Perception types ──────────────────────────────────────────────────────────
export interface PerceptionSignal {
+1
View File
@@ -2,6 +2,7 @@ export { ThemeProvider, useTheme } from './ThemeContext';
export { AuthProvider, useAuth } from './AuthContext';
export type { AuthUser } from './AuthContext';
export { PageStateProvider, usePageState } from './PageStateContext';
export { COMPLIANCE_INIT } from './pageStateDefaults';
export { LanguageProvider, useLanguage } from './LanguageContext';
export type { Lang } from './LanguageContext';
export type {
@@ -0,0 +1,27 @@
/**
* Default values for PageStateContext slices.
*
* These live outside PageStateContext.tsx because that file exports React
* components, and `react-refresh/only-export-components` requires shared
* constants to sit in their own module. Keeping the defaults here also gives
* consumers a single canonical initial state to spread from, instead of each
* page maintaining its own copy that silently drifts when a field is added.
*/
import type { ComplianceState } from './PageStateContext';
export const COMPLIANCE_INIT: ComplianceState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
analysisId: null,
isReadOnly: false,
activeFindingId: null,
progress: null,
conflicts: [],
};
@@ -7,7 +7,7 @@ import { useComplianceAnalysis } from './useComplianceAnalysis';
import { usePageState } from '../../contexts';
import { HistoryRail } from './HistoryRail';
import { FindingChatDrawer } from './FindingChatDrawer';
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
import type { FindingEvent, SourceEvent } from './useComplianceAnalysis';
const TOKEN_KEY = 'auth_token';
function authHeader(): Record<string, string> {
@@ -7,7 +7,7 @@
*/
import { useCallback } from 'react';
import { usePageState } from '../../contexts';
import { usePageState, COMPLIANCE_INIT } from '../../contexts';
import type {
ComplianceMeta,
ComplianceState,
@@ -28,21 +28,6 @@ function authHeader(): Record<string, string> {
return t ? { Authorization: `Bearer ${t}` } : {};
}
const INITIAL_STATE: ComplianceState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
analysisId: null,
isReadOnly: false,
progress: null,
conflicts: [],
};
export function useComplianceAnalysis() {
const { complianceState: state, setComplianceState: setState, complianceAbortRef, resetCompliance: reset } = usePageState();
@@ -51,7 +36,7 @@ export function useComplianceAnalysis() {
const ctrl = new AbortController();
complianceAbortRef.current = ctrl;
setState({ ...INITIAL_STATE, status: 'streaming', stageLabel: 'Starting…', meta });
setState({ ...COMPLIANCE_INIT, status: 'streaming', stageLabel: 'Starting…', meta });
try {
const res = await fetch('/api/v1/compliance/analyze-stream', {
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { FormEvent, useState } from 'react';
import { useState, type FormEvent } from 'react';
import { useAuth } from '../../contexts';
export function LoginPage() {
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
import { usePageState } from '../../contexts';
@@ -15,23 +15,24 @@ interface Stats {
total: number;
high_impact: number;
medium_impact: number;
last_90_days: number;
recent_90d: number;
}
const SOURCES = ['All', 'MIIT', 'UN-ECE', 'ISO', 'GB Comm.', 'EUR-Lex', 'IATF'];
const IMPACTS = ['All', 'High', 'Medium', 'Low'];
// Backend event → Signal
function mapEvent(e: Record<string, unknown>): PerceptionSignal {
const impact = String(e.impact_level ?? '').toLowerCase();
// The backend publishes a lifecycle stage, not a severity. Mapping it through
// an impact-level vocabulary sent every real value to the default branch,
// which renders as "已发布" — so consultation drafts were labelled as enacted.
const backendStatus = String(e.status ?? '').toLowerCase();
return {
id: String(e.id ?? e.event_id ?? ''),
source: String(e.source ?? ''),
standard: String(e.standard ?? e.standard_code ?? e.regulation_id ?? ''),
status: backendStatus === 'high' || backendStatus === 'urgent' ? 'risk'
: backendStatus === 'medium' || backendStatus === 'draft' ? 'warn'
: backendStatus === 'low' || backendStatus === 'final' ? 'ok'
status: backendStatus === 'enacted' ? 'ok'
: backendStatus === 'draft' || backendStatus === 'consultation' ? 'warn'
: 'info',
title: String(e.title ?? ''),
summary: String(e.summary ?? e.description ?? ''),
@@ -80,7 +81,13 @@ export function PerceptionPage() {
fetch('/api/v1/perception/stats', { headers: authHeader() })
.then(r => r.json())
.then(setStats)
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, recent_90d: 14 }));
}, []);
// Landing on this page is the acknowledgement — clear the sidebar badge by
// marking every currently-unread notification read. No dismiss UI needed.
useEffect(() => {
fetch('/api/v1/perception/notifications/read', { method: 'POST', headers: authHeader() }).catch(() => {});
}, []);
// Fetch signal list on first mount only (if empty), otherwise preserve context state
@@ -114,6 +121,17 @@ export function PerceptionPage() {
const selected = signals.find(s => s.id === selectedId) ?? null;
// Derived from the loaded data rather than hardcoded. The previous fixed list
// was written against the mock fixtures, so the two sources the crawlers
// actually produce — CATARC and 国标委 — had no chip and could never be
// filtered. Deriving them also means a new crawler needs no frontend change.
// sourceFilter survives navigation in PageStateContext, so a filter chosen
// against an earlier dataset is kept in the list; dropping it would strand
// the user on an empty list with no chip to click their way out of.
const sources = ['All', ...Array.from(
new Set([...signals.map(s => s.source), sourceFilter].filter(s => s && s !== 'All')),
).sort()];
const filtered = signals.filter(s => {
if (sourceFilter !== 'All' && s.source !== sourceFilter) return false;
if (impactFilter !== 'All' && s.impact !== impactFilter) return false;
@@ -178,6 +196,11 @@ export function PerceptionPage() {
}
async function runCrawl() {
// A crawl already in flight is superseded — cancel it so its SSE reader
// stops writing status text for a run the user has replaced.
perceptionCrawlAbortRef.current?.abort();
const ctrl = new AbortController();
perceptionCrawlAbortRef.current = ctrl;
setCrawling(true);
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusConnecting }));
try {
@@ -185,6 +208,7 @@ export function PerceptionPage() {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({}),
signal: ctrl.signal,
});
if (!res.body) {
setPerceptionState(s => ({ ...s, crawlStatus: 'No stream' }));
@@ -232,10 +256,14 @@ export function PerceptionPage() {
}
}
} catch (e: unknown) {
setPerceptionState(s => ({
...s,
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e)),
}));
// An abort is a deliberate supersede, not a backend failure — leaving the
// status untouched avoids reporting "connection failed" to the user.
if (!(e instanceof DOMException && e.name === 'AbortError')) {
setPerceptionState(s => ({
...s,
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e)),
}));
}
}
setCrawling(false);
}
@@ -293,14 +321,14 @@ export function PerceptionPage() {
<span className="sbar-lbl">{t.signals.statMedium}</span>
</div>
<div className="sbar-cell accent">
<span className="sbar-val">{stats?.last_90_days ?? '—'}</span>
<span className="sbar-val">{stats?.recent_90d ?? '—'}</span>
<span className="sbar-lbl">{t.signals.statLast90}</span>
</div>
</div>
<div className="filter-bar">
<div className="chip-group">
{SOURCES.map(s => (
{sources.map(s => (
<button
key={s}
className={`chip${sourceFilter === s ? ' active' : ''}`}
@@ -365,7 +393,7 @@ export function PerceptionPage() {
<span className={`status ${selected.status}`}>
{selected.status === 'risk' ? t.signals.badgeUrgent : selected.status === 'warn' ? t.signals.badgeDraft : t.signals.badgePublished}
</span>
{selectedFull?.change_summary && (
{Boolean(selectedFull?.change_summary) && (
<span className="status warn" style={{ marginLeft: 'auto' }}>CHANGED</span>
)}
</div>
@@ -411,9 +439,9 @@ export function PerceptionPage() {
<p className="detail-summary" style={{ marginTop: 8 }}>
{(selectedFull?.scope as string) || selected.summary}
</p>
{selectedFull?.penalties && (
{Boolean(selectedFull?.penalties) && (
<p style={{ fontSize: 13, color: 'var(--danger)', marginTop: 6 }}>
{selectedFull.penalties as string}
{selectedFull?.penalties as string}
</p>
)}
</div>
@@ -486,8 +514,8 @@ export function PerceptionPage() {
{String(d.doc_name || '')}
<span className="doc-clause">{String(d.key_clauses || d.clause || '')}</span>
</div>
{d.snippet && <div className="doc-snippet">{String(d.snippet)}</div>}
{d.recommendation && (
{Boolean(d.snippet) && <div className="doc-snippet">{String(d.snippet)}</div>}
{Boolean(d.recommendation) && (
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 2 }}> {String(d.recommendation)}</div>
)}
</div>
@@ -523,7 +551,7 @@ export function PerceptionPage() {
{String(s.new_text || '')}
</div>
</div>
{s.summary && <p style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>{String(s.summary)}</p>}
{Boolean(s.summary) && <p style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>{String(s.summary)}</p>}
</div>
));
})()}