Files
socaichat/langgraph/src/components/ThreadSidebar.tsx
T
gongzhiyong 66eaf7680d
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 37s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 47s
fix: sanitize thread titles — reject UUID/hash/empty values, fallback to 新对话
2026-04-12 15:01:29 +08:00

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>
);
}