mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
Merge branch 'main' of https://github.com/catlog22/Claude-Code-Workflow
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Cog,
|
Cog,
|
||||||
Users,
|
Users,
|
||||||
|
FileSearch,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -77,6 +78,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
|||||||
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
||||||
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
||||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||||
|
{ path: '/analysis', labelKey: 'navigation.main.analysis', icon: FileSearch },
|
||||||
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
||||||
{ path: '/terminal-dashboard', labelKey: 'navigation.main.terminalDashboard', icon: Terminal },
|
{ path: '/terminal-dashboard', labelKey: 'navigation.main.terminalDashboard', icon: Terminal },
|
||||||
],
|
],
|
||||||
|
|||||||
223
ccw/frontend/src/components/shared/JsonCardView.tsx
Normal file
223
ccw/frontend/src/components/shared/JsonCardView.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// ========================================
|
||||||
|
// JsonCardView Component
|
||||||
|
// ========================================
|
||||||
|
// Renders JSON data as structured cards for better readability
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface JsonCardViewProps {
|
||||||
|
/** JSON data to render - accepts any object type */
|
||||||
|
data: object | unknown[] | null;
|
||||||
|
/** Additional CSS className */
|
||||||
|
className?: string;
|
||||||
|
/** Initial expanded state */
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardItemProps {
|
||||||
|
label: string;
|
||||||
|
value: unknown;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Helper Functions ==========
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArray(value: unknown): value is unknown[] {
|
||||||
|
return Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(key: string): string {
|
||||||
|
return key
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/([A-Z])/g, ' $1')
|
||||||
|
.replace(/^./, (str) => str.toUpperCase())
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Sub Components ==========
|
||||||
|
|
||||||
|
function PrimitiveValue({ value }: { value: unknown }) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return <span className="text-muted-foreground italic">null</span>;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return (
|
||||||
|
<Badge variant={value ? 'default' : 'secondary'}>
|
||||||
|
{value ? 'true' : 'false'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return <span className="text-blue-600 dark:text-blue-400 font-mono">{value}</span>;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Check if it's a URL
|
||||||
|
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={value}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Long text
|
||||||
|
if (value.length > 100) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-foreground bg-muted/50 p-2 rounded whitespace-pre-wrap break-words">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-foreground">{value}</span>;
|
||||||
|
}
|
||||||
|
return <span>{String(value)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrayView({ items }: { items: unknown[] }) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground italic text-sm">Empty list</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple array of primitives
|
||||||
|
const allPrimitives = items.every(
|
||||||
|
(item) => typeof item !== 'object' || item === null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allPrimitives) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="font-normal">
|
||||||
|
{String(item)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{items.length} items
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="space-y-2 pl-4 border-l-2 border-border">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Card key={index} className="p-3">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">#{index + 1}</div>
|
||||||
|
{isObject(item) ? (
|
||||||
|
<ObjectView data={item} />
|
||||||
|
) : (
|
||||||
|
<PrimitiveValue value={item} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectView({ data, depth = 0 }: { data: Record<string, unknown>; depth?: number }) {
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <div className="text-muted-foreground italic text-sm">Empty object</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map(([key, value]) => (
|
||||||
|
<CardItem key={key} label={key} value={value} depth={depth} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardItem({ label, value, depth = 0 }: CardItemProps) {
|
||||||
|
const formattedLabel = formatLabel(label);
|
||||||
|
|
||||||
|
// Nested object
|
||||||
|
if (isObject(value)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium text-sm text-foreground">{formattedLabel}</div>
|
||||||
|
<div className={cn('pl-3 border-l-2 border-border', depth > 1 && 'ml-2')}>
|
||||||
|
<ObjectView data={value} depth={depth + 1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array
|
||||||
|
if (isArray(value)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium text-sm text-foreground">{formattedLabel}</div>
|
||||||
|
<ArrayView items={value} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primitive value
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground min-w-[120px] shrink-0">
|
||||||
|
{formattedLabel}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
<PrimitiveValue value={value} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Component ==========
|
||||||
|
|
||||||
|
export function JsonCardView({ data, className }: JsonCardViewProps) {
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground italic text-sm">No data available</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array at root level
|
||||||
|
if (isArray(data)) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
<ArrayView items={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle object
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<ObjectView data={data as Record<string, unknown>} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JsonCardView;
|
||||||
@@ -7132,3 +7132,38 @@ export async function triggerReindex(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Analysis API ==========
|
||||||
|
|
||||||
|
import type { AnalysisSessionSummary, AnalysisSessionDetail } from '../types/analysis';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch list of analysis sessions
|
||||||
|
*/
|
||||||
|
export async function fetchAnalysisSessions(
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<AnalysisSessionSummary[]> {
|
||||||
|
const data = await fetchApi<{ success: boolean; data: AnalysisSessionSummary[]; error?: string }>(
|
||||||
|
withPath('/api/analysis', projectPath)
|
||||||
|
);
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch analysis sessions');
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch analysis session detail
|
||||||
|
*/
|
||||||
|
export async function fetchAnalysisDetail(
|
||||||
|
sessionId: string,
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<AnalysisSessionDetail> {
|
||||||
|
const data = await fetchApi<{ success: boolean; data: AnalysisSessionDetail; error?: string }>(
|
||||||
|
withPath(`/api/analysis/${encodeURIComponent(sessionId)}`, projectPath)
|
||||||
|
);
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch analysis detail');
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
"graph": "Graph Explorer",
|
"graph": "Graph Explorer",
|
||||||
"teams": "Team Execution",
|
"teams": "Team Execution",
|
||||||
"terminalDashboard": "Terminal Dashboard",
|
"terminalDashboard": "Terminal Dashboard",
|
||||||
"skillHub": "Skill Hub"
|
"skillHub": "Skill Hub",
|
||||||
|
"analysis": "Analysis Viewer"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
"graph": "图浏览器",
|
"graph": "图浏览器",
|
||||||
"teams": "团队执行",
|
"teams": "团队执行",
|
||||||
"terminalDashboard": "终端仪表板",
|
"terminalDashboard": "终端仪表板",
|
||||||
"skillHub": "技能中心"
|
"skillHub": "技能中心",
|
||||||
|
"analysis": "分析查看器"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
|
|||||||
122
ccw/frontend/src/pages/AnalysisPage.test.tsx
Normal file
122
ccw/frontend/src/pages/AnalysisPage.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// ========================================
|
||||||
|
// Analysis Page Tests
|
||||||
|
// ========================================
|
||||||
|
// Tests for the Analysis Viewer page
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AnalysisPage } from './AnalysisPage';
|
||||||
|
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||||
|
import type { AnalysisSessionSummary } from '@/types/analysis';
|
||||||
|
|
||||||
|
// Mock sessions data
|
||||||
|
const mockSessions: AnalysisSessionSummary[] = [
|
||||||
|
{
|
||||||
|
id: 'ANL-test-session-2026-01-01',
|
||||||
|
name: 'test-session',
|
||||||
|
topic: 'Test Analysis Topic',
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
status: 'completed',
|
||||||
|
hasConclusions: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ANL-another-session-2026-01-02',
|
||||||
|
name: 'another-session',
|
||||||
|
topic: 'Another Analysis',
|
||||||
|
createdAt: '2026-01-02',
|
||||||
|
status: 'in_progress',
|
||||||
|
hasConclusions: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock API
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
fetchAnalysisSessions: vi.fn(() => Promise.resolve(mockSessions)),
|
||||||
|
fetchAnalysisDetail: vi.fn(() => Promise.resolve({
|
||||||
|
id: 'ANL-test-session-2026-01-01',
|
||||||
|
name: 'test-session',
|
||||||
|
topic: 'Test Analysis Topic',
|
||||||
|
createdAt: '2026-01-01',
|
||||||
|
status: 'completed',
|
||||||
|
discussion: 'Test discussion content',
|
||||||
|
conclusions: null,
|
||||||
|
explorations: null,
|
||||||
|
perspectives: null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create a wrapper with QueryClient
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AnalysisPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useWorkflowStore.setState({ projectPath: '/test/path' });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render page title', async () => {
|
||||||
|
render(<AnalysisPage />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('Analysis Viewer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render page description', async () => {
|
||||||
|
render(<AnalysisPage />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText(/查看.*analyze-with-file.*分析结果/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render search input', async () => {
|
||||||
|
render(<AnalysisPage />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByPlaceholderText('搜索分析会话...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading state initially', () => {
|
||||||
|
render(<AnalysisPage />, { wrapper: createWrapper() });
|
||||||
|
// Loading spinner should be present initially
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render session cards after loading', async () => {
|
||||||
|
render(<AnalysisPage />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Wait for sessions to load
|
||||||
|
const sessionTopic = await screen.findByText('Test Analysis Topic');
|
||||||
|
expect(sessionTopic).toBeInTheDocument();
|
||||||
|
|
||||||
|
const anotherSession = await screen.findByText('Another Analysis');
|
||||||
|
expect(anotherSession).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show completed badge for completed sessions', async () => {
|
||||||
|
render(<AnalysisPage />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Wait for sessions to load
|
||||||
|
await screen.findByText('Test Analysis Topic');
|
||||||
|
|
||||||
|
// Check for completed badge
|
||||||
|
expect(screen.getByText('完成')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show in-progress badge for running sessions', async () => {
|
||||||
|
render(<AnalysisPage />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Wait for sessions to load
|
||||||
|
await screen.findByText('Another Analysis');
|
||||||
|
|
||||||
|
// Check for in-progress badge
|
||||||
|
expect(screen.getByText('进行中')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
284
ccw/frontend/src/pages/AnalysisPage.tsx
Normal file
284
ccw/frontend/src/pages/AnalysisPage.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// ========================================
|
||||||
|
// Analysis Viewer Page
|
||||||
|
// ========================================
|
||||||
|
// View analysis sessions from /workflow:analyze-with-file command
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
FileSearch,
|
||||||
|
Search,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
FileText,
|
||||||
|
Code,
|
||||||
|
MessageSquare,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||||
|
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 { fetchAnalysisSessions, fetchAnalysisDetail } from '@/lib/api';
|
||||||
|
import { MessageRenderer } from '@/components/shared/CliStreamMonitor/MessageRenderer';
|
||||||
|
import { JsonCardView } from '@/components/shared/JsonCardView';
|
||||||
|
import type { AnalysisSessionSummary } from '@/types/analysis';
|
||||||
|
|
||||||
|
// ========== Session Card Component ==========
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: AnalysisSessionSummary;
|
||||||
|
onClick: () => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
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>
|
||||||
|
<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'}>
|
||||||
|
{session.status === 'completed' ? (
|
||||||
|
<><CheckCircle className="w-3 h-3 mr-1" />完成</>
|
||||||
|
) : (
|
||||||
|
<><Clock className="w-3 h-3 mr-1" />进行中</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{session.createdAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Detail Panel Component ==========
|
||||||
|
|
||||||
|
interface DetailPanelProps {
|
||||||
|
sessionId: string;
|
||||||
|
projectPath: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanel({ sessionId, projectPath, onClose }: DetailPanelProps) {
|
||||||
|
const { data: detail, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['analysis-detail', sessionId, projectPath],
|
||||||
|
queryFn: () => fetchAnalysisDetail(sessionId, projectPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<span>加载失败: {(error as Error).message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) return null;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
const defaultTab = tabs[0]?.id || 'discussion';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
||||||
|
<div className="min-w-0 flex-1 mr-2">
|
||||||
|
<h2 className="font-semibold truncate">{detail.topic}</h2>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant={detail.status === 'completed' ? 'default' : 'secondary'} className="text-xs">
|
||||||
|
{detail.status === 'completed' ? '完成' : '进行中'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">{detail.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose} className="shrink-0">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Conclusions Tab */}
|
||||||
|
<TabsContent value="conclusions" className="mt-0 h-full">
|
||||||
|
{detail.conclusions && (
|
||||||
|
<JsonCardView data={detail.conclusions} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Explorations Tab */}
|
||||||
|
<TabsContent value="explorations" className="mt-0 h-full">
|
||||||
|
{detail.explorations && (
|
||||||
|
<JsonCardView data={detail.explorations} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Perspectives Tab */}
|
||||||
|
<TabsContent value="perspectives" className="mt-0 h-full">
|
||||||
|
{detail.perspectives && (
|
||||||
|
<JsonCardView data={detail.perspectives} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
暂无分析内容
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Component ==========
|
||||||
|
|
||||||
|
export function AnalysisPage() {
|
||||||
|
const projectPath = useWorkflowStore((state) => state.projectPath);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedSession, setSelectedSession] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: sessions = [], isLoading, error } = useQuery({
|
||||||
|
queryKey: ['analysis-sessions', projectPath],
|
||||||
|
queryFn: () => fetchAnalysisSessions(projectPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter sessions by search query
|
||||||
|
const filteredSessions = sessions.filter((session) =>
|
||||||
|
session.topic.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
session.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex overflow-hidden">
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索分析会话..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<span>加载失败: {(error as Error).message}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : filteredSessions.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<FileSearch className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{searchQuery ? '没有匹配的分析会话' : '暂无分析会话'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
使用 /workflow:analyze-with-file 命令创建分析
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{filteredSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
isSelected={selectedSession === session.id}
|
||||||
|
onClick={() => setSelectedSession(session.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Detail */}
|
||||||
|
{selectedSession && (
|
||||||
|
<div className="flex-1 border-l bg-background min-w-0">
|
||||||
|
<DetailPanel
|
||||||
|
sessionId={selectedSession}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onClose={() => setSelectedSession(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnalysisPage;
|
||||||
@@ -37,3 +37,4 @@ export { IssueManagerPage } from './IssueManagerPage';
|
|||||||
export { TeamPage } from './TeamPage';
|
export { TeamPage } from './TeamPage';
|
||||||
export { TerminalDashboardPage } from './TerminalDashboardPage';
|
export { TerminalDashboardPage } from './TerminalDashboardPage';
|
||||||
export { SkillHubPage } from './SkillHubPage';
|
export { SkillHubPage } from './SkillHubPage';
|
||||||
|
export { AnalysisPage } from './AnalysisPage';
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
CliSessionSharePage,
|
CliSessionSharePage,
|
||||||
TeamPage,
|
TeamPage,
|
||||||
TerminalDashboardPage,
|
TerminalDashboardPage,
|
||||||
|
AnalysisPage,
|
||||||
} from '@/pages';
|
} from '@/pages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,6 +170,10 @@ const routes: RouteObject[] = [
|
|||||||
path: 'teams',
|
path: 'teams',
|
||||||
element: <TeamPage />,
|
element: <TeamPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'analysis',
|
||||||
|
element: <AnalysisPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'terminal-dashboard',
|
path: 'terminal-dashboard',
|
||||||
element: <TerminalDashboardPage />,
|
element: <TerminalDashboardPage />,
|
||||||
@@ -234,6 +239,7 @@ export const ROUTES = {
|
|||||||
TEAMS: '/teams',
|
TEAMS: '/teams',
|
||||||
TERMINAL_DASHBOARD: '/terminal-dashboard',
|
TERMINAL_DASHBOARD: '/terminal-dashboard',
|
||||||
SKILL_HUB: '/skill-hub',
|
SKILL_HUB: '/skill-hub',
|
||||||
|
ANALYSIS: '/analysis',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
||||||
|
|||||||
85
ccw/frontend/src/types/analysis.ts
Normal file
85
ccw/frontend/src/types/analysis.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Analysis Session Types
|
||||||
|
* Types for the Analysis Viewer feature
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis session summary for list view
|
||||||
|
*/
|
||||||
|
export interface AnalysisSessionSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
topic: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
hasConclusions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis conclusions structure
|
||||||
|
*/
|
||||||
|
export interface AnalysisConclusions {
|
||||||
|
session_id: string;
|
||||||
|
topic: string;
|
||||||
|
completed: string;
|
||||||
|
total_rounds: number;
|
||||||
|
summary: string;
|
||||||
|
key_conclusions: Array<{
|
||||||
|
point: string;
|
||||||
|
evidence: string;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
}>;
|
||||||
|
recommendations: Array<{
|
||||||
|
action: string;
|
||||||
|
rationale: string;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
}>;
|
||||||
|
open_questions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis explorations structure
|
||||||
|
*/
|
||||||
|
export interface AnalysisExplorations {
|
||||||
|
session_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
topic: string;
|
||||||
|
dimensions: string[];
|
||||||
|
key_findings: string[];
|
||||||
|
discussion_points: string[];
|
||||||
|
open_questions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis perspectives structure
|
||||||
|
*/
|
||||||
|
export interface AnalysisPerspectives {
|
||||||
|
session_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
topic: string;
|
||||||
|
perspectives: Array<{
|
||||||
|
name: string;
|
||||||
|
tool: string;
|
||||||
|
findings: string[];
|
||||||
|
insights: string[];
|
||||||
|
}>;
|
||||||
|
synthesis: {
|
||||||
|
convergent_themes: string[];
|
||||||
|
conflicting_views: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis session detail
|
||||||
|
*/
|
||||||
|
export interface AnalysisSessionDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
topic: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
discussion: string | null;
|
||||||
|
conclusions: AnalysisConclusions | null;
|
||||||
|
explorations: AnalysisExplorations | null;
|
||||||
|
perspectives: AnalysisPerspectives | null;
|
||||||
|
}
|
||||||
214
ccw/src/core/routes/analysis-routes.ts
Normal file
214
ccw/src/core/routes/analysis-routes.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Analysis Routes Module
|
||||||
|
* Provides API endpoints for viewing analysis sessions from .workflow/.analysis/
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/analysis - Returns list of all analysis sessions
|
||||||
|
* - GET /api/analysis/:id - Returns detailed content of a specific session
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdir, readFile, stat } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { RouteContext } from './types.js';
|
||||||
|
import { resolvePath } from '../../utils/path-resolver.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis session summary for list view
|
||||||
|
*/
|
||||||
|
export interface AnalysisSessionSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
topic: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
hasConclusions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis session detail
|
||||||
|
*/
|
||||||
|
export interface AnalysisSessionDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
topic: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
discussion: string | null;
|
||||||
|
conclusions: Record<string, unknown> | null;
|
||||||
|
explorations: Record<string, unknown> | null;
|
||||||
|
perspectives: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse session folder name to extract metadata
|
||||||
|
*/
|
||||||
|
function parseSessionId(folderName: string): { slug: string; date: string } | null {
|
||||||
|
// Format: ANL-{slug}-{YYYY-MM-DD}
|
||||||
|
const match = folderName.match(/^ANL-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { slug: match[1], date: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read JSON file safely
|
||||||
|
*/
|
||||||
|
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read text file safely
|
||||||
|
*/
|
||||||
|
async function readTextFile(filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
return await readFile(filePath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get analysis session summary from folder
|
||||||
|
*/
|
||||||
|
async function getSessionSummary(
|
||||||
|
analysisDir: string,
|
||||||
|
folderName: string
|
||||||
|
): Promise<AnalysisSessionSummary | null> {
|
||||||
|
const parsed = parseSessionId(folderName);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const sessionPath = join(analysisDir, folderName);
|
||||||
|
const folderStat = await stat(sessionPath);
|
||||||
|
if (!folderStat.isDirectory()) return null;
|
||||||
|
|
||||||
|
const conclusionsPath = join(sessionPath, 'conclusions.json');
|
||||||
|
|
||||||
|
const hasConclusions = existsSync(conclusionsPath);
|
||||||
|
const conclusions = hasConclusions ? await readJsonFile(conclusionsPath) : null;
|
||||||
|
|
||||||
|
// Extract topic from conclusions or folder name
|
||||||
|
const topic = (conclusions?.topic as string) || parsed.slug.replace(/-/g, ' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: folderName,
|
||||||
|
name: folderName,
|
||||||
|
topic,
|
||||||
|
createdAt: parsed.date,
|
||||||
|
status: hasConclusions ? 'completed' : 'in_progress',
|
||||||
|
hasConclusions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed session content
|
||||||
|
*/
|
||||||
|
async function getSessionDetail(
|
||||||
|
analysisDir: string,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<AnalysisSessionDetail | null> {
|
||||||
|
const parsed = parseSessionId(sessionId);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const sessionPath = join(analysisDir, sessionId);
|
||||||
|
if (!existsSync(sessionPath)) return null;
|
||||||
|
|
||||||
|
const [discussion, conclusions, explorations, perspectives] = await Promise.all([
|
||||||
|
readTextFile(join(sessionPath, 'discussion.md')),
|
||||||
|
readJsonFile(join(sessionPath, 'conclusions.json')),
|
||||||
|
readJsonFile(join(sessionPath, 'explorations.json')),
|
||||||
|
readJsonFile(join(sessionPath, 'perspectives.json'))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const topic = (conclusions?.topic as string) || parsed.slug.replace(/-/g, ' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
name: sessionId,
|
||||||
|
topic,
|
||||||
|
createdAt: parsed.date,
|
||||||
|
status: conclusions ? 'completed' : 'in_progress',
|
||||||
|
discussion,
|
||||||
|
conclusions,
|
||||||
|
explorations,
|
||||||
|
perspectives
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle analysis routes
|
||||||
|
* @returns true if route was handled, false otherwise
|
||||||
|
*/
|
||||||
|
export async function handleAnalysisRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
|
const { pathname, req, res, initialPath } = ctx;
|
||||||
|
|
||||||
|
// GET /api/analysis - List all analysis sessions
|
||||||
|
if (pathname === '/api/analysis' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const projectPath = ctx.url.searchParams.get('projectPath') || initialPath;
|
||||||
|
const resolvedPath = resolvePath(projectPath);
|
||||||
|
const analysisDir = join(resolvedPath, '.workflow', '.analysis');
|
||||||
|
|
||||||
|
if (!existsSync(analysisDir)) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: [], total: 0 }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = await readdir(analysisDir);
|
||||||
|
const sessions: AnalysisSessionSummary[] = [];
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
const summary = await getSessionSummary(analysisDir, folder);
|
||||||
|
if (summary) sessions.push(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending
|
||||||
|
sessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: sessions, total: sessions.length }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/analysis/:id - Get session detail
|
||||||
|
const detailMatch = pathname.match(/^\/api\/analysis\/([^/]+)$/);
|
||||||
|
if (detailMatch && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const sessionId = decodeURIComponent(detailMatch[1]!);
|
||||||
|
const projectPath = ctx.url.searchParams.get('projectPath') || initialPath;
|
||||||
|
const resolvedPath = resolvePath(projectPath);
|
||||||
|
const analysisDir = join(resolvedPath, '.workflow', '.analysis');
|
||||||
|
|
||||||
|
const detail = await getSessionDetail(analysisDir, sessionId);
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Session not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: detail }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import { handleOrchestratorRoutes } from './routes/orchestrator-routes.js';
|
|||||||
import { handleConfigRoutes } from './routes/config-routes.js';
|
import { handleConfigRoutes } from './routes/config-routes.js';
|
||||||
import { handleTeamRoutes } from './routes/team-routes.js';
|
import { handleTeamRoutes } from './routes/team-routes.js';
|
||||||
import { handleNotificationRoutes } from './routes/notification-routes.js';
|
import { handleNotificationRoutes } from './routes/notification-routes.js';
|
||||||
|
import { handleAnalysisRoutes } from './routes/analysis-routes.js';
|
||||||
|
|
||||||
// Import WebSocket handling
|
// Import WebSocket handling
|
||||||
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
||||||
@@ -434,6 +435,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
if (await handleDashboardRoutes(routeContext)) return;
|
if (await handleDashboardRoutes(routeContext)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Analysis routes (/api/analysis/*)
|
||||||
|
if (pathname.startsWith('/api/analysis')) {
|
||||||
|
if (await handleAnalysisRoutes(routeContext)) return;
|
||||||
|
}
|
||||||
|
|
||||||
// CLI sessions (PTY) routes (/api/cli-sessions/*) - independent from /api/cli/*
|
// CLI sessions (PTY) routes (/api/cli-sessions/*) - independent from /api/cli/*
|
||||||
if (pathname.startsWith('/api/cli-sessions')) {
|
if (pathname.startsWith('/api/cli-sessions')) {
|
||||||
if (await handleCliSessionsRoutes(routeContext)) return;
|
if (await handleCliSessionsRoutes(routeContext)) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user