feat: add templates for epics, product brief, and requirements PRD

- Created a new directory structure for epics and stories with templates for individual epics and an index file.
- Added a product brief template for generating product brief documents in Phase 2.
- Introduced a requirements PRD template for generating a Product Requirements Document as a directory of individual requirement files in Phase 3.

feat: implement V2PipelineTab component for Memory V2 management

- Developed the V2PipelineTab component to manage extraction and consolidation processes.
- Included ExtractionCard and ConsolidationCard components to handle respective functionalities.
- Added JobsList component to display job statuses and allow filtering by job kind.

feat: create hooks for Memory V2 pipeline

- Implemented custom hooks for managing extraction and consolidation statuses, as well as job listings.
- Added mutation hooks to trigger extraction and consolidation processes with automatic query invalidation on success.
This commit is contained in:
catlog22
2026-02-27 13:27:27 +08:00
parent 99a3561f71
commit dd72e95e4d
57 changed files with 11018 additions and 1915 deletions

View File

@@ -191,6 +191,41 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
trigger: 'SessionStart',
command: 'ccw',
args: ['hook', 'project-state', '--stdin']
},
// --- Memory V2 ---
{
id: 'memory-v2-extract',
name: 'Memory V2 Extract',
description: 'Trigger Phase 1 extraction when session ends (after idle period)',
category: 'indexing',
trigger: 'Stop',
command: 'ccw',
args: ['core-memory', 'extract', '--max-sessions', '10']
},
{
id: 'memory-v2-auto-consolidate',
name: 'Memory V2 Auto Consolidate',
description: 'Trigger Phase 2 consolidation after extraction jobs complete',
category: 'indexing',
trigger: 'Stop',
command: 'node',
args: [
'-e',
'const cp=require("child_process");const r=cp.spawnSync("ccw",["core-memory","extract","--json"],{encoding:"utf8",shell:true});try{const d=JSON.parse(r.stdout);if(d&&d.total_stage1>=5){cp.spawnSync("ccw",["core-memory","consolidate"],{stdio:"inherit",shell:true})}}catch(e){}'
]
},
{
id: 'memory-sync-dashboard',
name: 'Memory Sync Dashboard',
description: 'Sync memory V2 status to dashboard on changes',
category: 'notification',
trigger: 'PostToolUse',
matcher: 'core_memory',
command: 'node',
args: [
'-e',
'const cp=require("child_process");const payload=JSON.stringify({type:"MEMORY_V2_STATUS_UPDATED",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})'
]
}
] as const;

View File

