add batch process for threads
This commit is contained in:
@@ -87,6 +87,10 @@ class SessionAliasUpdateRequest(BaseModel):
|
||||
archived: Optional[bool] = None
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
session_ids: List[str]
|
||||
|
||||
|
||||
class SessionFileContextUpdateRequest(BaseModel):
|
||||
active_data_file: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -248,6 +252,30 @@ def delete_session(session_id: str):
|
||||
return {"status": "success"}
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
|
||||
@app.post("/nanobot/sessions/batch-delete")
|
||||
def batch_delete_sessions(request: BatchDeleteRequest):
|
||||
if not nanobot_service.agent:
|
||||
raise HTTPException(status_code=400, detail="Nanobot not running")
|
||||
|
||||
deleted_ids = []
|
||||
for session_id in request.session_ids:
|
||||
try:
|
||||
# Try to remove from cache and delete file
|
||||
session = nanobot_service.agent.sessions.get_or_create(session_id)
|
||||
if session:
|
||||
nanobot_service.agent.sessions.invalidate(session_id)
|
||||
path = nanobot_service.agent.sessions._get_session_path(session_id)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
session_alias_store.delete_session(session_id)
|
||||
deleted_ids.append(session_id)
|
||||
except Exception as e:
|
||||
print(f"Failed to delete session {session_id}: {e}")
|
||||
|
||||
return {"status": "success", "deleted_count": len(deleted_ids), "deleted_ids": deleted_ids}
|
||||
|
||||
|
||||
@app.put("/nanobot/sessions/{session_id}")
|
||||
def update_session(session_id: str, payload: SessionAliasUpdateRequest):
|
||||
updated = session_alias_store.update_alias_meta(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database } from "lucide-react";
|
||||
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
@@ -31,6 +31,7 @@ function Section({
|
||||
onRename,
|
||||
onTogglePinned,
|
||||
onToggleArchived,
|
||||
onBatchDelete,
|
||||
activeKey
|
||||
}: {
|
||||
title: string;
|
||||
@@ -41,37 +42,138 @@ function Section({
|
||||
onRename: (key: string, currentTitle: string) => void;
|
||||
onTogglePinned: (key: string, pinned: boolean) => void;
|
||||
onToggleArchived: (key: string, archived: boolean) => void;
|
||||
onBatchDelete: (keys: string[]) => void;
|
||||
activeKey: string | null;
|
||||
}) {
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
||||
const toggleSelect = (key: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedKeys(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (selectedKeys.length === items.length && items.length > 0) {
|
||||
setSelectedKeys([]);
|
||||
} else {
|
||||
setSelectedKeys(items.map(item => item.key));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvertSelection = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const allKeys = items.map(item => item.key);
|
||||
setSelectedKeys(allKeys.filter(key => !selectedKeys.includes(key)));
|
||||
};
|
||||
|
||||
const handleBatchDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (selectedKeys.length === 0) return;
|
||||
onBatchDelete(selectedKeys);
|
||||
setSelectedKeys([]);
|
||||
setIsSelectionMode(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelectionMode) {
|
||||
setSelectedKeys([]);
|
||||
}
|
||||
}, [isSelectionMode]);
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-6">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
<div className="text-xs font-semibold text-zinc-500 flex items-center gap-1 uppercase tracking-wider">
|
||||
{title}
|
||||
<span>({count})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isSelectionMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
title="全选/取消全选"
|
||||
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
|
||||
>
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInvertSelection}
|
||||
title="反选"
|
||||
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedKeys.length === 0}
|
||||
title="批量删除"
|
||||
className={`p-1 rounded transition-colors ${
|
||||
selectedKeys.length > 0
|
||||
? "hover:bg-red-100 text-red-500"
|
||||
: "text-zinc-300 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(false)}
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-zinc-200 rounded text-zinc-500 transition-colors ml-1"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
className="p-1 hover:bg-zinc-200 rounded text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ opacity: count > 0 ? undefined : 0 }}
|
||||
>
|
||||
<CheckSquare className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5 mt-2">
|
||||
{items.map((item) => {
|
||||
const displayTitle = item.metadata?.title || item.key.replace("api:", "");
|
||||
const isActive = activeKey === item.key;
|
||||
const isSelected = selectedKeys.includes(item.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`w-full h-9 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
|
||||
isActive ? 'bg-zinc-100 text-zinc-900 font-medium' : 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900'
|
||||
}`}
|
||||
onClick={() => onSelect(item.key)}
|
||||
isActive && !isSelectionMode ? 'bg-zinc-100 text-zinc-900 font-medium' : 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900'
|
||||
} ${isSelected ? 'bg-indigo-50/50 text-indigo-700' : ''}`}
|
||||
onClick={(e) => isSelectionMode ? toggleSelect(item.key, e) : onSelect(item.key)}
|
||||
>
|
||||
<div className="truncate pr-2 flex-1 flex items-center gap-1.5 min-w-0">
|
||||
<span className="w-4 shrink-0 flex items-center justify-center">
|
||||
{item.pinned && <Pin className="h-3.5 w-3.5 text-zinc-500" />}
|
||||
</span>
|
||||
{isSelectionMode ? (
|
||||
<span
|
||||
className="w-4 shrink-0 flex items-center justify-center"
|
||||
onClick={(e) => toggleSelect(item.key, e)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-indigo-600" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5 text-zinc-300" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-4 shrink-0 flex items-center justify-center">
|
||||
{item.pinned && <Pin className="h-3.5 w-3.5 text-zinc-500" />}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{displayTitle}</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
{!isSelectionMode && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()} className="h-6 w-6 flex items-center justify-center rounded hover:bg-zinc-200 text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity outline-none">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -139,6 +241,7 @@ function Section({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -234,6 +337,20 @@ function SidebarBody() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async (keys: string[]) => {
|
||||
if (!window.confirm(`确定要删除选中的 ${keys.length} 个会话吗?`)) return;
|
||||
try {
|
||||
await api.post("/nanobot/sessions/batch-delete", { session_ids: keys });
|
||||
if (keys.includes(activeSessionKey)) {
|
||||
navigate("/");
|
||||
}
|
||||
fetchSessions();
|
||||
window.dispatchEvent(new Event("nanobot:sessions-changed"));
|
||||
} catch (e) {
|
||||
console.error("Failed to batch delete sessions", e);
|
||||
}
|
||||
};
|
||||
|
||||
const openRenameDialog = (key: string, currentTitle: string) => {
|
||||
setSessionToRename({ key, title: currentTitle });
|
||||
setNewTitle(currentTitle);
|
||||
@@ -371,17 +488,19 @@ function SidebarBody() {
|
||||
items={activeSessions}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRename={openRenameDialog}
|
||||
onTogglePinned={handleTogglePinned}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
activeKey={activeSessionKey}
|
||||
/>
|
||||
<Section
|
||||
title="ARCIVED_THREADS"
|
||||
title="ARCHIVED_THREADS"
|
||||
count={archivedSessions.length}
|
||||
items={archivedSessions}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRename={openRenameDialog}
|
||||
onTogglePinned={handleTogglePinned}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
|
||||
Reference in New Issue
Block a user