Files
mini-openclaw/src/components/layout/Sidebar.tsx
T

460 lines
16 KiB
TypeScript

"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import {
MessageSquare,
Plus,
MoreHorizontal,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
FileCode,
Check,
X,
Maximize2,
Minimize2,
RefreshCw,
Wrench,
Loader2,
Database,
Building2,
Target,
Search,
Shield,
FileText,
Users,
Bot,
Blocks,
} from "lucide-react";
import { useApp } from "@/lib/store";
import { getSessionTokenCount } from "@/lib/api";
export default function Sidebar() {
const {
sessionId,
setSessionId,
sessions,
createSession,
renameSession,
deleteSession,
rawMessages,
loadRawMessages,
isCompressing,
compressCurrentSession,
ragMode,
toggleRagMode,
} = useApp();
const [rawOpen, setRawOpen] = useState(false);
const [rawExpanded, setRawExpanded] = useState(false);
const [sessionTokens, setSessionTokens] = useState<number | null>(null);
const [showCompressModal, setShowCompressModal] = useState(false);
// Load raw messages when section is opened or session changes
useEffect(() => {
if (rawOpen) {
loadRawMessages();
getSessionTokenCount(sessionId)
.then((data) => setSessionTokens(data.total_tokens))
.catch(() => setSessionTokens(null));
}
}, [rawOpen, sessionId, loadRawMessages]);
return (
<aside className="flex flex-col h-full relative">
{/* New Chat button */}
<div className="p-2 pb-0">
<button
onClick={createSession}
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] font-medium text-[#002fa7] hover:bg-[#002fa7]/[0.05] rounded-lg transition-all"
>
<Plus className="w-4 h-4" />
New Chat
</button>
</div>
<div className="mx-3 my-1.5 h-px bg-black/[0.04]" />
{/* Session list */}
<div className="flex-1 overflow-y-auto px-1.5">
{sessions.length > 0 && (
<div className="space-y-0.5">
<p className="px-3 pt-1 pb-1 text-[10px] font-semibold text-gray-400 uppercase tracking-widest">
Recent
</p>
{sessions.map((s) => (
<SessionItem
key={s.id}
id={s.id}
title={s.title}
isActive={sessionId === s.id}
onSelect={() => setSessionId(s.id)}
onRename={(title) => renameSession(s.id, title)}
onDelete={() => deleteSession(s.id)}
/>
))}
</div>
)}
</div>
{/* Divider */}
<div className="mx-3 h-px bg-black/[0.04]" />
{/* Xingbang Assistants section */}
<div className="shrink-0 px-1.5 py-2">
<p className="px-3 pt-1 pb-2 text-[10px] font-semibold text-gray-400 uppercase tracking-widest">
兴邦助手
</p>
<div className="space-y-0.5">
<a
href="/data-access"
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
>
<Shield className="w-3.5 h-3.5" />
<span>风险研判</span>
</a>
<a href="/review-workbench" className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 rounded-lg">
<Users className="w-3.5 h-3.5" />
<span>评审协同助手</span>
</a>
<a href="/collateral-workbench" className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 rounded-lg">
<Blocks className="w-3.5 h-3.5" />
<span>租赁物与增信核验</span>
</a>
<a href="/report-workbench" className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 rounded-lg">
<FileText className="w-3.5 h-3.5" />
<span>报告与经验助手</span>
</a>
<a
href="/rule-engine"
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
>
<FileText className="w-3.5 h-3.5" />
<span>规则引擎管理</span>
</a>
<a
href="/historical-data"
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
>
<Database className="w-3.5 h-3.5" />
<span>历史数据分析</span>
</a>
<a
href="/xingbang-workbench"
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
>
<Bot className="w-3.5 h-3.5" />
<span>兴邦项目总览</span>
</a>
</div>
</div>
{/* Divider */}
<div className="mx-3 h-px bg-black/[0.04]" />
{/* Raw Messages section */}
<div className="shrink-0">
<div className="flex items-center">
<button
onClick={() => setRawOpen((v) => !v)}
className="flex-1 flex items-center gap-2 px-4 py-2 text-[11px] font-semibold text-gray-500 hover:text-gray-700 transition-colors"
>
{rawOpen ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<FileCode className="w-3.5 h-3.5" />
Raw Messages
{rawOpen && sessionTokens !== null && (
<span className="text-[10px] text-gray-400 font-normal ml-1">
~{sessionTokens.toLocaleString()} tokens
</span>
)}
</button>
{rawOpen && (
<div className="flex items-center mr-2 gap-0.5">
<button
onClick={() => toggleRagMode()}
className={`p-1 rounded-md transition-colors ${
ragMode
? "text-white bg-[#002fa7] hover:bg-[#001f7a]"
: "text-gray-400 hover:text-gray-600 hover:bg-black/[0.04]"
}`}
title={ragMode ? "RAG Mode ON" : "RAG Mode OFF"}
>
<Database className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setShowCompressModal(true)}
disabled={isCompressing}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-black/[0.04] transition-colors disabled:opacity-40"
title="Compress history"
>
{isCompressing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Wrench className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={loadRawMessages}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-black/[0.04] transition-colors"
title="Refresh"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setRawExpanded(true)}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-black/[0.04] transition-colors"
title="Expand"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{rawOpen && !rawExpanded && (
<div className="px-2 pb-2 max-h-[40vh] overflow-y-auto">
<RawMessageViewer messages={rawMessages} truncate />
</div>
)}
</div>
{/* Full-screen Raw Messages overlay */}
{rawExpanded && (
<div className="fixed inset-0 z-[100] bg-black/50 flex items-center justify-center p-6 animate-fade-in">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-black/[0.06]">
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-gray-500" />
<span className="text-[14px] font-semibold text-gray-800">Raw Messages</span>
<span className="text-[11px] text-gray-400 ml-1">
{rawMessages ? `${rawMessages.length} messages` : ""}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={loadRawMessages}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-black/[0.04] transition-colors"
title="Refresh"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => setRawExpanded(false)}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-black/[0.04] transition-colors"
title="Close"
>
<Minimize2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5">
<RawMessageViewer messages={rawMessages} truncate={false} />
</div>
</div>
</div>
)}
{/* Compress confirmation modal */}
{showCompressModal && (
<div className="fixed inset-0 z-[110] bg-black/50 flex items-center justify-center p-6 animate-fade-in">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-sm p-5 space-y-4">
<h3 className="text-[14px] font-semibold text-gray-800">Compress History</h3>
<p className="text-[13px] text-gray-600 leading-relaxed">
Are you sure you want to compress 50% of conversation history? The compressed messages will be archived and replaced with a summary.
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setShowCompressModal(false)}
className="px-3 py-1.5 text-[12px] font-medium text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={async () => {
setShowCompressModal(false);
await compressCurrentSession();
// Refresh token count
getSessionTokenCount(sessionId)
.then((data) => setSessionTokens(data.total_tokens))
.catch(() => {});
}}
className="px-3 py-1.5 text-[12px] font-medium text-white bg-[#002fa7] hover:bg-[#001f7a] rounded-lg transition-colors"
>
Confirm
</button>
</div>
</div>
</div>
)}
</aside>
);
}
// ── Session Item ────────────────────────────────────────
function SessionItem({
id,
title,
isActive,
onSelect,
onRename,
onDelete,
}: {
id: string;
title: string;
isActive: boolean;
onSelect: () => void;
onRename: (title: string) => void;
onDelete: () => void;
}) {
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input when renaming
useEffect(() => {
if (renaming && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [renaming]);
const handleRenameSubmit = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== title) {
onRename(trimmed);
}
setRenaming(false);
}, [renameValue, title, onRename]);
const handleDelete = useCallback(() => {
if (confirm("删除该会话?此操作不可恢复。")) {
onDelete();
}
}, [onDelete]);
if (renaming) {
return (
<div className="flex items-center gap-1 px-2 py-1">
<input
ref={inputRef}
className="flex-1 px-2 py-1 text-[13px] rounded-md border border-[#002fa7]/30 bg-white outline-none focus:border-[#002fa7]"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") setRenaming(false);
}}
onBlur={handleRenameSubmit}
/>
<button
onClick={handleRenameSubmit}
className="p-1 text-green-600 hover:bg-green-50 rounded"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setRenaming(false)}
className="p-1 text-gray-400 hover:bg-gray-100 rounded"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
return (
<div className="relative group">
<button
onClick={onSelect}
className={`w-full flex items-center gap-2 px-3 py-2 text-[13px] rounded-lg transition-all text-left relative pr-16 ${
isActive
? "bg-white/70 text-gray-800 font-medium shadow-sm"
: "text-gray-500 hover:bg-white/40"
}`}
>
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 bg-[#002fa7] rounded-r-full" />
)}
<MessageSquare className="w-3.5 h-3.5 shrink-0 text-gray-400" />
<span className="truncate">{title}</span>
</button>
{/* Action buttons: Rename and Delete */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
setRenameValue(title);
setRenaming(true);
}}
className="p-1 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
title="重命名"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="p-1 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="删除会话"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
// ── Raw Message Viewer ──────────────────────────────────
function RawMessageViewer({
messages,
truncate = true,
}: {
messages: Array<{ role: string; content: string }> | null;
truncate?: boolean;
}) {
if (!messages) {
return (
<div className="text-[11px] text-gray-400 text-center py-4">
No messages yet
</div>
);
}
return (
<div className="raw-message-viewer space-y-1">
{messages.map((msg, i) => {
const roleClass =
msg.role === "system"
? "msg-system"
: msg.role === "user"
? "msg-user"
: "msg-assistant";
const displayContent =
truncate && msg.content.length > 500
? msg.content.slice(0, 500) + "..."
: msg.content;
return (
<div key={i} className={roleClass}>
<div className="msg-role">{msg.role}</div>
<div className={`msg-content ${truncate ? "" : "!max-h-none"}`}>
{displayContent}
</div>
</div>
);
})}
</div>
);
}