@@ -0,0 +1,351 @@
// ========================================
// V2PipelineTab Component
// ========================================
// Memory V2 Pipeline management UI
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Zap,
CheckCircle,
Clock,
AlertCircle,
Play,
Eye,
Loader2,
RefreshCw,
FileText,
Database,
Activity,
} from 'lucide-react';
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 {
useExtractionStatus,
useConsolidationStatus,
useV2Jobs,
useTriggerExtraction,
useTriggerConsolidation,
} from '@/hooks/useMemoryV2';
import { cn } from '@/lib/utils';
// ========== Status Badge ==========
const STATUS_CONFIG: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
idle: { color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', icon: <Clock className="w-3 h-3" />, label: 'Idle' },
running: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: <Loader2 className="w-3 h-3 animate-spin" />, label: 'Running' },
completed: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle className="w-3 h-3" />, label: 'Completed' },
done: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle className="w-3 h-3" />, label: 'Done' },
error: { color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: <AlertCircle className="w-3 h-3" />, label: 'Error' },
pending: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: <Clock className="w-3 h-3" />, label: 'Pending' },
};
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.idle;
return (
<Badge className={cn('flex items-center gap-1', config.color)}>
{config.icon}
{config.label}
</Badge>
);
}
// ========== Extraction Card ==========
function ExtractionCard() {
const intl = useIntl();
const { data: status, isLoading, refetch } = useExtractionStatus();
const trigger = useTriggerExtraction();
const [maxSessions, setMaxSessions] = useState(10);
const handleTrigger = () => {
trigger.mutate(maxSessions);
};
// Check if any job is running
const hasRunningJob = status?.jobs?.some(j => j.status === 'running');
return (
<Card className="p-4">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Phase 1: {intl.formatMessage({ id: 'memory.v2.extraction.title', defaultMessage: 'Extraction' })}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.description', defaultMessage: 'Extract structured memories from CLI sessions' })}
</p>
</div>
{status && (
<div className="text-right">
<div className="text-2xl font-bold">{status.total_stage1}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.extraction.extracted', defaultMessage: 'Extracted' })}
</div>
</div>
)}
</div>
<div className="flex items-center gap-2 mb-4">
<input
type="number"
value={maxSessions}
onChange={(e) => setMaxSessions(Math.max(1, parseInt(e.target.value) || 10))}
className="w-20 px-2 py-1 text-sm border rounded bg-background"
min={1}
max={64}
/>
<span className="text-sm text-muted-foreground">sessions max</span>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTrigger}
disabled={trigger.isPending || hasRunningJob}
size="sm"
>
{trigger.isPending || hasRunningJob ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
</>
) : (
<>
<Play className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.extraction.trigger', defaultMessage: 'Trigger Extraction' })}
</>
)}
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
{status?.jobs && status.jobs.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground mb-2">
{intl.formatMessage({ id: 'memory.v2.extraction.recentJobs', defaultMessage: 'Recent Jobs' })}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{status.jobs.slice(0, 5).map((job) => (
<div key={job.job_key} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs truncate max-w-[150px]">{job.job_key}</span>
<StatusBadge status={job.status} />
</div>
))}
</div>
</div>
)}
</Card>
);
}
// ========== Consolidation Card ==========
function ConsolidationCard() {
const intl = useIntl();
const { data: status, isLoading, refetch } = useConsolidationStatus();
const trigger = useTriggerConsolidation();
const [showPreview, setShowPreview] = useState(false);
const handleTrigger = () => {
trigger.mutate();
};
const isRunning = status?.status === 'running';
return (
<>
<Card className="p-4">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Database className="w-5 h-5 text-blue-500" />
Phase 2: {intl.formatMessage({ id: 'memory.v2.consolidation.title', defaultMessage: 'Consolidation' })}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.consolidation.description', defaultMessage: 'Merge extracted results into MEMORY.md' })}
</p>
</div>
{status && <StatusBadge status={status.status} />}
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold">
{status?.memoryMdAvailable ? '✅' : '❌'}
</div>
<div className="text-xs text-muted-foreground">MEMORY.md</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold">{status?.inputCount ?? '-'}</div>
<div className="text-xs text-muted-foreground">Inputs</div>
</div>
</div>
{status?.lastError && (
<div className="mb-4 p-2 bg-red-100 dark:bg-red-900/30 rounded text-xs text-red-800 dark:text-red-300">
{status.lastError}
</div>
)}
<div className="flex items-center gap-2">
<Button
onClick={handleTrigger}
disabled={trigger.isPending || isRunning}
size="sm"
>
{trigger.isPending || isRunning ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.consolidation.consolidating', defaultMessage: 'Consolidating...' })}
</>
) : (
<>
<Play className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.consolidation.trigger', defaultMessage: 'Trigger Consolidation' })}
</>
)}
</Button>
{status?.memoryMdAvailable && (
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)}>
<Eye className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.consolidation.preview', defaultMessage: 'Preview' })}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
</Card>
{/* MEMORY.md Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-3xl 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>
</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>
</DialogContent>
</Dialog>
</>
);
}
// ========== Jobs List ==========
function JobsList() {
const intl = useIntl();
const [kindFilter, setKindFilter] = useState<string>('');
const { data, isLoading, refetch } = useV2Jobs(kindFilter ? { kind: kindFilter } : undefined);
return (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium flex items-center gap-2">
<Activity className="w-5 h-5 text-purple-500" />
{intl.formatMessage({ id: 'memory.v2.jobs.title', defaultMessage: 'Jobs' })}
</h3>
<div className="flex items-center gap-2">
<select
value={kindFilter}
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>
</select>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
</div>
{data?.jobs && data.jobs.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-2">
{intl.formatMessage({ id: 'memory.v2.jobs.kind', defaultMessage: 'Kind' })}
</th>
<th className="text-left p-2">
{intl.formatMessage({ id: 'memory.v2.jobs.key', defaultMessage: 'Key' })}
</th>
<th className="text-left p-2">
{intl.formatMessage({ id: 'memory.v2.jobs.status', defaultMessage: 'Status' })}
</th>
<th className="text-left p-2">
{intl.formatMessage({ id: 'memory.v2.jobs.error', defaultMessage: 'Error' })}
</th>
</tr>
</thead>
<tbody>
{data.jobs.map((job) => (
<tr key={`${job.kind}-${job.job_key}`} className="border-b">
<td className="p-2">
<Badge variant="outline" className="text-xs">
{job.kind === 'phase1_extraction' ? 'Extraction' :
job.kind === 'memory_consolidate_global' ? 'Consolidation' : job.kind}
</Badge>
</td>
<td className="p-2 font-mono text-xs truncate max-w-[150px]">{job.job_key}</td>
<td className="p-2"><StatusBadge status={job.status} /></td>
<td className="p-2 text-red-500 text-xs truncate max-w-[200px]">{job.last_error || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center text-muted-foreground py-8">
{intl.formatMessage({ id: 'memory.v2.jobs.noJobs', defaultMessage: 'No jobs found' })}
</div>
)}
{/* 按状态统计 */}
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
<div className="mt-4 pt-4 border-t flex items-center gap-4 text-sm flex-wrap">
{Object.entries(data.byStatus).map(([status, count]) => (
<span key={status} className="flex items-center gap-1">
<StatusBadge status={status} />
<span className="font-bold">{count}</span>
</span>
))}
<span className="text-muted-foreground ml-auto">
Total: {data.total}
</span>
</div>
)}
</Card>
);
}
// ========== Main Component ==========
export function V2PipelineTab() {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ExtractionCard />
<ConsolidationCard />
</div>
<JobsList />
</div>
);
}
export default V2PipelineTab;

View File

@@ -187,10 +187,7 @@ export function GlobalSettingsTab() {
});
// Fetch project-tech stats
const {
data: projectTechStats,
isLoading: isLoadingProjectTech,
} = useQuery({
const { data: projectTechStats } = useQuery({
queryKey: settingsKeys.projectTech(),
queryFn: fetchProjectTechStats,
staleTime: 60000, // 1 minute
@@ -490,7 +487,7 @@ export function GlobalSettingsTab() {
)}
onClick={() => localDevProgress.enabled && handleCategoryToggle(cat)}
>
{cat} ({projectTechStats?.categories[cat] || 0})
{formatMessage({ id: `specs.devCategory.${cat}`, defaultMessage: cat })} ({projectTechStats?.categories[cat] || 0})
</Badge>
))}
</div>

View File

