From e70c8af4fa2e8d5702004d4b64fc743bc720a30d Mon Sep 17 00:00:00 2001 From: a7m-1st Date: Fri, 24 Oct 2025 16:25:47 +0300 Subject: [PATCH] feat: init grouped history views --- .../GroupedHistoryView/ProjectGroup.tsx | 148 +++++++++++++++ .../GroupedHistoryView/TaskItem.tsx | 176 ++++++++++++++++++ src/components/GroupedHistoryView/index.tsx | 122 ++++++++++++ src/components/HistorySidebar/index.tsx | 11 ++ src/components/SearchHistoryDialog.tsx | 64 +++++-- src/pages/Dashboard/Project.tsx | 11 ++ src/service/historyApi.ts | 79 ++++++++ src/store/globalStore.ts | 17 +- src/types/history.d.ts | 55 ++++++ 9 files changed, 656 insertions(+), 27 deletions(-) create mode 100644 src/components/GroupedHistoryView/ProjectGroup.tsx create mode 100644 src/components/GroupedHistoryView/TaskItem.tsx create mode 100644 src/components/GroupedHistoryView/index.tsx create mode 100644 src/types/history.d.ts diff --git a/src/components/GroupedHistoryView/ProjectGroup.tsx b/src/components/GroupedHistoryView/ProjectGroup.tsx new file mode 100644 index 000000000..246ccd3ac --- /dev/null +++ b/src/components/GroupedHistoryView/ProjectGroup.tsx @@ -0,0 +1,148 @@ +import React, { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { ChevronDown, ChevronRight, Folder, Calendar, Target, Clock } from "lucide-react"; +import { ProjectGroup as ProjectGroupType, HistoryTask } from "@/types/history"; +import { Button } from "@/components/ui/button"; +import { Tag } from "@/components/ui/tag"; +import { TooltipSimple } from "@/components/ui/tooltip"; +import { useTranslation } from "react-i18next"; +import TaskItem from "./TaskItem"; + +interface ProjectGroupProps { + project: ProjectGroupType; + onTaskSelect: (projectId: string, question: string, historyId: string) => void; + onTaskDelete: (taskId: string) => void; + onTaskShare: (taskId: string) => void; + activeTaskId?: string; + searchValue?: string; +} + +export default function ProjectGroup({ + project, + onTaskSelect, + onTaskDelete, + onTaskShare, + activeTaskId, + searchValue = "" +}: ProjectGroupProps) { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(true); + + // Filter tasks based on search value + const filteredTasks = project.tasks.filter(task => + task.question?.toLowerCase().includes(searchValue.toLowerCase()) + ); + + // Don't render if no tasks match the search + if (searchValue && filteredTasks.length === 0) { + return null; + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffInDays === 0) return t("layout.today"); + if (diffInDays === 1) return t("layout.yesterday"); + if (diffInDays < 7) return `${diffInDays} ${t("layout.days-ago")}`; + + return date.toLocaleDateString(); + }; + + const getStatusColor = (completedTasks: number, totalTasks: number) => { + const ratio = totalTasks > 0 ? completedTasks / totalTasks : 0; + if (ratio >= 0.8) return "text-green-600"; + if (ratio >= 0.5) return "text-yellow-600"; + return "text-red-600"; + }; + + return ( +
+ {/* Project Header */} + + + {/* Tasks List */} + + {isExpanded && ( + +
+ {(searchValue ? filteredTasks : project.tasks).map((task, index) => ( + onTaskSelect(task.project_id, task.question, task.id.toString())} + onDelete={() => onTaskDelete(task.id.toString())} + onShare={() => onTaskShare(task.task_id)} + isLast={index === (searchValue ? filteredTasks : project.tasks).length - 1} + /> + ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/GroupedHistoryView/TaskItem.tsx b/src/components/GroupedHistoryView/TaskItem.tsx new file mode 100644 index 000000000..1309f7f8d --- /dev/null +++ b/src/components/GroupedHistoryView/TaskItem.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { Ellipsis, Share, Trash2, Clock, CheckCircle, XCircle } from "lucide-react"; +import { HistoryTask } from "@/types/history"; +import { Button } from "@/components/ui/button"; +import { Tag } from "@/components/ui/tag"; +import { TooltipSimple } from "@/components/ui/tooltip"; +import { + Popover, + PopoverContent, + PopoverTrigger, + PopoverClose, +} from "@/components/ui/popover"; +import { useTranslation } from "react-i18next"; +import folderIcon from "@/assets/Folder-1.svg"; + +interface TaskItemProps { + task: HistoryTask; + isActive: boolean; + onSelect: () => void; + onDelete: () => void; + onShare: () => void; + isLast: boolean; +} + +export default function TaskItem({ + task, + isActive, + onSelect, + onDelete, + onShare, + isLast +}: TaskItemProps) { + const { t } = useTranslation(); + + const getStatusIcon = (status: number) => { + switch (status) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ; + } + }; + + const getStatusText = (status: number) => { + switch (status) { + case 1: + return t("layout.running"); + case 2: + return t("layout.completed"); + case 3: + return t("layout.failed"); + default: + return t("layout.unknown"); + } + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return ""; + const date = new Date(dateString); + return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + return ( +
+
+ task-icon + +
+ +
{task.question}
+ {task.summary && ( +
{task.summary}
+ )} +
+ {t("layout.created")}: {formatDate(task.created_at)} +
+
+ } + > + + {task.question || t("layout.new-project")} + + + + {task.summary && ( + + {task.summary} + + )} +
+
+ +
+
+ {getStatusIcon(task.status)} + + {getStatusText(task.status)} + +
+ + + {task.tokens ? task.tokens.toLocaleString() : "0"} + + + + + + + +
+ + + + + + + +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/src/components/GroupedHistoryView/index.tsx b/src/components/GroupedHistoryView/index.tsx new file mode 100644 index 000000000..8d8cb6028 --- /dev/null +++ b/src/components/GroupedHistoryView/index.tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { ProjectGroup as ProjectGroupType } from "@/types/history"; +import { fetchGroupedHistoryTasks } from "@/service/historyApi"; +import ProjectGroup from "./ProjectGroup"; +import { useTranslation } from "react-i18next"; +import { Loader2, FolderOpen } from "lucide-react"; + +interface GroupedHistoryViewProps { + searchValue?: string; + onTaskSelect: (projectId: string, question: string, historyId: string) => void; + onTaskDelete: (taskId: string) => void; + onTaskShare: (taskId: string) => void; + activeTaskId?: string; + refreshTrigger?: number; // For triggering refresh from parent +} + +export default function GroupedHistoryView({ + searchValue = "", + onTaskSelect, + onTaskDelete, + onTaskShare, + activeTaskId, + refreshTrigger +}: GroupedHistoryViewProps) { + const { t } = useTranslation(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + + const loadProjects = async () => { + setLoading(true); + try { + await fetchGroupedHistoryTasks(setProjects); + } catch (error) { + console.error("Failed to load grouped projects:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadProjects(); + }, [refreshTrigger]); + + // Filter projects based on search value + const filteredProjects = projects.filter(project => { + if (!searchValue) return true; + + // Check if project name matches + if (project.project_name?.toLowerCase().includes(searchValue.toLowerCase())) { + return true; + } + + // Check if any task in the project matches + return project.tasks.some(task => + task.question?.toLowerCase().includes(searchValue.toLowerCase()) + ); + }); + + if (loading) { + return ( +
+ + {t("layout.loading")} +
+ ); + } + + if (filteredProjects.length === 0) { + return ( +
+ +
+ {searchValue + ? t("dashboard.no-projects-match-search") + : t("dashboard.no-projects-found") + } +
+ {searchValue && ( +
+ {t("dashboard.try-different-search")} +
+ )} +
+ ); + } + + return ( +
+ + {filteredProjects.map((project) => ( + + + + ))} + + + {/* Summary footer */} +
+
+ {filteredProjects.length} {t("layout.projects")} • {" "} + {filteredProjects.reduce((total, project) => total + project.task_count, 0)} {t("layout.total-tasks")} • {" "} + {filteredProjects.reduce((total, project) => total + project.total_tokens, 0).toLocaleString()} {t("layout.total-tokens")} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/HistorySidebar/index.tsx b/src/components/HistorySidebar/index.tsx index e6a2b8713..7de59f81a 100644 --- a/src/components/HistorySidebar/index.tsx +++ b/src/components/HistorySidebar/index.tsx @@ -40,6 +40,7 @@ import { useTranslation } from "react-i18next"; import useChatStoreAdapter from "@/hooks/useChatStoreAdapter"; import {getAuthStore} from "@/store/authStore"; import { fetchHistoryTasks } from "@/service/historyApi"; +import GroupedHistoryView from "@/components/GroupedHistoryView"; export default function HistorySidebar() { const { t } = useTranslation(); @@ -497,6 +498,16 @@ export default function HistorySidebar() { ); })} + ) : history_type === "grouped" ? ( + // Grouped view + ) : ( // List */} diff --git a/src/components/SearchHistoryDialog.tsx b/src/components/SearchHistoryDialog.tsx index 6f78787d5..7734c1163 100644 --- a/src/components/SearchHistoryDialog.tsx +++ b/src/components/SearchHistoryDialog.tsx @@ -32,11 +32,14 @@ import { useTranslation } from "react-i18next"; import useChatStoreAdapter from "@/hooks/useChatStoreAdapter"; import { replayProject } from "@/lib"; import { fetchHistoryTasks } from "@/service/historyApi"; +import GroupedHistoryView from "@/components/GroupedHistoryView"; +import { useGlobalStore } from "@/store/globalStore"; export function SearchHistoryDialog() { const {t} = useTranslation() const [open, setOpen] = useState(false); const [historyTasks, setHistoryTasks] = useState([]); + const { history_type } = useGlobalStore(); //Get Chatstore for the active project's task const { chatStore, projectStore } = useChatStoreAdapter(); if (!chatStore) { @@ -52,7 +55,7 @@ export function SearchHistoryDialog() { projectStore.setHistoryId(projectId, historyId); projectStore.setActiveProject(projectId) navigate(`/`); - close(); + setOpen(false); } else { // if there is no record, execute replay handleReplay(projectId, question, historyId); @@ -60,10 +63,20 @@ export function SearchHistoryDialog() { }; const handleReplay = async (projectId: string, question: string, historyId: string) => { - close(); + setOpen(false); await replayProject(projectStore, navigate, projectId, question, historyId); }; + const handleDelete = (taskId: string) => { + // TODO: Implement delete functionality similar to HistorySidebar + console.log("Delete task:", taskId); + }; + + const handleShare = (taskId: string) => { + // TODO: Implement share functionality similar to HistorySidebar + console.log("Share task:", taskId); + }; + useEffect(() => { fetchHistoryTasks(setHistoryTasks); }, []); @@ -85,24 +98,35 @@ export function SearchHistoryDialog() { {t("dashboard.no-results")} - - {historyTasks.map((task) => ( - handleSetActive(task.task_id, task.question, task.id)} - > - -
- {task.question} -
-
- ))} -
+ {history_type === "grouped" ? ( +
+ +
+ ) : ( + + {historyTasks.map((task) => ( + handleSetActive(task.task_id, task.question, task.id)} + > + +
+ {task.question} +
+
+ ))} +
+ )}
diff --git a/src/pages/Dashboard/Project.tsx b/src/pages/Dashboard/Project.tsx index 2acd57b72..9a8b1f04a 100644 --- a/src/pages/Dashboard/Project.tsx +++ b/src/pages/Dashboard/Project.tsx @@ -42,6 +42,7 @@ import { share } from "@/lib/share"; import { useTranslation } from "react-i18next"; import AlertDialog from "@/components/ui/alertDialog"; import { fetchHistoryTasks } from "@/service/historyApi"; +import GroupedHistoryView from "@/components/GroupedHistoryView"; export default function Project() { @@ -548,6 +549,16 @@ export default function Project() { ); })} + ) : history_type === "grouped" ? ( + // Grouped view +
+ +
) : ( // List
diff --git a/src/service/historyApi.ts b/src/service/historyApi.ts index a3a881e97..d12fc0ced 100644 --- a/src/service/historyApi.ts +++ b/src/service/historyApi.ts @@ -1,4 +1,66 @@ import { proxyFetchGet } from "@/api/http"; +import { HistoryTask, ProjectGroup, GroupedHistoryResponse } from "@/types/history"; + +// Group tasks by project_id and add project-level metadata +const groupTasksByProject = (tasks: HistoryTask[]): ProjectGroup[] => { + const projectMap = new Map(); + + tasks.forEach(task => { + const projectId = task.project_id; + + if (!projectMap.has(projectId)) { + projectMap.set(projectId, { + project_id: projectId, + project_name: task.project_name || `Project ${projectId}`, + total_tokens: 0, + task_count: 0, + latest_task_date: task.created_at || new Date().toISOString(), + tasks: [], + total_completed_tasks: 0, + total_failed_tasks: 0, + average_tokens_per_task: 0 + }); + } + + const project = projectMap.get(projectId)!; + project.tasks.push(task); + project.task_count++; + project.total_tokens += task.tokens || 0; + + // Count status-based metrics + if (task.status === 2) { // Assuming 2 is completed + project.total_completed_tasks++; + } else if (task.status === 3) { // Assuming 3 is failed + project.total_failed_tasks++; + } + + // Update latest task date + if (task.created_at && task.created_at > project.latest_task_date) { + project.latest_task_date = task.created_at; + } + }); + + // Calculate averages and sort tasks within each project + projectMap.forEach(project => { + project.average_tokens_per_task = project.task_count > 0 + ? Math.round(project.total_tokens / project.task_count) + : 0; + + // Sort tasks by creation date (newest first) + project.tasks.sort((a, b) => { + const dateA = new Date(a.created_at || 0).getTime(); + const dateB = new Date(b.created_at || 0).getTime(); + return dateB - dateA; + }); + }); + + // Convert to array and sort by latest task date (newest first) + return Array.from(projectMap.values()).sort((a, b) => { + const dateA = new Date(a.latest_task_date).getTime(); + const dateB = new Date(b.latest_task_date).getTime(); + return dateB - dateA; + }); +}; export const fetchHistoryTasks = async (setTasks: React.Dispatch>) => { try { @@ -8,4 +70,21 @@ export const fetchHistoryTasks = async (setTasks: React.Dispatch>) => { + try { + const res = await proxyFetchGet(`/api/chat/histories`); + const groupedProjects = groupTasksByProject(res.items); + setProjects(groupedProjects); + } catch (error) { + console.error("Failed to fetch grouped history tasks:", error); + setProjects([]); + } +}; + +// Utility function to get all tasks from grouped data (for backward compatibility) +export const flattenProjectTasks = (projects: ProjectGroup[]): HistoryTask[] => { + return projects.flatMap(project => project.tasks); }; \ No newline at end of file diff --git a/src/store/globalStore.ts b/src/store/globalStore.ts index 2b5f56d9a..7cb816212 100644 --- a/src/store/globalStore.ts +++ b/src/store/globalStore.ts @@ -3,8 +3,8 @@ import { persist } from 'zustand/middleware'; // Define state types interface GlobalStore { - history_type: "table" | "list"; - setHistoryType: (history_type: "table" | "list") => void; + history_type: "table" | "list" | "grouped"; + setHistoryType: (history_type: "table" | "list" | "grouped") => void; toggleHistoryType: () => void; } @@ -12,13 +12,16 @@ interface GlobalStore { const globalStore = create()( persist( (set) => ({ - history_type: "list", - setHistoryType: (history_type: "table" | "list") => + history_type: "grouped", + setHistoryType: (history_type: "table" | "list" | "grouped") => set({ history_type }), toggleHistoryType: () => - set((state) => ({ - history_type: state.history_type === "table" ? "list" : "table", - })), + set((state) => { + // Cycle through: grouped -> list -> table -> grouped + if (state.history_type === "grouped") return { history_type: "list" }; + if (state.history_type === "list") return { history_type: "table" }; + return { history_type: "grouped" }; + }), }), { name: 'global-storage', diff --git a/src/types/history.d.ts b/src/types/history.d.ts new file mode 100644 index 000000000..1ccc511ad --- /dev/null +++ b/src/types/history.d.ts @@ -0,0 +1,55 @@ +// History API types for project-grouped structure + +export interface HistoryTask { + id: number; + task_id: string; + project_id: string; + question: string; + language: string; + model_platform: string; + model_type: string; + api_key?: string; + api_url?: string; + max_retries: number; + file_save_path?: string; + installed_mcp?: string; + project_name?: string; + summary?: string; + tokens: number; + status: number; + created_at?: string; + updated_at?: string; +} + +export interface ProjectGroup { + project_id: string; + project_name?: string; + total_tokens: number; + task_count: number; + latest_task_date: string; + tasks: HistoryTask[]; + // Additional project-level metadata + total_completed_tasks: number; + total_failed_tasks: number; + average_tokens_per_task: number; +} + +export interface GroupedHistoryResponse { + projects: ProjectGroup[]; + total_projects: number; + total_tasks: number; + total_tokens: number; +} + +// Legacy flat response for backward compatibility +export interface FlatHistoryResponse { + items: HistoryTask[]; + total: number; + page: number; + size: number; +} + +export interface HistoryApiOptions { + grouped?: boolean; // New parameter to control response format + include_tasks?: boolean; // Whether to include individual tasks in groups +} \ No newline at end of file