feat: 添加多CLI计划支持,更新数据聚合和导航组件以处理新任务类型

This commit is contained in:
catlog22
2026-01-14 17:06:36 +08:00
parent 6ff3e5f8fe
commit aeb111420e
7 changed files with 451 additions and 25 deletions

View File

@@ -77,6 +77,7 @@ interface DashboardData {
liteTasks: {
litePlan: unknown[];
liteFix: unknown[];
multiCliPlan: unknown[];
};
reviewData: ReviewData | null;
projectOverview: ProjectOverview | null;
@@ -88,6 +89,7 @@ interface DashboardData {
reviewFindings: number;
litePlanCount: number;
liteFixCount: number;
multiCliPlanCount: number;
};
}
@@ -211,7 +213,8 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s
archivedSessions: [],
liteTasks: {
litePlan: [],
liteFix: []
liteFix: [],
multiCliPlan: []
},
reviewData: null,
projectOverview: null,
@@ -222,7 +225,8 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s
completedTasks: 0,
reviewFindings: 0,
litePlanCount: 0,
liteFixCount: 0
liteFixCount: 0,
multiCliPlanCount: 0
}
};
@@ -257,6 +261,7 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s
data.liteTasks = liteTasks;
data.statistics.litePlanCount = liteTasks.litePlan.length;
data.statistics.liteFixCount = liteTasks.liteFix.length;
data.statistics.multiCliPlanCount = liteTasks.multiCliPlan.length;
} catch (err) {
console.error('Error scanning lite tasks:', (err as Error).message);
}

View File

