Compare commits

...

5 Commits

Author SHA1 Message Date
gaoziman
0f8fd2ce1f feat(聊天页面): 实现搜索结果跳转高亮
- 聊天页面支持通过 URL 参数定位消息
- MessageBubble 组件添加高亮状态支持
- 新增消息高亮动画样式,支持亮色/暗色主题
- 跳转后自动滚动到目标消息并高亮闪烁
- 3秒后自动清除高亮效果
2025-12-24 22:51:04 +08:00
gaoziman
57e8631e10 feat(侧边栏): 集成消息搜索入口
- 添加搜索入口按钮显示快捷键提示
- 支持全局快捷键 ⌘K / Ctrl+K 打开搜索
- 集成 SearchModal 搜索弹框组件
2025-12-24 22:50:45 +08:00
gaoziman
236b368537 feat(组件): 添加消息全局搜索弹框
- 新增 SearchModal 搜索模态框组件
- 新增 SearchResultItem 搜索结果项组件
- 支持键盘快捷键导航(上下箭头选择、回车打开)
- 实现关键词高亮显示和智能截取上下文
- 包含骨架屏加载、空状态、错误状态等完整交互
- 支持按角色筛选搜索结果
2025-12-24 22:50:28 +08:00
gaoziman
2b44aca254 feat(Hook): 添加消息搜索状态管理 Hook
- 新增 useSearch Hook 封装搜索逻辑
- 实现 300ms 防抖搜索避免频繁请求
- 支持角色筛选切换即时刷新
- 支持分页加载和结果缓存
- 提供完整的搜索状态管理
2025-12-24 22:50:09 +08:00
gaoziman
dcd757e584 feat(API): 添加消息全局搜索接口
- 新增 /api/messages/search 搜索 API
- 支持关键词模糊搜索消息内容
- 支持角色筛选(用户消息/AI回复/全部)
- 支持分页查询,返回结果总数
- 仅查询当前用户的未归档对话
2025-12-24 22:49:41 +08:00
8 changed files with 1128 additions and 5 deletions

View File

@ -0,0 +1,146 @@
import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { messages, conversations } from '@/drizzle/schema';
import { getCurrentUser } from '@/lib/auth';
import { eq, and, ilike, desc, sql, or } from 'drizzle-orm';
/**
*
*/
export interface SearchResult {
messageId: string;
conversationId: string;
conversationTitle: string;
role: string;
content: string;
createdAt: Date;
}
/**
* GET /api/messages/search -
*
* Query Parameters:
* - q: string () -
* - role: 'user' | 'assistant' | 'all' () - 'all'
* - page: number () - 1
* - limit: number () - 20 50
*/
export async function GET(request: Request) {
try {
// 验证用户身份
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
// 解析查询参数
const { searchParams } = new URL(request.url);
const query = searchParams.get('q')?.trim();
const role = searchParams.get('role') || 'all';
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') || '20', 10)));
const offset = (page - 1) * limit;
// 验证搜索关键词
if (!query || query.length < 1) {
return NextResponse.json(
{ error: '请输入搜索关键词' },
{ status: 400 }
);
}
// 关键词长度限制
if (query.length > 100) {
return NextResponse.json(
{ error: '搜索关键词过长,请限制在 100 字符以内' },
{ status: 400 }
);
}
// 构建搜索条件
const searchPattern = `%${query}%`;
// 基础条件:用户的对话
const baseConditions = [
eq(conversations.userId, user.userId),
eq(conversations.isArchived, false), // 排除已归档对话
];
// 角色筛选条件
const roleCondition = role === 'all'
? or(eq(messages.role, 'user'), eq(messages.role, 'assistant'))
: eq(messages.role, role);
// 内容搜索条件
const contentCondition = ilike(messages.content, searchPattern);
// 执行搜索查询
const results = await db
.select({
messageId: messages.messageId,
conversationId: messages.conversationId,
conversationTitle: conversations.title,
role: messages.role,
content: messages.content,
createdAt: messages.createdAt,
})
.from(messages)
.innerJoin(
conversations,
eq(messages.conversationId, conversations.conversationId)
)
.where(
and(
...baseConditions,
roleCondition,
contentCondition
)
)
.orderBy(desc(messages.createdAt))
.limit(limit)
.offset(offset);
// 获取总数(用于分页)
const countResult = await db
.select({
count: sql<number>`count(*)::int`,
})
.from(messages)
.innerJoin(
conversations,
eq(messages.conversationId, conversations.conversationId)
)
.where(
and(
...baseConditions,
roleCondition,
contentCondition
)
);
const total = countResult[0]?.count || 0;
const totalPages = Math.ceil(total / limit);
return NextResponse.json({
success: true,
data: {
results,
total,
page,
limit,
totalPages,
query,
role,
},
});
} catch (error) {
console.error('Search messages error:', error);
return NextResponse.json(
{ error: '搜索失败,请稍后重试' },
{ status: 500 }
);
}
}

View File

@ -30,14 +30,19 @@ export default function ChatPage({ params }: PageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const initialMessage = searchParams.get('message');
const highlightParam = searchParams.get('highlight'); // 搜索高亮参数
const { user } = useAuth();
const { setOptimizedPrompt } = usePromptOptimizer();
const [sidebarOpen, setSidebarOpen] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isNewChat, setIsNewChat] = useState(false);
const [initialMessageSent, setInitialMessageSent] = useState(false);
// 高亮消息状态
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null);
// 标题下拉菜单状态
const [titleMenuOpen, setTitleMenuOpen] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
@ -128,6 +133,36 @@ export default function ChatPage({ params }: PageProps) {
scrollToBottom();
}, [messages]);
// 处理搜索高亮:滚动到目标消息并高亮
useEffect(() => {
if (highlightParam && messages.length > 0) {
// 查找目标消息
const targetMessage = messages.find(m => m.id === highlightParam);
if (targetMessage) {
// 设置高亮状态
setHighlightedMessageId(highlightParam);
// 延迟滚动,确保 DOM 已渲染
setTimeout(() => {
const messageElement = document.querySelector(`[data-message-id="${highlightParam}"]`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
// 清除 URL 中的 highlight 参数
router.replace(`/chat/${chatId}`, { scroll: false });
// 3秒后清除高亮状态
const timer = setTimeout(() => {
setHighlightedMessageId(null);
}, 3000);
return () => clearTimeout(timer);
}
}
}, [highlightParam, messages, chatId, router]);
// 聚焦标题输入框
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
@ -595,6 +630,7 @@ export default function ChatPage({ params }: PageProps) {
onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined}
onLinkClick={handleLinkClick}
conversationId={chatId}
isHighlighted={highlightedMessageId === message.id}
/>
))
)}

View File

@ -616,3 +616,36 @@ pre[class*="language-"] {
[data-theme="dark"] .token.italic {
font-style: italic;
}
/* ========================================
消息搜索高亮动画
用于搜索跳转后高亮显示目标消息
风格轻量闪烁类似搜索引擎高亮
======================================== */
@keyframes messageHighlight {
0% {
background-color: rgba(255, 235, 120, 0.6);
}
100% {
background-color: transparent;
}
}
.message-highlight {
animation: messageHighlight 2.5s ease-out forwards;
border-radius: var(--radius-md);
}
/* 暗色模式下使用更暗的黄色 */
[data-theme="dark"] .message-highlight {
animation-name: messageHighlightDark;
}
@keyframes messageHighlightDark {
0% {
background-color: rgba(255, 200, 50, 0.25);
}
100% {
background-color: transparent;
}
}

View File

@ -56,6 +56,8 @@ interface MessageBubbleProps {
onLinkClick?: (url: string) => void;
/** 对话 ID用于关联笔记来源 */
conversationId?: string;
/** 是否高亮显示(搜索跳转时使用) */
isHighlighted?: boolean;
}
// 格式化文件大小
@ -75,7 +77,7 @@ function getDocumentIcon(type: string) {
return FileText;
}
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId }: MessageBubbleProps) {
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId, isHighlighted }: MessageBubbleProps) {
const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false);
@ -138,7 +140,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
if (isUser) {
return (
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in group">
<div
data-message-id={message.id}
className={cn(
"flex justify-end items-start gap-3 mb-8 animate-fade-in group",
isHighlighted && "message-highlight"
)}
>
<div className="max-w-[70%] relative">
{/* 用户上传的图片 */}
{uploadedImages && uploadedImages.length > 0 && (
@ -227,7 +235,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
}
return (
<div className="flex items-start gap-4 mb-8 animate-fade-in">
<div
data-message-id={message.id}
className={cn(
"flex items-start gap-4 mb-8 animate-fade-in",
isHighlighted && "message-highlight"
)}
>
{/* AI 图标 */}
<div className="flex-shrink-0 mt-4">
<AILogo size={28} />

View File

@ -0,0 +1,367 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Search,
X,
MessageSquare,
User,
Bot,
Loader2,
SearchX,
MessageCircleQuestion,
ChevronDown,
ArrowUp,
ArrowDown,
CornerDownLeft,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSearch, type SearchResult, type SearchRole } from '@/hooks/useSearch';
import { SearchResultItem } from './SearchResultItem';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
/**
*
*/
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
// 搜索状态
const {
query,
results,
total,
status,
error,
role,
setQuery,
setRole,
reset,
} = useSearch();
// 选中项索引(用于键盘导航)
const [selectedIndex, setSelectedIndex] = useState(-1);
// 聚焦输入框
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [isOpen]);
// 关闭时重置状态
useEffect(() => {
if (!isOpen) {
reset();
setSelectedIndex(-1);
}
}, [isOpen, reset]);
// 选中项变化时滚动到可视区域
useEffect(() => {
if (selectedIndex >= 0 && resultsRef.current) {
const selectedElement = resultsRef.current.querySelector(
`[data-index="${selectedIndex}"]`
);
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}
}, [selectedIndex]);
/**
*
*/
const handleNavigate = useCallback((result: SearchResult) => {
// 添加 highlight 查询参数,用于定位和高亮消息
router.push(`/chat/${result.conversationId}?highlight=${result.messageId}`);
onClose();
}, [router, onClose]);
/**
*
*/
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev =>
prev < results.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
handleNavigate(results[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
}, [results, selectedIndex, handleNavigate, onClose]);
/**
*
*/
const handleClear = useCallback(() => {
setQuery('');
setSelectedIndex(-1);
inputRef.current?.focus();
}, [setQuery]);
/**
*
*/
const RoleFilterButton = ({
filterRole,
icon: Icon,
label,
}: {
filterRole: SearchRole;
icon: React.ElementType;
label: string;
}) => (
<button
onClick={() => setRole(filterRole)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-all',
'border',
role === filterRole
? 'bg-[var(--color-primary-alpha)] border-[var(--color-primary)] text-[var(--color-primary)]'
: 'bg-[var(--color-bg-primary)] border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
)}
>
<Icon size={14} />
{label}
</button>
);
if (!isOpen) return null;
return (
<>
{/* 遮罩层 */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] animate-fade-in-fast"
onClick={onClose}
/>
{/* 模态框 */}
<div
className="fixed top-[10vh] left-1/2 -translate-x-1/2 w-full max-w-[640px] z-[101] px-4"
onKeyDown={handleKeyDown}
>
<div className="bg-[var(--color-bg-primary)] rounded shadow-2xl overflow-hidden animate-scale-in-fast">
{/* 搜索输入区 */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-[var(--color-border-light)]">
<Search
size={20}
className="text-[var(--color-text-tertiary)] flex-shrink-0"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(-1);
}}
placeholder="搜索消息内容..."
className="flex-1 bg-transparent text-base text-[var(--color-text-primary)] placeholder:text-[var(--color-text-placeholder)] outline-none"
/>
{query && status !== 'loading' && (
<button
onClick={handleClear}
className="flex items-center justify-center w-6 h-6 rounded bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<X size={14} />
</button>
)}
{status === 'loading' && (
<Loader2
size={18}
className="text-[var(--color-primary)] animate-spin"
/>
)}
<button
onClick={onClose}
className="px-2.5 py-1 text-xs text-[var(--color-text-secondary)] border border-[var(--color-border)] rounded-md hover:bg-[var(--color-bg-hover)] transition-colors"
>
ESC
</button>
</div>
{/* 筛选器 */}
<div className="flex items-center gap-2 px-5 py-3 bg-[var(--color-bg-secondary)] border-b border-[var(--color-border-light)]">
<RoleFilterButton
filterRole="all"
icon={MessageSquare}
label="全部"
/>
<RoleFilterButton
filterRole="user"
icon={User}
label="我的消息"
/>
<RoleFilterButton
filterRole="assistant"
icon={Bot}
label="AI 回复"
/>
</div>
{/* 搜索结果区 */}
<div
ref={resultsRef}
className="max-h-[400px] overflow-y-auto"
>
{/* 初始状态 */}
{status === 'idle' && !query && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-full mb-4">
<MessageCircleQuestion
size={28}
className="text-[var(--color-text-tertiary)]"
/>
</div>
<div className="text-base font-medium text-[var(--color-text-primary)] mb-2">
</div>
<div className="text-sm text-[var(--color-text-tertiary)] max-w-[280px]">
</div>
</div>
)}
{/* 加载状态 - 骨架屏 */}
{status === 'loading' && results.length === 0 && (
<div className="space-y-0">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex gap-3.5 px-5 py-4 border-b border-[var(--color-border-light)]"
>
<div className="w-9 h-9 rounded-full bg-[var(--color-bg-tertiary)] animate-pulse flex-shrink-0" />
<div className="flex-1 space-y-2.5">
<div className="h-4 w-48 bg-[var(--color-bg-tertiary)] rounded animate-pulse" />
<div className="h-4 w-full bg-[var(--color-bg-tertiary)] rounded animate-pulse" />
<div className="h-4 w-3/4 bg-[var(--color-bg-tertiary)] rounded animate-pulse" />
</div>
</div>
))}
</div>
)}
{/* 搜索结果 */}
{results.length > 0 && (
<>
{/* 结果统计 */}
<div className="flex items-center justify-between px-5 py-2.5 bg-[var(--color-bg-secondary)] border-b border-[var(--color-border-light)]">
<span className="text-xs text-[var(--color-text-tertiary)]">
<strong className="text-[var(--color-text-secondary)]">{total}</strong>
</span>
<button className="flex items-center gap-1 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors">
<ChevronDown size={14} />
</button>
</div>
{/* 结果列表 */}
<div>
{results.map((result, index) => (
<SearchResultItem
key={result.messageId}
result={result}
query={query}
isSelected={index === selectedIndex}
onClick={() => handleNavigate(result)}
dataIndex={index}
/>
))}
</div>
</>
)}
{/* 无结果状态 */}
{status === 'success' && results.length === 0 && query && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-full mb-4">
<SearchX
size={28}
className="text-[var(--color-text-tertiary)]"
/>
</div>
<div className="text-base font-medium text-[var(--color-text-primary)] mb-2">
</div>
<div className="text-sm text-[var(--color-text-tertiary)] max-w-[280px]">
</div>
</div>
)}
{/* 错误状态 */}
{status === 'error' && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 flex items-center justify-center bg-red-100 dark:bg-red-900/20 rounded-full mb-4">
<X size={28} className="text-red-500" />
</div>
<div className="text-base font-medium text-[var(--color-text-primary)] mb-2">
</div>
<div className="text-sm text-[var(--color-text-tertiary)] max-w-[280px]">
{error || '请稍后重试'}
</div>
</div>
)}
</div>
{/* 底部快捷键提示 */}
<div className="flex items-center justify-between px-5 py-3 bg-[var(--color-bg-secondary)] border-t border-[var(--color-border-light)]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px]">
<ArrowUp size={10} />
</kbd>
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px]">
<ArrowDown size={10} />
</kbd>
<span></span>
</div>
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px]">
<CornerDownLeft size={10} />
</kbd>
<span></span>
</div>
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[11px]">
esc
</kbd>
<span></span>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,198 @@
'use client';
import { useMemo } from 'react';
import { User, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SearchResult } from '@/hooks/useSearch';
interface SearchResultItemProps {
result: SearchResult;
query: string;
isSelected: boolean;
onClick: () => void;
dataIndex: number;
}
/**
*
*/
function formatTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateOnly.getTime() === today.getTime()) {
return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
} else if (dateOnly.getTime() === yesterday.getTime()) {
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
} else if (now.getTime() - date.getTime() < 7 * 24 * 60 * 60 * 1000) {
const days = Math.floor((now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000));
return `${days}天前`;
} else {
return `${date.getMonth() + 1}${date.getDate()}`;
}
}
/**
*
*/
function highlightText(text: string, query: string): React.ReactNode {
if (!query.trim()) {
return text;
}
// 转义正则特殊字符
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) => {
if (part.toLowerCase() === query.toLowerCase()) {
return (
<mark
key={index}
className="bg-[rgba(224,107,62,0.25)] text-[var(--color-primary)] px-0.5 rounded font-medium"
>
{part}
</mark>
);
}
return part;
});
}
/**
*
*/
function truncateContent(content: string, query: string, maxLength: number = 150): string {
// 移除多余的换行和空格
const cleanContent = content.replace(/\s+/g, ' ').trim();
if (cleanContent.length <= maxLength) {
return cleanContent;
}
// 查找关键词位置
const lowerContent = cleanContent.toLowerCase();
const lowerQuery = query.toLowerCase();
const keywordIndex = lowerContent.indexOf(lowerQuery);
if (keywordIndex === -1) {
// 没有找到关键词,从头截取
return cleanContent.slice(0, maxLength) + '...';
}
// 计算截取范围,尽量让关键词在中间
const contextBefore = 40;
const contextAfter = maxLength - contextBefore - query.length;
let start = Math.max(0, keywordIndex - contextBefore);
let end = Math.min(cleanContent.length, keywordIndex + query.length + contextAfter);
// 调整边界
if (start > 0) {
// 找到前一个空格,避免截断单词
const spaceIndex = cleanContent.lastIndexOf(' ', start);
if (spaceIndex > start - 20) {
start = spaceIndex + 1;
}
}
if (end < cleanContent.length) {
// 找到后一个空格,避免截断单词
const spaceIndex = cleanContent.indexOf(' ', end);
if (spaceIndex !== -1 && spaceIndex < end + 20) {
end = spaceIndex;
}
}
let result = cleanContent.slice(start, end);
// 添加省略号
if (start > 0) {
result = '...' + result;
}
if (end < cleanContent.length) {
result = result + '...';
}
return result;
}
/**
*
*/
export function SearchResultItem({
result,
query,
isSelected,
onClick,
dataIndex,
}: SearchResultItemProps) {
const isUser = result.role === 'user';
// 处理显示内容
const displayContent = useMemo(() => {
return truncateContent(result.content, query);
}, [result.content, query]);
// 高亮后的内容
const highlightedContent = useMemo(() => {
return highlightText(displayContent, query);
}, [displayContent, query]);
return (
<div
data-index={dataIndex}
onClick={onClick}
className={cn(
'flex gap-3.5 px-5 py-4 cursor-pointer transition-colors',
'border-b border-[var(--color-border-light)] last:border-b-0',
isSelected
? 'bg-[var(--color-bg-hover)]'
: 'hover:bg-[var(--color-bg-hover)]'
)}
>
{/* 头像 */}
<div
className={cn(
'w-9 h-9 rounded-full flex-shrink-0 flex items-center justify-center',
isUser
? 'bg-[var(--color-message-user)] text-[var(--color-text-secondary)]'
: 'bg-gradient-to-br from-[var(--color-primary)] to-[#D4643E] text-white'
)}
>
{isUser ? <User size={18} /> : <Bot size={18} />}
</div>
{/* 内容 */}
<div className="flex-1 min-w-0">
{/* 元信息 */}
<div className="flex items-center gap-2 mb-1.5">
<span className="text-sm font-medium text-[var(--color-text-primary)] truncate max-w-[200px]">
{result.conversationTitle}
</span>
<span
className={cn(
'text-[11px] px-2 py-0.5 rounded-full flex-shrink-0',
'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]'
)}
>
{isUser ? '我' : 'AI'}
</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto flex-shrink-0">
{formatTime(result.createdAt)}
</span>
</div>
{/* 消息内容 */}
<div className="text-sm text-[var(--color-text-secondary)] leading-relaxed line-clamp-2">
{highlightedContent}
</div>
</div>
</div>
);
}

