feat: Enhance team lifecycle roles with checkpoint handling and inner loop execution

- Added checkpoint gate handling to the coordinator role, defining behavior based on quality gate results.
- Updated planner role to utilize inner loop pattern for structured implementation planning and reporting.
- Revised writer role to implement inner loop for document generation, delegating CLI execution to a subagent.
- Introduced a new doc-generation subagent for isolated CLI calls and document generation strategies.
- Enhanced UI components in the frontend to display job statuses, last run times, and improved error handling.
- Updated localization files to include new strings for job details and status banners.
- Improved CSS styles for markdown previews to enhance readability and presentation.
This commit is contained in:
catlog22
2026-02-27 14:45:38 +08:00
parent b449b225fe
commit 3db74cc7b0
15 changed files with 1110 additions and 48 deletions

View File

@@ -53,10 +53,10 @@
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.0",
"web-vitals": "^5.1.0",
"zod": "^4.1.13",
"zustand": "^5.0.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
"xterm-addon-fit": "^0.8.0",
"zod": "^4.1.13",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",

View File

@@ -5,6 +5,7 @@
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { formatDistanceToNow } from 'date-fns';
import {
Zap,
CheckCircle,
@@ -17,11 +18,17 @@ import {
FileText,
Database,
Activity,
Copy,
Check,
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import 'highlight.js/styles/github-dark.css';
import {
useExtractionStatus,
useConsolidationStatus,
@@ -31,6 +38,24 @@ import {
} from '@/hooks/useMemoryV2';
import { cn } from '@/lib/utils';
// ========== Helper Functions ==========
/**
* Format a timestamp to relative time string
*/
function formatRelativeTime(timestamp: number | string | undefined): string | null {
if (!timestamp) return null;
try {
const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp);
if (isNaN(date.getTime())) return null;
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return null;
}
}
// ========== Status Badge ==========
const STATUS_CONFIG: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
@@ -66,6 +91,7 @@ function ExtractionCard() {
// Check if any job is running
const hasRunningJob = status?.jobs?.some(j => j.status === 'running');
const lastRunText = formatRelativeTime(status?.lastRun);
return (
<Card className="p-4">
@@ -78,6 +104,11 @@ function ExtractionCard() {
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.description', defaultMessage: 'Extract structured memories from CLI sessions' })}
</p>
{lastRunText && (
<p className="text-xs text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.lastRun', defaultMessage: 'Last run' })}: {lastRunText}
</p>
)}
</div>
{status && (
<div className="text-right">
@@ -150,12 +181,24 @@ function ConsolidationCard() {
const { data: status, isLoading, refetch } = useConsolidationStatus();
const trigger = useTriggerConsolidation();
const [showPreview, setShowPreview] = useState(false);
const [copied, setCopied] = useState(false);
const handleTrigger = () => {
trigger.mutate();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(status?.memoryMdPreview || '');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const isRunning = status?.status === 'running';
const lastRunText = formatRelativeTime(status?.lastRun);
return (
<>
@@ -169,6 +212,11 @@ function ConsolidationCard() {
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.consolidation.description', defaultMessage: 'Merge extracted results into MEMORY.md' })}
</p>
{lastRunText && (
<p className="text-xs text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.consolidation.lastRun', defaultMessage: 'Last run' })}: {lastRunText}
</p>
)}
</div>
{status && <StatusBadge status={status.status} />}
</div>
@@ -226,17 +274,43 @@ function ConsolidationCard() {
{/* MEMORY.md Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
MEMORY.md
<DialogTitle className="flex items-center justify-between pr-8">
<span className="flex items-center gap-2">
<FileText className="w-5 h-5" />
MEMORY.md
</span>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 px-2"
title={copied
? intl.formatMessage({ id: 'memory.v2.consolidation.copySuccess', defaultMessage: 'Copied' })
: intl.formatMessage({ id: 'memory.actions.copy', defaultMessage: 'Copy' })
}
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</DialogTitle>
</DialogHeader>
<div className="overflow-auto flex-1">
<pre className="text-sm whitespace-pre-wrap p-4 bg-muted rounded font-mono">
{status?.memoryMdPreview || 'No content available'}
</pre>
<div className="overflow-auto flex-1 markdown-preview">
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{status?.memoryMdPreview || intl.formatMessage({
id: 'memory.v2.consolidation.noContent',
defaultMessage: 'No content available'
})}
</ReactMarkdown>
</div>
</div>
</DialogContent>
</Dialog>
@@ -246,10 +320,55 @@ function ConsolidationCard() {
// ========== Jobs List ==========
interface V2Job {
kind: string;
job_key: string;
status: 'pending' | 'running' | 'done' | 'error';
last_error?: string;
worker_id?: string;
started_at?: number;
finished_at?: number;
retry_remaining?: number;
}
function JobsList() {
const intl = useIntl();
const [kindFilter, setKindFilter] = useState<string>('');
const { data, isLoading, refetch } = useV2Jobs(kindFilter ? { kind: kindFilter } : undefined);
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedJob, setSelectedJob] = useState<V2Job | null>(null);
const [copiedError, setCopiedError] = useState(false);
// Build filters object
const filters = {
...(kindFilter && { kind: kindFilter }),
...(statusFilter && { status_filter: statusFilter }),
};
const { data, isLoading, refetch } = useV2Jobs(Object.keys(filters).length > 0 ? filters : undefined);
// Format timestamp to readable string
const formatTimestamp = (timestamp: number | undefined): string => {
if (!timestamp) return '-';
try {
const date = new Date(timestamp);
return date.toLocaleString();
} catch {
return '-';
}
};
// Copy error to clipboard
const handleCopyError = async () => {
if (selectedJob?.last_error) {
try {
await navigator.clipboard.writeText(selectedJob.last_error);
setCopiedError(true);
setTimeout(() => setCopiedError(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
return (
<Card className="p-4">
@@ -264,9 +383,36 @@ function JobsList() {
onChange={(e) => setKindFilter(e.target.value)}
className="px-2 py-1 text-sm border rounded bg-background"
>
<option value="">All Kinds</option>
<option value="phase1_extraction">Extraction</option>
<option value="memory_consolidate_global">Consolidation</option>
<option value="">
{intl.formatMessage({ id: 'memory.v2.jobs.allKinds', defaultMessage: 'All Kinds' })}
</option>
<option value="phase1_extraction">
{intl.formatMessage({ id: 'memory.v2.jobs.extraction', defaultMessage: 'Extraction' })}
</option>
<option value="memory_consolidate_global">
{intl.formatMessage({ id: 'memory.v2.jobs.consolidation', defaultMessage: 'Consolidation' })}
</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-2 py-1 text-sm border rounded bg-background"
>
<option value="">
{intl.formatMessage({ id: 'memory.v2.jobs.statusFilter.all', defaultMessage: 'All Status' })}
</option>
<option value="pending">
{intl.formatMessage({ id: 'memory.v2.jobs.statusFilter.pending', defaultMessage: 'Pending' })}
</option>
<option value="running">
{intl.formatMessage({ id: 'memory.v2.jobs.statusFilter.running', defaultMessage: 'Running' })}
</option>
<option value="done">
{intl.formatMessage({ id: 'memory.v2.jobs.statusFilter.done', defaultMessage: 'Done' })}
</option>
<option value="error">
{intl.formatMessage({ id: 'memory.v2.jobs.statusFilter.error', defaultMessage: 'Error' })}
</option>
</select>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
@@ -295,7 +441,11 @@ function JobsList() {
</thead>
<tbody>
{data.jobs.map((job) => (
<tr key={`${job.kind}-${job.job_key}`} className="border-b">
<tr
key={`${job.kind}-${job.job_key}`}
className="border-b cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setSelectedJob(job)}
>
<td className="p-2">
<Badge variant="outline" className="text-xs">
{job.kind === 'phase1_extraction' ? 'Extraction' :
@@ -330,15 +480,164 @@ function JobsList() {
</span>
</div>
)}
{/* Job Detail Dialog */}
<Dialog open={!!selectedJob} onOpenChange={() => setSelectedJob(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{intl.formatMessage({ id: 'memory.v2.jobs.detail.title', defaultMessage: 'Job Details' })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.kind', defaultMessage: 'Kind' })}
</label>
<p className="text-sm font-medium">
{selectedJob?.kind === 'phase1_extraction' ? 'Extraction' :
selectedJob?.kind === 'memory_consolidate_global' ? 'Consolidation' : selectedJob?.kind}
</p>
</div>
<div>
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.status', defaultMessage: 'Status' })}
</label>
<div className="mt-1">
{selectedJob && <StatusBadge status={selectedJob.status} />}
</div>
</div>
<div className="col-span-2">
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.jobKey', defaultMessage: 'Job ID' })}
</label>
<p className="text-sm font-mono break-all">{selectedJob?.job_key}</p>
</div>
<div>
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.startedAt', defaultMessage: 'Started At' })}
</label>
<p className="text-sm">{formatTimestamp(selectedJob?.started_at)}</p>
</div>
<div>
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.finishedAt', defaultMessage: 'Finished At' })}
</label>
<p className="text-sm">{formatTimestamp(selectedJob?.finished_at)}</p>
</div>
<div>
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.workerId', defaultMessage: 'Worker ID' })}
</label>
<p className="text-sm font-mono truncate">{selectedJob?.worker_id || '-'}</p>
</div>
<div>
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.retryRemaining', defaultMessage: 'Retry Remaining' })}
</label>
<p className="text-sm">{selectedJob?.retry_remaining ?? '-'}</p>
</div>
</div>
{/* Error Section */}
{selectedJob?.last_error && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.error', defaultMessage: 'Error' })}
</label>
<Button
variant="ghost"
size="sm"
onClick={handleCopyError}
className="h-6 px-2"
title={copiedError
? intl.formatMessage({ id: 'memory.actions.copySuccess', defaultMessage: 'Copied' })
: intl.formatMessage({ id: 'memory.actions.copy', defaultMessage: 'Copy' })
}
>
{copiedError ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3" />
)}
</Button>
</div>
<pre className="text-xs bg-red-50 dark:bg-red-950/30 text-red-800 dark:text-red-300 p-3 rounded overflow-auto max-h-40 whitespace-pre-wrap break-all">
{selectedJob.last_error}
</pre>
</div>
)}
{!selectedJob?.last_error && (
<div>
<label className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.error', defaultMessage: 'Error' })}
</label>
<p className="text-sm text-muted-foreground italic">
{intl.formatMessage({ id: 'memory.v2.jobs.detail.noError', defaultMessage: 'No error' })}
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</Card>
);
}
// ========== Pipeline Status Banner ==========
function PipelineStatusBanner() {
const intl = useIntl();
const { data } = useV2Jobs();
// Detect running and error jobs
const jobs = data?.jobs || [];
const runningJobs = jobs.filter(j => j.status === 'running');
const errorJobs = jobs.filter(j => j.status === 'error');
const hasRunningJobs = runningJobs.length > 0;
const errorCount = errorJobs.length;
const runningCount = runningJobs.length;
if (hasRunningJobs) {
return (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
<span className="text-sm text-blue-700 dark:text-blue-300">
{intl.formatMessage(
{ id: 'memory.v2.statusBanner.running', defaultMessage: 'Pipeline Running - {count} job(s) in progress' },
{ count: runningCount }
)}
</span>
</div>
);
}
if (errorCount > 0) {
return (
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-yellow-700 dark:text-yellow-300">
{intl.formatMessage(
{ id: 'memory.v2.statusBanner.hasErrors', defaultMessage: 'Pipeline Idle - {count} job(s) failed' },
{ count: errorCount }
)}
</span>
</div>
);
}
return null;
}
// ========== Main Component ==========
export function V2PipelineTab() {
return (
<div className="space-y-4">
<PipelineStatusBanner />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ExtractionCard />
<ConsolidationCard />

View File

@@ -744,3 +744,125 @@
[data-reduced-motion="true"] .bg-image-layer {
transition: none !important;
}
/* ===========================
Markdown Preview Styles
=========================== */
.markdown-preview {
padding: 1rem;
}
.markdown-preview .prose {
color: hsl(var(--text));
max-width: none;
}
.markdown-preview .prose h1,
.markdown-preview .prose h2,
.markdown-preview .prose h3,
.markdown-preview .prose h4 {
color: hsl(var(--text));
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
}
.markdown-preview .prose h1 { font-size: 1.5em; }
.markdown-preview .prose h2 { font-size: 1.25em; }
.markdown-preview .prose h3 { font-size: 1.125em; }
.markdown-preview .prose h4 { font-size: 1em; }
.markdown-preview .prose p {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.markdown-preview .prose ul,
.markdown-preview .prose ol {
margin-top: 0.75em;
margin-bottom: 0.75em;
padding-left: 1.5em;
}
.markdown-preview .prose li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.markdown-preview .prose code {
background-color: hsl(var(--muted));
padding: 0.125em 0.375em;
border-radius: 0.25em;
font-size: 0.875em;
}
.markdown-preview .prose pre {
background-color: hsl(var(--muted));
padding: 1em;
border-radius: 0.5em;
overflow-x: auto;
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.markdown-preview .prose pre code {
background-color: transparent;
padding: 0;
font-size: 0.875em;
}
.markdown-preview .prose blockquote {
border-left: 3px solid hsl(var(--accent));
padding-left: 1em;
margin-left: 0;
color: hsl(var(--text-secondary));
font-style: italic;
}
.markdown-preview .prose a {
color: hsl(var(--accent));
text-decoration: underline;
}
.markdown-preview .prose a:hover {
opacity: 0.8;
}
.markdown-preview .prose table {
width: 100%;
border-collapse: collapse;
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.markdown-preview .prose th,
.markdown-preview .prose td {
border: 1px solid hsl(var(--border));
padding: 0.5em 0.75em;
text-align: left;
}
.markdown-preview .prose th {
background-color: hsl(var(--muted));
font-weight: 600;
}
.markdown-preview .prose hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin-top: 1.5em;
margin-bottom: 1.5em;
}
/* Highlight.js overrides for dark theme */
.markdown-preview .hljs {
background: transparent !important;
}
[data-theme^="dark"] .markdown-preview .prose pre {
background-color: hsl(220 25% 12%);
}
[data-theme^="dark"] .markdown-preview .prose code {
background-color: hsl(220 25% 18%);
}

View File

@@ -1642,6 +1642,7 @@ export async function unarchiveMemory(memoryId: string, projectPath?: string): P
export interface ExtractionStatus {
total_stage1: number;
lastRun?: number;
jobs: Array<{
job_key: string;
status: string;

View File

@@ -119,6 +119,7 @@
"extracting": "Extracting...",
"extracted": "Extracted",
"recentJobs": "Recent Jobs",
"lastRun": "Last run",
"triggered": "Extraction triggered",
"triggerError": "Failed to trigger extraction"
},
@@ -132,8 +133,11 @@
"exists": "Exists",
"notExists": "Not Exists",
"inputs": "Inputs",
"lastRun": "Last run",
"triggered": "Consolidation triggered",
"triggerError": "Failed to trigger consolidation"
"triggerError": "Failed to trigger consolidation",
"copySuccess": "Copied",
"noContent": "No content available"
},
"jobs": {
"title": "Jobs",
@@ -144,7 +148,26 @@
"noJobs": "No jobs found",
"allKinds": "All Kinds",
"extraction": "Extraction",
"consolidation": "Consolidation"
"consolidation": "Consolidation",
"statusFilter": {
"all": "All Status",
"pending": "Pending",
"running": "Running",
"done": "Done",
"error": "Error"
},
"detail": {
"title": "Job Details",
"kind": "Kind",
"jobKey": "Job ID",
"status": "Status",
"startedAt": "Started At",
"finishedAt": "Finished At",
"workerId": "Worker ID",
"retryRemaining": "Retry Remaining",
"error": "Error",
"noError": "No error"
}
},
"status": {
"idle": "Idle",
@@ -153,6 +176,10 @@
"done": "Done",
"error": "Error",
"pending": "Pending"
},
"statusBanner": {
"running": "Pipeline Running - {count} job(s) in progress",
"hasErrors": "Pipeline Idle - {count} job(s) failed"
}
}
}

View File

@@ -119,6 +119,7 @@
"extracting": "提取中...",
"extracted": "已提取",
"recentJobs": "最近作业",
"lastRun": "上次运行",
"triggered": "提取已触发",
"triggerError": "触发提取失败"
},
@@ -132,8 +133,11 @@
"exists": "存在",
"notExists": "不存在",
"inputs": "输入",
"lastRun": "上次运行",
"triggered": "合并已触发",
"triggerError": "触发合并失败"
"triggerError": "触发合并失败",
"copySuccess": "复制成功",
"noContent": "暂无内容"
},
"jobs": {
"title": "作业列表",
@@ -144,7 +148,26 @@
"noJobs": "暂无作业记录",
"allKinds": "所有类型",
"extraction": "提取",
"consolidation": "合并"
"consolidation": "合并",
"statusFilter": {
"all": "所有状态",
"pending": "等待",
"running": "运行中",
"done": "完成",
"error": "错误"
},
"detail": {
"title": "作业详情",
"kind": "类型",
"jobKey": "作业 ID",
"status": "状态",
"startedAt": "开始时间",
"finishedAt": "结束时间",
"workerId": "Worker ID",
"retryRemaining": "剩余重试次数",
"error": "错误信息",
"noError": "无错误"
}
},
"status": {
"idle": "空闲",
@@ -153,6 +176,10 @@
"done": "完成",
"error": "错误",
"pending": "等待"
},
"statusBanner": {
"running": "Pipeline 运行中 - {count} 个作业正在执行",
"hasErrors": "Pipeline 空闲 - {count} 个作业失败"
}
}
}