@@ -60,6 +60,43 @@ interface LiteSession {
progress: Progress;
}
// Multi-CLI specific session state from session-state.json
interface MultiCliSessionState {
session_id: string;
task_description: string;
status: string;
current_phase: number;
phases: Record<string, { status: string; rounds_completed?: number }>;
ace_context?: { relevant_files: string[]; detected_patterns: string[] };
user_decisions?: Array<{ round: number; decision: string; selected: string }>;
updated_at?: string;
}
// Discussion topic structure for frontend rendering
interface DiscussionTopic {
title: string;
description: string;
scope: { included: string[]; excluded: string[] };
keyQuestions: string[];
status: string;
tags: string[];
}
// Extended session interface for multi-cli-plan
interface MultiCliSession extends LiteSession {
roundCount: number;
topicTitle: string;
status: string;
metadata: {
roundId: number;
timestamp: string;
currentPhase: number;
};
discussionTopic: DiscussionTopic;
rounds: RoundSynthesis[];
latestSynthesis: RoundSynthesis | null;
}
interface LiteTasks {
litePlan: LiteSession[];
liteFix: LiteSession[];
@@ -145,12 +182,53 @@ async function scanLiteDir(dir: string, type: string): Promise<LiteSession[]> {
}
}
/**
* Load session-state.json from multi-cli session directory
* @param sessionPath - Session directory path
* @returns Session state or null if not found
*/
async function loadSessionState(sessionPath: string): Promise<MultiCliSessionState | null> {
const statePath = join(sessionPath, 'session-state.json');
try {
const content = await readFile(statePath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* Build discussion topic structure from session state and synthesis
* @param state - Session state from session-state.json
* @param synthesis - Latest round synthesis
* @returns Discussion topic for frontend rendering
*/
function buildDiscussionTopic(
state: MultiCliSessionState | null,
synthesis: RoundSynthesis | null
): DiscussionTopic {
const keyQuestions = synthesis?.clarification_questions || [];
const solutions = synthesis?.solutions || [];
return {
title: state?.task_description || 'Discussion Topic',
description: solutions[0]?.summary || '',
scope: {
included: state?.ace_context?.relevant_files || [],
excluded: [],
},
keyQuestions,
status: state?.status || 'analyzing',
tags: solutions.map((s) => s.name).slice(0, 3),
};
}
/**
* Scan multi-cli-plan directory for sessions
* @param dir - Directory path to .multi-cli-plan
* @returns Array of multi-cli sessions
* @returns Array of multi-cli sessions with extended metadata
*/
async function scanMultiCliDir(dir: string): Promise<LiteSession[]> {
async function scanMultiCliDir(dir: string): Promise<MultiCliSession[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
@@ -160,18 +238,27 @@ async function scanMultiCliDir(dir: string): Promise<LiteSession[]> {
.map(async (entry) => {
const sessionPath = join(dir, entry.name);
const [createdAt, syntheses] = await Promise.all([
const [createdAt, syntheses, sessionState] = await Promise.all([
getCreatedTime(sessionPath),
loadRoundSyntheses(sessionPath),
loadSessionState(sessionPath),
]);
// Extract plan from latest synthesis if available
// Extract data from syntheses
const roundCount = syntheses.length;
const latestSynthesis = syntheses.length > 0 ? syntheses[syntheses.length - 1] : null;
// Calculate progress based on round count and convergence
const progress = calculateMultiCliProgress(syntheses);
const session: LiteSession = {
// Build discussion topic for frontend
const discussionTopic = buildDiscussionTopic(sessionState, latestSynthesis);
// Determine status from session state or synthesis convergence
const status = sessionState?.status ||
(latestSynthesis?.convergence?.recommendation === 'converged' ? 'converged' : 'analyzing');
const session: MultiCliSession = {
id: entry.name,
type: 'multi-cli-plan',
path: sessionPath,
@@ -179,12 +266,24 @@ async function scanMultiCliDir(dir: string): Promise<LiteSession[]> {
plan: latestSynthesis,
tasks: extractTasksFromSyntheses(syntheses),
progress,
// Extended multi-cli specific fields
roundCount,
topicTitle: sessionState?.task_description || 'Discussion Topic',
status,
metadata: {
roundId: roundCount,
timestamp: sessionState?.updated_at || createdAt,
currentPhase: sessionState?.current_phase || 1,
},
discussionTopic,
rounds: syntheses,
latestSynthesis,
};
return session;
}),
))
.filter((session): session is LiteSession => session !== null)
.filter((session): session is MultiCliSession => session !== null)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;

View File

@@ -102,6 +102,87 @@
color: hsl(220 80% 40%);
}
/* Session Status Badge (used in detail page header) */
.session-status-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
text-transform: lowercase;
}
.session-status-badge.plan_generated,
.session-status-badge.converged,
.session-status-badge.completed,
.session-status-badge.decided {
background: hsl(var(--success-light, 142 70% 95%));
color: hsl(var(--success, 142 70% 45%));
}
.session-status-badge.analyzing,
.session-status-badge.debating {
background: hsl(var(--warning-light, 45 90% 95%));
color: hsl(var(--warning, 45 90% 40%));
}
.session-status-badge.initialized,
.session-status-badge.exploring {
background: hsl(var(--info-light, 220 80% 95%));
color: hsl(var(--info, 220 80% 55%));
}
.session-status-badge.blocked,
.session-status-badge.conflict {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
.session-status-badge.pending {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
/* Status Badge Colors (used in card list meta) */
.session-meta-item.status-badge.success {
background: hsl(var(--success-light, 142 70% 95%));
color: hsl(var(--success, 142 70% 45%));
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.session-meta-item.status-badge.warning {
background: hsl(var(--warning-light, 45 90% 95%));
color: hsl(var(--warning, 45 90% 40%));
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.session-meta-item.status-badge.info {
background: hsl(var(--info-light, 220 80% 95%));
color: hsl(var(--info, 220 80% 55%));
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.session-meta-item.status-badge.error {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.session-meta-item.status-badge.default {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.session-body {
display: flex;
flex-direction: column;

View File

@@ -1368,6 +1368,43 @@
white-space: pre-wrap;
}
/* Topic Meta Container */
.topic-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
/* Tag Badge */
.tag-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
/* Additional Multi-CLI Status Variants */
.multi-cli-status.plan_generated {
background: hsl(var(--success-light, 142 70% 95%));
color: hsl(var(--success, 142 70% 45%));
}
.multi-cli-status.initialized,
.multi-cli-status.exploring {
background: hsl(var(--info-light, 220 80% 95%));
color: hsl(var(--info, 220 80% 55%));
}
.multi-cli-status.completed {
background: hsl(var(--success-light, 142 70% 95%));
color: hsl(var(--success, 142 70% 45%));
}
.multi-cli-complexity-badge {
display: inline-flex;
align-items: center;

View File

@@ -321,10 +321,10 @@ function updateSidebarCounts(data) {
if (archivedCount) archivedCount.textContent = data.archivedSessions?.length || 0;
if (allCount) allCount.textContent = (data.activeSessions?.length || 0) + (data.archivedSessions?.length || 0);
// Update lite task counts
const litePlanCount = document.querySelector('.nav-item[data-lite="lite-plan"] .nav-count');
const liteFixCount = document.querySelector('.nav-item[data-lite="lite-fix"] .nav-count');
const multiCliPlanCount = document.querySelector('.nav-item[data-lite="multi-cli-plan"] .nav-count');
// Update lite task counts (using ID selectors to match dashboard.html structure)
const litePlanCount = document.getElementById('badgeLitePlan');
const liteFixCount = document.getElementById('badgeLiteFix');
const multiCliPlanCount = document.getElementById('badgeMultiCliPlan');
if (litePlanCount) litePlanCount.textContent = data.liteTasks?.litePlan?.length || 0;
if (liteFixCount) liteFixCount.textContent = data.liteTasks?.liteFix?.length || 0;

View File

@@ -51,6 +51,7 @@ function updateBadges() {
const liteTasks = workflowData.liteTasks || {};
document.getElementById('badgeLitePlan').textContent = liteTasks.litePlan?.length || 0;
document.getElementById('badgeLiteFix').textContent = liteTasks.liteFix?.length || 0;
document.getElementById('badgeMultiCliPlan').textContent = liteTasks.multiCliPlan?.length || 0;
// MCP badge - load async if needed
if (typeof loadMcpConfig === 'function') {

View File

@@ -105,7 +105,10 @@ function renderMultiCliCard(session) {
const statusColors = {
'decided': 'success',
'converged': 'success',
'plan_generated': 'success',
'completed': 'success',
'exploring': 'info',
'initialized': 'info',
'analyzing': 'warning',
'debating': 'warning',
'blocked': 'error',
@@ -147,6 +150,203 @@ function getI18nText(label) {
return label[lang] || label.en || label.zh || '';
}
// ============================================
// SYNTHESIS DATA TRANSFORMATION HELPERS
// ============================================
/**
* Extract files from synthesis solutions[].implementation_plan.tasks[].files
* Returns object with fileTree and impactSummary arrays
*/
function extractFilesFromSynthesis(synthesis) {
if (!synthesis || !synthesis.solutions) {
return { fileTree: [], impactSummary: [], dependencyGraph: [] };
}
const fileSet = new Set();
const impactMap = new Map();
synthesis.solutions.forEach(solution => {
const tasks = solution.implementation_plan?.tasks || [];
tasks.forEach(task => {
const files = task.files || [];
files.forEach(filePath => {
fileSet.add(filePath);
// Build impact summary based on task context
if (!impactMap.has(filePath)) {
impactMap.set(filePath, {
filePath: filePath,
score: 'medium',
reasoning: { en: `Part of ${solution.title?.en || solution.id} implementation`, zh: `${solution.title?.zh || solution.id} 实现的一部分` }
});
}
});
});
});
// Convert to fileTree format (flat list with file type)
const fileTree = Array.from(fileSet).map(path => ({
path: path,
type: 'file',
modificationStatus: 'modified',
impactScore: 'medium'
}));
return {
fileTree: fileTree,
impactSummary: Array.from(impactMap.values()),
dependencyGraph: []
};
}
/**
* Extract planning data from synthesis solutions[].implementation_plan
* Builds planning object with functional requirements format
*/
function extractPlanningFromSynthesis(synthesis) {
if (!synthesis || !synthesis.solutions) {
return { functional: [], nonFunctional: [], acceptanceCriteria: [] };
}
const functional = [];
const acceptanceCriteria = [];
let reqCounter = 1;
let acCounter = 1;
synthesis.solutions.forEach(solution => {
const plan = solution.implementation_plan;
if (!plan) return;
// Extract approach as functional requirement
if (plan.approach) {
functional.push({
id: `FR-${String(reqCounter++).padStart(3, '0')}`,
description: plan.approach,
priority: solution.feasibility?.score >= 0.8 ? 'high' : 'medium',
source: solution.id
});
}
// Extract tasks as acceptance criteria
const tasks = plan.tasks || [];
tasks.forEach(task => {
acceptanceCriteria.push({
id: `AC-${String(acCounter++).padStart(3, '0')}`,
description: task.title || { en: task.id, zh: task.id },
isMet: false
});
});
});
return {
functional: functional,
nonFunctional: [],
acceptanceCriteria: acceptanceCriteria
};
}
/**
* Extract decision data from synthesis solutions
* Sorts by feasibility score, returns highest as selected, rest as rejected
*/
function extractDecisionFromSynthesis(synthesis) {
if (!synthesis || !synthesis.solutions || synthesis.solutions.length === 0) {
return {};
}
// Sort solutions by feasibility score (highest first)
const sortedSolutions = [...synthesis.solutions].sort((a, b) => {
const scoreA = a.feasibility?.score || 0;
const scoreB = b.feasibility?.score || 0;
return scoreB - scoreA;
});
const selectedSolution = sortedSolutions[0];
const rejectedAlternatives = sortedSolutions.slice(1).map(sol => ({
...sol,
rejectionReason: sol.cons?.length > 0 ? sol.cons[0] : { en: 'Lower feasibility score', zh: '可行性评分较低' }
}));
// Calculate confidence from convergence level
let confidenceScore = 0.5;
if (synthesis.convergence) {
const level = synthesis.convergence.level;
if (level === 'high' || level === 'converged') confidenceScore = 0.9;
else if (level === 'medium') confidenceScore = 0.7;
else if (level === 'low') confidenceScore = 0.4;
}
return {
status: synthesis.convergence?.recommendation || 'pending',
summary: synthesis.convergence?.summary || {},
selectedSolution: selectedSolution,
rejectedAlternatives: rejectedAlternatives,
confidenceScore: confidenceScore
};
}
/**
* Extract timeline data from synthesis convergence and cross_verification
* Builds timeline array with events from discussion process
*/
function extractTimelineFromSynthesis(synthesis) {
if (!synthesis) {
return [];
}
const timeline = [];
const now = new Date().toISOString();
// Add convergence summary as decision event
if (synthesis.convergence?.summary) {
timeline.push({
type: 'decision',
timestamp: now,
summary: synthesis.convergence.summary,
contributor: { name: 'Synthesis', id: 'synthesis' },
reversibility: synthesis.convergence.recommendation === 'proceed' ? 'irreversible' : 'reversible'
});
}
// Add cross-verification agreements as agreement events
const agreements = synthesis.cross_verification?.agreements || [];
agreements.forEach((agreement, idx) => {
timeline.push({
type: 'agreement',
timestamp: now,
summary: typeof agreement === 'string' ? { en: agreement, zh: agreement } : agreement,
contributor: { name: 'Cross-Verification', id: 'cross-verify' },
evidence: []
});
});
// Add cross-verification disagreements as disagreement events
const disagreements = synthesis.cross_verification?.disagreements || [];
disagreements.forEach((disagreement, idx) => {
timeline.push({
type: 'disagreement',
timestamp: now,
summary: typeof disagreement === 'string' ? { en: disagreement, zh: disagreement } : disagreement,
contributor: { name: 'Cross-Verification', id: 'cross-verify' },
evidence: []
});
});
// Add solutions as proposal events
const solutions = synthesis.solutions || [];
solutions.forEach(solution => {
timeline.push({
type: 'proposal',
timestamp: now,
summary: solution.description || solution.title || {},
contributor: { name: solution.id, id: solution.id },
evidence: solution.pros?.map(p => ({ type: 'pro', description: p })) || []
});
});
return timeline;
}
/**
* Show multi-cli detail page with tabs
*/
@@ -317,11 +517,11 @@ function renderMultiCliTopicTab(session) {
// Title and Description
sections.push(`
<div class="multi-cli-section topic-header-section">
<h3 class="topic-main-title">${escapeHtml(title)}</h3>
${description ? `<p class="topic-description">${escapeHtml(description)}</p>` : ''}
<div class="multi-cli-topic-section">
<h3 class="multi-cli-topic-title">${escapeHtml(title)}</h3>
${description ? `<p class="multi-cli-topic-description">${escapeHtml(description)}</p>` : ''}
<div class="topic-meta">
<span class="status-badge ${status}">${escapeHtml(status)}</span>
<span class="multi-cli-status ${status}">${escapeHtml(status)}</span>
${tags.length ? tags.map(tag => `<span class="tag-badge">${escapeHtml(tag)}</span>`).join('') : ''}
</div>
</div>
@@ -372,9 +572,10 @@ function renderMultiCliTopicTab(session) {
* Shows: fileTree, impactSummary
*/
function renderMultiCliFilesTab(session) {
const relatedFiles = session.relatedFiles || session.latestSynthesis?.relatedFiles || {};
// Use helper to extract files from synthesis data structure
const relatedFiles = extractFilesFromSynthesis(session.latestSynthesis);
if (!relatedFiles || Object.keys(relatedFiles).length === 0) {
if (!relatedFiles || (!relatedFiles.fileTree?.length && !relatedFiles.impactSummary?.length)) {
return `
<div class="tab-empty-state">
<div class="empty-icon"><i data-lucide="folder-tree" class="w-12 h-12"></i></div>
@@ -498,9 +699,10 @@ function renderFileTreeNodes(nodes, depth = 0) {
* Shows: functional, nonFunctional requirements, acceptanceCriteria
*/
function renderMultiCliPlanningTab(session) {
const planning = session.planning || session.latestSynthesis?.planning || {};
// Use helper to extract planning from synthesis data structure
const planning = extractPlanningFromSynthesis(session.latestSynthesis);
if (!planning || Object.keys(planning).length === 0) {
if (!planning || (!planning.functional?.length && !planning.acceptanceCriteria?.length)) {
return `
<div class="tab-empty-state">
<div class="empty-icon"><i data-lucide="list-checks" class="w-12 h-12"></i></div>
@@ -596,9 +798,10 @@ function renderRequirementItem(req) {
* Shows: selectedSolution, rejectedAlternatives, confidenceScore
*/
function renderMultiCliDecisionTab(session) {
const decision = session.decision || session.latestSynthesis?.decision || {};
// Use helper to extract decision from synthesis data structure
const decision = extractDecisionFromSynthesis(session.latestSynthesis);
if (!decision || Object.keys(decision).length === 0) {
if (!decision || !decision.selectedSolution) {
return `
<div class="tab-empty-state">
<div class="empty-icon"><i data-lucide="check-circle" class="w-12 h-12"></i></div>
@@ -708,10 +911,10 @@ function renderSolutionCard(solution, isSelected) {
* Shows: decisionRecords.timeline
*/
function renderMultiCliTimelineTab(session) {
const decisionRecords = session.decisionRecords || session.latestSynthesis?.decisionRecords || {};
const timeline = decisionRecords.timeline || [];
// Use helper to extract timeline from synthesis data structure
const timeline = extractTimelineFromSynthesis(session.latestSynthesis);
if (!timeline.length) {
if (!timeline || !timeline.length) {
return `
<div class="tab-empty-state">
<div class="empty-icon"><i data-lucide="git-commit" class="w-12 h-12"></i></div>