feat: 更新分析会话 API,支持分页和并发处理

This commit is contained in:
catlog22
2026-02-26 14:31:30 +08:00
parent a94c790c4f
commit 2487a8330c
6 changed files with 196 additions and 95 deletions

View File

@@ -7141,11 +7141,19 @@ import type { AnalysisSessionSummary, AnalysisSessionDetail } from '../types/ana
* Fetch list of analysis sessions
*/
export async function fetchAnalysisSessions(
projectPath?: string
projectPath?: string,
options?: { limit?: number; offset?: number }
): Promise<AnalysisSessionSummary[]> {
const data = await fetchApi<{ success: boolean; data: AnalysisSessionSummary[]; error?: string }>(
withPath('/api/analysis', projectPath)
);
const params = new URLSearchParams();
if (options?.limit) params.set('limit', String(options.limit));
if (options?.offset) params.set('offset', String(options.offset));
const queryString = params.toString();
const path = queryString
? `${withPath('/api/analysis', projectPath)}&${queryString}`
: withPath('/api/analysis', projectPath);
const data = await fetchApi<{ success: boolean; data: AnalysisSessionSummary[]; error?: string }>(path);
if (!data.success) {
throw new Error(data.error || 'Failed to fetch analysis sessions');
}

View File

@@ -24,10 +24,11 @@ import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { fetchAnalysisSessions, fetchAnalysisDetail } from '@/lib/api';
import { MessageRenderer } from '@/components/shared/CliStreamMonitor/MessageRenderer';
import { JsonCardView } from '@/components/shared/JsonCardView';
import { cn } from '@/lib/utils';
import type { AnalysisSessionSummary } from '@/types/analysis';
// ========== Session Card Component ==========
@@ -41,23 +42,22 @@ interface SessionCardProps {
function SessionCard({ session, onClick, isSelected }: SessionCardProps) {
return (
<Card
className={`p-4 cursor-pointer transition-colors ${
isSelected ? 'ring-2 ring-primary bg-accent/50' : 'hover:bg-accent/50'
}`}
className={cn(
'p-4 cursor-pointer transition-all hover:shadow-md',
isSelected && 'ring-2 ring-primary'
)}
onClick={onClick}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<FileSearch className="w-4 h-4 text-muted-foreground" />
<span className="font-medium truncate">{session.topic}</span>
</div>
<p className="text-sm text-muted-foreground truncate">{session.id}</p>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<FileSearch className="w-5 h-5 text-primary flex-shrink-0" />
<h3 className="font-medium text-foreground truncate">{session.topic}</h3>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</div>
<div className="flex items-center gap-3 mt-3">
<Badge variant={session.status === 'completed' ? 'default' : 'secondary'}>
<p className="text-sm text-muted-foreground truncate mb-3">{session.id}</p>
<div className="flex items-center gap-3">
<Badge variant={session.status === 'completed' ? 'success' : 'warning'}>
{session.status === 'completed' ? (
<><CheckCircle className="w-3 h-3 mr-1" /></>
) : (
@@ -82,6 +82,8 @@ interface DetailPanelProps {
}
function DetailPanel({ sessionId, projectPath, onClose }: DetailPanelProps) {
const [activeTab, setActiveTab] = useState('discussion');
const { data: detail, isLoading, error } = useQuery({
queryKey: ['analysis-detail', sessionId, projectPath],
queryFn: () => fetchAnalysisDetail(sessionId, projectPath),
@@ -110,13 +112,20 @@ function DetailPanel({ sessionId, projectPath, onClose }: DetailPanelProps) {
// Build available tabs based on content
const tabs = [
{ id: 'discussion', label: '讨论记录', icon: MessageSquare, content: detail.discussion },
{ id: 'conclusions', label: '结论', icon: CheckCircle, content: detail.conclusions },
{ id: 'explorations', label: '代码探索', icon: Code, content: detail.explorations },
{ id: 'perspectives', label: '视角分析', icon: FileText, content: detail.perspectives },
].filter(tab => tab.content);
{ value: 'discussion', label: '讨论记录', icon: <MessageSquare className="w-4 h-4" /> },
{ value: 'conclusions', label: '结论', icon: <CheckCircle className="w-4 h-4" /> },
{ value: 'explorations', label: '代码探索', icon: <Code className="w-4 h-4" /> },
{ value: 'perspectives', label: '视角分析', icon: <FileText className="w-4 h-4" /> },
].filter(tab => {
const key = tab.value as keyof typeof detail;
return detail[key];
});
const defaultTab = tabs[0]?.id || 'discussion';
// Ensure activeTab is valid
const validTab = tabs.find(t => t.value === activeTab);
if (!validTab && tabs.length > 0) {
setActiveTab(tabs[0].value);
}
return (
<div className="h-full flex flex-col">
@@ -138,46 +147,35 @@ function DetailPanel({ sessionId, projectPath, onClose }: DetailPanelProps) {
{/* Tabs Content */}
{tabs.length > 0 ? (
<Tabs defaultValue={defaultTab} className="flex-1 flex flex-col min-h-0">
<TabsList className="mx-4 mt-4 shrink-0 w-fit">
{tabs.map(tab => (
<TabsTrigger key={tab.id} value={tab.id} className="gap-1.5">
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
<>
<TabsNavigation
value={activeTab}
onValueChange={setActiveTab}
tabs={tabs}
className="px-4"
/>
<div className="flex-1 overflow-auto min-h-0 p-4">
{/* Discussion Tab */}
<TabsContent value="discussion" className="mt-0 h-full">
{detail.discussion && (
<MessageRenderer content={detail.discussion} format="markdown" />
)}
</TabsContent>
{activeTab === 'discussion' && detail.discussion && (
<MessageRenderer content={detail.discussion} format="markdown" />
)}
{/* Conclusions Tab */}
<TabsContent value="conclusions" className="mt-0 h-full">
{detail.conclusions && (
<JsonCardView data={detail.conclusions} />
)}
</TabsContent>
{activeTab === 'conclusions' && detail.conclusions && (
<JsonCardView data={detail.conclusions} />
)}
{/* Explorations Tab */}
<TabsContent value="explorations" className="mt-0 h-full">
{detail.explorations && (
<JsonCardView data={detail.explorations} />
)}
</TabsContent>
{activeTab === 'explorations' && detail.explorations && (
<JsonCardView data={detail.explorations} />
)}
{/* Perspectives Tab */}
<TabsContent value="perspectives" className="mt-0 h-full">
{detail.perspectives && (
<JsonCardView data={detail.perspectives} />
)}
</TabsContent>
{activeTab === 'perspectives' && detail.perspectives && (
<JsonCardView data={detail.perspectives} />
)}
</div>
</Tabs>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -210,14 +208,16 @@ export function AnalysisPage() {
{/* Left Panel - List */}
<div className={`p-6 space-y-6 overflow-auto ${selectedSession ? 'w-[400px] shrink-0' : 'flex-1'}`}>
{/* Header */}
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileSearch className="w-6 h-6" />
Analysis Viewer
</h1>
<p className="text-muted-foreground mt-1">
/workflow:analyze-with-file
</p>
<div className="flex items-center gap-2">
<FileSearch className="w-6 h-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold text-foreground">
Analysis Viewer
</h1>
<p className="text-sm text-muted-foreground mt-1">
/workflow:analyze-with-file
</p>
</div>
</div>
{/* Search */}

View File

@@ -8,11 +8,16 @@
*/
import { readdir, readFile, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { existsSync, statSync } from 'fs';
import { join } from 'path';
import type { RouteContext } from './types.js';
import { resolvePath } from '../../utils/path-resolver.js';
// Concurrency limit for processing folders
const MAX_CONCURRENT = 10;
// Timeout for individual file operations (ms)
const FILE_TIMEOUT = 5000;
/**
* Analysis session summary for list view
*/
@@ -153,6 +158,8 @@ export async function handleAnalysisRoutes(ctx: RouteContext): Promise<boolean>
if (pathname === '/api/analysis' && req.method === 'GET') {
try {
const projectPath = ctx.url.searchParams.get('projectPath') || initialPath;
const limit = Math.min(parseInt(ctx.url.searchParams.get('limit') || '50', 10), 100);
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
const resolvedPath = resolvePath(projectPath);
const analysisDir = join(resolvedPath, '.workflow', '.analysis');
@@ -163,18 +170,22 @@ export async function handleAnalysisRoutes(ctx: RouteContext): Promise<boolean>
}
const folders = await readdir(analysisDir);
const sessions: AnalysisSessionSummary[] = [];
for (const folder of folders) {
const summary = await getSessionSummary(analysisDir, folder);
if (summary) sessions.push(summary);
}
// Parallel processing for better performance
const summaries = await Promise.all(
folders.map(folder => getSessionSummary(analysisDir, folder))
);
// Sort by date descending
sessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
// Filter valid sessions and sort by date descending
const allSessions = summaries
.filter((s): s is AnalysisSessionSummary => s !== null)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const total = allSessions.length;
const paginatedSessions = allSessions.slice(offset, offset + limit);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: sessions, total: sessions.length }));
res.end(JSON.stringify({ success: true, data: paginatedSessions, total }));
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });