- 同步阻塞操作(附件处理、历史加载、scheduler/memory_search 的 SQLite 调用) 移入 spawn_blocking,避免占用 async worker - LLM Provider reqwest::Client 按超时配置缓存复用,减少 TLS/连接开销 - agent loop:图片过滤加廉价预判避免全量深拷贝;请求克隆改借用;工具定义 Arc 化 - 定向 COUNT/LIMIT 1 查询替代全量加载计数(wait_coordinator、task session 重建) - 前端:面板/侧栏/聊天组件 memo 化;merged_tool 对象按值复用缓存; 流式 delta rAF 节流批量 flush;useMemo 缓存分组排序结果
363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
|
||
import {
|
||
Plus,
|
||
MessageSquare,
|
||
Layers,
|
||
Hash,
|
||
Clock,
|
||
RefreshCw,
|
||
Trash2,
|
||
Check,
|
||
X,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Edit2,
|
||
} from 'lucide-react';
|
||
import type { Topic } from '../../types/protocol';
|
||
|
||
interface TopicListProps {
|
||
sessionId: string | null;
|
||
topics: Topic[];
|
||
currentTopicId: string | null;
|
||
isReadOnly: boolean;
|
||
onCreateTopic: () => void;
|
||
onRefresh: () => void;
|
||
onSwitchTopic: (topicId: string) => void;
|
||
onDeleteTopic: (topicId: string) => void;
|
||
onRenameTopic: (topicId: string, title: string) => void;
|
||
}
|
||
|
||
function formatTime(timestamp: number): string {
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||
|
||
if (diffDays === 0) {
|
||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||
} else if (diffDays === 1) {
|
||
return '昨天';
|
||
} else if (diffDays < 7) {
|
||
return `${diffDays}天前`;
|
||
} else {
|
||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||
}
|
||
}
|
||
|
||
// memo:props 全部稳定(topics 仅在 topic_list 消息到达时换引用、回调均 useCallback),
|
||
// 流式期间 App 每帧重渲染时跳过话题列表重渲染与分页重算。
|
||
export const TopicList = memo(function TopicList({
|
||
sessionId,
|
||
topics,
|
||
currentTopicId,
|
||
isReadOnly,
|
||
onCreateTopic,
|
||
onRefresh,
|
||
onSwitchTopic,
|
||
onDeleteTopic,
|
||
onRenameTopic,
|
||
}: TopicListProps) {
|
||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
|
||
const [editingTitle, setEditingTitle] = useState('');
|
||
const editInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 进入编辑模式时自动聚焦 input
|
||
useEffect(() => {
|
||
if (editingTopicId && editInputRef.current) {
|
||
editInputRef.current.focus();
|
||
editInputRef.current.select();
|
||
}
|
||
}, [editingTopicId]);
|
||
|
||
const startEdit = useCallback((topic: Topic) => {
|
||
setConfirmDeleteId(null);
|
||
setEditingTopicId(topic.id);
|
||
// 预填当前显示值(description 优先,与列表显示逻辑一致)
|
||
setEditingTitle(topic.description || topic.title);
|
||
}, []);
|
||
|
||
const cancelEdit = useCallback(() => {
|
||
setEditingTopicId(null);
|
||
setEditingTitle('');
|
||
}, []);
|
||
|
||
const commitEdit = useCallback(() => {
|
||
const trimmed = editingTitle.trim();
|
||
if (!trimmed || !editingTopicId) {
|
||
cancelEdit();
|
||
return;
|
||
}
|
||
onRenameTopic(editingTopicId, trimmed);
|
||
setEditingTopicId(null);
|
||
setEditingTitle('');
|
||
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]);
|
||
|
||
// Pagination — dynamically sized to fill one screen without scrolling
|
||
const ESTIMATED_ITEM_HEIGHT = 64; // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px)
|
||
const LIST_PADDING = 24; // p-3 top + bottom
|
||
const [pageSize, setPageSize] = useState(8); // fallback before measurement
|
||
const [currentPage, setCurrentPage] = useState(0);
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
|
||
const measurePageSize = useCallback(() => {
|
||
const el = listRef.current;
|
||
if (!el) return;
|
||
const available = el.clientHeight - LIST_PADDING;
|
||
setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
measurePageSize();
|
||
const el = listRef.current;
|
||
if (!el) return;
|
||
const observer = new ResizeObserver(() => measurePageSize());
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
}, [measurePageSize]);
|
||
|
||
const totalPages = useMemo(
|
||
() => Math.max(1, Math.ceil(topics.length / pageSize)),
|
||
[topics.length, pageSize],
|
||
);
|
||
const pagedTopics = useMemo(
|
||
() => topics.slice(currentPage * pageSize, (currentPage + 1) * pageSize),
|
||
[topics, currentPage, pageSize],
|
||
);
|
||
|
||
// Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page)
|
||
useEffect(() => {
|
||
if (currentPage >= totalPages) {
|
||
setCurrentPage(Math.max(0, totalPages - 1));
|
||
}
|
||
}, [currentPage, totalPages]);
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between border-b border-[var(--border-color)] px-4 py-3">
|
||
<h2 className="font-semibold text-[var(--text-primary)] flex items-center gap-2 text-sm">
|
||
<Layers className="h-4 w-4 text-[var(--accent-cyan)]" />
|
||
话题列表
|
||
{topics.length > 0 && (
|
||
<span className="text-xs text-[var(--text-muted)]">({topics.length})</span>
|
||
)}
|
||
</h2>
|
||
{isReadOnly ? (
|
||
<button
|
||
onClick={onRefresh}
|
||
disabled={!sessionId}
|
||
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition-all ${
|
||
!sessionId
|
||
? 'text-[var(--text-muted)] cursor-not-allowed'
|
||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)]'
|
||
}`}
|
||
title="刷新话题列表"
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
刷新
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={onCreateTopic}
|
||
disabled={!sessionId}
|
||
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||
!sessionId
|
||
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
|
||
: 'bg-[var(--accent-cyan)] text-white hover:opacity-90'
|
||
}`}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
新建
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Topics 列表 */}
|
||
<div ref={listRef} className="flex-1 overflow-y-auto p-3">
|
||
{!sessionId ? (
|
||
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
|
||
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||
<p>等待连接...</p>
|
||
</div>
|
||
) : topics.length === 0 ? (
|
||
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
|
||
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||
<p>暂无话题</p>
|
||
<p className="text-xs mt-1">点击上方"新建"创建话题</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
{pagedTopics.map((topic, index) => (
|
||
<div key={topic.id} className="group relative">
|
||
{editingTopicId === topic.id ? (
|
||
// 编辑模式:内联输入框 + 提交/取消按钮
|
||
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
commitEdit();
|
||
}}
|
||
className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40"
|
||
>
|
||
<input
|
||
ref={editInputRef}
|
||
value={editingTitle}
|
||
onChange={(e) => setEditingTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
cancelEdit();
|
||
}
|
||
}}
|
||
onBlur={cancelEdit}
|
||
className="flex-1 min-w-0 bg-transparent text-sm text-[var(--text-primary)] outline-none border-none focus:ring-0 placeholder:text-[var(--text-muted)]"
|
||
placeholder="话题标题"
|
||
maxLength={120}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors shrink-0"
|
||
title="确认 (Enter)"
|
||
>
|
||
<Check className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={cancelEdit}
|
||
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors shrink-0"
|
||
title="取消 (Esc)"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</form>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={() => onSwitchTopic(topic.id)}
|
||
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-colors ${
|
||
topic.id === currentTopicId
|
||
? 'bg-[var(--overlay-subtle)] border border-[var(--border-color)]'
|
||
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<span className="mt-0.5 text-xs text-[var(--text-muted)] w-4">
|
||
{currentPage * pageSize + index + 1}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div
|
||
className={`truncate font-medium ${
|
||
topic.id === currentTopicId
|
||
? 'text-[var(--text-primary)]'
|
||
: 'text-[var(--text-secondary)]'
|
||
}`}
|
||
>
|
||
{topic.description || topic.title}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5">
|
||
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
|
||
<Hash className="h-3 w-3" />
|
||
{topic.message_count} 条消息
|
||
</span>
|
||
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
|
||
<Clock className="h-3 w-3" />
|
||
{formatTime(topic.updated_at)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{topic.id === currentTopicId && (
|
||
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] mt-1.5" />
|
||
)}
|
||
</div>
|
||
</button>
|
||
|
||
{/* 编辑/删除按钮 — 悬停可见 */}
|
||
<div className="absolute top-2.5 right-2.5">
|
||
{confirmDeleteId === topic.id ? (
|
||
<span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in">
|
||
<span className="text-xs text-[rgb(242,90,90)] whitespace-nowrap">
|
||
确认删除?
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDeleteTopic(topic.id);
|
||
setConfirmDeleteId(null);
|
||
}}
|
||
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors"
|
||
title="确认"
|
||
>
|
||
<Check className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setConfirmDeleteId(null);
|
||
}}
|
||
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors"
|
||
title="取消"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
) : (
|
||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
startEdit(topic);
|
||
}}
|
||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||
title="重命名话题"
|
||
>
|
||
<Edit2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setConfirmDeleteId(topic.id);
|
||
}}
|
||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
|
||
title="删除话题"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Pagination bar */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2">
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||
disabled={currentPage === 0}
|
||
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
<span className="text-xs text-[var(--text-muted)] min-w-[3rem] text-center select-none">
|
||
{currentPage + 1} / {totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={currentPage >= totalPages - 1}
|
||
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|