From 12be252e8e37da2daffe85c5171ddd21e5da35dd Mon Sep 17 00:00:00 2001 From: AXC00 <119300301+AXC00@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:04:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E6=B7=BB=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=9F=A5=E7=9C=8B=E5=99=A8=E9=A1=B5=E9=9D=A2=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AnalysisPage 页面查看 /workflow:analyze-with-file 分析结果 - 支持 Tab 分组展示:讨论记录、结论、代码探索、视角分析 - Markdown 内容富文本渲染,JSON 数据结构化卡片展示 - 添加后端 API 路由 /api/analysis - 添加侧边栏导航入口和中英文翻译 Co-authored-by: Claude Opus 4.5 --- .../src/components/layout/Sidebar.tsx | 2 + .../src/components/shared/JsonCardView.tsx | 223 ++++++++++++++ ccw/frontend/src/lib/api.ts | 35 +++ ccw/frontend/src/locales/en/navigation.json | 3 +- ccw/frontend/src/locales/zh/navigation.json | 3 +- ccw/frontend/src/pages/AnalysisPage.test.tsx | 122 ++++++++ ccw/frontend/src/pages/AnalysisPage.tsx | 284 ++++++++++++++++++ ccw/frontend/src/pages/index.ts | 1 + ccw/frontend/src/router.tsx | 6 + ccw/frontend/src/types/analysis.ts | 85 ++++++ ccw/src/core/routes/analysis-routes.ts | 214 +++++++++++++ ccw/src/core/server.ts | 6 + 12 files changed, 982 insertions(+), 2 deletions(-) create mode 100644 ccw/frontend/src/components/shared/JsonCardView.tsx create mode 100644 ccw/frontend/src/pages/AnalysisPage.test.tsx create mode 100644 ccw/frontend/src/pages/AnalysisPage.tsx create mode 100644 ccw/frontend/src/types/analysis.ts create mode 100644 ccw/src/core/routes/analysis-routes.ts diff --git a/ccw/frontend/src/components/layout/Sidebar.tsx b/ccw/frontend/src/components/layout/Sidebar.tsx index cd91e4ac..bbe6b0d6 100644 --- a/ccw/frontend/src/components/layout/Sidebar.tsx +++ b/ccw/frontend/src/components/layout/Sidebar.tsx @@ -25,6 +25,7 @@ import { Wrench, Cog, Users, + FileSearch, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; @@ -77,6 +78,7 @@ const navGroupDefinitions: NavGroupDef[] = [ { path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban }, { path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap }, { 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: '/terminal-dashboard', labelKey: 'navigation.main.terminalDashboard', icon: Terminal }, ], diff --git a/ccw/frontend/src/components/shared/JsonCardView.tsx b/ccw/frontend/src/components/shared/JsonCardView.tsx new file mode 100644 index 00000000..15a4fc9f --- /dev/null +++ b/ccw/frontend/src/components/shared/JsonCardView.tsx @@ -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 { + 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 null; + } + if (typeof value === 'boolean') { + return ( + + {value ? 'true' : 'false'} + + ); + } + if (typeof value === 'number') { + return {value}; + } + if (typeof value === 'string') { + // Check if it's a URL + if (value.startsWith('http://') || value.startsWith('https://')) { + return ( + + {value} + + ); + } + // Long text + if (value.length > 100) { + return ( +
+ {value} +
+ ); + } + return {value}; + } + return {String(value)}; +} + +function ArrayView({ items }: { items: unknown[] }) { + const [expanded, setExpanded] = useState(true); + + if (items.length === 0) { + return ( +
Empty list
+ ); + } + + // Simple array of primitives + const allPrimitives = items.every( + (item) => typeof item !== 'object' || item === null + ); + + if (allPrimitives) { + return ( +
+ {items.map((item, index) => ( + + {String(item)} + + ))} +
+ ); + } + + return ( +
+ + {expanded && ( +
+ {items.map((item, index) => ( + +
#{index + 1}
+ {isObject(item) ? ( + + ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} + +function ObjectView({ data, depth = 0 }: { data: Record; depth?: number }) { + const entries = Object.entries(data); + + if (entries.length === 0) { + return
Empty object
; + } + + return ( +
+ {entries.map(([key, value]) => ( + + ))} +
+ ); +} + +function CardItem({ label, value, depth = 0 }: CardItemProps) { + const formattedLabel = formatLabel(label); + + // Nested object + if (isObject(value)) { + return ( +
+
{formattedLabel}
+
1 && 'ml-2')}> + +
+
+ ); + } + + // Array + if (isArray(value)) { + return ( +
+
{formattedLabel}
+ +
+ ); + } + + // Primitive value + return ( +
+
+ {formattedLabel} +
+
+ +
+
+ ); +} + +// ========== Main Component ========== + +export function JsonCardView({ data, className }: JsonCardViewProps) { + if (!data) { + return ( +
No data available
+ ); + } + + // Handle array at root level + if (isArray(data)) { + return ( +
+ +
+ ); + } + + // Handle object + return ( +
+ } /> +
+ ); +} + +export default JsonCardView; diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 52990103..dda89c0c 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -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 { + 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 { + 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; +} diff --git a/ccw/frontend/src/locales/en/navigation.json b/ccw/frontend/src/locales/en/navigation.json index f4d57452..692e6f92 100644 --- a/ccw/frontend/src/locales/en/navigation.json +++ b/ccw/frontend/src/locales/en/navigation.json @@ -37,7 +37,8 @@ "graph": "Graph Explorer", "teams": "Team Execution", "terminalDashboard": "Terminal Dashboard", - "skillHub": "Skill Hub" + "skillHub": "Skill Hub", + "analysis": "Analysis Viewer" }, "sidebar": { "collapse": "Collapse", diff --git a/ccw/frontend/src/locales/zh/navigation.json b/ccw/frontend/src/locales/zh/navigation.json index 7e55a61f..12e63099 100644 --- a/ccw/frontend/src/locales/zh/navigation.json +++ b/ccw/frontend/src/locales/zh/navigation.json @@ -37,7 +37,8 @@ "graph": "图浏览器", "teams": "团队执行", "terminalDashboard": "终端仪表板", - "skillHub": "技能中心" + "skillHub": "技能中心", + "analysis": "分析查看器" }, "sidebar": { "collapse": "收起", diff --git a/ccw/frontend/src/pages/AnalysisPage.test.tsx b/ccw/frontend/src/pages/AnalysisPage.test.tsx new file mode 100644 index 00000000..74f91359 --- /dev/null +++ b/ccw/frontend/src/pages/AnalysisPage.test.tsx @@ -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 }) => ( + + {children} + + ); +}; + +describe('AnalysisPage', () => { + beforeEach(() => { + useWorkflowStore.setState({ projectPath: '/test/path' }); + vi.clearAllMocks(); + }); + + it('should render page title', async () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText('Analysis Viewer')).toBeInTheDocument(); + }); + + it('should render page description', async () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText(/查看.*analyze-with-file.*分析结果/)).toBeInTheDocument(); + }); + + it('should render search input', async () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByPlaceholderText('搜索分析会话...')).toBeInTheDocument(); + }); + + it('should show loading state initially', () => { + render(, { wrapper: createWrapper() }); + // Loading spinner should be present initially + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('should render session cards after loading', async () => { + render(, { 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(, { 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(, { wrapper: createWrapper() }); + + // Wait for sessions to load + await screen.findByText('Another Analysis'); + + // Check for in-progress badge + expect(screen.getByText('进行中')).toBeInTheDocument(); + }); +}); diff --git a/ccw/frontend/src/pages/AnalysisPage.tsx b/ccw/frontend/src/pages/AnalysisPage.tsx new file mode 100644 index 00000000..ab6f9745 --- /dev/null +++ b/ccw/frontend/src/pages/AnalysisPage.tsx @@ -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 ( + +
+
+
+ + {session.topic} +
+

{session.id}

+
+ +
+
+ + {session.status === 'completed' ? ( + <>完成 + ) : ( + <>进行中 + )} + + + + {session.createdAt} + +
+
+ ); +} + +// ========== 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ + 加载失败: {(error as Error).message} +
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+

