add batch process for threads
This commit is contained in:
@@ -87,6 +87,10 @@ class SessionAliasUpdateRequest(BaseModel):
|
|||||||
archived: Optional[bool] = None
|
archived: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeleteRequest(BaseModel):
|
||||||
|
session_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
class SessionFileContextUpdateRequest(BaseModel):
|
class SessionFileContextUpdateRequest(BaseModel):
|
||||||
active_data_file: Optional[Dict[str, Any]] = None
|
active_data_file: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
@@ -248,6 +252,30 @@ def delete_session(session_id: str):
|
|||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
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}")
|
@app.put("/nanobot/sessions/{session_id}")
|
||||||
def update_session(session_id: str, payload: SessionAliasUpdateRequest):
|
def update_session(session_id: str, payload: SessionAliasUpdateRequest):
|
||||||
updated = session_alias_store.update_alias_meta(
|
updated = session_alias_store.update_alias_meta(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 { useState, useRef, useEffect } from "react";
|
||||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
@@ -31,6 +31,7 @@ function Section({
|
|||||||
onRename,
|
onRename,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
|
onBatchDelete,
|
||||||
activeKey
|
activeKey
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -41,37 +42,138 @@ function Section({
|
|||||||
onRename: (key: string, currentTitle: string) => void;
|
onRename: (key: string, currentTitle: string) => void;
|
||||||
onTogglePinned: (key: string, pinned: boolean) => void;
|
onTogglePinned: (key: string, pinned: boolean) => void;
|
||||||
onToggleArchived: (key: string, archived: boolean) => void;
|
onToggleArchived: (key: string, archived: boolean) => void;
|
||||||
|
onBatchDelete: (keys: string[]) => void;
|
||||||
activeKey: string | null;
|
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 (
|
return (
|
||||||
<div className="px-3 pt-6">
|
<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">
|
<div className="text-xs font-semibold text-zinc-500 flex items-center gap-1 uppercase tracking-wider">
|
||||||
{title}
|
{title}
|
||||||
<span>({count})</span>
|
<span>({count})</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="space-y-0.5 mt-2">
|
<div className="space-y-0.5 mt-2">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const displayTitle = item.metadata?.title || item.key.replace("api:", "");
|
const displayTitle = item.metadata?.title || item.key.replace("api:", "");
|
||||||
const isActive = activeKey === item.key;
|
const isActive = activeKey === item.key;
|
||||||
|
const isSelected = selectedKeys.includes(item.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
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 ${
|
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'
|
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={() => onSelect(item.key)}
|
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">
|
<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">
|
{isSelectionMode ? (
|
||||||
{item.pinned && <Pin className="h-3.5 w-3.5 text-zinc-500" />}
|
<span
|
||||||
</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>
|
<span className="truncate">{displayTitle}</span>
|
||||||
</div>
|
</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">
|
<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" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -139,6 +241,7 @@ function Section({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</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) => {
|
const openRenameDialog = (key: string, currentTitle: string) => {
|
||||||
setSessionToRename({ key, title: currentTitle });
|
setSessionToRename({ key, title: currentTitle });
|
||||||
setNewTitle(currentTitle);
|
setNewTitle(currentTitle);
|
||||||
@@ -371,17 +488,19 @@ function SidebarBody() {
|
|||||||
items={activeSessions}
|
items={activeSessions}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
onDelete={handleDeleteSession}
|
onDelete={handleDeleteSession}
|
||||||
|
onBatchDelete={handleBatchDelete}
|
||||||
onRename={openRenameDialog}
|
onRename={openRenameDialog}
|
||||||
onTogglePinned={handleTogglePinned}
|
onTogglePinned={handleTogglePinned}
|
||||||
onToggleArchived={handleToggleArchived}
|
onToggleArchived={handleToggleArchived}
|
||||||
activeKey={activeSessionKey}
|
activeKey={activeSessionKey}
|
||||||
/>
|
/>
|
||||||
<Section
|
<Section
|
||||||
title="ARCIVED_THREADS"
|
title="ARCHIVED_THREADS"
|
||||||
count={archivedSessions.length}
|
count={archivedSessions.length}
|
||||||
items={archivedSessions}
|
items={archivedSessions}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
onDelete={handleDeleteSession}
|
onDelete={handleDeleteSession}
|
||||||
|
onBatchDelete={handleBatchDelete}
|
||||||
onRename={openRenameDialog}
|
onRename={openRenameDialog}
|
||||||
onTogglePinned={handleTogglePinned}
|
onTogglePinned={handleTogglePinned}
|
||||||
onToggleArchived={handleToggleArchived}
|
onToggleArchived={handleToggleArchived}
|
||||||
|
|||||||
Reference in New Issue
Block a user