View File

@ -2,14 +2,15 @@
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot, Bookmark } from 'lucide-react';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot, Bookmark, Search } from 'lucide-react';
import { UserMenu } from '@/components/ui/UserMenu';
import { NewChatModal } from '@/components/features/NewChatModal';
import { SearchModal } from '@/components/features/SearchModal';
import { cn } from '@/lib/utils';
import { useConversations } from '@/hooks/useConversations';
import { useAuth } from '@/providers/AuthProvider';
import type { Conversation } from '@/drizzle/schema';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
interface SidebarProps {
isOpen?: boolean;
@ -26,6 +27,7 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
const [editingTitle, setEditingTitle] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showNewChatModal, setShowNewChatModal] = useState(false);
const [showSearchModal, setShowSearchModal] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
@ -52,6 +54,22 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
};
}, [menuOpen]);
// 全局键盘快捷键⌘K / Ctrl+K 打开搜索
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// ⌘K (Mac) 或 Ctrl+K (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setShowSearchModal(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
// 创建新对话 - 显示选择助手弹框
const handleNewChat = () => {
setShowNewChatModal(true);
@ -142,6 +160,21 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
</button>
</div>
{/* 搜索入口按钮 */}
<div className="px-3 pb-3">
<button
onClick={() => setShowSearchModal(true)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 bg-[var(--color-bg-tertiary)] border border-[var(--color-border-light)] rounded text-[var(--color-text-tertiary)] text-sm hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-border)] hover:text-[var(--color-text-secondary)] transition-all"
>
<Search size={16} className="flex-shrink-0" />
<span className="flex-1 text-left">...</span>
<span className="flex items-center gap-0.5 text-[11px]">
<kbd className="inline-flex items-center justify-center px-1.5 py-0.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px] shadow-sm"></kbd>
<kbd className="inline-flex items-center justify-center px-1.5 py-0.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px] shadow-sm">K</kbd>
</span>
</button>
</div>
{/* 助手库入口 */}
<div className="px-4 pb-2">
<Link
@ -319,6 +352,12 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
isOpen={showNewChatModal}
onClose={() => setShowNewChatModal(false)}
/>
{/* 搜索弹框 */}
<SearchModal
isOpen={showSearchModal}
onClose={() => setShowSearchModal(false)}
/>
</>
);
}