{detail.topic}

+
+ + {detail.status === 'completed' ? '完成' : '进行中'} + + {detail.createdAt} +
+
+ +
+ + {/* Tabs Content */} + {tabs.length > 0 ? ( + + + {tabs.map(tab => ( + + + {tab.label} + + ))} + + +
+ {/* Discussion Tab */} + + {detail.discussion && ( + + )} + + + {/* Conclusions Tab */} + + {detail.conclusions && ( + + )} + + + {/* Explorations Tab */} + + {detail.explorations && ( + + )} + + + {/* Perspectives Tab */} + + {detail.perspectives && ( + + )} + +
+
+ ) : ( +
+ 暂无分析内容 +
+ )} +
+ ); +} + +// ========== Main Component ========== + +export function AnalysisPage() { + const projectPath = useWorkflowStore((state) => state.projectPath); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedSession, setSelectedSession] = useState(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 ( +
+ {/* Left Panel - List */} +
+ {/* Header */} +
+

+ + Analysis Viewer +

+

+ 查看 /workflow:analyze-with-file 命令的分析结果 +

+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + {/* Content */} + {isLoading ? ( +
+ +
+ ) : error ? ( + +
+ + 加载失败: {(error as Error).message} +
+
+ ) : filteredSessions.length === 0 ? ( + + +

+ {searchQuery ? '没有匹配的分析会话' : '暂无分析会话'} +

+

+ 使用 /workflow:analyze-with-file 命令创建分析 +

+
+ ) : ( +
+ {filteredSessions.map((session) => ( + setSelectedSession(session.id)} + /> + ))} +
+ )} +
+ + {/* Right Panel - Detail */} + {selectedSession && ( +
+ setSelectedSession(null)} + /> +
+ )} +
+ ); +} + +export default AnalysisPage; diff --git a/ccw/frontend/src/pages/index.ts b/ccw/frontend/src/pages/index.ts index 386befef..c7a0b0f6 100644 --- a/ccw/frontend/src/pages/index.ts +++ b/ccw/frontend/src/pages/index.ts @@ -37,3 +37,4 @@ export { IssueManagerPage } from './IssueManagerPage'; export { TeamPage } from './TeamPage'; export { TerminalDashboardPage } from './TerminalDashboardPage'; export { SkillHubPage } from './SkillHubPage'; +export { AnalysisPage } from './AnalysisPage'; diff --git a/ccw/frontend/src/router.tsx b/ccw/frontend/src/router.tsx index e26dce6b..e1f381a7 100644 --- a/ccw/frontend/src/router.tsx +++ b/ccw/frontend/src/router.tsx @@ -36,6 +36,7 @@ import { CliSessionSharePage, TeamPage, TerminalDashboardPage, + AnalysisPage, } from '@/pages'; /** @@ -169,6 +170,10 @@ const routes: RouteObject[] = [ path: 'teams', element: , }, + { + path: 'analysis', + element: , + }, { path: 'terminal-dashboard', element: , @@ -234,6 +239,7 @@ export const ROUTES = { TEAMS: '/teams', TERMINAL_DASHBOARD: '/terminal-dashboard', SKILL_HUB: '/skill-hub', + ANALYSIS: '/analysis', } as const; export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES]; diff --git a/ccw/frontend/src/types/analysis.ts b/ccw/frontend/src/types/analysis.ts new file mode 100644 index 00000000..04cd1917 --- /dev/null +++ b/ccw/frontend/src/types/analysis.ts @@ -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; +} diff --git a/ccw/src/core/routes/analysis-routes.ts b/ccw/src/core/routes/analysis-routes.ts new file mode 100644 index 00000000..b684d8ab --- /dev/null +++ b/ccw/src/core/routes/analysis-routes.ts @@ -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 | null; + explorations: Record | null; + perspectives: Record | 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 | 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index ee11d4b4..9ac47574 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -40,6 +40,7 @@ import { handleOrchestratorRoutes } from './routes/orchestrator-routes.js'; import { handleConfigRoutes } from './routes/config-routes.js'; import { handleTeamRoutes } from './routes/team-routes.js'; import { handleNotificationRoutes } from './routes/notification-routes.js'; +import { handleAnalysisRoutes } from './routes/analysis-routes.js'; // Import WebSocket handling import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js'; @@ -434,6 +435,11 @@ export async function startServer(options: ServerOptions = {}): Promise