feat: add Accordion component for UI and Zustand store for coordinator management

- Implemented Accordion component using Radix UI for collapsible sections.
- Created Zustand store to manage coordinator execution state, command chains, logs, and interactive questions.
- Added validation tests for CLI settings type definitions, ensuring type safety and correct behavior of helper functions.
This commit is contained in:
catlog22
2026-02-03 10:02:40 +08:00
parent bcb4af3ba0
commit 5483a72e9f
82 changed files with 6156 additions and 7605 deletions

View File

@@ -10,7 +10,6 @@ import {
updateMemory,
deleteMemory,
type CoreMemory,
type MemoryResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
@@ -30,6 +29,8 @@ const STALE_TIME = 60 * 1000;
export interface MemoryFilter {
search?: string;
tags?: string[];
favorite?: boolean;
archived?: boolean;
}
export interface UseMemoryOptions {
@@ -93,6 +94,26 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
);
}
// Filter by favorite status (from metadata)
if (filter?.favorite === true) {
memories = memories.filter((m) => {
if (!m.metadata) return false;
try {
const metadata = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata;
return metadata.favorite === true;
} catch {
return false;
}
});
}
// Filter by archived status
if (filter?.archived === true) {
memories = memories.filter((m) => m.archived === true);
} else if (filter?.archived === false) {
memories = memories.filter((m) => m.archived !== true);
}
return memories;
})();
@@ -202,6 +223,64 @@ export function useDeleteMemory(): UseDeleteMemoryReturn {
};
}
export interface UseArchiveMemoryReturn {
archiveMemory: (memoryId: string) => Promise<void>;
isArchiving: boolean;
error: Error | null;
}
export function useArchiveMemory(): UseArchiveMemoryReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: (memoryId: string) =>
fetch(`/api/core-memory/memories/${encodeURIComponent(memoryId)}/archive?path=${encodeURIComponent(projectPath)}`, {
method: 'POST',
credentials: 'same-origin',
}).then(res => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
},
});
return {
archiveMemory: mutation.mutateAsync,
isArchiving: mutation.isPending,
error: mutation.error,
};
}
export interface UseUnarchiveMemoryReturn {
unarchiveMemory: (memoryId: string) => Promise<void>;
isUnarchiving: boolean;
error: Error | null;
}
export function useUnarchiveMemory(): UseUnarchiveMemoryReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: (memoryId: string) =>
fetch(`/api/core-memory/memories?path=${encodeURIComponent(projectPath)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ id: memoryId, archived: false }),
}).then(res => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
},
});
return {
unarchiveMemory: mutation.mutateAsync,
isUnarchiving: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all memory mutations
*/
@@ -209,14 +288,20 @@ export function useMemoryMutations() {
const create = useCreateMemory();
const update = useUpdateMemory();
const remove = useDeleteMemory();
const archive = useArchiveMemory();
const unarchive = useUnarchiveMemory();
return {
createMemory: create.createMemory,
updateMemory: update.updateMemory,
deleteMemory: remove.deleteMemory,
archiveMemory: archive.archiveMemory,
unarchiveMemory: unarchive.unarchiveMemory,
isCreating: create.isCreating,
isUpdating: update.isUpdating,
isDeleting: remove.isDeleting,
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
isArchiving: archive.isArchiving,
isUnarchiving: unarchive.isUnarchiving,
isMutating: create.isCreating || update.isUpdating || remove.isDeleting || archive.isArchiving || unarchive.isUnarchiving,
};
}

View File

@@ -7,14 +7,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchPrompts,
fetchPromptInsights,
fetchInsightsHistory,
analyzePrompts,
deletePrompt,
batchDeletePrompts,
deleteInsight,
type Prompt,
type PromptInsight,
type Pattern,
type Suggestion,
type PromptsResponse,
type PromptInsightsResponse,
type InsightsHistoryResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -24,6 +25,7 @@ export const promptHistoryKeys = {
lists: () => [...promptHistoryKeys.all, 'list'] as const,
list: (filters?: PromptHistoryFilter) => [...promptHistoryKeys.lists(), filters] as const,
insights: () => [...promptHistoryKeys.all, 'insights'] as const,
insightsHistory: () => [...promptHistoryKeys.all, 'insightsHistory'] as const,
};
// Default stale time: 30 seconds (prompts update less frequently)
@@ -32,6 +34,7 @@ const STALE_TIME = 30 * 1000;
export interface PromptHistoryFilter {
search?: string;
intent?: string;
project?: string;
dateRange?: { start: Date | null; end: Date | null };
}
@@ -43,12 +46,19 @@ export interface UsePromptHistoryOptions {
export interface UsePromptHistoryReturn {
prompts: Prompt[];
allPrompts: Prompt[];
totalPrompts: number;
promptsBySession: Record<string, Prompt[]>;
stats: {
totalCount: number;
avgLength: number;
topIntent: string | null;
avgQualityScore?: number;
qualityDistribution?: {
high: number;
medium: number;
low: number;
};
};
isLoading: boolean;
isFetching: boolean;
@@ -96,6 +106,10 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
prompts = prompts.filter((p) => p.category === filter.intent);
}
if (filter?.project) {
prompts = prompts.filter((p) => p.project === filter.project);
}
if (filter?.dateRange?.start || filter?.dateRange?.end) {
prompts = prompts.filter((p) => {
const date = new Date(p.createdAt);
@@ -132,6 +146,34 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
}
const topIntent = Object.entries(intentCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
// Calculate quality distribution
const qualityDistribution = {
high: 0,
medium: 0,
low: 0,
};
let totalQualityScore = 0;
let qualityScoreCount = 0;
for (const prompt of allPrompts) {
if (prompt.quality_score !== undefined && prompt.quality_score !== null) {
totalQualityScore += prompt.quality_score;
qualityScoreCount++;
if (prompt.quality_score >= 80) {
qualityDistribution.high++;
} else if (prompt.quality_score >= 60) {
qualityDistribution.medium++;
} else {
qualityDistribution.low++;
}
}
}
const avgQualityScore = qualityScoreCount > 0
? totalQualityScore / qualityScoreCount
: undefined;
const refetch = async () => {
await query.refetch();
};
@@ -142,12 +184,15 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
return {
prompts: filteredPrompts,
allPrompts,
totalPrompts: totalCount,
promptsBySession,
stats: {
totalCount: allPrompts.length,
avgLength,
topIntent,
avgQualityScore,
qualityDistribution,
},
isLoading: query.isLoading,
isFetching: query.isFetching,
@@ -157,6 +202,8 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
};
}
/**
* Hook for fetching prompt insights
*/
@@ -175,6 +222,28 @@ export function usePromptInsights(options: { enabled?: boolean; staleTime?: numb
});
}
/**
* Hook for fetching insights history (past CLI analyses)
*/
export function useInsightsHistory(options: {
limit?: number;
enabled?: boolean;
staleTime?: number;
} = {}) {
const { limit = 20, enabled = true, staleTime = STALE_TIME } = options;
const projectPath = useWorkflowStore(selectProjectPath);
const queryEnabled = enabled && !!projectPath;
return useQuery({
queryKey: promptHistoryKeys.insightsHistory(),
queryFn: () => fetchInsightsHistory(projectPath, limit),
staleTime,
enabled: queryEnabled,
retry: 2,
});
}
// ========== Mutations ==========
export interface UseAnalyzePromptsReturn {
@@ -244,18 +313,120 @@ export function useDeletePrompt(): UseDeletePromptReturn {
};
}
export interface UseBatchDeletePromptsReturn {
batchDeletePrompts: (promptIds: string[]) => Promise<{ deleted: number }>;
isBatchDeleting: boolean;
error: Error | null;
}
export function useBatchDeletePrompts(): UseBatchDeletePromptsReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: batchDeletePrompts,
onMutate: async (promptIds) => {
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.all });
const previousPrompts = queryClient.getQueryData<PromptsResponse>(promptHistoryKeys.list());
queryClient.setQueryData<PromptsResponse>(promptHistoryKeys.list(), (old) => {
if (!old) return old;
return {
...old,
prompts: old.prompts.filter((p) => !promptIds.includes(p.id)),
total: old.total - promptIds.length,
};
});
return { previousPrompts };
},
onError: (_error, _promptIds, context) => {
if (context?.previousPrompts) {
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
},
});
return {
batchDeletePrompts: mutation.mutateAsync,
isBatchDeleting: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteInsightReturn {
deleteInsight: (insightId: string) => Promise<{ success: boolean }>;
isDeleting: boolean;
error: Error | null;
}
export function useDeleteInsight(): UseDeleteInsightReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: (insightId: string) => deleteInsight(insightId, projectPath),
onMutate: async (insightId) => {
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.insightsHistory() });
const previousInsights = queryClient.getQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory());
queryClient.setQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory(), (old) => {
if (!old) return old;
return {
...old,
insights: old.insights.filter((i) => i.id !== insightId),
};
});
return { previousInsights };
},
onError: (_error, _insightId, context) => {
if (context?.previousInsights) {
queryClient.setQueryData(promptHistoryKeys.insightsHistory(), context.previousInsights);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insightsHistory() });
},
});
return {
deleteInsight: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all prompt history mutations
*/
export function usePromptHistoryMutations() {
const analyze = useAnalyzePrompts();
const remove = useDeletePrompt();
const batchRemove = useBatchDeletePrompts();
return {
analyzePrompts: analyze.analyzePrompts,
deletePrompt: remove.deletePrompt,
batchDeletePrompts: batchRemove.batchDeletePrompts,
isAnalyzing: analyze.isAnalyzing,
isDeleting: remove.isDeleting,
isMutating: analyze.isAnalyzing || remove.isDeleting,
isBatchDeleting: batchRemove.isBatchDeleting,
isMutating: analyze.isAnalyzing || remove.isDeleting || batchRemove.isBatchDeleting,
};
}
/**
* Extract unique projects from prompts list
*/
export function extractUniqueProjects(prompts: Prompt[]): string[] {
const projectsSet = new Set<string>();
for (const prompt of prompts) {
if (prompt.project) {
projectsSet.add(prompt.project);
}
}
return Array.from(projectsSet).sort();
}

View File

@@ -8,6 +8,7 @@ import { useNotificationStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore';
import { useFlowStore } from '@/stores';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import {
OrchestratorMessageSchema,
type OrchestratorWebSocketMessage,
@@ -60,6 +61,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// CLI stream store for CLI output handling
const addOutput = useCliStreamStore((state) => state.addOutput);
// Coordinator store for coordinator state updates
const updateNodeStatus = useCoordinatorStore((state) => state.updateNodeStatus);
const addCoordinatorLog = useCoordinatorStore((state) => state.addLog);
const setActiveQuestion = useCoordinatorStore((state) => state.setActiveQuestion);
const markExecutionComplete = useCoordinatorStore((state) => state.markExecutionComplete);
const coordinatorExecutionId = useCoordinatorStore((state) => state.currentExecutionId);
// Handle incoming WebSocket messages
const handleMessage = useCallback(
(event: MessageEvent) => {
@@ -143,6 +151,56 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
return;
}
// Handle Coordinator messages
if (data.type?.startsWith('COORDINATOR_')) {
// Only process messages for current coordinator execution
if (coordinatorExecutionId && data.executionId !== coordinatorExecutionId) {
return;
}
// Dispatch to coordinator store based on message type
switch (data.type) {
case 'COORDINATOR_STATE_UPDATE':
// Check for completion
if (data.status === 'completed') {
markExecutionComplete(true);
} else if (data.status === 'failed') {
markExecutionComplete(false);
}
break;
case 'COORDINATOR_COMMAND_STARTED':
updateNodeStatus(data.nodeId, 'running');
break;
case 'COORDINATOR_COMMAND_COMPLETED':
updateNodeStatus(data.nodeId, 'completed', data.result);
break;
case 'COORDINATOR_COMMAND_FAILED':
updateNodeStatus(data.nodeId, 'failed', undefined, data.error);
break;
case 'COORDINATOR_LOG_ENTRY':
addCoordinatorLog(
data.log.message,
data.log.level,
data.log.nodeId,
data.log.source
);
break;
case 'COORDINATOR_QUESTION_ASKED':
setActiveQuestion(data.question);
break;
case 'COORDINATOR_ANSWER_RECEIVED':
// Answer received - handled by submitAnswer in the store
break;
}
return;
}
// Check if this is an orchestrator message
if (!data.type?.startsWith('ORCHESTRATOR_')) {
return;
@@ -210,6 +268,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
},
[
currentExecution,
coordinatorExecutionId,
setWsLastMessage,
setExecutionStatus,
setNodeStarted,
@@ -220,6 +279,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
updateNode,
addOutput,
addA2UINotification,
updateNodeStatus,
addCoordinatorLog,
setActiveQuestion,
markExecutionComplete,
onMessage,
]
);