mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add Discuss and Explore subagents for dynamic critique and code exploration
- Implement Discuss Subagent for multi-perspective critique with dynamic perspectives. - Create Explore Subagent for shared codebase exploration with centralized caching. - Add tests for CcwToolsMcpCard component to ensure enabled tools are preserved on config save. - Introduce SessionPreviewPanel component for previewing and selecting sessions for Memory V2 extraction. - Develop CommandCreateDialog component for creating/importing commands with import and CLI generate modes.
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
GitBranch,
|
||||
Send,
|
||||
FileBarChart,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -31,7 +32,7 @@ import type { HookTriggerType } from './HookCard';
|
||||
/**
|
||||
* Template category type
|
||||
*/
|
||||
export type TemplateCategory = 'notification' | 'indexing' | 'automation';
|
||||
export type TemplateCategory = 'notification' | 'indexing' | 'automation' | 'utility';
|
||||
|
||||
/**
|
||||
* Hook template definition
|
||||
@@ -226,6 +227,34 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||
'-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})'
|
||||
]
|
||||
},
|
||||
// --- Memory Operations ---
|
||||
{
|
||||
id: 'memory-auto-compress',
|
||||
name: 'Auto Memory Compress',
|
||||
description: 'Automatically compress memory when entries exceed threshold',
|
||||
category: 'automation',
|
||||
trigger: 'Stop',
|
||||
command: 'ccw',
|
||||
args: ['memory', 'consolidate', '--threshold', '50']
|
||||
},
|
||||
{
|
||||
id: 'memory-preview-extract',
|
||||
name: 'Memory Preview & Extract',
|
||||
description: 'Preview extraction queue and extract eligible sessions',
|
||||
category: 'automation',
|
||||
trigger: 'SessionStart',
|
||||
command: 'ccw',
|
||||
args: ['memory', 'preview', '--include-native']
|
||||
},
|
||||
{
|
||||
id: 'memory-status-check',
|
||||
name: 'Memory Status Check',
|
||||
description: 'Check memory extraction and consolidation status',
|
||||
category: 'utility',
|
||||
trigger: 'SessionStart',
|
||||
command: 'ccw',
|
||||
args: ['memory', 'status']
|
||||
}
|
||||
] as const;
|
||||
|
||||
@@ -234,7 +263,8 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||
const CATEGORY_ICONS: Record<TemplateCategory, { icon: typeof Bell; color: string; bg: string }> = {
|
||||
notification: { icon: Bell, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
indexing: { icon: Database, color: 'text-purple-500', bg: 'bg-purple-500/10' },
|
||||
automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' }
|
||||
automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' },
|
||||
utility: { icon: Settings, color: 'text-gray-500', bg: 'bg-gray-500/10' }
|
||||
};
|
||||
|
||||
// ========== Template Icons ==========
|
||||
@@ -258,7 +288,8 @@ function getCategoryName(category: TemplateCategory, formatMessage: ReturnType<t
|
||||
const names: Record<TemplateCategory, string> = {
|
||||
notification: formatMessage({ id: 'cliHooks.templates.categories.notification' }),
|
||||
indexing: formatMessage({ id: 'cliHooks.templates.categories.indexing' }),
|
||||
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' })
|
||||
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' }),
|
||||
utility: formatMessage({ id: 'cliHooks.templates.categories.utility' })
|
||||
};
|
||||
return names[category];
|
||||
}
|
||||
@@ -352,7 +383,9 @@ export function HookQuickTemplates({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-foreground leading-tight">
|
||||
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })}
|
||||
{formatMessage(
|
||||
{ id: `cliHooks.templates.templates.${template.id}.name`, defaultMessage: template.name }
|
||||
)}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
@@ -394,7 +427,9 @@ export function HookQuickTemplates({
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed flex-1 pl-11">
|
||||
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })}
|
||||
{formatMessage(
|
||||
{ id: `cliHooks.templates.templates.${template.id}.description`, defaultMessage: template.description }
|
||||
)}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
102
ccw/frontend/src/components/mcp/CcwToolsMcpCard.test.tsx
Normal file
102
ccw/frontend/src/components/mcp/CcwToolsMcpCard.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
// ========================================
|
||||
// CcwToolsMcpCard Component Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CcwToolsMcpCard } from './CcwToolsMcpCard';
|
||||
import { updateCcwConfig, updateCcwConfigForCodex } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
installCcwMcp: vi.fn(),
|
||||
uninstallCcwMcp: vi.fn(),
|
||||
updateCcwConfig: vi.fn(),
|
||||
installCcwMcpToCodex: vi.fn(),
|
||||
uninstallCcwMcpFromCodex: vi.fn(),
|
||||
updateCcwConfigForCodex: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useNotifications', () => ({
|
||||
useNotifications: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CcwToolsMcpCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('preserves enabledTools when saving config (Codex)', async () => {
|
||||
const updateCodexMock = vi.mocked(updateCcwConfigForCodex);
|
||||
updateCodexMock.mockResolvedValue({
|
||||
isInstalled: true,
|
||||
enabledTools: [],
|
||||
installedScopes: ['global'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CcwToolsMcpCard
|
||||
target="codex"
|
||||
isInstalled={true}
|
||||
enabledTools={['write_file', 'read_many_files']}
|
||||
onToggleTool={vi.fn()}
|
||||
onUpdateConfig={vi.fn()}
|
||||
onInstall={vi.fn()}
|
||||
/>,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateCodexMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledTools: ['write_file', 'read_many_files'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves enabledTools when saving config (Claude)', async () => {
|
||||
const updateClaudeMock = vi.mocked(updateCcwConfig);
|
||||
updateClaudeMock.mockResolvedValue({
|
||||
isInstalled: true,
|
||||
enabledTools: [],
|
||||
installedScopes: ['global'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CcwToolsMcpCard
|
||||
isInstalled={true}
|
||||
enabledTools={['write_file', 'smart_search']}
|
||||
onToggleTool={vi.fn()}
|
||||
onUpdateConfig={vi.fn()}
|
||||
onInstall={vi.fn()}
|
||||
/>,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateClaudeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledTools: ['write_file', 'smart_search'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
uninstallCcwMcpFromCodex,
|
||||
updateCcwConfigForCodex,
|
||||
} from '@/lib/api';
|
||||
import { mcpServersKeys } from '@/hooks';
|
||||
import { mcpServersKeys, useNotifications } from '@/hooks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
@@ -128,6 +128,7 @@ export function CcwToolsMcpCard({
|
||||
}: CcwToolsMcpCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
const { success: notifySuccess, error: notifyError } = useNotifications();
|
||||
const currentProjectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Local state for config inputs
|
||||
@@ -179,9 +180,19 @@ export function CcwToolsMcpCard({
|
||||
onSuccess: () => {
|
||||
if (isCodex) {
|
||||
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfigCodex'] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
}
|
||||
notifySuccess(formatMessage({ id: 'mcp.ccw.feedback.saveSuccess' }));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update CCW config:', error);
|
||||
notifyError(
|
||||
formatMessage({ id: 'mcp.ccw.feedback.saveError' }),
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -201,6 +212,9 @@ export function CcwToolsMcpCard({
|
||||
|
||||
const handleConfigSave = () => {
|
||||
updateConfigMutation.mutate({
|
||||
// Preserve current tool selection; otherwise updateCcwConfig* falls back to defaults
|
||||
// and can unintentionally overwrite user-chosen enabled tools.
|
||||
enabledTools,
|
||||
projectRoot: projectRootInput || undefined,
|
||||
allowedDirs: allowedDirsInput || undefined,
|
||||
enableSandbox: enableSandboxInput,
|
||||
|
||||
332
ccw/frontend/src/components/memory/SessionPreviewPanel.tsx
Normal file
332
ccw/frontend/src/components/memory/SessionPreviewPanel.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
// ========================================
|
||||
// SessionPreviewPanel Component
|
||||
// ========================================
|
||||
// Preview and select sessions for Memory V2 extraction
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Search, Eye, Loader2, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import {
|
||||
usePreviewSessions,
|
||||
useTriggerSelectiveExtraction,
|
||||
} from '@/hooks/useMemoryV2';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SessionPreviewPanelProps {
|
||||
onClose?: () => void;
|
||||
onExtractComplete?: () => void;
|
||||
}
|
||||
|
||||
// Helper function to format bytes
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// Helper function to format timestamp
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return formatDistanceToNow(date, { addSuffix: true });
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionPreviewPanel({ onClose, onExtractComplete }: SessionPreviewPanelProps) {
|
||||
const intl = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [includeNative, setIncludeNative] = useState(false);
|
||||
|
||||
const { data, isLoading, refetch } = usePreviewSessions(includeNative);
|
||||
const triggerExtraction = useTriggerSelectiveExtraction();
|
||||
|
||||
// Filter sessions based on search query
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!data?.sessions) return [];
|
||||
if (!searchQuery.trim()) return data.sessions;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return data.sessions.filter(
|
||||
(session) =>
|
||||
session.sessionId.toLowerCase().includes(query) ||
|
||||
session.tool.toLowerCase().includes(query) ||
|
||||
session.source.toLowerCase().includes(query)
|
||||
);
|
||||
}, [data?.sessions, searchQuery]);
|
||||
|
||||
// Get ready sessions (eligible and not extracted)
|
||||
const readySessions = useMemo(() => {
|
||||
return filteredSessions.filter((s) => s.eligible && !s.extracted);
|
||||
}, [filteredSessions]);
|
||||
|
||||
// Toggle session selection
|
||||
const toggleSelection = (sessionId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Select all ready sessions
|
||||
const selectAll = () => {
|
||||
setSelectedIds(new Set(readySessions.map((s) => s.sessionId)));
|
||||
};
|
||||
|
||||
// Clear selection
|
||||
const selectNone = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
// Trigger extraction for selected sessions
|
||||
const handleExtract = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
triggerExtraction.mutate(
|
||||
{
|
||||
sessionIds: Array.from(selectedIds),
|
||||
includeNative,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedIds(new Set());
|
||||
onExtractComplete?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Eye className="w-5 h-5" />
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.title', defaultMessage: 'Extraction Queue Preview' })}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={includeNative}
|
||||
onCheckedChange={(checked) => setIncludeNative(checked === true)}
|
||||
/>
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.includeNative', defaultMessage: 'Include Native Sessions' })}
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Refresh'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Bar */}
|
||||
{data?.summary && (
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="text-center p-2 bg-muted rounded">
|
||||
<div className="text-lg font-bold">{data.summary.total}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.total', defaultMessage: 'Total' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-muted rounded">
|
||||
<div className="text-lg font-bold text-blue-600">{data.summary.eligible}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.eligible', defaultMessage: 'Eligible' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-muted rounded">
|
||||
<div className="text-lg font-bold text-green-600">{data.summary.alreadyExtracted}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.extracted', defaultMessage: 'Already Extracted' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-muted rounded">
|
||||
<div className="text-lg font-bold text-amber-600">{data.summary.readyForExtraction}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.ready', defaultMessage: 'Ready' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Actions */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'memory.v2.preview.selectSessions',
|
||||
defaultMessage: 'Search sessions...',
|
||||
})}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={selectAll}>
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.selectAll', defaultMessage: 'Select All' })}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={selectNone}>
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.selectNone', defaultMessage: 'Select None' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session Table */}
|
||||
<div className="flex-1 overflow-auto border rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.noSessions', defaultMessage: 'No sessions found' })}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="w-10 p-2"></th>
|
||||
<th className="text-left p-2">Source</th>
|
||||
<th className="text-left p-2">Session ID</th>
|
||||
<th className="text-left p-2">Tool</th>
|
||||
<th className="text-left p-2">Timestamp</th>
|
||||
<th className="text-right p-2">Size</th>
|
||||
<th className="text-right p-2">Turns</th>
|
||||
<th className="text-center p-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSessions.map((session) => {
|
||||
const isReady = session.eligible && !session.extracted;
|
||||
const isSelected = selectedIds.has(session.sessionId);
|
||||
const isDisabled = !isReady;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={session.sessionId}
|
||||
className={cn(
|
||||
'border-b hover:bg-muted/50 transition-colors',
|
||||
isDisabled && 'opacity-60',
|
||||
isSelected && 'bg-blue-50 dark:bg-blue-950/20'
|
||||
)}
|
||||
>
|
||||
<td className="p-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={() => toggleSelection(session.sessionId)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
session.source === 'ccw'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300'
|
||||
)}
|
||||
>
|
||||
{session.source === 'ccw'
|
||||
? intl.formatMessage({ id: 'memory.v2.preview.sourceCcw', defaultMessage: 'CCW' })
|
||||
: intl.formatMessage({ id: 'memory.v2.preview.sourceNative', defaultMessage: 'Native' })}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 font-mono text-xs truncate max-w-[150px]" title={session.sessionId}>
|
||||
{session.sessionId}
|
||||
</td>
|
||||
<td className="p-2 truncate max-w-[100px]" title={session.tool}>
|
||||
{session.tool || '-'}
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">
|
||||
{formatTimestamp(session.timestamp)}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-xs">
|
||||
{formatBytes(session.bytes)}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
{session.turns}
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
{session.extracted ? (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.extracted', defaultMessage: 'Extracted' })}
|
||||
</Badge>
|
||||
) : session.eligible ? (
|
||||
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.ready', defaultMessage: 'Ready' })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.ineligible', defaultMessage: 'Ineligible' })}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0 ? (
|
||||
intl.formatMessage(
|
||||
{ id: 'memory.v2.preview.selected', defaultMessage: '{count} sessions selected' },
|
||||
{ count: selectedIds.size }
|
||||
)
|
||||
) : (
|
||||
intl.formatMessage({ id: 'memory.v2.preview.selectHint', defaultMessage: 'Select sessions to extract' })
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onClose && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{intl.formatMessage({ id: 'common.close', defaultMessage: 'Close' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleExtract}
|
||||
disabled={selectedIds.size === 0 || triggerExtraction.isPending}
|
||||
>
|
||||
{triggerExtraction.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
|
||||
</>
|
||||
) : (
|
||||
intl.formatMessage(
|
||||
{ id: 'memory.v2.preview.extractSelected', defaultMessage: 'Extract Selected ({count})' },
|
||||
{ count: selectedIds.size }
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionPreviewPanel;
|
||||
@@ -28,6 +28,7 @@ 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 { SessionPreviewPanel } from '@/components/memory/SessionPreviewPanel';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import {
|
||||
useExtractionStatus,
|
||||
@@ -84,6 +85,7 @@ function ExtractionCard() {
|
||||
const { data: status, isLoading, refetch } = useExtractionStatus();
|
||||
const trigger = useTriggerExtraction();
|
||||
const [maxSessions, setMaxSessions] = useState(10);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const handleTrigger = () => {
|
||||
trigger.mutate(maxSessions);
|
||||
@@ -94,83 +96,107 @@ function ExtractionCard() {
|
||||
const lastRunText = formatRelativeTime(status?.lastRun);
|
||||
|
||||
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>
|
||||
{lastRunText && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{intl.formatMessage({ id: 'memory.v2.extraction.lastRun', defaultMessage: 'Last run' })}: {lastRunText}
|
||||
<>
|
||||
<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>
|
||||
{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">
|
||||
<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>
|
||||
{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 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={() => setShowPreview(true)}
|
||||
title={intl.formatMessage({ id: 'memory.v2.preview.previewQueue', defaultMessage: 'Preview Queue' })}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{intl.formatMessage({ id: 'memory.v2.preview.previewQueue', defaultMessage: 'Preview Queue' })}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
{/* Preview Queue Dialog */}
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<SessionPreviewPanel
|
||||
onClose={() => setShowPreview(false)}
|
||||
onExtractComplete={() => {
|
||||
setShowPreview(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
408
ccw/frontend/src/components/shared/CommandCreateDialog.tsx
Normal file
408
ccw/frontend/src/components/shared/CommandCreateDialog.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
// ========================================
|
||||
// Command Create Dialog Component
|
||||
// ========================================
|
||||
// Modal dialog for creating/importing commands with two modes:
|
||||
// - Import: import existing command file
|
||||
// - CLI Generate: AI-generated command from description
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Folder,
|
||||
User,
|
||||
FileCode,
|
||||
Sparkles,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { validateCommandImport, createCommand } from '@/lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CommandCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
cliType?: 'claude' | 'codex';
|
||||
}
|
||||
|
||||
type CreateMode = 'import' | 'cli-generate';
|
||||
type CommandLocation = 'project' | 'user';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
commandInfo?: { name: string; description: string; usage?: string };
|
||||
}
|
||||
|
||||
export function CommandCreateDialog({ open, onOpenChange, onCreated, cliType = 'claude' }: CommandCreateDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const [mode, setMode] = useState<CreateMode>('import');
|
||||
const [location, setLocation] = useState<CommandLocation>('project');
|
||||
|
||||
// Import mode state
|
||||
const [sourcePath, setSourcePath] = useState('');
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
// CLI Generate mode state
|
||||
const [commandName, setCommandName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setMode('import');
|
||||
setLocation('project');
|
||||
setSourcePath('');
|
||||
setCustomName('');
|
||||
setValidationResult(null);
|
||||
setIsValidating(false);
|
||||
setCommandName('');
|
||||
setDescription('');
|
||||
setIsCreating(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
onOpenChange(open);
|
||||
}, [onOpenChange, resetState]);
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
if (!sourcePath.trim()) return;
|
||||
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await validateCommandImport(sourcePath.trim());
|
||||
setValidationResult(result);
|
||||
} catch (err) {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : String(err)],
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [sourcePath]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (mode === 'import') {
|
||||
if (!sourcePath.trim()) return;
|
||||
if (!validationResult?.valid) return;
|
||||
} else {
|
||||
if (!commandName.trim()) return;
|
||||
if (!description.trim()) return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
await createCommand({
|
||||
mode,
|
||||
location,
|
||||
sourcePath: mode === 'import' ? sourcePath.trim() : undefined,
|
||||
commandName: mode === 'import' ? (customName.trim() || undefined) : commandName.trim(),
|
||||
description: mode === 'cli-generate' ? description.trim() : undefined,
|
||||
generationType: mode === 'cli-generate' ? 'description' : undefined,
|
||||
projectPath,
|
||||
cliType,
|
||||
});
|
||||
|
||||
handleOpenChange(false);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('Failed to create command:', err);
|
||||
if (mode === 'import') {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : formatMessage({ id: 'commands.create.createError' })],
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [mode, location, sourcePath, customName, commandName, description, validationResult, projectPath, handleOpenChange, onCreated, formatMessage]);
|
||||
|
||||
const canCreate = mode === 'import'
|
||||
? sourcePath.trim() && validationResult?.valid && !isCreating
|
||||
: commandName.trim() && description.trim() && !isCreating;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'commands.create.title' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'commands.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Location Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'commands.create.location' })}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
location === 'project'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setLocation('project')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.locationProject' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{`.${cliType}/commands/`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
location === 'user'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setLocation('user')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.locationUser' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{`~/.${cliType}/commands/`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'commands.create.mode' })}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
mode === 'import'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setMode('import')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.modeImport' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.modeImportHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
mode === 'cli-generate'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setMode('cli-generate')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.modeGenerate' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.modeGenerateHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Mode Content */}
|
||||
{mode === 'import' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourcePath">{formatMessage({ id: 'commands.create.sourcePath' })}</Label>
|
||||
<Input
|
||||
id="sourcePath"
|
||||
value={sourcePath}
|
||||
onChange={(e) => {
|
||||
setSourcePath(e.target.value);
|
||||
setValidationResult(null);
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'commands.create.sourcePathPlaceholder' })}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.sourcePathHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customName">
|
||||
{formatMessage({ id: 'commands.create.customName' })}
|
||||
<span className="text-muted-foreground ml-1">({formatMessage({ id: 'commands.create.customNameHint' })})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="customName"
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'commands.create.customNamePlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{isValidating && (
|
||||
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'commands.create.validating' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{validationResult && !isValidating && (
|
||||
validationResult.valid ? (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{formatMessage({ id: 'commands.create.validCommand' })}</span>
|
||||
</div>
|
||||
{validationResult.commandInfo && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.name' })}: </span>
|
||||
<span>{validationResult.commandInfo.name}</span>
|
||||
</div>
|
||||
{validationResult.commandInfo.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.description' })}: </span>
|
||||
<span>{validationResult.commandInfo.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{validationResult.commandInfo.usage && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.usage' })}: </span>
|
||||
<span>{validationResult.commandInfo.usage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-destructive mb-2">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{formatMessage({ id: 'commands.create.invalidCommand' })}</span>
|
||||
</div>
|
||||
{validationResult.errors && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationResult.errors.map((error, i) => (
|
||||
<li key={i} className="text-destructive">{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CLI Generate Mode Content */}
|
||||
{mode === 'cli-generate' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="commandName">
|
||||
{formatMessage({ id: 'commands.create.commandName' })} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="commandName"
|
||||
value={commandName}
|
||||
onChange={(e) => setCommandName(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'commands.create.commandNamePlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.commandNameHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
{formatMessage({ id: 'commands.create.descriptionLabel' })} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'commands.create.descriptionPlaceholder' })}
|
||||
rows={6}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.descriptionHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-blue-600 mt-0.5" />
|
||||
<div className="text-sm text-blue-600">
|
||||
<p className="font-medium">{formatMessage({ id: 'commands.create.generateInfo' })}</p>
|
||||
<p className="text-xs mt-1">{formatMessage({ id: 'commands.create.generateTimeHint' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isCreating}>
|
||||
{formatMessage({ id: 'commands.actions.cancel' })}
|
||||
</Button>
|
||||
{mode === 'import' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleValidate}
|
||||
disabled={!sourcePath.trim() || isValidating || isCreating}
|
||||
>
|
||||
{isValidating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{formatMessage({ id: 'commands.create.validate' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
{isCreating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{isCreating
|
||||
? formatMessage({ id: 'commands.create.creating' })
|
||||
: mode === 'import'
|
||||
? formatMessage({ id: 'commands.create.import' })
|
||||
: formatMessage({ id: 'commands.create.generate' })
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandCreateDialog;
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
triggerConsolidation,
|
||||
getConsolidationStatus,
|
||||
getV2Jobs,
|
||||
previewExtractionQueue,
|
||||
triggerSelectiveExtraction,
|
||||
type ExtractionStatus,
|
||||
type ConsolidationStatus,
|
||||
type V2JobsResponse,
|
||||
type ExtractionPreviewResponse,
|
||||
type SelectiveExtractionResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
@@ -23,6 +27,8 @@ export const memoryV2Keys = {
|
||||
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,
|
||||
preview: (path?: string, includeNative?: boolean) =>
|
||||
[...memoryV2Keys.all, 'preview', path, includeNative] as const,
|
||||
};
|
||||
|
||||
// Default stale time: 30 seconds (V2 status changes frequently)
|
||||
@@ -97,5 +103,35 @@ export function useTriggerConsolidation() {
|
||||
});
|
||||
}
|
||||
|
||||
// Hook: Preview sessions for extraction
|
||||
export function usePreviewSessions(includeNative: boolean = false) {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
return useQuery({
|
||||
queryKey: memoryV2Keys.preview(projectPath, includeNative),
|
||||
queryFn: () => previewExtractionQueue(includeNative, undefined, projectPath),
|
||||
enabled: !!projectPath,
|
||||
staleTime: 10 * 1000, // 10 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Hook: Trigger selective extraction
|
||||
export function useTriggerSelectiveExtraction() {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: { sessionIds: string[]; includeNative?: boolean }) =>
|
||||
triggerSelectiveExtraction({
|
||||
sessionIds: params.sessionIds,
|
||||
includeNative: params.includeNative,
|
||||
path: projectPath,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: memoryV2Keys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export types
|
||||
export type { ExtractionStatus, ConsolidationStatus, V2JobsResponse };
|
||||
export type { ExtractionStatus, ConsolidationStatus, V2JobsResponse, ExtractionPreviewResponse, SelectiveExtractionResponse };
|
||||
|
||||
@@ -1492,6 +1492,39 @@ export async function getCommandsGroupsConfig(
|
||||
return fetchApi<{ groups: Record<string, any>; assignments: Record<string, string> }>(`/api/commands/groups/config?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a command file for import
|
||||
*/
|
||||
export async function validateCommandImport(sourcePath: string): Promise<{
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
commandInfo?: { name: string; description: string; version?: string };
|
||||
}> {
|
||||
return fetchApi('/api/commands/validate-import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourcePath }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/import a command
|
||||
*/
|
||||
export async function createCommand(params: {
|
||||
mode: 'import' | 'cli-generate';
|
||||
location: 'project' | 'user';
|
||||
sourcePath?: string;
|
||||
commandName?: string;
|
||||
description?: string;
|
||||
generationType?: 'description' | 'template';
|
||||
projectPath?: string;
|
||||
cliType?: 'claude' | 'codex';
|
||||
}): Promise<{ commandName: string; path: string }> {
|
||||
return fetchApi('/api/commands/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Memory API ==========
|
||||
|
||||
export interface CoreMemory {
|
||||
@@ -1744,6 +1777,79 @@ export async function getV2Jobs(
|
||||
return fetchApi<V2JobsResponse>(`/api/core-memory/jobs?${params}`);
|
||||
}
|
||||
|
||||
// ========== Memory V2 Preview API ==========
|
||||
|
||||
export interface SessionPreviewItem {
|
||||
sessionId: string;
|
||||
source: 'ccw' | 'native';
|
||||
tool: string;
|
||||
timestamp: number;
|
||||
eligible: boolean;
|
||||
extracted: boolean;
|
||||
bytes: number;
|
||||
turns: number;
|
||||
}
|
||||
|
||||
export interface ExtractionPreviewResponse {
|
||||
success: boolean;
|
||||
sessions: SessionPreviewItem[];
|
||||
summary: {
|
||||
total: number;
|
||||
eligible: number;
|
||||
alreadyExtracted: number;
|
||||
readyForExtraction: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelectiveExtractionRequest {
|
||||
sessionIds: string[];
|
||||
includeNative?: boolean;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface SelectiveExtractionResponse {
|
||||
success: boolean;
|
||||
jobId: string;
|
||||
queued: number;
|
||||
skipped: number;
|
||||
invalidIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview extraction queue - get list of sessions available for extraction
|
||||
*/
|
||||
export async function previewExtractionQueue(
|
||||
includeNative: boolean = false,
|
||||
maxSessions?: number,
|
||||
projectPath?: string
|
||||
): Promise<ExtractionPreviewResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (projectPath) params.set('path', projectPath);
|
||||
if (includeNative) params.set('include_native', 'true');
|
||||
if (maxSessions) params.set('max_sessions', String(maxSessions));
|
||||
return fetchApi<ExtractionPreviewResponse>(`/api/core-memory/extract/preview?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger selective extraction for specific sessions
|
||||
*/
|
||||
export async function triggerSelectiveExtraction(
|
||||
request: SelectiveExtractionRequest
|
||||
): Promise<SelectiveExtractionResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (request.path) params.set('path', request.path);
|
||||
return fetchApi<SelectiveExtractionResponse>(
|
||||
`/api/core-memory/extract/selective?${params}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_ids: request.sessionIds,
|
||||
include_native: request.includeNative,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Project Overview API ==========
|
||||
|
||||
export interface TechnologyStack {
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"categories": {
|
||||
"notification": "Notification",
|
||||
"indexing": "Indexing",
|
||||
"automation": "Automation"
|
||||
"automation": "Automation",
|
||||
"utility": "Utility"
|
||||
},
|
||||
"templates": {
|
||||
"session-start-notify": {
|
||||
@@ -116,6 +117,30 @@
|
||||
"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"
|
||||
},
|
||||
"memory-auto-compress": {
|
||||
"name": "Auto Memory Compress",
|
||||
"description": "Automatically compress memory when entries exceed threshold"
|
||||
},
|
||||
"memory-preview-extract": {
|
||||
"name": "Memory Preview & Extract",
|
||||
"description": "Preview extraction queue and extract eligible sessions"
|
||||
},
|
||||
"memory-status-check": {
|
||||
"name": "Memory Status Check",
|
||||
"description": "Check memory extraction and consolidation status"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"collapseAll": "Collapse All",
|
||||
"copy": "Copy",
|
||||
"showDisabled": "Show Disabled",
|
||||
"hideDisabled": "Hide Disabled"
|
||||
"hideDisabled": "Hide Disabled",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"source": {
|
||||
"builtin": "Built-in",
|
||||
@@ -57,5 +58,45 @@
|
||||
"clickToDisableAll": "Click to disable all",
|
||||
"noCommands": "No commands in this group",
|
||||
"noEnabledCommands": "No enabled commands in this group"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Command",
|
||||
"location": "Location",
|
||||
"locationProject": "Project Commands",
|
||||
"locationProjectHint": ".claude/commands/",
|
||||
"locationUser": "Global Commands",
|
||||
"locationUserHint": "~/.claude/commands/",
|
||||
"mode": "Creation Mode",
|
||||
"modeImport": "Import File",
|
||||
"modeImportHint": "Import command from existing file",
|
||||
"modeGenerate": "AI Generate",
|
||||
"modeGenerateHint": "Generate command using AI",
|
||||
"sourcePath": "Source File Path",
|
||||
"sourcePathPlaceholder": "Enter absolute path to command file",
|
||||
"sourcePathHint": "File must be a valid command markdown file",
|
||||
"customName": "Custom Name",
|
||||
"customNamePlaceholder": "Leave empty to use original name",
|
||||
"customNameHint": "Optional, overrides default command name",
|
||||
"commandName": "Command Name",
|
||||
"commandNamePlaceholder": "Enter command name",
|
||||
"commandNameHint": "Used as the command file name",
|
||||
"descriptionLabel": "Command Description",
|
||||
"descriptionPlaceholder": "Describe what this command should do...",
|
||||
"descriptionHint": "AI will generate command content based on this description",
|
||||
"generateInfo": "AI will use CLI tools to generate the command",
|
||||
"generateTimeHint": "Generation may take some time",
|
||||
"validate": "Validate",
|
||||
"import": "Import",
|
||||
"generate": "Generate",
|
||||
"validating": "Validating...",
|
||||
"validCommand": "Validation passed",
|
||||
"invalidCommand": "Validation failed",
|
||||
"creating": "Creating...",
|
||||
"created": "Command \"{name}\" created successfully",
|
||||
"createError": "Failed to create command",
|
||||
"sourcePathRequired": "Please enter source file path",
|
||||
"commandNameRequired": "Please enter command name",
|
||||
"descriptionRequired": "Please enter command description",
|
||||
"validateFirst": "Please validate the command file first"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,10 @@
|
||||
"saveConfig": "Save Configuration",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"feedback": {
|
||||
"saveSuccess": "Configuration saved",
|
||||
"saveError": "Failed to save configuration"
|
||||
},
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"project": "Project",
|
||||
|
||||
@@ -180,6 +180,28 @@
|
||||
"statusBanner": {
|
||||
"running": "Pipeline Running - {count} job(s) in progress",
|
||||
"hasErrors": "Pipeline Idle - {count} job(s) failed"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Extraction Queue Preview",
|
||||
"selectSessions": "Search sessions...",
|
||||
"sourceCcw": "CCW",
|
||||
"sourceNative": "Native",
|
||||
"selectAll": "Select All",
|
||||
"selectNone": "Select None",
|
||||
"extractSelected": "Extract Selected ({count})",
|
||||
"noSessions": "No sessions found",
|
||||
"total": "Total",
|
||||
"eligible": "Eligible",
|
||||
"extracted": "Already Extracted",
|
||||
"ready": "Ready",
|
||||
"previewQueue": "Preview Queue",
|
||||
"includeNative": "Include Native Sessions",
|
||||
"selected": "{count} sessions selected",
|
||||
"selectHint": "Select sessions to extract",
|
||||
"ineligible": "Ineligible"
|
||||
},
|
||||
"extraction": {
|
||||
"selectiveTriggered": "Selective extraction triggered"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"categories": {
|
||||
"notification": "通知",
|
||||
"indexing": "索引",
|
||||
"automation": "自动化"
|
||||
"automation": "自动化",
|
||||
"utility": "实用工具"
|
||||
},
|
||||
"templates": {
|
||||
"session-start-notify": {
|
||||
@@ -116,6 +117,30 @@
|
||||
"project-state-inject": {
|
||||
"name": "项目状态注入",
|
||||
"description": "会话启动时注入项目约束和最近开发历史"
|
||||
},
|
||||
"memory-v2-extract": {
|
||||
"name": "Memory V2 提取",
|
||||
"description": "会话结束时触发第一阶段提取(空闲期后)"
|
||||
},
|
||||
"memory-v2-auto-consolidate": {
|
||||
"name": "Memory V2 自动合并",
|
||||
"description": "提取作业完成后触发第二阶段合并"
|
||||
},
|
||||
"memory-sync-dashboard": {
|
||||
"name": "Memory 同步仪表盘",
|
||||
"description": "变更时同步 Memory V2 状态到仪表盘"
|
||||
},
|
||||
"memory-auto-compress": {
|
||||
"name": "自动内存压缩",
|
||||
"description": "当条目超过阈值时自动压缩内存"
|
||||
},
|
||||
"memory-preview-extract": {
|
||||
"name": "内存预览与提取",
|
||||
"description": "预览提取队列并提取符合条件的会话"
|
||||
},
|
||||
"memory-status-check": {
|
||||
"name": "内存状态检查",
|
||||
"description": "检查内存提取和合并状态"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"collapseAll": "全部收起",
|
||||
"copy": "复制",
|
||||
"showDisabled": "显示已禁用",
|
||||
"hideDisabled": "隐藏已禁用"
|
||||
"hideDisabled": "隐藏已禁用",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"source": {
|
||||
"builtin": "内置",
|
||||
@@ -57,5 +58,45 @@
|
||||
"clickToDisableAll": "点击全部禁用",
|
||||
"noCommands": "此分组中没有命令",
|
||||
"noEnabledCommands": "此分组中没有已启用的命令"
|
||||
},
|
||||
"create": {
|
||||
"title": "创建命令",
|
||||
"location": "存储位置",
|
||||
"locationProject": "项目命令",
|
||||
"locationProjectHint": ".claude/commands/",
|
||||
"locationUser": "全局命令",
|
||||
"locationUserHint": "~/.claude/commands/",
|
||||
"mode": "创建方式",
|
||||
"modeImport": "导入文件",
|
||||
"modeImportHint": "从现有文件导入命令",
|
||||
"modeGenerate": "AI 生成",
|
||||
"modeGenerateHint": "使用 AI 生成命令",
|
||||
"sourcePath": "源文件路径",
|
||||
"sourcePathPlaceholder": "输入命令文件的绝对路径",
|
||||
"sourcePathHint": "文件必须是有效的命令 Markdown 文件",
|
||||
"customName": "自定义名称",
|
||||
"customNamePlaceholder": "留空则使用原始名称",
|
||||
"customNameHint": "可选,覆盖默认命令名称",
|
||||
"commandName": "命令名称",
|
||||
"commandNamePlaceholder": "输入命令名称",
|
||||
"commandNameHint": "用作命令文件名称",
|
||||
"descriptionLabel": "命令描述",
|
||||
"descriptionPlaceholder": "描述这个命令应该做什么...",
|
||||
"descriptionHint": "AI 将根据描述生成命令内容",
|
||||
"generateInfo": "AI 将使用 CLI 工具生成命令",
|
||||
"generateTimeHint": "生成过程可能需要一些时间",
|
||||
"validate": "验证",
|
||||
"import": "导入",
|
||||
"generate": "生成",
|
||||
"validating": "验证中...",
|
||||
"validCommand": "验证通过",
|
||||
"invalidCommand": "验证失败",
|
||||
"creating": "创建中...",
|
||||
"created": "命令 \"{name}\" 创建成功",
|
||||
"createError": "创建命令失败",
|
||||
"sourcePathRequired": "请输入源文件路径",
|
||||
"commandNameRequired": "请输入命令名称",
|
||||
"descriptionRequired": "请输入命令描述",
|
||||
"validateFirst": "请先验证命令文件"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,10 @@
|
||||
"saveConfig": "保存配置",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"feedback": {
|
||||
"saveSuccess": "配置已保存",
|
||||
"saveError": "保存配置失败"
|
||||
},
|
||||
"scope": {
|
||||
"global": "全局",
|
||||
"project": "项目",
|
||||
|
||||
@@ -180,6 +180,28 @@
|
||||
"statusBanner": {
|
||||
"running": "Pipeline 运行中 - {count} 个作业正在执行",
|
||||
"hasErrors": "Pipeline 空闲 - {count} 个作业失败"
|
||||
},
|
||||
"preview": {
|
||||
"title": "提取队列预览",
|
||||
"selectSessions": "搜索会话...",
|
||||
"sourceCcw": "CCW",
|
||||
"sourceNative": "原生",
|
||||
"selectAll": "全选",
|
||||
"selectNone": "取消全选",
|
||||
"extractSelected": "提取选中 ({count})",
|
||||
"noSessions": "未找到会话",
|
||||
"total": "总计",
|
||||
"eligible": "符合条件",
|
||||
"extracted": "已提取",
|
||||
"ready": "就绪",
|
||||
"previewQueue": "预览队列",
|
||||
"includeNative": "包含原生会话",
|
||||
"selected": "已选择 {count} 个会话",
|
||||
"selectHint": "选择要提取的会话",
|
||||
"ineligible": "不符合条件"
|
||||
},
|
||||
"extraction": {
|
||||
"selectiveTriggered": "选择性提取已触发"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user