@@ -48,7 +48,7 @@ import {
} from 'lucide-react';
import { useInstallRecommendedHooks } from '@/hooks/useSystemSettings';
import type { InjectionPreviewFile, InjectionPreviewResponse } from '@/lib/api';
import { getInjectionPreview } from '@/lib/api';
import { getInjectionPreview, COMMAND_PREVIEWS, type CommandPreviewConfig } from '@/lib/api';
// ========== Types ==========
@@ -209,6 +209,12 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<InjectionPreviewFile | null>(null);
// State for command preview
const [selectedCommand, setSelectedCommand] = useState<CommandPreviewConfig>(COMMAND_PREVIEWS[0]);
const [commandPreviewData, setCommandPreviewData] = useState<InjectionPreviewResponse | null>(null);
const [commandPreviewLoading, setCommandPreviewLoading] = useState(false);
const [commandPreviewDialogOpen, setCommandPreviewDialogOpen] = useState(false);
// Fetch stats
const loadStats = useCallback(async () => {
setStatsLoading(true);
@@ -264,6 +270,21 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
}
}, [previewMode]);
// Load command preview content
const loadCommandPreview = useCallback(async (command: CommandPreviewConfig) => {
setCommandPreviewLoading(true);
try {
const data = await getInjectionPreview(command.mode, true, undefined, command.category);
setCommandPreviewData(data);
setSelectedCommand(command);
setCommandPreviewDialogOpen(true);
} catch (err) {
console.error('Failed to load command preview:', err);
} finally {
setCommandPreviewLoading(false);
}
}, []);
// Initial load
useEffect(() => {
loadStats();
@@ -406,15 +427,20 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
return stats;
}, [previewData]);
// Calculate progress and status
// Calculate progress and status - use API's maxLength for consistency
const currentLength = stats?.injectionLength?.withKeywords || 0;
const maxLength = settings.maxLength;
const apiMaxLength = stats?.injectionLength?.maxLength || settings.maxLength;
const maxLength = apiMaxLength; // Use API's maxLength for consistency
const warnThreshold = settings.warnThreshold;
const percentage = calculatePercentage(currentLength, maxLength);
const isOverLimit = currentLength > maxLength;
const isOverWarning = currentLength > warnThreshold;
const remainingSpace = Math.max(0, maxLength - currentLength);
// Calculate approximate line count (assuming ~80 chars per line)
const estimatedLineCount = Math.ceil(currentLength / 80);
const maxLineCount = Math.ceil(maxLength / 80);
return (
<div className={cn('space-y-6', className)}>
{/* Recommended Hooks Section */}
@@ -556,36 +582,40 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
>
{formatNumber(currentLength)} / {formatNumber(maxLength)}{' '}
{formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
<span className="text-muted-foreground ml-2">
(~{formatNumber(estimatedLineCount)} / {formatNumber(maxLineCount)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })})
</span>
</span>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<Progress
value={percentage}
className={cn(
'h-3',
isOverLimit && 'bg-destructive/20',
!isOverLimit && isOverWarning && 'bg-yellow-100 dark:bg-yellow-900/30'
)}
indicatorClassName={cn(
isOverLimit && 'bg-destructive',
!isOverLimit && isOverWarning && 'bg-yellow-500'
)}
/>
{/* Warning threshold marker */}
<div
className="relative h-0"
style={{
left: `${Math.min(100, (warnThreshold / maxLength) * 100)}%`,
}}
>
<div className="absolute -top-5 transform -translate-x-1/2 flex flex-col items-center">
<AlertTriangle className="h-3 w-3 text-yellow-500" />
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatMessage({ id: 'specs.injection.warnThreshold', defaultMessage: 'Warn' })}
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>{percentage}%</span>
<span>{formatMessage({ id: 'specs.injection.maxLimit', defaultMessage: 'Max' })}: {formatNumber(maxLength)}</span>
</div>
<div className="relative">
<Progress
value={percentage}
className={cn(
'h-3',
isOverLimit && 'bg-destructive/20',
!isOverLimit && isOverWarning && 'bg-yellow-100 dark:bg-yellow-900/30'
)}
indicatorClassName={cn(
isOverLimit && 'bg-destructive',
!isOverLimit && isOverWarning && 'bg-yellow-500'
)}
/>
{/* Warning threshold marker */}
<div
className="absolute top-0 h-3 flex flex-col items-center"
style={{
left: `${Math.min(100, (warnThreshold / maxLength) * 100)}%`,
transform: 'translateX(-50%)',
}}
>
<AlertTriangle className="h-3 w-3 text-yellow-500 -mt-4" />
</div>
</div>
</div>
@@ -791,6 +821,59 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
</CardContent>
</Card>
{/* Command Preview Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
{formatMessage({ id: 'specs.injection.commandPreview', defaultMessage: 'Command Injection Preview' })}
</CardTitle>
<CardDescription>
{formatMessage({
id: 'specs.injection.commandPreviewDesc',
defaultMessage: 'Preview the content that would be injected by different CLI commands',
})}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{COMMAND_PREVIEWS.map((cmd) => (
<Button
key={cmd.command}
variant="outline"
className="h-auto flex-col items-start py-3 px-4"
onClick={() => loadCommandPreview(cmd)}
disabled={commandPreviewLoading}
>
<div className="font-medium text-sm">
{formatMessage({
id: `specs.commandPreview.${cmd.labelKey}.label`,
defaultMessage: cmd.labelKey
})}
</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{formatMessage({
id: `specs.commandPreview.${cmd.descriptionKey}.description`,
defaultMessage: cmd.descriptionKey
})}
</div>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded mt-2 w-full text-center">
{cmd.command.replace('ccw spec load', '').trim() || 'default'}
</code>
</Button>
))}
</div>
{commandPreviewLoading && (
<div className="flex items-center justify-center py-4 mt-4 border rounded-lg">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground mr-2" />
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'specs.injection.loadingPreview', defaultMessage: 'Loading preview...' })}
</span>
</div>
)}
</CardContent>
</Card>
{/* Settings Card */}
<Card>
<CardHeader>
@@ -814,39 +897,83 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
{/* Max Injection Length */}
<div className="space-y-2">
<Label htmlFor="maxLength">
{formatMessage({ id: 'specs.injection.maxLength', defaultMessage: 'Max Injection Length (characters)' })}
{formatMessage({ id: 'specs.injection.maxLength', defaultMessage: 'Max Injection Length' })}
</Label>
<Input
id="maxLength"
type="number"
min={1000}
max={50000}
step={500}
value={formData.maxLength}
onChange={(e) => handleFieldChange('maxLength', Number(e.target.value))}
/>
<div className="flex items-center gap-4">
<div className="flex-1">
<Input
id="maxLength"
type="number"
min={1000}
max={50000}
step={500}
value={formData.maxLength}
onChange={(e) => handleFieldChange('maxLength', Number(e.target.value))}
/>
</div>
<span className="text-sm text-muted-foreground whitespace-nowrap">
(~{Math.ceil(formData.maxLength / 80)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })})
</span>
</div>
<p className="text-sm text-muted-foreground">
{formatMessage({
id: 'specs.injection.maxLengthHelp',
defaultMessage: 'Recommended: 4000-10000. Too large may consume too much context; too small may truncate important specs.',
defaultMessage: 'Recommended: 4000-10000 characters (50-125 lines). Too large may consume too much context.',
})}
</p>
{/* Quick presets */}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'specs.injection.quickPresets', defaultMessage: 'Quick presets:' })}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleFieldChange('maxLength', 4000)}
>
4000 (50 lines)
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleFieldChange('maxLength', 8000)}
>
8000 (100 lines)
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleFieldChange('maxLength', 12000)}
>
12000 (150 lines)
</Button>
</div>
</div>
{/* Warning Threshold */}
<div className="space-y-2">
<Label htmlFor="warnThreshold">
{formatMessage({ id: 'specs.injection.warnThresholdLabel', defaultMessage: 'Warning Threshold (characters)' })}
{formatMessage({ id: 'specs.injection.warnThresholdLabel', defaultMessage: 'Warning Threshold' })}
</Label>
<Input
id="warnThreshold"
type="number"
min={500}
max={formData.maxLength - 1}
step={500}
value={formData.warnThreshold}
onChange={(e) => handleFieldChange('warnThreshold', Number(e.target.value))}
/>
<div className="flex items-center gap-4">
<div className="flex-1">
<Input
id="warnThreshold"
type="number"
min={500}
max={formData.maxLength - 1}
step={500}
value={formData.warnThreshold}
onChange={(e) => handleFieldChange('warnThreshold', Number(e.target.value))}
/>
</div>
<span className="text-sm text-muted-foreground whitespace-nowrap">
(~{Math.ceil(formData.warnThreshold / 80)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })})
</span>
</div>
<p className="text-sm text-muted-foreground">
{formatMessage({
id: 'specs.injection.warnThresholdHelp',
@@ -916,6 +1043,84 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
</div>
</DialogContent>
</Dialog>
{/* Command Preview Dialog */}
<Dialog open={commandPreviewDialogOpen} onOpenChange={setCommandPreviewDialogOpen}>
<DialogContent className="max-w-4xl max-h-[85vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
{formatMessage({ id: 'specs.injection.previewTitle', defaultMessage: 'Injection Preview' })}
</DialogTitle>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<code className="bg-muted px-2 py-1 rounded text-xs">{selectedCommand.command}</code>
<span></span>
<span>
{formatMessage({
id: `specs.commandPreview.${selectedCommand.descriptionKey}.description`,
defaultMessage: selectedCommand.descriptionKey
})}
</span>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Stats */}
{commandPreviewData && (
<div className="flex items-center gap-4 text-sm">
<Badge variant="secondary">
{commandPreviewData.stats.count} {formatMessage({ id: 'specs.injection.files', defaultMessage: 'files' })}
</Badge>
<Badge variant="secondary">
{formatNumber(commandPreviewData.stats.totalLength)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</Badge>
<Badge variant="secondary">
~{Math.ceil(commandPreviewData.stats.totalLength / 80)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })}
</Badge>
</div>
)}
{/* Preview Content */}
<div className="flex-1 overflow-auto max-h-[60vh] border rounded-lg">
{commandPreviewData?.files.length ? (
<div className="space-y-4 p-4">
{commandPreviewData.files.map((file, idx) => (
<div key={file.file} className="space-y-2">
{/* File Header */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{idx + 1}
</Badge>
<span className="font-medium">{file.title}</span>
{file.category && (
<Badge variant="outline" className={cn(
'text-xs',
CATEGORY_CONFIG[file.category as SpecCategory]?.bgColor,
CATEGORY_CONFIG[file.category as SpecCategory]?.color
)}>
{file.category}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatNumber(file.contentLength)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</span>
</div>
{/* File Content */}
<pre className="text-xs whitespace-pre-wrap p-3 bg-muted rounded border-l-2 border-primary/30">
{file.content || formatMessage({ id: 'specs.content.noContent', defaultMessage: 'No content available' })}
</pre>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
{formatMessage({ id: 'specs.injection.noFiles', defaultMessage: 'No files match this command' })}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -7,21 +7,26 @@ const Progress = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
>(({ className, value, indicatorClassName, ...props }, ref) => {
// Ensure value is a valid number between 0-100
const safeValue = Math.max(0, Math.min(100, Number(value) || 0));
return (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - safeValue}%)` }}
/>
</ProgressPrimitive.Root>
);
});
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -161,6 +161,21 @@ export type {
UseReindexReturn,
} from './useUnifiedSearch';
// ========== Memory V2 Pipeline ==========
export {
useExtractionStatus,
useConsolidationStatus,
useV2Jobs,
useTriggerExtraction,
useTriggerConsolidation,
memoryV2Keys,
} from './useMemoryV2';
export type {
ExtractionStatus,
ConsolidationStatus,
V2JobsResponse,
} from './useMemoryV2';
// ========== MCP Servers ==========
export {
useMcpServers,

View File

@@ -0,0 +1,101 @@
// ========================================
// useMemoryV2 Hook
// ========================================
// TanStack Query hooks for Memory V2 pipeline
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
triggerExtraction,
getExtractionStatus,
triggerConsolidation,
getConsolidationStatus,
getV2Jobs,
type ExtractionStatus,
type ConsolidationStatus,
type V2JobsResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// Query keys
export const memoryV2Keys = {
all: ['memoryV2'] as const,
extractionStatus: (path?: string) => [...memoryV2Keys.all, 'extraction', path] as const,
consolidationStatus: (path?: string) => [...memoryV2Keys.all, 'consolidation', path] as const,
jobs: (path?: string, filters?: { kind?: string; status_filter?: string }) =>
[...memoryV2Keys.all, 'jobs', path, filters] as const,
};
// Default stale time: 30 seconds (V2 status changes frequently)
const STALE_TIME = 30 * 1000;
// Hook: 提取状态
export function useExtractionStatus() {
const projectPath = useWorkflowStore(selectProjectPath);
return useQuery({
queryKey: memoryV2Keys.extractionStatus(projectPath),
queryFn: () => getExtractionStatus(projectPath),
enabled: !!projectPath,
staleTime: STALE_TIME,
refetchInterval: 5000, // 每 5 秒刷新
});
}
// Hook: 合并状态
export function useConsolidationStatus() {
const projectPath = useWorkflowStore(selectProjectPath);
return useQuery({
queryKey: memoryV2Keys.consolidationStatus(projectPath),
queryFn: () => getConsolidationStatus(projectPath),
enabled: !!projectPath,
staleTime: STALE_TIME,
refetchInterval: 5000,
});
}
// Hook: V2 作业列表
export function useV2Jobs(filters?: { kind?: string; status_filter?: string }) {
const projectPath = useWorkflowStore(selectProjectPath);
return useQuery({
queryKey: memoryV2Keys.jobs(projectPath, filters),
queryFn: () => getV2Jobs(filters, projectPath),
enabled: !!projectPath,
staleTime: STALE_TIME,
refetchInterval: 10000, // 每 10 秒刷新
});
}
// Hook: 触发提取
export function useTriggerExtraction() {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
return useMutation({
mutationFn: (maxSessions?: number) => triggerExtraction(maxSessions, projectPath),
onSuccess: () => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: memoryV2Keys.extractionStatus(projectPath) });
queryClient.invalidateQueries({ queryKey: memoryV2Keys.jobs(projectPath) });
},
});
}
// Hook: 触发合并
export function useTriggerConsolidation() {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
return useMutation({
mutationFn: () => triggerConsolidation(projectPath),
onSuccess: () => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: memoryV2Keys.consolidationStatus(projectPath) });
queryClient.invalidateQueries({ queryKey: memoryV2Keys.jobs(projectPath) });
},
});
}
// Export types
export type { ExtractionStatus, ConsolidationStatus, V2JobsResponse };

View File

@@ -1638,6 +1638,111 @@ export async function unarchiveMemory(memoryId: string, projectPath?: string): P
});
}
// ========== Memory V2 API ==========
export interface ExtractionStatus {
total_stage1: number;
jobs: Array<{
job_key: string;
status: string;
last_error?: string;
}>;
}
export interface ConsolidationStatus {
status: 'idle' | 'running' | 'completed' | 'error';
memoryMdAvailable: boolean;
memoryMdPreview?: string;
inputCount?: number;
lastRun?: number;
lastError?: string;
}
export 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;
}
export interface V2JobsResponse {
jobs: V2Job[];
total: number;
byStatus: Record<string, number>;
}
/**
* Trigger Phase 1 extraction for eligible CLI sessions
*/
export async function triggerExtraction(
maxSessions?: number,
projectPath?: string
): Promise<{ triggered: boolean; jobIds: string[]; message: string }> {
const params = new URLSearchParams();
if (projectPath) params.set('path', projectPath);
return fetchApi<{ triggered: boolean; jobIds: string[]; message: string }>(
`/api/core-memory/extract?${params}`,
{
method: 'POST',
body: JSON.stringify({ max_sessions: maxSessions }),
}
);
}
/**
* Get Phase 1 extraction status
*/
export async function getExtractionStatus(
projectPath?: string
): Promise<ExtractionStatus> {
const params = new URLSearchParams();
if (projectPath) params.set('path', projectPath);
return fetchApi<ExtractionStatus>(`/api/core-memory/extract/status?${params}`);
}
/**
* Trigger Phase 2 consolidation to generate MEMORY.md
*/
export async function triggerConsolidation(
projectPath?: string
): Promise<{ triggered: boolean; message: string }> {
const params = new URLSearchParams();
if (projectPath) params.set('path', projectPath);
return fetchApi<{ triggered: boolean; message: string }>(
`/api/core-memory/consolidate?${params}`,
{ method: 'POST' }
);
}
/**
* Get Phase 2 consolidation status
*/
export async function getConsolidationStatus(
projectPath?: string
): Promise<ConsolidationStatus> {
const params = new URLSearchParams();
if (projectPath) params.set('path', projectPath);
return fetchApi<ConsolidationStatus>(`/api/core-memory/consolidate/status?${params}`);
}
/**
* Get V2 pipeline jobs list
*/
export async function getV2Jobs(
options?: { kind?: string; status_filter?: string },
projectPath?: string
): Promise<V2JobsResponse> {
const params = new URLSearchParams();
if (projectPath) params.set('path', projectPath);
if (options?.kind) params.set('kind', options.kind);
if (options?.status_filter) params.set('status_filter', options.status_filter);
return fetchApi<V2JobsResponse>(`/api/core-memory/jobs?${params}`);
}
// ========== Project Overview API ==========
export interface TechnologyStack {
@@ -7365,47 +7470,48 @@ export async function getInjectionPreview(
*/
export interface CommandPreviewConfig {
command: string;
label: string;
description: string;
labelKey: string; // i18n key for label
descriptionKey: string; // i18n key for description
category?: string;
mode: 'required' | 'all';
}
/**
* Predefined command preview configurations
* Labels and descriptions use i18n keys: commandPreview.{key}.label / commandPreview.{key}.description
*/
export const COMMAND_PREVIEWS: CommandPreviewConfig[] = [
{
command: 'ccw spec load',
label: 'Default (All Categories)',
description: 'Load all required specs without category filter',
labelKey: 'default',
descriptionKey: 'default',
mode: 'required',
},
{
command: 'ccw spec load --category exploration',
label: 'Exploration',
description: 'Specs for code exploration, analysis, debugging',
labelKey: 'exploration',
descriptionKey: 'exploration',
category: 'exploration',
mode: 'required',
},
{
command: 'ccw spec load --category planning',
label: 'Planning',
description: 'Specs for task planning, requirements',
labelKey: 'planning',
descriptionKey: 'planning',
category: 'planning',
mode: 'required',
},
{
command: 'ccw spec load --category execution',
label: 'Execution',
description: 'Specs for implementation, testing, deployment',
labelKey: 'execution',
descriptionKey: 'execution',
category: 'execution',
mode: 'required',
},
{
command: 'ccw spec load --category general',
label: 'General',
description: 'Specs that apply to all stages',
labelKey: 'general',
descriptionKey: 'general',
category: 'general',
mode: 'required',
},

View File

@@ -158,6 +158,50 @@
"session-state-watch": {
"name": "Session State Watch",
"description": "Watch for session metadata file changes (workflow-session.json)"
},
"stop-notify": {
"name": "Stop Notify",
"description": "Notify dashboard when Claude finishes responding"
},
"auto-format-on-write": {
"name": "Auto Format on Write",
"description": "Auto-format files after Claude writes or edits them"
},
"auto-lint-on-write": {
"name": "Auto Lint on Write",
"description": "Auto-lint files after Claude writes or edits them"
},
"block-sensitive-files": {
"name": "Block Sensitive Files",
"description": "Block modifications to sensitive files (.env, secrets, credentials)"
},
"git-auto-stage": {
"name": "Git Auto Stage",
"description": "Auto stage all modified files when Claude finishes responding"
},
"post-edit-index": {
"name": "Post Edit Index",
"description": "Notify indexing service when files are modified"
},
"session-end-summary": {
"name": "Session End Summary",
"description": "Send session summary to dashboard on session end"
},
"project-state-inject": {
"name": "Project State Inject",
"description": "Inject project guidelines and recent dev history at session start"
},
"memory-v2-extract": {
"name": "Memory V2 Extract",
"description": "Trigger Phase 1 extraction when session ends (after idle period)"
},
"memory-v2-auto-consolidate": {
"name": "Memory V2 Auto Consolidate",
"description": "Trigger Phase 2 consolidation after extraction jobs complete"
},
"memory-sync-dashboard": {
"name": "Memory Sync Dashboard",
"description": "Sync memory V2 status to dashboard on changes"
}
},
"actions": {

View File

@@ -109,5 +109,50 @@
"vectorRank": "Vector #{rank}",
"ftsRank": "FTS #{rank}",
"heatScore": "Heat: {score}"
},
"v2": {
"title": "Memory V2 Pipeline",
"extraction": {
"title": "Extraction",
"description": "Extract structured memories from CLI sessions",
"trigger": "Trigger Extraction",
"extracting": "Extracting...",
"extracted": "Extracted",
"recentJobs": "Recent Jobs",
"triggered": "Extraction triggered",
"triggerError": "Failed to trigger extraction"
},
"consolidation": {
"title": "Consolidation",
"description": "Consolidate extraction results into MEMORY.md",
"trigger": "Trigger Consolidation",
"consolidating": "Consolidating...",
"preview": "Preview",
"memoryMd": "MEMORY.md",
"exists": "Exists",
"notExists": "Not Exists",
"inputs": "Inputs",
"triggered": "Consolidation triggered",
"triggerError": "Failed to trigger consolidation"
},
"jobs": {
"title": "Jobs",
"kind": "Kind",
"key": "Key",
"status": "Status",
"error": "Error",
"noJobs": "No jobs found",
"allKinds": "All Kinds",
"extraction": "Extraction",
"consolidation": "Consolidation"
},
"status": {
"idle": "Idle",
"running": "Running",
"completed": "Completed",
"done": "Done",
"error": "Error",
"pending": "Pending"
}
}
}

View File

@@ -213,13 +213,46 @@
"warning": "Approaching Limit",
"normal": "Normal",
"characters": "characters",
"lines": "lines",
"maxLimit": "Max",
"quickPresets": "Quick presets:",
"statsInfo": "Statistics",
"requiredLength": "Required specs length:",
"matchedLength": "Keyword-matched length:",
"remaining": "Remaining space:",
"loadError": "Failed to load stats",
"saveSuccess": "Settings saved successfully",
"saveError": "Failed to save settings"
"saveError": "Failed to save settings",
"filesList": "Injection Files",
"files": "files",
"noFiles": "No files match this command",
"loadingPreview": "Loading preview...",
"commandPreview": "Command Injection Preview",
"commandPreviewDesc": "Preview the content that would be injected by different CLI commands",
"previewTitle": "Injection Preview"
},
"commandPreview": {
"default": {
"label": "All Categories",
"description": "Load all required specs without category filter"
},
"exploration": {
"label": "Exploration",
"description": "Specs for code exploration, analysis, debugging"
},
"planning": {
"label": "Planning",
"description": "Specs for task planning, requirements"
},
"execution": {
"label": "Execution",
"description": "Specs for implementation, testing, deployment"
},
"general": {
"label": "General",
"description": "Specs that apply to all stages"
}
},
"settings": {
@@ -231,9 +264,27 @@
"defaultReadModeHelp": "The default read mode for newly created personal specs",
"selectReadMode": "Select read mode",
"autoEnable": "Auto Enable New Specs",
"autoEnableDescription": "Automatically enable newly created personal specs",
"autoEnableDescription": "New personal specs are set to required (readMode=required) by default and automatically included in context injection",
"specStatistics": "Spec Statistics",
"totalSpecs": "Total: {count} spec files"
"totalSpecs": "Total: {count} spec files",
"devProgressInjection": "Development Progress Injection",
"devProgressInjectionDesc": "Control how development progress from project-tech.json is injected into AI context",
"enableDevProgress": "Enable Injection",
"enableDevProgressDesc": "Include development history in AI context",
"maxEntries": "Max Entries per Category",
"maxEntriesDesc": "Maximum number of entries to include per category (1-50)",
"includeCategories": "Include Categories",
"categoriesDesc": "Click to toggle category inclusion",
"devProgressStats": "{total} entries from {sessions} sessions, last updated: {date}",
"devProgressStatsNoDate": "{total} entries from {sessions} sessions"
},
"devCategory": {
"feature": "Feature",
"enhancement": "Enhancement",
"bugfix": "Bug Fix",
"refactor": "Refactor",
"docs": "Docs"
},
"dialog": {

View File

@@ -158,6 +158,50 @@
"session-state-watch": {
"name": "会话状态监控",
"description": "监控会话元数据文件变更 (workflow-session.json)"
},
"stop-notify": {
"name": "停止通知",
"description": "当 Claude 完成响应时通知仪表盘"
},
"auto-format-on-write": {
"name": "写入自动格式化",
"description": "在 Claude 写入或编辑文件后自动格式化"
},
"auto-lint-on-write": {
"name": "写入自动检查",
"description": "在 Claude 写入或编辑文件后自动进行 Lint 检查"
},
"block-sensitive-files": {
"name": "阻止敏感文件修改",
"description": "阻止对敏感文件 (.env、密钥、凭据) 的修改"
},
"git-auto-stage": {
"name": "Git 自动暂存",
"description": "当 Claude 完成响应时自动暂存所有修改的文件"
},
"post-edit-index": {
"name": "编辑后索引",
"description": "文件修改时通知索引服务"
},
"session-end-summary": {
"name": "会话结束摘要",
"description": "会话结束时向仪表盘发送会话摘要"
},
"project-state-inject": {
"name": "项目状态注入",
"description": "会话开始时注入项目指南和最近开发历史"
},
"memory-v2-extract": {
"name": "Memory V2 提取",
"description": "会话结束时触发 Phase 1 提取(空闲期后)"
},
"memory-v2-auto-consolidate": {
"name": "Memory V2 自动合并",
"description": "提取作业完成后触发 Phase 2 合并"
},
"memory-sync-dashboard": {
"name": "Memory 同步仪表盘",
"description": "变更时将 Memory V2 状态同步到仪表盘"
}
},
"actions": {

View File

@@ -109,5 +109,50 @@
"vectorRank": "向量 #{rank}",
"ftsRank": "全文 #{rank}",
"heatScore": "热度: {score}"
},
"v2": {
"title": "Memory V2 Pipeline",
"extraction": {
"title": "提取",
"description": "从 CLI 会话中提取结构化记忆",
"trigger": "触发提取",
"extracting": "提取中...",
"extracted": "已提取",
"recentJobs": "最近作业",
"triggered": "提取已触发",
"triggerError": "触发提取失败"
},
"consolidation": {
"title": "合并",
"description": "合并提取结果生成 MEMORY.md",
"trigger": "触发合并",
"consolidating": "合并中...",
"preview": "预览",
"memoryMd": "MEMORY.md",
"exists": "存在",
"notExists": "不存在",
"inputs": "输入",
"triggered": "合并已触发",
"triggerError": "触发合并失败"
},
"jobs": {
"title": "作业列表",
"kind": "类型",
"key": "Key",
"status": "状态",
"error": "错误",
"noJobs": "暂无作业记录",
"allKinds": "所有类型",
"extraction": "提取",
"consolidation": "合并"
},
"status": {
"idle": "空闲",
"running": "运行中",
"completed": "已完成",
"done": "完成",
"error": "错误",
"pending": "等待"
}
}
}

View File

@@ -214,6 +214,9 @@
"normal": "正常",
"characters": "字符",
"chars": "字符",
"lines": "行",
"maxLimit": "最大",
"quickPresets": "快速预设:",
"statsInfo": "统计信息",
"requiredLength": "必读规范长度:",
"matchedLength": "关键词匹配长度:",
@@ -222,7 +225,34 @@
"saveSuccess": "设置已保存",
"saveError": "保存设置失败",
"filesList": "注入文件列表",
"files": "个文件"
"files": "个文件",
"noFiles": "没有匹配此命令的文件",
"loadingPreview": "加载预览中...",
"commandPreview": "命令注入预览",
"commandPreviewDesc": "预览不同 CLI 命令将注入的内容"
},
"commandPreview": {
"default": {
"label": "全部类别",
"description": "加载所有必读规范,不进行类别过滤"
},
"exploration": {
"label": "探索",
"description": "代码探索、分析、调试相关规范"
},
"planning": {
"label": "规划",
"description": "任务规划、需求相关规范"
},
"execution": {
"label": "执行",
"description": "实现、测试、部署相关规范"
},
"general": {
"label": "通用",
"description": "适用于所有阶段的规范"
}
},
"priority": {
@@ -241,9 +271,27 @@
"defaultReadModeHelp": "新创建的个人规范的默认读取模式",
"selectReadMode": "选择读取模式",
"autoEnable": "自动启用新规范",
"autoEnableDescription": "自动启用新创建的个人规范",
"autoEnableDescription": "新创建的个人规范默认设置为必读readMode=required自动加入注入上下文",
"specStatistics": "规范统计",
"totalSpecs": "总计:{count} 个规范文件"
"totalSpecs": "总计:{count} 个规范文件",
"devProgressInjection": "开发进度注入",
"devProgressInjectionDesc": "控制如何将 project-tech.json 中的开发进度注入到 AI 上下文中",
"enableDevProgress": "启用注入",
"enableDevProgressDesc": "在 AI 上下文中包含开发历史记录",
"maxEntries": "每类最大条目数",
"maxEntriesDesc": "每个类别包含的最大条目数 (1-50)",
"includeCategories": "包含的类别",
"categoriesDesc": "点击切换类别包含状态",
"devProgressStats": "共 {total} 条记录,来自 {sessions} 个会话,最后更新:{date}",
"devProgressStatsNoDate": "共 {total} 条记录,来自 {sessions} 个会话"
},
"devCategory": {
"feature": "新功能",
"enhancement": "增强",
"bugfix": "修复",
"refactor": "重构",
"docs": "文档"
},
"dialog": {

View File

@@ -42,6 +42,7 @@ import { Checkbox } from '@/components/ui/Checkbox';
import { useMemory, useMemoryMutations, useUnifiedSearch, useUnifiedStats, useRecommendations, useReindex } from '@/hooks';
import type { CoreMemory, UnifiedSearchResult } from '@/lib/api';
import { cn, parseMemoryMetadata } from '@/lib/utils';
import { V2PipelineTab } from '@/components/memory/V2PipelineTab';
// ========== Source Type Helpers ==========
@@ -624,7 +625,7 @@ export function MemoryPage() {
const [isNewMemoryOpen, setIsNewMemoryOpen] = useState(false);
const [editingMemory, setEditingMemory] = useState<CoreMemory | null>(null);
const [viewingMemory, setViewingMemory] = useState<CoreMemory | null>(null);
const [currentTab, setCurrentTab] = useState<'memories' | 'favorites' | 'archived' | 'unifiedSearch'>('memories');
const [currentTab, setCurrentTab] = useState<'memories' | 'favorites' | 'archived' | 'unifiedSearch' | 'v2pipeline'>('memories');
const [unifiedQuery, setUnifiedQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const isImmersiveMode = useAppStore(selectIsImmersiveMode);
@@ -866,6 +867,11 @@ export function MemoryPage() {
label: formatMessage({ id: 'memory.tabs.unifiedSearch' }),
icon: <Search className="h-4 w-4" />,
},
{
value: 'v2pipeline',
label: 'V2 Pipeline',
icon: <Zap className="h-4 w-4" />,
},
]}
/>
@@ -1062,7 +1068,10 @@ export function MemoryPage() {
)}
{/* Content Area */}
{isUnifiedTab ? (
{currentTab === 'v2pipeline' ? (
/* V2 Pipeline Tab */
<V2PipelineTab />
) : isUnifiedTab ? (
/* Unified Search Results */
unifiedLoading ? (
<div className="flex items-center justify-center py-12">

View File

@@ -376,5 +376,5 @@ export function run(argv: string[]): void {
program.parse(argv);
}
// Invoke CLI when run directly
run(process.argv);
// Note: run() is called by bin/ccw.js entry point
// Do not call run() here to avoid duplicate execution

View File

@@ -224,6 +224,7 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
const resolvedPath = resolvePath(projectPath);
const mode = url.searchParams.get('mode') || 'required'; // required | all | keywords
const preview = url.searchParams.get('preview') === 'true';
const category = url.searchParams.get('category') || undefined; // optional category filter
try {
const { getDimensionIndex, SPEC_DIMENSIONS } = await import(
@@ -254,6 +255,11 @@ export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
continue;
}
// Filter by category if specified
if (category && (entry.category || 'general') !== category) {
continue;
}
const fileData: InjectionFile = {
file: entry.file,
title: entry.title,

View File

@@ -444,28 +444,28 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
// API: Get project-tech stats for development progress injection
if (pathname === '/api/project-tech/stats' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const techPath = join(resolvedPath, '.workflow', 'project-tech.json');
if (!existsSync(techPath)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
total_features: 0,
total_sessions: 0,
last_updated: null,
categories: {
feature: 0,
enhancement: 0,
bugfix: 0,
refactor: 0,
docs: 0
}
}));
return true;
}
try {
const projectPath = url.searchParams.get('path') || initialPath;
const resolvedPath = resolvePath(projectPath);
const techPath = join(resolvedPath, '.workflow', 'project-tech.json');
if (!existsSync(techPath)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
total_features: 0,
total_sessions: 0,
last_updated: null,
categories: {
feature: 0,
enhancement: 0,
bugfix: 0,
refactor: 0,
docs: 0
}
}));
return true;
}
const rawContent = readFileSync(techPath, 'utf-8');
const tech = JSON.parse(rawContent) as {
development_index?: {

View File

@@ -626,13 +626,13 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleFilesRoutes(routeContext)) return;
}
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker, system settings)
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker, system settings, project-tech)
if (pathname === '/api/data' || pathname === '/api/health' ||
pathname === '/api/version-check' || pathname === '/api/shutdown' ||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
pathname === '/api/system/settings' || pathname === '/api/system/hooks/install-recommended' ||
pathname === '/api/a2ui/answer' ||
pathname === '/api/a2ui/answer' || pathname === '/api/project-tech/stats' ||
pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) {
if (await handleSystemRoutes(routeContext)) return;
}