290
src/hooks/useSearch.ts Normal file
View File

@ -0,0 +1,290 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
/**
*
*/
export interface SearchResult {
messageId: string;
conversationId: string;
conversationTitle: string;
role: string;
content: string;
createdAt: string;
}
/**
*
*/
interface SearchResponse {
success: boolean;
data?: {
results: SearchResult[];
total: number;
page: number;
limit: number;
totalPages: number;
query: string;
role: string;
};
error?: string;
}
/**
*
*/
type SearchStatus = 'idle' | 'loading' | 'success' | 'error';
/**
*
*/
export type SearchRole = 'all' | 'user' | 'assistant';
/**
* useSearch Hook
*/
interface UseSearchReturn {
// 状态
query: string;
results: SearchResult[];
total: number;
page: number;
totalPages: number;
status: SearchStatus;
error: string | null;
role: SearchRole;
// 操作
setQuery: (query: string) => void;
setRole: (role: SearchRole) => void;
search: (searchQuery?: string) => Promise<void>;
loadMore: () => Promise<void>;
reset: () => void;
}
/**
* Hook
*
*
* - 300ms
* -
* -
* -
* -
*/
export function useSearch(): UseSearchReturn {
// 搜索状态
const [query, setQueryState] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [status, setStatus] = useState<SearchStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [role, setRoleState] = useState<SearchRole>('all');
// 防抖定时器
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// 缓存最后一次搜索的参数,避免重复请求
const lastSearchRef = useRef<{ query: string; role: SearchRole } | null>(null);
// AbortController 用于取消请求
const abortControllerRef = useRef<AbortController | null>(null);
/**
*
*/
const doSearch = useCallback(async (
searchQuery: string,
searchRole: SearchRole,
searchPage: number = 1,
append: boolean = false
) => {
// 空查询时重置状态
if (!searchQuery.trim()) {
setResults([]);
setTotal(0);
setPage(1);
setTotalPages(0);
setStatus('idle');
setError(null);
return;
}
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
setStatus('loading');
setError(null);
const params = new URLSearchParams({
q: searchQuery.trim(),
role: searchRole,
page: searchPage.toString(),
limit: '20',
});
const response = await fetch(`/api/messages/search?${params}`, {
signal: abortControllerRef.current.signal,
});
const data: SearchResponse = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '搜索失败');
}
// 更新状态
if (append && data.data) {
setResults(prev => [...prev, ...data.data!.results]);
} else if (data.data) {
setResults(data.data.results);
}
if (data.data) {
setTotal(data.data.total);
setPage(data.data.page);
setTotalPages(data.data.totalPages);
}
setStatus('success');
// 更新缓存
lastSearchRef.current = { query: searchQuery, role: searchRole };
} catch (err) {
// 忽略取消的请求
if (err instanceof Error && err.name === 'AbortError') {
return;
}
setStatus('error');
setError(err instanceof Error ? err.message : '搜索失败');
}
}, []);
/**
*
*/
const setQuery = useCallback((newQuery: string) => {
setQueryState(newQuery);
// 清除之前的定时器
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// 空查询立即清空结果
if (!newQuery.trim()) {
setResults([]);
setTotal(0);
setPage(1);
setTotalPages(0);
setStatus('idle');
setError(null);
return;
}
// 设置新的防抖定时器
debounceTimerRef.current = setTimeout(() => {
doSearch(newQuery, role, 1, false);
}, 300);
}, [role, doSearch]);
/**
*
*/
const setRole = useCallback((newRole: SearchRole) => {
setRoleState(newRole);
// 如果有搜索关键词,立即重新搜索
if (query.trim()) {
// 清除防抖定时器
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
doSearch(query, newRole, 1, false);
}
}, [query, doSearch]);
/**
*
*/
const search = useCallback(async (searchQuery?: string) => {
const q = searchQuery ?? query;
if (q.trim()) {
// 清除防抖定时器
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
await doSearch(q, role, 1, false);
}
}, [query, role, doSearch]);
/**
*
*/
const loadMore = useCallback(async () => {
if (status === 'loading' || page >= totalPages) {
return;
}
await doSearch(query, role, page + 1, true);
}, [query, role, page, totalPages, status, doSearch]);
/**
*
*/
const reset = useCallback(() => {
// 清除防抖定时器
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// 取消进行中的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setQueryState('');
setResults([]);
setTotal(0);
setPage(1);
setTotalPages(0);
setStatus('idle');
setError(null);
setRoleState('all');
lastSearchRef.current = null;
}, []);
// 清理定时器和请求
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
query,
results,
total,
page,
totalPages,
status,
error,
role,
setQuery,
setRole,
search,
loadMore,
reset,
};
}