Fix SSE route dependency and align architecture docs
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { streamSSE, type ComplianceResult, type SSEMessage } from './index';
|
||||
import { API_BASE_URL, streamSSE, type ComplianceResult, type SSEMessage } from './index';
|
||||
|
||||
// Upload and analyze a design document
|
||||
export async function analyzeDocument(file: File): Promise<{ task_id: string; status: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/compliance/analyze', {
|
||||
const response = await fetch(`${API_BASE_URL}/compliance/analyze`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -14,21 +13,21 @@ export async function analyzeDocument(file: File): Promise<{ task_id: string; st
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.json() as Promise<{ task_id: string; status: string }>;
|
||||
}
|
||||
|
||||
// Get analysis result
|
||||
export async function getComplianceResult(taskId: string): Promise<ComplianceResult | { status: string; message: string }> {
|
||||
const response = await fetch(`/api/compliance/result/${taskId}`);
|
||||
export async function getComplianceResult(
|
||||
taskId: string
|
||||
): Promise<ComplianceResult | { status: string; message: string }> {
|
||||
const response = await fetch(`${API_BASE_URL}/compliance/result/${taskId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Get result failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.json() as Promise<ComplianceResult | { status: string; message: string }>;
|
||||
}
|
||||
|
||||
// Compliance chat with SSE streaming
|
||||
export function complianceChat(
|
||||
segmentId: number,
|
||||
query: string,
|
||||
@@ -36,8 +35,7 @@ export function complianceChat(
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void
|
||||
): void {
|
||||
streamSSE(`/compliance/chat/${segmentId}`, { query }, onMessage, onError, onComplete);
|
||||
void streamSSE<SSEMessage>(`/compliance/chat/${segmentId}`, { query }, onMessage, onError, onComplete);
|
||||
}
|
||||
|
||||
// Export types
|
||||
export type { ComplianceResult, SSEMessage };
|
||||
export type { ComplianceResult, SSEMessage };
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { DocInfo, DocListResponse, DocUploadResponse } from './index';
|
||||
|
||||
const DOCS_API_BASE = '/api/v1';
|
||||
import { API_BASE_URL } from './index';
|
||||
|
||||
interface BackendDocumentItem {
|
||||
doc_id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
object_name: string;
|
||||
download_url: string;
|
||||
last_modified?: string | null;
|
||||
doc_name: string;
|
||||
status: string;
|
||||
chunk_count: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface BackendDocumentListResponse {
|
||||
@@ -45,22 +43,14 @@ export interface RegulationSearchResponse {
|
||||
results: RegulationSearchItem[];
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${bytes}B`;
|
||||
}
|
||||
|
||||
function mapDoc(item: BackendDocumentItem): DocInfo {
|
||||
return {
|
||||
id: item.doc_id,
|
||||
name: item.filename,
|
||||
chunks: 0,
|
||||
status: 'indexed',
|
||||
created_at: item.last_modified || undefined,
|
||||
download_url: `${DOCS_API_BASE}/documents/download/${item.doc_id}`,
|
||||
size_text: formatFileSize(item.size),
|
||||
name: item.doc_name,
|
||||
chunks: item.chunk_count,
|
||||
status: item.status,
|
||||
updated_at: item.updated_at,
|
||||
download_url: `${API_BASE_URL}/documents/download/${item.doc_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,7 +60,7 @@ export async function uploadDocument(file: File): Promise<DocUploadResponse> {
|
||||
formData.append('doc_name', file.name);
|
||||
formData.append('generate_summary', 'true');
|
||||
|
||||
const response = await fetch(`${DOCS_API_BASE}/documents/upload`, {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -79,35 +69,35 @@ export async function uploadDocument(file: File): Promise<DocUploadResponse> {
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as DocUploadResponse;
|
||||
return {
|
||||
doc_id: data.doc_id,
|
||||
filename: data.doc_name || file.name,
|
||||
size: file.size,
|
||||
doc_name: data.doc_name || file.name,
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
num_chunks: data.num_chunks,
|
||||
summary: data.summary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDocumentList(): Promise<DocListResponse> {
|
||||
const response = await fetch(`${DOCS_API_BASE}/documents/management-list`);
|
||||
const response = await fetch(`${API_BASE_URL}/documents/management-list`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`List failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as BackendDocumentListResponse;
|
||||
const data = (await response.json()) as BackendDocumentListResponse;
|
||||
return {
|
||||
docs: data.documents.map(mapDoc),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchRegulations(query: string, topK: number = 8): Promise<RegulationSearchResponse> {
|
||||
const response = await fetch(`${DOCS_API_BASE}/knowledge/retrieval`, {
|
||||
const response = await fetch(`${API_BASE_URL}/knowledge/retrieval`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, top_k: topK }),
|
||||
});
|
||||
@@ -116,7 +106,7 @@ export async function searchRegulations(query: string, topK: number = 8): Promis
|
||||
throw new Error(`Search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as BackendKnowledgeResponse;
|
||||
const data = (await response.json()) as BackendKnowledgeResponse;
|
||||
return {
|
||||
query: data.query,
|
||||
total: data.total,
|
||||
@@ -125,12 +115,13 @@ export async function searchRegulations(query: string, topK: number = 8): Promis
|
||||
return {
|
||||
id: item.id,
|
||||
file: String(metadata.doc_name || metadata.filename || metadata.source || '法规知识库'),
|
||||
clause: String(metadata.chunk_type || metadata.section || metadata.clause || '法规片段'),
|
||||
clause: String(metadata.section_title || metadata.clause_number || metadata.clause || '法规片段'),
|
||||
score: item.score,
|
||||
content: item.content,
|
||||
tags: [
|
||||
metadata.regulation_type ? String(metadata.regulation_type) : '',
|
||||
metadata.version ? `v${String(metadata.version)}` : '',
|
||||
metadata.page_number ? `p.${String(metadata.page_number)}` : '',
|
||||
].filter(Boolean),
|
||||
};
|
||||
}),
|
||||
@@ -138,7 +129,7 @@ export async function searchRegulations(query: string, topK: number = 8): Promis
|
||||
}
|
||||
|
||||
export function getDocumentDownloadUrl(docId: string): string {
|
||||
return `${DOCS_API_BASE}/documents/download/${docId}`;
|
||||
return `${API_BASE_URL}/documents/download/${docId}`;
|
||||
}
|
||||
|
||||
export type { DocInfo, DocListResponse, DocUploadResponse };
|
||||
|
||||
@@ -1,63 +1,75 @@
|
||||
// API configuration - 使用相对路径,通过 Vite proxy 转发
|
||||
const API_BASE_URL = '/api';
|
||||
const API_BASE_URL = '/api/v1';
|
||||
|
||||
// Helper function for fetch requests
|
||||
async function fetchAPI<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
interface ApiErrorPayload {
|
||||
detail?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function buildUrl(endpoint: string): string {
|
||||
return `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as ApiErrorPayload;
|
||||
return payload.detail || payload.message || payload.error || `${response.status} ${response.statusText}`;
|
||||
} catch {
|
||||
return `${response.status} ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAPI<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(options?.headers);
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
if (!headers.has('Content-Type') && !(options?.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(endpoint), {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`API Error: ${await readErrorMessage(response)}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// SSE helper for streaming responses
|
||||
function createSSEConnection(endpoint: string, body: unknown): EventSource {
|
||||
// For POST requests with SSE, we need to use fetch with ReadableStream
|
||||
// since EventSource only supports GET requests
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
return new EventSource(url); // This won't work for POST, we'll handle it differently
|
||||
export interface SSEMessage {
|
||||
type: string;
|
||||
text?: string;
|
||||
docs?: RetrievedDoc[];
|
||||
}
|
||||
|
||||
// SSE streaming helper for POST requests
|
||||
async function streamSSE(
|
||||
export async function streamSSE<TMessage extends SSEMessage>(
|
||||
endpoint: string,
|
||||
body: unknown,
|
||||
onMessage: (data: unknown) => void,
|
||||
onMessage: (data: TMessage) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void
|
||||
): Promise<void> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(buildUrl(endpoint), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (onError) {
|
||||
onError(new Error(`HTTP error! status: ${response.status}`));
|
||||
}
|
||||
onError?.(new Error(`HTTP error! status: ${await readErrorMessage(response)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
if (onError) {
|
||||
onError(new Error('No response body'));
|
||||
}
|
||||
onError?.(new Error('No response body'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,47 +79,61 @@ async function streamSSE(
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split('\n\n');
|
||||
buffer = events.pop() || '';
|
||||
|
||||
// Process SSE events
|
||||
const lines = buffer.split('\n');
|
||||
buffer = '';
|
||||
for (const eventBlock of events) {
|
||||
const dataLines = eventBlock
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
const data = line.slice(5).trim();
|
||||
if (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
onMessage(parsed);
|
||||
} catch {
|
||||
// Handle non-JSON data
|
||||
onMessage({ type: 'raw', text: data });
|
||||
}
|
||||
}
|
||||
if (dataLines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
onMessage(JSON.parse(rawData) as TMessage);
|
||||
} catch {
|
||||
onMessage({ type: 'raw', text: rawData } as TMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
if (buffer.trim()) {
|
||||
const dataLines = buffer
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trim());
|
||||
|
||||
if (dataLines.length > 0) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
onMessage(JSON.parse(rawData) as TMessage);
|
||||
} catch {
|
||||
onMessage({ type: 'raw', text: rawData } as TMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
// Export types
|
||||
export interface DocInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
chunks: number;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
download_url?: string;
|
||||
size_text?: string;
|
||||
}
|
||||
@@ -118,9 +144,9 @@ export interface DocListResponse {
|
||||
|
||||
export interface DocUploadResponse {
|
||||
doc_id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
doc_name: string;
|
||||
status: string;
|
||||
message?: string;
|
||||
num_chunks?: number;
|
||||
summary?: string;
|
||||
}
|
||||
@@ -145,16 +171,10 @@ export interface RetrievedDoc {
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
export interface SSEMessage {
|
||||
type: string;
|
||||
text?: string;
|
||||
docs?: RetrievedDoc[];
|
||||
}
|
||||
|
||||
export interface Regulation {
|
||||
id: number;
|
||||
name: string;
|
||||
clause: string;
|
||||
clause: string | null;
|
||||
score: number;
|
||||
match_keyword: string;
|
||||
category: string;
|
||||
@@ -197,17 +217,20 @@ export interface ComplianceResult {
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
docs: number;
|
||||
chunks: number;
|
||||
vectors: number;
|
||||
segments: number;
|
||||
documents_total: number;
|
||||
documents_indexed: number;
|
||||
documents_failed: number;
|
||||
chunks_total: number;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
llm: { model: string };
|
||||
embedding: { model: string; dimension: number };
|
||||
milvus: { host: string; port: number };
|
||||
retrieval: { vector_top_k: number; final_top_k: number };
|
||||
embedding_model: string;
|
||||
embedding_dim: number;
|
||||
embedding_base_url: string;
|
||||
milvus_collection: string;
|
||||
llm_provider: string;
|
||||
llm_model: string;
|
||||
document_metadata_path: string;
|
||||
}
|
||||
|
||||
export { fetchAPI, streamSSE, API_BASE_URL };
|
||||
export { API_BASE_URL };
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { fetchAPI, type SystemStats, type SystemConfig } from './index';
|
||||
import { fetchAPI, type SystemConfig, type SystemStats } from './index';
|
||||
|
||||
// Get system statistics
|
||||
export async function getSystemStats(): Promise<SystemStats> {
|
||||
return fetchAPI<SystemStats>('/status/stats');
|
||||
}
|
||||
|
||||
// Get system configuration
|
||||
export async function getSystemConfig(): Promise<SystemConfig> {
|
||||
return fetchAPI<SystemConfig>('/status/config');
|
||||
}
|
||||
|
||||
// Get Milvus health status
|
||||
export async function getMilvusHealth(): Promise<{ connected: boolean; collections: string[] }> {
|
||||
return fetchAPI('/status/milvus/health');
|
||||
}
|
||||
|
||||
// Export types
|
||||
export type { SystemStats, SystemConfig };
|
||||
export type { SystemConfig, SystemStats };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
export const TPattern: React.FC = () => {
|
||||
const { theme, isDark } = useTheme();
|
||||
@@ -27,4 +27,4 @@ export const TPattern: React.FC = () => {
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
export const ThemeToggle: React.FC = () => {
|
||||
const { isDark, toggleTheme, theme } = useTheme();
|
||||
@@ -32,4 +32,4 @@ export const ThemeToggle: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface ContentProps {
|
||||
children: React.ReactNode;
|
||||
@@ -24,4 +24,4 @@ export const Content: React.FC<ContentProps> = ({ children, wide = false }) => {
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { TLogo } from '../common/TLogo';
|
||||
import { ThemeToggle } from '../common/ThemeToggle';
|
||||
|
||||
@@ -44,4 +44,4 @@ export const Header: React.FC = () => {
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTheme, useApp } from '../../contexts';
|
||||
import type { TabId } from '../../contexts';
|
||||
|
||||
const tabs = [
|
||||
const tabs: Array<{ id: TabId; label: string }> = [
|
||||
{ id: 'docs', label: '文档管理' },
|
||||
{ id: 'compliance', label: '合规分析' },
|
||||
{ id: 'status', label: '系统状态' },
|
||||
@@ -24,7 +25,7 @@ export const Tabs: React.FC = () => {
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
height: 56,
|
||||
padding: '0 32px',
|
||||
@@ -44,4 +45,4 @@ export const Tabs: React.FC = () => {
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
@@ -37,4 +37,4 @@ export const Badge: React.FC<BadgeProps> = ({
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
@@ -57,4 +57,4 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface CardProps {
|
||||
accent?: boolean;
|
||||
@@ -51,4 +51,4 @@ export const Card: React.FC<CardProps> = ({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface InputProps {
|
||||
value: string;
|
||||
@@ -42,4 +42,4 @@ export const Input: React.FC<InputProps> = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface ProgressBarProps {
|
||||
percent: number;
|
||||
@@ -40,4 +40,4 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface ScoreBarProps {
|
||||
score: number; // 0-100
|
||||
@@ -49,4 +49,4 @@ export const ScoreBar: React.FC<ScoreBarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
type TabId = 'docs' | 'compliance' | 'status' | 'rag';
|
||||
|
||||
interface AppContextValue {
|
||||
activeTab: TabId;
|
||||
setActiveTab: (tab: TabId) => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue | undefined>(undefined);
|
||||
|
||||
export const useApp = (): AppContextValue => {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) {
|
||||
throw new Error('useApp must be used within an AppProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
import { AppContext, type TabId } from './app-context';
|
||||
|
||||
interface AppProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -29,4 +14,4 @@ export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import React, { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import { darkTheme, lightTheme } from '../types/theme';
|
||||
import type { ThemeColors } from '../types/theme';
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: boolean;
|
||||
theme: ThemeColors;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
export const useTheme = (): ThemeContextValue => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
import { ThemeContext } from './theme-context';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -30,15 +15,15 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
setIsDark((prev) => !prev);
|
||||
};
|
||||
|
||||
// Apply class to document for Tailwind dark mode + body background
|
||||
useEffect(() => {
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.body.style.background = '#0a0a12';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.style.background = '#ffffff';
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.style.background = '#ffffff';
|
||||
}, [isDark]);
|
||||
|
||||
return (
|
||||
@@ -46,4 +31,4 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
10
frontend/src/contexts/app-context.ts
Normal file
10
frontend/src/contexts/app-context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type TabId = 'docs' | 'compliance' | 'status' | 'rag';
|
||||
|
||||
export interface AppContextValue {
|
||||
activeTab: TabId;
|
||||
setActiveTab: (tab: TabId) => void;
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextValue | undefined>(undefined);
|
||||
@@ -1,2 +1,5 @@
|
||||
export { ThemeProvider, useTheme } from './ThemeContext';
|
||||
export { AppProvider, useApp } from './AppContext';
|
||||
export { ThemeProvider } from './ThemeContext';
|
||||
export { useTheme } from './useTheme';
|
||||
export { AppProvider } from './AppContext';
|
||||
export type { AppContextValue, TabId } from './app-context';
|
||||
export { useApp } from './useApp';
|
||||
|
||||
11
frontend/src/contexts/theme-context.ts
Normal file
11
frontend/src/contexts/theme-context.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { ThemeColors } from '../types/theme';
|
||||
|
||||
export interface ThemeContextValue {
|
||||
isDark: boolean;
|
||||
theme: ThemeColors;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
11
frontend/src/contexts/useApp.ts
Normal file
11
frontend/src/contexts/useApp.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { AppContext, type AppContextValue } from './app-context';
|
||||
|
||||
export function useApp(): AppContextValue {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) {
|
||||
throw new Error('useApp must be used within an AppProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
11
frontend/src/contexts/useTheme.ts
Normal file
11
frontend/src/contexts/useTheme.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { ThemeContext, type ThemeContextValue } from './theme-context';
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type {
|
||||
AgentChatResponse,
|
||||
DocumentListResponse,
|
||||
KnowledgeSearchResponse,
|
||||
UploadDocumentResponse,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = (
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
`${window.location.protocol}//${window.location.hostname}:8000/api/v1`
|
||||
).replace(/\/$/, '');
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed: ${response.status}`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
message = data.detail || data.message || message;
|
||||
} catch {
|
||||
// Fall back to HTTP status when the response body is not JSON.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listDocuments: () => request<DocumentListResponse>('/documents/list'),
|
||||
|
||||
listDocumentManagementItems: () => request<DocumentListResponse>('/documents/management-list'),
|
||||
|
||||
uploadDocument: async (payload: {
|
||||
file: File;
|
||||
docName?: string;
|
||||
regulationType?: string;
|
||||
version?: string;
|
||||
generateSummary?: boolean;
|
||||
}) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', payload.file);
|
||||
if (payload.docName) formData.append('doc_name', payload.docName);
|
||||
if (payload.regulationType) formData.append('regulation_type', payload.regulationType);
|
||||
if (payload.version) formData.append('version', payload.version);
|
||||
formData.append('generate_summary', String(Boolean(payload.generateSummary)));
|
||||
|
||||
return request<UploadDocumentResponse>('/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
|
||||
searchKnowledge: (query: string, topK = 8) =>
|
||||
request<KnowledgeSearchResponse>('/knowledge/retrieval', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, top_k: topK }),
|
||||
}),
|
||||
|
||||
chat: (payload: { query: string; sessionId?: string }) =>
|
||||
request<AgentChatResponse>('/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: payload.query,
|
||||
session_id: payload.sessionId,
|
||||
}),
|
||||
}),
|
||||
|
||||
get downloadBase() {
|
||||
return API_BASE.replace(/\/api\/v1$/, '');
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
import type { ComplianceChunk } from '../../types';
|
||||
|
||||
interface ChatPanelProps {
|
||||
@@ -243,4 +243,4 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import type { UploadedDoc, ComplianceChunk, Regulation, SegmentRisk, RiskDashboardData } from '../../types';
|
||||
import {
|
||||
mockComplianceChunks,
|
||||
@@ -82,9 +82,11 @@ const getRegsByCategory = (regulations: Regulation[]) => {
|
||||
|
||||
export const CompliancePage: React.FC = () => {
|
||||
const { theme, isDark } = useTheme();
|
||||
const nextMessageIdRef = useRef(1);
|
||||
|
||||
// Upload & Analysis States
|
||||
const [uploadedDoc, setUploadedDoc] = useState<UploadedDoc | null>(null);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState<boolean>(false);
|
||||
const [analyzeStep, setAnalyzeStep] = useState<number>(0);
|
||||
const [analyzePercent, setAnalyzePercent] = useState<number>(0);
|
||||
@@ -100,10 +102,17 @@ export const CompliancePage: React.FC = () => {
|
||||
const [chatLoading, setChatLoading] = useState<boolean>(false);
|
||||
const [dashboardExpanded, setDashboardExpanded] = useState<boolean>(false);
|
||||
|
||||
const nextMessageId = () => {
|
||||
const currentId = nextMessageIdRef.current;
|
||||
nextMessageIdRef.current += 1;
|
||||
return currentId;
|
||||
};
|
||||
|
||||
// Handlers
|
||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploadedFile(file);
|
||||
setUploadedDoc({
|
||||
name: file.name,
|
||||
size: `${(file.size / 1024 / 1024).toFixed(2)}MB`,
|
||||
@@ -113,27 +122,14 @@ export const CompliancePage: React.FC = () => {
|
||||
};
|
||||
|
||||
const startAnalysis = async () => {
|
||||
if (!uploadedDoc) return;
|
||||
|
||||
if (!uploadedDoc || !uploadedFile) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setAnalyzeStep(1);
|
||||
setAnalyzePercent(0);
|
||||
|
||||
try {
|
||||
// Get file from upload input
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = fileInput?.files?.[0];
|
||||
console.log("123")
|
||||
|
||||
// if (!file) {
|
||||
// // setIsAnalyzing(false);
|
||||
// // return;
|
||||
// }
|
||||
|
||||
console.log("456")
|
||||
// Upload and get task ID
|
||||
const uploadRes = await analyzeDocument(file);
|
||||
const uploadRes = await analyzeDocument(uploadedFile);
|
||||
|
||||
// Simulate progress
|
||||
setTimeout(() => {
|
||||
@@ -174,7 +170,7 @@ export const CompliancePage: React.FC = () => {
|
||||
regulations: s.regulations.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
clause: r.clause,
|
||||
clause: r.clause || '',
|
||||
score: r.score,
|
||||
matchKeyword: r.match_keyword,
|
||||
category: r.category as 'high' | 'medium' | 'low',
|
||||
@@ -204,7 +200,6 @@ export const CompliancePage: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get compliance result:', error);
|
||||
// Fallback to mock data
|
||||
setChunks(mockComplianceChunks);
|
||||
}
|
||||
|
||||
@@ -215,7 +210,6 @@ export const CompliancePage: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze document:', error);
|
||||
setIsAnalyzing(false);
|
||||
// Fallback to mock data after delay
|
||||
setTimeout(() => {
|
||||
setChunks(mockComplianceChunks);
|
||||
}, 4500);
|
||||
@@ -230,7 +224,7 @@ export const CompliancePage: React.FC = () => {
|
||||
setChatMessages(prev => ({
|
||||
...prev,
|
||||
[chunkId]: [{
|
||||
id: Date.now(),
|
||||
id: nextMessageId(),
|
||||
role: 'assistant',
|
||||
content: `您好!我是法规合规分析助手。当前段落涉及 ${chunk?.regulations.length} 条相关法规,您可以询问合规性评估、法规解读或修改建议。`,
|
||||
}]
|
||||
@@ -248,7 +242,7 @@ export const CompliancePage: React.FC = () => {
|
||||
const chunk = chunks.find(c => c.id === activeChunkId);
|
||||
if (!chunk) return;
|
||||
|
||||
const userMsg = { id: Date.now(), role: 'user' as const, content: chatInput };
|
||||
const userMsg = { id: nextMessageId(), role: 'user' as const, content: chatInput };
|
||||
setChatMessages(prev => ({
|
||||
...prev,
|
||||
[activeChunkId]: [...(prev[activeChunkId] || []), userMsg],
|
||||
@@ -267,7 +261,7 @@ export const CompliancePage: React.FC = () => {
|
||||
currentResponse += sseData.text;
|
||||
setChatMessages(prev => ({
|
||||
...prev,
|
||||
[activeChunkId]: [...(prev[activeChunkId] || []).slice(0, -1), { id: Date.now() + 1, role: 'assistant', content: currentResponse }],
|
||||
[activeChunkId]: [...(prev[activeChunkId] || []).slice(0, -1), { id: nextMessageId(), role: 'assistant', content: currentResponse }],
|
||||
}));
|
||||
} else if (sseData.type === 'done') {
|
||||
setChatLoading(false);
|
||||
@@ -291,7 +285,7 @@ export const CompliancePage: React.FC = () => {
|
||||
}
|
||||
setChatMessages(prev => ({
|
||||
...prev,
|
||||
[activeChunkId]: [...(prev[activeChunkId] || []), { id: Date.now() + 1, role: 'assistant', content: response }],
|
||||
[activeChunkId]: [...(prev[activeChunkId] || []), { id: nextMessageId(), role: 'assistant', content: response }],
|
||||
}));
|
||||
},
|
||||
() => {
|
||||
@@ -1913,4 +1907,4 @@ export const CompliancePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { Content } from '../../components/layout/Content';
|
||||
import { TPattern } from '../../components/common/TPattern';
|
||||
import { getDocumentList, searchRegulations, uploadDocument, type RegulationSearchItem } from '../../api/docs';
|
||||
@@ -16,6 +16,7 @@ const PIPELINE_STEPS = [
|
||||
];
|
||||
|
||||
const STEP_DURATION_MS = 700;
|
||||
const INITIAL_SEARCH_QUERY = '新能源汽车电池安全要求';
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -35,33 +36,12 @@ export const DocsPage: React.FC = () => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadFileName, setUploadFileName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('新能源汽车电池安全要求');
|
||||
const [searchQuery, setSearchQuery] = useState(INITIAL_SEARCH_QUERY);
|
||||
const [searchResults, setSearchResults] = useState<RegulationSearchItem[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
void loadDocuments();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void runSearch(searchQuery);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pipelineRunIdRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetPipeline = (status: PipelineStatus = 'idle') => {
|
||||
pipelineRunIdRef.current += 1;
|
||||
setActiveStep(-1);
|
||||
setCompletedSteps([]);
|
||||
setPipelineStatus(status);
|
||||
};
|
||||
|
||||
const loadDocuments = async () => {
|
||||
async function loadDocuments() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getDocumentList();
|
||||
@@ -69,10 +49,11 @@ export const DocsPage: React.FC = () => {
|
||||
id: parseInt(String(doc.id).replace('doc-', ''), 10) || Math.floor(Math.random() * 10000),
|
||||
name: doc.name,
|
||||
chunks: doc.chunks,
|
||||
size: doc.size_text || `${((doc.chunks * 8) / 1024).toFixed(1)}MB`,
|
||||
status: doc.status === 'indexed' ? 'indexed' : 'parsing',
|
||||
size: doc.updated_at ? new Date(doc.updated_at).toLocaleString() : 'Indexed document',
|
||||
status: doc.status === 'indexed' ? 'indexed' : doc.status === 'failed' ? 'failed' : 'parsing',
|
||||
docId: doc.id,
|
||||
downloadUrl: doc.download_url,
|
||||
updatedAt: doc.updated_at,
|
||||
}));
|
||||
setDocs(apiDocs);
|
||||
} catch (error) {
|
||||
@@ -81,7 +62,43 @@ export const DocsPage: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function runSearch(query: string) {
|
||||
if (!query.trim()) return;
|
||||
setSearchLoading(true);
|
||||
setSearchError('');
|
||||
try {
|
||||
const response = await searchRegulations(query.trim(), 8);
|
||||
setSearchResults(response.results);
|
||||
} catch (error) {
|
||||
console.error('Failed to search regulations:', error);
|
||||
setSearchError(error instanceof Error ? error.message : '检索失败');
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
void loadDocuments();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
void runSearch(INITIAL_SEARCH_QUERY);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pipelineRunIdRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runPipelineFlow = async (runId: number, uploadPromise: Promise<Awaited<ReturnType<typeof uploadDocument>>>) => {
|
||||
const guardedSetActiveStep = (step: number) => {
|
||||
@@ -209,22 +226,6 @@ export const DocsPage: React.FC = () => {
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
};
|
||||
|
||||
const runSearch = async (query: string) => {
|
||||
if (!query.trim()) return;
|
||||
setSearchLoading(true);
|
||||
setSearchError('');
|
||||
try {
|
||||
const response = await searchRegulations(query.trim(), 8);
|
||||
setSearchResults(response.results);
|
||||
} catch (error) {
|
||||
console.error('Failed to search regulations:', error);
|
||||
setSearchError(error instanceof Error ? error.message : '检索失败');
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepStyle = (index: number) => {
|
||||
const isActive = activeStep === index;
|
||||
const isCompleted = completedSteps.includes(index);
|
||||
@@ -525,7 +526,7 @@ export const DocsPage: React.FC = () => {
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500 }}>{doc.name}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3 }}>
|
||||
{doc.size}
|
||||
{doc.updatedAt ? new Date(doc.updatedAt).toLocaleString() : doc.size}
|
||||
{doc.docId ? ` · ${doc.docId}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,10 +550,14 @@ export const DocsPage: React.FC = () => {
|
||||
padding: '6px 12px',
|
||||
background: theme.bgHover,
|
||||
borderRadius: 6,
|
||||
color: theme.text2,
|
||||
color: doc.status === 'failed' ? '#d64545' : theme.text2,
|
||||
}}
|
||||
>
|
||||
{doc.status === 'parsing' ? '处理中...' : `${doc.chunks} chunks`}
|
||||
{doc.status === 'parsing'
|
||||
? '处理中...'
|
||||
: doc.status === 'failed'
|
||||
? '处理失败'
|
||||
: `${doc.chunks} chunks`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import type { ChatMessage, RetrievalData } from '../../types';
|
||||
import { getQuickQuestions, ragChat } from '../../api/rag';
|
||||
|
||||
@@ -16,6 +16,7 @@ const ragQuickQuestionsDefault = [
|
||||
|
||||
export const RagChatPage: React.FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const nextMessageIdRef = useRef(1);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [retrievals, setRetrievals] = useState<RetrievalData[]>([]);
|
||||
const [input, setInput] = useState<string>('');
|
||||
@@ -24,23 +25,32 @@ export const RagChatPage: React.FC = () => {
|
||||
const [selectedRetrieval, setSelectedRetrieval] = useState<RetrievalData | null>(null);
|
||||
const [quickQuestions, setQuickQuestions] = useState<string[]>(ragQuickQuestionsDefault);
|
||||
|
||||
useEffect(() => {
|
||||
void loadQuickQuestions();
|
||||
}, []);
|
||||
function nextMessageId() {
|
||||
const currentId = nextMessageIdRef.current;
|
||||
nextMessageIdRef.current += 1;
|
||||
return currentId;
|
||||
}
|
||||
|
||||
const loadQuickQuestions = async () => {
|
||||
async function loadQuickQuestions() {
|
||||
try {
|
||||
const response = await getQuickQuestions();
|
||||
setQuickQuestions(response.questions.map(q => q.question));
|
||||
} catch (error) {
|
||||
console.error('Failed to load quick questions:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
void loadQuickQuestions();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, []);
|
||||
|
||||
const sendMessage = (text: string) => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
const userMsg = { id: Date.now(), role: 'user' as const, content: text };
|
||||
const userMsg = { id: nextMessageId(), role: 'user' as const, content: text };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
@@ -84,7 +94,7 @@ export const RagChatPage: React.FC = () => {
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...lastMsg, content: currentResponse }];
|
||||
}
|
||||
return [...prev, { id: Date.now() + 1, role: 'assistant' as const, content: currentResponse }];
|
||||
return [...prev, { id: nextMessageId(), role: 'assistant' as const, content: currentResponse }];
|
||||
});
|
||||
} else if (sseData.type === 'done') {
|
||||
setLoading(false);
|
||||
@@ -97,7 +107,7 @@ export const RagChatPage: React.FC = () => {
|
||||
setLoading(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: Date.now() + 1, role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' }
|
||||
{ id: nextMessageId(), role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' }
|
||||
]);
|
||||
},
|
||||
() => {
|
||||
@@ -159,7 +169,7 @@ export const RagChatPage: React.FC = () => {
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...lastMsg, content: currentResponse }];
|
||||
}
|
||||
return [...prev, { id: Date.now() + 1, role: 'assistant' as const, content: currentResponse }];
|
||||
return [...prev, { id: nextMessageId(), role: 'assistant' as const, content: currentResponse }];
|
||||
});
|
||||
} else if (sseData.type === 'done') {
|
||||
setLoading(false);
|
||||
@@ -170,7 +180,7 @@ export const RagChatPage: React.FC = () => {
|
||||
setLoading(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: Date.now() + 1, role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' }
|
||||
{ id: nextMessageId(), role: 'assistant' as const, content: '抱歉,连接服务器时出错,请稍后再试。' }
|
||||
]);
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { Content } from '../../components/layout/Content';
|
||||
import { TPattern } from '../../components/common/TPattern';
|
||||
import { getSystemStats, getSystemConfig, type SystemStats, type SystemConfig } from '../../api/status';
|
||||
@@ -38,17 +38,16 @@ const StatsCard = ({ label, value, accent = false }: {
|
||||
|
||||
export const StatusPage: React.FC = () => {
|
||||
const { theme, isDark } = useTheme();
|
||||
const [stats, setStats] = useState<SystemStats>({ docs: 0, chunks: 0, vectors: 0, segments: 0 });
|
||||
const [stats, setStats] = useState<SystemStats>({
|
||||
documents_total: 0,
|
||||
documents_indexed: 0,
|
||||
documents_failed: 0,
|
||||
chunks_total: 0,
|
||||
});
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [docs, setDocs] = useState<DocInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
async function loadData() {
|
||||
try {
|
||||
const [statsRes, configRes, docsRes] = await Promise.all([
|
||||
getSystemStats(),
|
||||
@@ -61,30 +60,31 @@ export const StatusPage: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error('Failed to load status data:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
}
|
||||
|
||||
// 计算总chunks
|
||||
const totalChunks = docs.reduce((sum, d) => sum + d.chunks, 0);
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
void loadData();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<TPattern />
|
||||
{/* System Stats */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: 16,
|
||||
}}>
|
||||
<StatsCard label="DOCUMENTS" value={stats.docs} />
|
||||
<StatsCard label="CHUNKS" value={stats.chunks} />
|
||||
<StatsCard label="DIMENSIONS" value={config?.embedding.dimension || 1536} />
|
||||
<StatsCard label="CLAUSES" value={stats.vectors} accent />
|
||||
<StatsCard label="DOCUMENTS" value={stats.documents_total} />
|
||||
<StatsCard label="INDEXED" value={stats.documents_indexed} />
|
||||
<StatsCard label="FAILED" value={stats.documents_failed} />
|
||||
<StatsCard label="CHUNKS" value={stats.chunks_total} accent />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Configuration */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<h2 style={{
|
||||
fontSize: 14,
|
||||
@@ -94,49 +94,18 @@ export const StatusPage: React.FC = () => {
|
||||
letterSpacing: '1px',
|
||||
}}>SYSTEM CONFIGURATION</h2>
|
||||
|
||||
{/* ChromaDB Config */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>VECTOR DATABASE</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: 12,
|
||||
}}>
|
||||
{[
|
||||
['Vector DB', 'Milvus'],
|
||||
['Host', config?.milvus.host || 'localhost'],
|
||||
['Port', String(config?.milvus.port || 19530)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.text3 }}>{k}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LLM Config */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>LLM CONFIGURATION</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>MODELS</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 12,
|
||||
}}>
|
||||
{[
|
||||
['LLM Model', config?.llm.model || 'qwen-max'],
|
||||
['Embedding Model', config?.embedding.model || 'text-embedding-v3'],
|
||||
['Embedding Dim', String(config?.embedding.dimension || 1536)],
|
||||
['Temperature', '0.1'],
|
||||
['LLM Provider', config?.llm_provider || '-'],
|
||||
['LLM Model', config?.llm_model || '-'],
|
||||
['Embedding Model', config?.embedding_model || '-'],
|
||||
['Embedding Dim', String(config?.embedding_dim || 0)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} style={{
|
||||
display: 'flex',
|
||||
@@ -155,47 +124,17 @@ export const StatusPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Retrieval Config */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>RETRIEVAL CONFIGURATION (HYBRID)</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: 12,
|
||||
}}>
|
||||
{[
|
||||
['Vector Top-K', String(config?.retrieval.vector_top_k || 10)],
|
||||
['BM25 Top-K', '10'],
|
||||
['Final Top-K', String(config?.retrieval.final_top_k || 5)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.text3 }}>{k}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chunk Config */}
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>CHUNK CONFIGURATION</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 12, letterSpacing: '1px' }}>STORAGE AND PATHS</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 12,
|
||||
}}>
|
||||
{[
|
||||
['Chunk Size', '800'],
|
||||
['Chunk Overlap', '100'],
|
||||
['Milvus Collection', config?.milvus_collection || '-'],
|
||||
['Metadata Path', config?.document_metadata_path || '-'],
|
||||
['Embedding Base URL', config?.embedding_base_url || '-'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} style={{
|
||||
display: 'flex',
|
||||
@@ -215,7 +154,6 @@ export const StatusPage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Indexed Docs Overview */}
|
||||
<section>
|
||||
<h2 style={{
|
||||
fontSize: 14,
|
||||
@@ -237,16 +175,20 @@ export const StatusPage: React.FC = () => {
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 14 }}>{d.name}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>{(d.chunks * 8 / 1024).toFixed(1)}MB</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>
|
||||
{d.updated_at ? new Date(d.updated_at).toLocaleString() : d.status}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{d.chunks} chunks</span>
|
||||
<div style={{
|
||||
padding: '4px 12px',
|
||||
background: theme.green,
|
||||
background: d.status === 'failed' ? '#d64545' : theme.green,
|
||||
borderRadius: 6,
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 600, color: '#fff' }}>INDEXED</span>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 600, color: '#fff' }}>
|
||||
{d.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,4 +196,4 @@ export const StatusPage: React.FC = () => {
|
||||
</section>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,10 +3,11 @@ export interface Doc {
|
||||
name: string;
|
||||
chunks: number;
|
||||
size: string;
|
||||
status: 'indexed' | 'parsing' | 'pending';
|
||||
status: 'indexed' | 'parsing' | 'pending' | 'failed';
|
||||
docId?: string;
|
||||
downloadUrl?: string;
|
||||
summary?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
|
||||
Reference in New Issue
Block a user