320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { useApp } from "@/lib/app-context";
|
|
import { getImageUrl, fetchLlmModels } from "@/lib/api";
|
|
import type { LlmModelInfo } from "@/lib/types";
|
|
|
|
function timeAgo(ts: number): string {
|
|
const diff = Date.now() - ts;
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return "刚刚";
|
|
if (mins < 60) return `${mins}分钟前`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}小时前`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}天前`;
|
|
return new Date(ts).toLocaleDateString("zh-CN");
|
|
}
|
|
|
|
export function SessionList() {
|
|
const {
|
|
sessions, activeSessionId, tags, selectedTagIds,
|
|
createSession, switchSession, deleteSession, renameSession,
|
|
updateSessionLlmModel,
|
|
} = useApp();
|
|
|
|
const [menuSessionId, setMenuSessionId] = useState<string | null>(null);
|
|
const [showModelSub, setShowModelSub] = useState(false);
|
|
const [llmModels, setLlmModels] = useState<LlmModelInfo[]>([]);
|
|
const [defaultLlmModel, setDefaultLlmModel] = useState("");
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editTitle, setEditTitle] = useState("");
|
|
const editRef = useRef<HTMLInputElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const filtered = selectedTagIds.length === 0
|
|
? sessions
|
|
: sessions.filter((s) => selectedTagIds.some((tid) => s.tags.includes(tid)));
|
|
|
|
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
|
|
|
useEffect(() => {
|
|
fetchLlmModels()
|
|
.then((data) => {
|
|
setLlmModels(data.models);
|
|
setDefaultLlmModel(data.default);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
closeMenu();
|
|
}
|
|
};
|
|
if (menuSessionId) {
|
|
window.addEventListener("mousedown", handler);
|
|
return () => window.removeEventListener("mousedown", handler);
|
|
}
|
|
}, [menuSessionId]);
|
|
|
|
useEffect(() => {
|
|
if (editingId) editRef.current?.focus();
|
|
}, [editingId]);
|
|
|
|
const closeMenu = useCallback(() => {
|
|
setMenuSessionId(null);
|
|
setShowModelSub(false);
|
|
}, []);
|
|
|
|
const handleDots = (e: React.MouseEvent, sessionId: string) => {
|
|
e.stopPropagation();
|
|
if (menuSessionId === sessionId) {
|
|
closeMenu();
|
|
return;
|
|
}
|
|
setMenuSessionId(sessionId);
|
|
setShowModelSub(false);
|
|
};
|
|
|
|
const startRename = (id: string, currentTitle: string) => {
|
|
setEditingId(id);
|
|
setEditTitle(currentTitle);
|
|
closeMenu();
|
|
};
|
|
|
|
const commitRename = () => {
|
|
if (editingId && editTitle.trim()) {
|
|
renameSession(editingId, editTitle.trim());
|
|
}
|
|
setEditingId(null);
|
|
};
|
|
|
|
const handleSelectModel = (sessionId: string, modelId: string) => {
|
|
updateSessionLlmModel(sessionId, modelId);
|
|
closeMenu();
|
|
};
|
|
|
|
const menuSession = menuSessionId
|
|
? sessions.find((s) => s.id === menuSessionId)
|
|
: null;
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto fog-scroll">
|
|
{/* 新建按钮 */}
|
|
<div className="px-3 py-3">
|
|
<button
|
|
onClick={() => createSession()}
|
|
className="w-full flex items-center justify-center gap-2 px-3 py-2.5
|
|
text-sm rounded-xl border border-dashed border-[var(--accent)]/30
|
|
text-[var(--accent)]/70 hover:border-[var(--accent)]
|
|
hover:text-[var(--accent)] hover:bg-[var(--accent)]/5
|
|
transition-all cursor-pointer"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
新建对话
|
|
</button>
|
|
</div>
|
|
|
|
{/* 会话列表 */}
|
|
<div className="px-2 pb-2 space-y-1">
|
|
{filtered.map((session) => (
|
|
<div key={session.id} className="relative">
|
|
<div
|
|
onClick={() => switchSession(session.id)}
|
|
className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
|
|
transition-all ${
|
|
session.id === activeSessionId
|
|
? "glow-border !bg-[var(--bg-secondary)]"
|
|
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
|
|
}`}
|
|
>
|
|
{/* 缩略图 */}
|
|
{session.thumbnail ? (
|
|
<img
|
|
src={getImageUrl(session.thumbnail)}
|
|
alt=""
|
|
className="w-10 h-10 rounded-lg object-cover flex-shrink-0 border border-[var(--border)]
|
|
shadow-[0_0_8px_rgba(77,184,164,0.08)]"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
|
|
flex items-center justify-center flex-shrink-0">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="1.5">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{/* 会话信息 */}
|
|
<div className="flex-1 min-w-0">
|
|
{editingId === session.id ? (
|
|
<input
|
|
ref={editRef}
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
onBlur={commitRename}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commitRename();
|
|
if (e.key === "Escape") setEditingId(null);
|
|
}}
|
|
className="w-full text-sm bg-transparent border-b border-[var(--accent)]
|
|
text-[var(--text-primary)] focus:outline-none"
|
|
/>
|
|
) : (
|
|
<div className={`text-sm truncate pr-5 ${
|
|
session.id === activeSessionId ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-primary)]"
|
|
}`}>
|
|
{session.title}
|
|
</div>
|
|
)}
|
|
|
|
{/* 标签 + 时间 */}
|
|
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
|
{session.tags.slice(0, 3).map((tid) => {
|
|
const tag = tagMap.get(tid);
|
|
if (!tag) return null;
|
|
return (
|
|
<span
|
|
key={tid}
|
|
className="text-[10px] px-1.5 py-px rounded-full font-medium"
|
|
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
|
>
|
|
{tag.name}
|
|
</span>
|
|
);
|
|
})}
|
|
{session.llmModel && (
|
|
<span className="text-[10px] px-1.5 py-px rounded-full font-medium
|
|
bg-[var(--accent)]/10 text-[var(--accent)]">
|
|
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
|
|
{timeAgo(session.updatedAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 三点按钮 */}
|
|
<button
|
|
onClick={(e) => handleDots(e, session.id)}
|
|
className="absolute right-2 top-2.5 p-1 rounded-md
|
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
|
hover:bg-[var(--bg-tertiary)]
|
|
opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="12" cy="5" r="2" />
|
|
<circle cx="12" cy="12" r="2" />
|
|
<circle cx="12" cy="19" r="2" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 下拉菜单:在会话项正下方展开 */}
|
|
{menuSessionId === session.id && menuSession && (
|
|
<div
|
|
ref={menuRef}
|
|
className="mx-1 mt-1 glass-panel rounded-xl shadow-xl shadow-black/30 py-1.5
|
|
overflow-hidden z-10 relative"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 模型选择(折叠/展开式) */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowModelSub(!showModelSub);
|
|
}}
|
|
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
|
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
|
|
flex items-center justify-between"
|
|
>
|
|
<span>对话模型</span>
|
|
<svg
|
|
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2"
|
|
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
|
|
>
|
|
<path d="M9 18l6-6-6-6" />
|
|
</svg>
|
|
</button>
|
|
|
|
{showModelSub && (
|
|
<div className="border-t border-[var(--border)] mx-2 mt-1 pt-1 max-h-[240px] overflow-y-auto">
|
|
{llmModels.map((model) => {
|
|
const isActive = menuSession.llmModel
|
|
? menuSession.llmModel === model.id
|
|
: model.id === defaultLlmModel;
|
|
return (
|
|
<button
|
|
key={model.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleSelectModel(menuSessionId!, model.id);
|
|
}}
|
|
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer transition-colors
|
|
rounded-lg flex items-center gap-2 ${
|
|
isActive
|
|
? "text-[var(--accent)] bg-[var(--accent)]/5"
|
|
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
|
}`}
|
|
>
|
|
<span className="w-4 flex-shrink-0 text-center">
|
|
{isActive && (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="truncate">{model.name}</div>
|
|
<div className="text-[10px] text-[var(--text-secondary)] truncate">{model.description}</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mx-2 my-1 border-t border-[var(--border)]" />
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (menuSession) startRename(menuSession.id, menuSession.title);
|
|
}}
|
|
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
|
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
|
>
|
|
重命名
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
deleteSession(menuSessionId!);
|
|
closeMenu();
|
|
}}
|
|
className="w-full text-left px-3.5 py-2 text-sm text-[var(--hot)]
|
|
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
|
>
|
|
删除
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{filtered.length === 0 && (
|
|
<div className="text-center text-xs text-[var(--text-secondary)] py-8">
|
|
没有匹配的对话
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|