mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-19 16:31:36 +00:00
feat: init grouped history views
This commit is contained in:
parent
b2b1435108
commit
e70c8af4fa
9 changed files with 656 additions and 27 deletions
148
src/components/GroupedHistoryView/ProjectGroup.tsx
Normal file
148
src/components/GroupedHistoryView/ProjectGroup.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="border border-solid border-border-disabled rounded-xl bg-white-30% overflow-hidden">
|
||||
{/* Project Header */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-4 h-auto justify-start hover:bg-white-50% transition-all duration-200"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-icon-secondary" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-icon-secondary" />
|
||||
)}
|
||||
<Folder className="w-5 h-5 text-icon-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<TooltipSimple
|
||||
content={<p className="max-w-xs break-words">{project.project_name}</p>}
|
||||
className="bg-surface-tertiary p-2 text-wrap break-words text-label-xs select-text pointer-events-auto shadow-perfect"
|
||||
>
|
||||
<span className="text-text-primary font-semibold text-left truncate max-w-[200px]">
|
||||
{project.project_name}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<div className="flex items-center gap-1">
|
||||
<Target className="w-3 h-3" />
|
||||
<span>{project.task_count} {t("layout.tasks")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{formatDate(project.latest_task_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag
|
||||
variant="primary"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("layout.token")} {project.total_tokens.toLocaleString()}
|
||||
</Tag>
|
||||
|
||||
<Tag
|
||||
variant="primary"
|
||||
className={`text-xs ${getStatusColor(project.total_completed_tasks, project.task_count)}`}
|
||||
>
|
||||
{project.total_completed_tasks}/{project.task_count} {t("layout.completed")}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Tasks List */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{(searchValue ? filteredTasks : project.tasks).map((task, index) => (
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isActive={activeTaskId === task.task_id}
|
||||
onSelect={() => 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
src/components/GroupedHistoryView/TaskItem.tsx
Normal file
176
src/components/GroupedHistoryView/TaskItem.tsx
Normal file
|
|
@ -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 <Clock className="w-3 h-3 text-yellow-500" />;
|
||||
case 2:
|
||||
return <CheckCircle className="w-3 h-3 text-green-500" />;
|
||||
case 3:
|
||||
return <XCircle className="w-3 h-3 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="w-3 h-3 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`
|
||||
${isActive ? "!bg-white-100%" : ""}
|
||||
relative cursor-pointer transition-all duration-300 bg-white-30% hover:bg-white-100%
|
||||
rounded-xl flex justify-between items-center gap-md w-full p-3 h-14
|
||||
shadow-history-item border border-solid border-border-disabled
|
||||
${!isLast ? "mb-2" : ""}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<img className="w-6 h-6 flex-shrink-0" src={folderIcon} alt="task-icon" />
|
||||
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<TooltipSimple
|
||||
align="start"
|
||||
className="max-w-xs bg-surface-tertiary p-2 text-wrap break-words text-label-xs select-text pointer-events-auto shadow-perfect"
|
||||
content={
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{task.question}</div>
|
||||
{task.summary && (
|
||||
<div className="text-xs opacity-75">{task.summary}</div>
|
||||
)}
|
||||
<div className="text-xs opacity-60">
|
||||
{t("layout.created")}: {formatDate(task.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="text-text-body font-medium text-sm overflow-hidden text-ellipsis whitespace-nowrap block">
|
||||
{task.question || t("layout.new-project")}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
|
||||
{task.summary && (
|
||||
<span className="text-xs text-text-secondary overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{task.summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(task.status)}
|
||||
<span className="text-xs text-text-secondary hidden sm:inline">
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tag
|
||||
variant="primary"
|
||||
className="text-xs leading-17 font-medium text-nowrap"
|
||||
>
|
||||
{task.tokens ? task.tokens.toLocaleString() : "0"}
|
||||
</Tag>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="ghost"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
>
|
||||
<Ellipsis size={14} className="text-text-primary" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[98px] p-sm rounded-[12px] bg-dropdown-bg border border-solid border-dropdown-border">
|
||||
<div className="space-y-1">
|
||||
<PopoverClose asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare();
|
||||
}}
|
||||
>
|
||||
<Share size={14} />
|
||||
{t("layout.share")}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
|
||||
<PopoverClose asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2
|
||||
size={14}
|
||||
className="text-icon-primary group-hover:text-icon-cuation"
|
||||
/>
|
||||
{t("layout.delete")}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/components/GroupedHistoryView/index.tsx
Normal file
122
src/components/GroupedHistoryView/index.tsx
Normal file
|
|
@ -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<ProjectGroupType[]>([]);
|
||||
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 (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-icon-secondary" />
|
||||
<span className="ml-2 text-text-secondary">{t("layout.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredProjects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<FolderOpen className="w-12 h-12 text-icon-tertiary mb-4" />
|
||||
<div className="text-text-secondary text-sm">
|
||||
{searchValue
|
||||
? t("dashboard.no-projects-match-search")
|
||||
: t("dashboard.no-projects-found")
|
||||
}
|
||||
</div>
|
||||
{searchValue && (
|
||||
<div className="text-text-tertiary text-xs mt-1">
|
||||
{t("dashboard.try-different-search")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredProjects.map((project) => (
|
||||
<motion.div
|
||||
key={project.project_id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
layout
|
||||
>
|
||||
<ProjectGroup
|
||||
project={project}
|
||||
onTaskSelect={onTaskSelect}
|
||||
onTaskDelete={onTaskDelete}
|
||||
onTaskShare={onTaskShare}
|
||||
activeTaskId={activeTaskId}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Summary footer */}
|
||||
<div className="flex justify-center pt-4 border-t border-border-disabled">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{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")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
) : history_type === "grouped" ? (
|
||||
// Grouped view
|
||||
<GroupedHistoryView
|
||||
searchValue={searchValue}
|
||||
onTaskSelect={handleSetActive}
|
||||
onTaskDelete={handleDelete}
|
||||
onTaskShare={handleShare}
|
||||
activeTaskId={chatStore.activeTaskId}
|
||||
refreshTrigger={chatStore.updateCount}
|
||||
/>
|
||||
) : (
|
||||
// List
|
||||
*/}
|
||||
|
|
|
|||
|
|
@ -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<any[]>([]);
|
||||
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() {
|
|||
<CommandInput placeholder={t("dashboard.search-dialog-placeholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("dashboard.no-results")}</CommandEmpty>
|
||||
<CommandGroup heading="Today">
|
||||
{historyTasks.map((task) => (
|
||||
<CommandItem
|
||||
key={task.id}
|
||||
className="cursor-pointer"
|
||||
/**
|
||||
* TODO(history): Update to use project_id field
|
||||
* after update instead.
|
||||
*/
|
||||
onSelect={() => handleSetActive(task.task_id, task.question, task.id)}
|
||||
>
|
||||
<ScanFace />
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{task.question}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{history_type === "grouped" ? (
|
||||
<div className="p-4">
|
||||
<GroupedHistoryView
|
||||
onTaskSelect={handleSetActive}
|
||||
onTaskDelete={handleDelete}
|
||||
onTaskShare={handleShare}
|
||||
activeTaskId={chatStore.activeTaskId || undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CommandGroup heading="Today">
|
||||
{historyTasks.map((task) => (
|
||||
<CommandItem
|
||||
key={task.id}
|
||||
className="cursor-pointer"
|
||||
/**
|
||||
* TODO(history): Update to use project_id field
|
||||
* after update instead.
|
||||
*/
|
||||
onSelect={() => handleSetActive(task.task_id, task.question, task.id)}
|
||||
>
|
||||
<ScanFace />
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{task.question}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
) : history_type === "grouped" ? (
|
||||
// Grouped view
|
||||
<div className="p-6 pb-40">
|
||||
<GroupedHistoryView
|
||||
onTaskSelect={handleSetActive}
|
||||
onTaskDelete={handleDelete}
|
||||
onTaskShare={handleShare}
|
||||
activeTaskId={chatStore.activeTaskId || undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// List
|
||||
<div className="p-6 flex flex-col justify-start items-center gap-4 pb-40">
|
||||
|
|
|
|||
|
|
@ -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<string, ProjectGroup>();
|
||||
|
||||
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<React.SetStateAction<any[]>>) => {
|
||||
try {
|
||||
|
|
@ -8,4 +70,21 @@ export const fetchHistoryTasks = async (setTasks: React.Dispatch<React.SetStateA
|
|||
console.error("Failed to fetch history tasks:", error);
|
||||
setTasks([])
|
||||
}
|
||||
};
|
||||
|
||||
// New function to fetch grouped history tasks
|
||||
export const fetchGroupedHistoryTasks = async (setProjects: React.Dispatch<React.SetStateAction<ProjectGroup[]>>) => {
|
||||
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);
|
||||
};
|
||||
|
|
@ -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<GlobalStore>()(
|
||||
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',
|
||||
|
|
|
|||
55
src/types/history.d.ts
vendored
Normal file
55
src/types/history.d.ts
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue