251 lines
8.3 KiB
TypeScript
251 lines
8.3 KiB
TypeScript
import { Plus, MessageSquare, Trash2, Search } from "lucide-react";
|
|
import { useState, useRef } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
export type ThreadItem = {
|
|
thread_id: string;
|
|
created_at: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
type Props = {
|
|
threads: ThreadItem[];
|
|
currentThreadId: string | null;
|
|
onNewThread: () => void;
|
|
onSelectThread: (threadId: string) => void;
|
|
onDeleteThread: (threadId: string) => void;
|
|
};
|
|
|
|
// Get lastActive timestamp from localStorage, fallback to created_at
|
|
function getLastActive(threadId: string, createdAt: string): number {
|
|
try {
|
|
const stored = localStorage.getItem(`lastActive_${threadId}`);
|
|
if (stored) return parseInt(stored, 10);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return new Date(createdAt).getTime();
|
|
}
|
|
|
|
// Get thread title from localStorage (saved by main.tsx on first message)
|
|
function getLocalTitle(threadId: string): string | null {
|
|
try {
|
|
return localStorage.getItem(`title_${threadId}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Reject titles that look like UUIDs, short hashes, or empty strings
|
|
const BAD_TITLE = /^[0-9a-f-]{8,}$/i;
|
|
function sanitizeTitle(s: unknown): string | null {
|
|
if (typeof s !== "string" || !s.trim() || BAD_TITLE.test(s.trim())) return null;
|
|
return s.trim();
|
|
}
|
|
|
|
function groupByDate(threads: ThreadItem[]): { label: string; threads: ThreadItem[] }[] {
|
|
const now = new Date();
|
|
const groups: Record<string, ThreadItem[]> = {};
|
|
|
|
// Sort by lastActive descending before grouping
|
|
const sorted = [...threads].sort(
|
|
(a, b) =>
|
|
getLastActive(b.thread_id, b.created_at) - getLastActive(a.thread_id, a.created_at),
|
|
);
|
|
|
|
sorted.forEach((t) => {
|
|
const lastActive = getLastActive(t.thread_id, t.created_at);
|
|
const diffDays = (now.getTime() - lastActive) / 86400000;
|
|
let label: string;
|
|
if (diffDays < 1) label = "今天";
|
|
else if (diffDays < 2) label = "昨天";
|
|
else if (diffDays < 7) label = "本周";
|
|
else label = "更早";
|
|
|
|
if (!groups[label]) groups[label] = [];
|
|
groups[label].push(t);
|
|
});
|
|
|
|
return ["今天", "昨天", "本周", "更早"]
|
|
.filter((label) => groups[label]?.length)
|
|
.map((label) => ({ label, threads: groups[label] }));
|
|
}
|
|
|
|
export function updateThreadLastActive(threadId: string) {
|
|
try {
|
|
localStorage.setItem(`lastActive_${threadId}`, String(Date.now()));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function formatTime(iso: string) {
|
|
try {
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - d.getTime();
|
|
const diffHrs = diffMs / (1000 * 60 * 60);
|
|
if (diffHrs < 24) {
|
|
return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
return d.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function ThreadSidebar({
|
|
threads,
|
|
currentThreadId,
|
|
onNewThread,
|
|
onSelectThread,
|
|
onDeleteThread,
|
|
}: Props) {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const filtered = threads.filter((t) => {
|
|
const label =
|
|
sanitizeTitle(getLocalTitle(t.thread_id)) ??
|
|
sanitizeTitle(t.metadata?.title) ??
|
|
sanitizeTitle(t.metadata?.firstMessage) ??
|
|
"新对话";
|
|
return label.toLowerCase().includes(searchQuery.toLowerCase());
|
|
});
|
|
|
|
const groups = groupByDate(filtered);
|
|
|
|
return (
|
|
<div className="w-full h-full shrink-0 border-r border-border flex flex-col bg-muted/30">
|
|
{/* New chat button */}
|
|
<div className="p-3 border-b border-border flex flex-col gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 justify-start gap-2"
|
|
onClick={onNewThread}
|
|
>
|
|
<Plus className="size-4" />
|
|
新建对话
|
|
</Button>
|
|
{/* Search toggle icon */}
|
|
<button
|
|
type="button"
|
|
title="搜索对话"
|
|
onClick={() => {
|
|
setSearchExpanded((v) => {
|
|
if (!v) setTimeout(() => searchInputRef.current?.focus(), 50);
|
|
else setSearchQuery("");
|
|
return !v;
|
|
});
|
|
}}
|
|
className={cn(
|
|
"p-1.5 rounded-md transition-colors",
|
|
searchExpanded
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-accent",
|
|
)}
|
|
>
|
|
<Search className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Collapsible search input */}
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden transition-all duration-200",
|
|
searchExpanded ? "max-h-10 opacity-100" : "max-h-0 opacity-0",
|
|
)}
|
|
>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onBlur={() => {
|
|
if (!searchQuery) {
|
|
setSearchExpanded(false);
|
|
}
|
|
}}
|
|
placeholder="搜索对话..."
|
|
className="w-full text-xs border border-border rounded-md pl-8 pr-3 py-1.5 bg-background/50 text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Thread list */}
|
|
<div className="flex-1 overflow-y-auto py-2">
|
|
{filtered.length === 0 && (
|
|
<p className="text-xs text-muted-foreground text-center mt-8 px-4">
|
|
{searchQuery ? "未找到匹配对话" : "暂无历史对话"}
|
|
</p>
|
|
)}
|
|
|
|
{groups.map(({ label, threads: groupThreads }) => (
|
|
<div key={label}>
|
|
{/* Group heading */}
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider px-3 py-1.5 mt-2">
|
|
{label}
|
|
</p>
|
|
|
|
{groupThreads.map((t) => {
|
|
const isActive = t.thread_id === currentThreadId;
|
|
const itemLabel =
|
|
sanitizeTitle(getLocalTitle(t.thread_id)) ??
|
|
sanitizeTitle(t.metadata?.title) ??
|
|
sanitizeTitle(t.metadata?.firstMessage) ??
|
|
"新对话";
|
|
const displayLabel = itemLabel.length > 16
|
|
? itemLabel.slice(0, 16) + "…"
|
|
: itemLabel;
|
|
return (
|
|
<div
|
|
key={t.thread_id}
|
|
className={cn(
|
|
"group flex items-center gap-2 px-3 py-2 mx-1 rounded-lg cursor-pointer transition-colors",
|
|
isActive
|
|
? "bg-primary/10 text-foreground"
|
|
: "hover:bg-accent text-muted-foreground hover:text-foreground",
|
|
)}
|
|
onClick={() => onSelectThread(t.thread_id)}
|
|
>
|
|
<MessageSquare className="size-4 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p
|
|
className="text-xs font-medium truncate"
|
|
title={itemLabel.length > 16 ? itemLabel : undefined}
|
|
>
|
|
{displayLabel}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{formatTime(t.created_at)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (window.confirm("确定要删除这个对话吗?")) {
|
|
onDeleteThread(t.thread_id);
|
|
}
|
|
}}
|
|
title="删除对话"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|