feat: 优化侧边栏,加入深度搜索
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
6c4c743e5a
commit
47e6da63e9
309
src/App.tsx
309
src/App.tsx
@ -4,6 +4,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Info,
|
Info,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
PanelLeft,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@ -54,6 +55,8 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
ChatToolCall,
|
ChatToolCall,
|
||||||
|
DeepSearchResult,
|
||||||
|
DeepSearchState,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
ModelParameters,
|
ModelParameters,
|
||||||
PromptTemplate,
|
PromptTemplate,
|
||||||
@ -106,10 +109,21 @@ const STATUS_TONE: Record<RequestState | "done", Notice["tone"]> = {
|
|||||||
done: "success",
|
done: "success",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DATA_SAVE_DELAY_MS = 800
|
||||||
|
const DEEP_SEARCH_BATCH_SIZE = 32
|
||||||
|
const DEEP_SEARCH_SNIPPET_LIMIT = 3
|
||||||
|
|
||||||
function isBusy(status: RequestState) {
|
function isBusy(status: RequestState) {
|
||||||
return status === "requesting" || status === "generating"
|
return status === "requesting" || status === "generating"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobileSidebarViewport() {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(max-width: 900px)").matches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message
|
return error.message
|
||||||
@ -118,6 +132,41 @@ function getErrorMessage(error: unknown) {
|
|||||||
return "发生未知错误"
|
return "发生未知错误"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSearchableMessageContent(message: ChatMessage) {
|
||||||
|
return [message.content, message.reasoningContent]
|
||||||
|
.filter((value): value is string => Boolean(value?.trim()))
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeepSearchExcerpt(content: string, query: string) {
|
||||||
|
const compactContent = content.replace(/\s+/g, " ").trim()
|
||||||
|
|
||||||
|
if (!compactContent) {
|
||||||
|
return "空消息"
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedContent = compactContent.toLowerCase()
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
const matchIndex = normalizedContent.indexOf(normalizedQuery)
|
||||||
|
|
||||||
|
if (matchIndex < 0) {
|
||||||
|
return compactContent.length > 160
|
||||||
|
? `${compactContent.slice(0, 160)}...`
|
||||||
|
: compactContent
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, matchIndex - 48)
|
||||||
|
const end = Math.min(compactContent.length, matchIndex + query.length + 96)
|
||||||
|
|
||||||
|
return `${start > 0 ? "..." : ""}${compactContent.slice(start, end)}${
|
||||||
|
end < compactContent.length ? "..." : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function yieldToBrowser() {
|
||||||
|
return new Promise<void>((resolve) => window.setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
function updateSessionInData(
|
function updateSessionInData(
|
||||||
source: AppData,
|
source: AppData,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@ -249,8 +298,21 @@ function AppContent() {
|
|||||||
const initialLoad = React.useMemo(() => loadAppData(), [])
|
const initialLoad = React.useMemo(() => loadAppData(), [])
|
||||||
const { setTheme } = useTheme()
|
const { setTheme } = useTheme()
|
||||||
const [data, setData] = React.useState<AppData>(initialLoad.data)
|
const [data, setData] = React.useState<AppData>(initialLoad.data)
|
||||||
|
const latestDataRef = React.useRef(data)
|
||||||
|
const saveTimerRef = React.useRef<number | undefined>(undefined)
|
||||||
const [activeView, setActiveView] = React.useState<ViewKey>("chat")
|
const [activeView, setActiveView] = React.useState<ViewKey>("chat")
|
||||||
|
const [sidebarOpen, setSidebarOpen] = React.useState(
|
||||||
|
() => !isMobileSidebarViewport()
|
||||||
|
)
|
||||||
const [sessionSearch, setSessionSearch] = React.useState("")
|
const [sessionSearch, setSessionSearch] = React.useState("")
|
||||||
|
const [deepSearch, setDeepSearch] = React.useState<DeepSearchState>({
|
||||||
|
open: false,
|
||||||
|
query: "",
|
||||||
|
status: "idle",
|
||||||
|
scanned: 0,
|
||||||
|
total: 0,
|
||||||
|
results: [],
|
||||||
|
})
|
||||||
const [chatInput, setChatInput] = React.useState("")
|
const [chatInput, setChatInput] = React.useState("")
|
||||||
const [editingMessageId, setEditingMessageId] = React.useState<string | null>(
|
const [editingMessageId, setEditingMessageId] = React.useState<string | null>(
|
||||||
null
|
null
|
||||||
@ -272,6 +334,7 @@ function AppContent() {
|
|||||||
const settingsImportRef = React.useRef<HTMLInputElement>(null)
|
const settingsImportRef = React.useRef<HTMLInputElement>(null)
|
||||||
const allImportRef = React.useRef<HTMLInputElement>(null)
|
const allImportRef = React.useRef<HTMLInputElement>(null)
|
||||||
const activeRequestRef = React.useRef<ActiveRequest | null>(null)
|
const activeRequestRef = React.useRef<ActiveRequest | null>(null)
|
||||||
|
const deepSearchRunRef = React.useRef(0)
|
||||||
|
|
||||||
const currentSession = React.useMemo(
|
const currentSession = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -328,14 +391,63 @@ function AppContent() {
|
|||||||
}, [data.prompts, promptSearch])
|
}, [data.prompts, promptSearch])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const result = saveAppData(data)
|
latestDataRef.current = data
|
||||||
|
const hasBusySession = data.sessions.some((session) =>
|
||||||
|
isBusy(session.status)
|
||||||
|
)
|
||||||
|
|
||||||
if (!result.ok) {
|
if (saveTimerRef.current !== undefined) {
|
||||||
const message = result.message
|
window.clearTimeout(saveTimerRef.current)
|
||||||
window.setTimeout(() => setStorageWarning(message), 0)
|
saveTimerRef.current = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBusySession) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimerRef.current = window.setTimeout(() => {
|
||||||
|
saveTimerRef.current = undefined
|
||||||
|
const result = saveAppData(latestDataRef.current)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = result.message
|
||||||
|
window.setTimeout(() => setStorageWarning(message), 0)
|
||||||
|
}
|
||||||
|
}, DATA_SAVE_DELAY_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current !== undefined) {
|
||||||
|
window.clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const flushPendingSave = () => {
|
||||||
|
if (saveTimerRef.current !== undefined) {
|
||||||
|
window.clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAppData(latestDataRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
flushPendingSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", flushPendingSave)
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", flushPendingSave)
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setTheme(data.settings.theme)
|
setTheme(data.settings.theme)
|
||||||
}, [data.settings.theme, setTheme])
|
}, [data.settings.theme, setTheme])
|
||||||
@ -367,6 +479,119 @@ function AppContent() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDeepSearchOpen(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
deepSearchRunRef.current += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeepSearch((previous) => ({ ...previous, open }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeepSearchQuery(query: string) {
|
||||||
|
setDeepSearch((previous) => ({ ...previous, query }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeepSearch() {
|
||||||
|
void runDeepSearch(sessionSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDeepSearch(rawQuery: string) {
|
||||||
|
const query = rawQuery.trim()
|
||||||
|
const runId = deepSearchRunRef.current + 1
|
||||||
|
const sessions = [...latestDataRef.current.sessions].sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
|
||||||
|
)
|
||||||
|
const total = sessions.reduce(
|
||||||
|
(count, session) => count + session.messages.length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
deepSearchRunRef.current = runId
|
||||||
|
setDeepSearch({
|
||||||
|
open: true,
|
||||||
|
query: rawQuery,
|
||||||
|
status: query ? "searching" : "idle",
|
||||||
|
scanned: 0,
|
||||||
|
total,
|
||||||
|
results: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
const results: DeepSearchResult[] = []
|
||||||
|
let scanned = 0
|
||||||
|
let messagesSinceYield = 0
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
let matchCount = 0
|
||||||
|
const snippets: DeepSearchResult["snippets"] = []
|
||||||
|
|
||||||
|
for (const message of session.messages) {
|
||||||
|
const content = getSearchableMessageContent(message)
|
||||||
|
|
||||||
|
if (content.toLowerCase().includes(normalizedQuery)) {
|
||||||
|
matchCount += 1
|
||||||
|
|
||||||
|
if (snippets.length < DEEP_SEARCH_SNIPPET_LIMIT) {
|
||||||
|
snippets.push({
|
||||||
|
messageId: message.id,
|
||||||
|
role: message.role,
|
||||||
|
excerpt: createDeepSearchExcerpt(content, query),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned += 1
|
||||||
|
messagesSinceYield += 1
|
||||||
|
|
||||||
|
if (messagesSinceYield >= DEEP_SEARCH_BATCH_SIZE) {
|
||||||
|
messagesSinceYield = 0
|
||||||
|
setDeepSearch((previous) =>
|
||||||
|
deepSearchRunRef.current === runId
|
||||||
|
? {
|
||||||
|
...previous,
|
||||||
|
scanned,
|
||||||
|
total,
|
||||||
|
results: [...results],
|
||||||
|
}
|
||||||
|
: previous
|
||||||
|
)
|
||||||
|
await yieldToBrowser()
|
||||||
|
|
||||||
|
if (deepSearchRunRef.current !== runId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchCount > 0) {
|
||||||
|
results.push({
|
||||||
|
sessionId: session.id,
|
||||||
|
title: session.title,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
matchCount,
|
||||||
|
snippets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deepSearchRunRef.current !== runId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeepSearch((previous) => ({
|
||||||
|
...previous,
|
||||||
|
status: "done",
|
||||||
|
scanned: total,
|
||||||
|
total,
|
||||||
|
results,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function updateService(serviceId: string, patch: Partial<ServiceConfig>) {
|
function updateService(serviceId: string, patch: Partial<ServiceConfig>) {
|
||||||
setData((previous) => ({
|
setData((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
@ -431,6 +656,10 @@ function AppContent() {
|
|||||||
setChatInput("")
|
setChatInput("")
|
||||||
setEditingMessageId(null)
|
setEditingMessageId(null)
|
||||||
setActiveView("chat")
|
setActiveView("chat")
|
||||||
|
|
||||||
|
if (isMobileSidebarViewport()) {
|
||||||
|
setSidebarOpen(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameSession(sessionId: string) {
|
function renameSession(sessionId: string) {
|
||||||
@ -473,23 +702,14 @@ function AppContent() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAllSessions() {
|
|
||||||
if (!window.confirm("确定清空所有会话吗?")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = createSession()
|
|
||||||
setData((previous) => ({
|
|
||||||
...previous,
|
|
||||||
currentSessionId: session.id,
|
|
||||||
sessions: [session],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchSession(sessionId: string) {
|
function switchSession(sessionId: string) {
|
||||||
setData((previous) => ({ ...previous, currentSessionId: sessionId }))
|
setData((previous) => ({ ...previous, currentSessionId: sessionId }))
|
||||||
setEditingMessageId(null)
|
setEditingMessageId(null)
|
||||||
setActiveView("chat")
|
setActiveView("chat")
|
||||||
|
|
||||||
|
if (isMobileSidebarViewport()) {
|
||||||
|
setSidebarOpen(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAndReport(session: ChatSession) {
|
function validateAndReport(session: ChatSession) {
|
||||||
@ -1437,6 +1657,7 @@ function AppContent() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"app-shell min-h-svh bg-background text-foreground",
|
"app-shell min-h-svh bg-background text-foreground",
|
||||||
|
!sidebarOpen && "app-shell-sidebar-collapsed",
|
||||||
data.settings.compactMode && "app-shell-compact"
|
data.settings.compactMode && "app-shell-compact"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -1444,9 +1665,13 @@ function AppContent() {
|
|||||||
sessions={filteredSessions}
|
sessions={filteredSessions}
|
||||||
currentSession={currentSession}
|
currentSession={currentSession}
|
||||||
search={sessionSearch}
|
search={sessionSearch}
|
||||||
|
deepSearch={deepSearch}
|
||||||
onSearchChange={setSessionSearch}
|
onSearchChange={setSessionSearch}
|
||||||
|
onOpenDeepSearch={openDeepSearch}
|
||||||
|
onDeepSearchOpenChange={setDeepSearchOpen}
|
||||||
|
onDeepSearchQueryChange={updateDeepSearchQuery}
|
||||||
|
onRunDeepSearch={(query) => void runDeepSearch(query)}
|
||||||
onNewSession={() => createNewSession()}
|
onNewSession={() => createNewSession()}
|
||||||
onClearSessions={clearAllSessions}
|
|
||||||
onSwitchSession={switchSession}
|
onSwitchSession={switchSession}
|
||||||
onRenameSession={renameSession}
|
onRenameSession={renameSession}
|
||||||
onDeleteSession={deleteSession}
|
onDeleteSession={deleteSession}
|
||||||
@ -1454,23 +1679,37 @@ function AppContent() {
|
|||||||
|
|
||||||
<main className="main-panel min-w-0">
|
<main className="main-panel min-w-0">
|
||||||
<header className="topbar border-b bg-background/95 px-3 py-2 backdrop-blur">
|
<header className="topbar border-b bg-background/95 px-3 py-2 backdrop-blur">
|
||||||
<nav className="flex min-w-0 flex-1 gap-1 overflow-x-auto">
|
<div className="topbar-left">
|
||||||
{VIEW_ITEMS.map((item) => {
|
<Button
|
||||||
const Icon = item.icon
|
type="button"
|
||||||
return (
|
variant="ghost"
|
||||||
<Button
|
size="icon-sm"
|
||||||
key={item.key}
|
className="sidebar-toggle-button"
|
||||||
type="button"
|
title={sidebarOpen ? "收起会话列表" : "展开会话列表"}
|
||||||
variant={activeView === item.key ? "secondary" : "ghost"}
|
aria-label={sidebarOpen ? "收起会话列表" : "展开会话列表"}
|
||||||
size="sm"
|
aria-expanded={sidebarOpen}
|
||||||
onClick={() => setActiveView(item.key)}
|
onClick={() => setSidebarOpen((open) => !open)}
|
||||||
>
|
>
|
||||||
<Icon />
|
<PanelLeft />
|
||||||
{item.label}
|
</Button>
|
||||||
</Button>
|
<nav className="flex min-w-0 flex-1 gap-1 overflow-x-auto">
|
||||||
)
|
{VIEW_ITEMS.map((item) => {
|
||||||
})}
|
const Icon = item.icon
|
||||||
</nav>
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
variant={activeView === item.key ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveView(item.key)}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge tone={configIsValid ? "success" : "warning"}>
|
<Badge tone={configIsValid ? "success" : "warning"}>
|
||||||
{configIsValid ? "配置有效" : "待配置"}
|
{configIsValid ? "配置有效" : "待配置"}
|
||||||
|
|||||||
@ -173,6 +173,18 @@ export function ChatView({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: ChatViewProps) {
|
}: ChatViewProps) {
|
||||||
const canSend = chatInput.trim().length > 0 && !isBusy
|
const canSend = chatInput.trim().length > 0 && !isBusy
|
||||||
|
const messagesScrollRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const element = messagesScrollRef.current
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep session switching predictable by jumping to the latest message.
|
||||||
|
element.scrollTop = element.scrollHeight
|
||||||
|
}, [currentSession.id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-view chat-view">
|
<section className="content-view chat-view">
|
||||||
@ -279,7 +291,10 @@ export function ChatView({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="messages-scroll min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div
|
||||||
|
ref={messagesScrollRef}
|
||||||
|
className="messages-scroll min-h-0 flex-1 overflow-y-auto px-4 py-4"
|
||||||
|
>
|
||||||
{currentSession.messages.length === 0 ? (
|
{currentSession.messages.length === 0 ? (
|
||||||
<div className="empty-state mx-auto flex max-w-2xl flex-col gap-4 py-16 text-center">
|
<div className="empty-state mx-auto flex max-w-2xl flex-col gap-4 py-16 text-center">
|
||||||
<div className="mx-auto flex size-12 items-center justify-center rounded-xl border bg-muted">
|
<div className="mx-auto flex size-12 items-center justify-center rounded-xl border bg-muted">
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
import { Bot, Edit3, Plus, Search, Trash2 } from "lucide-react"
|
import type * as React from "react"
|
||||||
|
import { Edit3, Plus, Search, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChatSession, RequestState } from "@/types"
|
import type {
|
||||||
|
ChatSession,
|
||||||
|
DeepSearchState,
|
||||||
|
MessageRole,
|
||||||
|
RequestState,
|
||||||
|
} from "@/types"
|
||||||
|
|
||||||
|
const ROLE_TEXT: Record<MessageRole, string> = {
|
||||||
|
user: "你",
|
||||||
|
assistant: "助手",
|
||||||
|
tool: "工具",
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string) {
|
||||||
return new Intl.DateTimeFormat("zh-CN", {
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
@ -22,9 +34,13 @@ type AppSidebarProps = {
|
|||||||
sessions: ChatSession[]
|
sessions: ChatSession[]
|
||||||
currentSession: ChatSession
|
currentSession: ChatSession
|
||||||
search: string
|
search: string
|
||||||
|
deepSearch: DeepSearchState
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
|
onOpenDeepSearch: () => void
|
||||||
|
onDeepSearchOpenChange: (open: boolean) => void
|
||||||
|
onDeepSearchQueryChange: (value: string) => void
|
||||||
|
onRunDeepSearch: (query: string) => void
|
||||||
onNewSession: () => void
|
onNewSession: () => void
|
||||||
onClearSessions: () => void
|
|
||||||
onSwitchSession: (sessionId: string) => void
|
onSwitchSession: (sessionId: string) => void
|
||||||
onRenameSession: (sessionId: string) => void
|
onRenameSession: (sessionId: string) => void
|
||||||
onDeleteSession: (sessionId: string) => void
|
onDeleteSession: (sessionId: string) => void
|
||||||
@ -34,107 +50,312 @@ export function AppSidebar({
|
|||||||
sessions,
|
sessions,
|
||||||
currentSession,
|
currentSession,
|
||||||
search,
|
search,
|
||||||
|
deepSearch,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
onOpenDeepSearch,
|
||||||
|
onDeepSearchOpenChange,
|
||||||
|
onDeepSearchQueryChange,
|
||||||
|
onRunDeepSearch,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onClearSessions,
|
|
||||||
onSwitchSession,
|
onSwitchSession,
|
||||||
onRenameSession,
|
onRenameSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar border-r bg-sidebar text-sidebar-foreground">
|
<>
|
||||||
<div className="flex items-center gap-2 border-b px-3 py-3">
|
<aside className="sidebar border-r bg-sidebar text-sidebar-foreground">
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
<div className="sidebar-header flex items-center justify-between border-b px-4 py-3">
|
||||||
<Bot className="size-4" />
|
<span className="text-sm font-semibold tracking-wide text-sidebar-foreground/80">
|
||||||
</div>
|
本地 AI 聊天面板
|
||||||
<div className="min-w-0 flex-1">
|
</span>
|
||||||
<div className="truncate text-sm font-semibold">
|
<Button
|
||||||
simple-llm-chat-ui
|
type="button"
|
||||||
</div>
|
size="icon-sm"
|
||||||
<div className="text-xs text-muted-foreground">本地 AI 聊天面板</div>
|
title="新建会话"
|
||||||
</div>
|
onClick={onNewSession}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
title="新建会话"
|
|
||||||
onClick={onNewSession}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 border-b p-3">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
|
||||||
placeholder="搜索会话"
|
|
||||||
className="pl-7"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="outline"
|
|
||||||
title="清空所有会话"
|
|
||||||
onClick={onClearSessions}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="session-list min-h-0 flex-1 overflow-y-auto p-2">
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<div
|
|
||||||
key={session.id}
|
|
||||||
className={cn(
|
|
||||||
"group relative mb-1 rounded-lg border border-transparent",
|
|
||||||
session.id === currentSession.id &&
|
|
||||||
"border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<button
|
<Plus />
|
||||||
type="button"
|
</Button>
|
||||||
className="w-full min-w-0 px-2.5 py-2 pr-16 text-left"
|
</div>
|
||||||
onClick={() => onSwitchSession(session.id)}
|
|
||||||
>
|
<div className="flex items-center gap-2 border-b p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative flex-1">
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
{session.title}
|
<Input
|
||||||
</span>
|
value={search}
|
||||||
{isBusy(session.status) ? (
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
<span className="size-2 rounded-full bg-amber-500" />
|
onKeyDown={(event) => {
|
||||||
) : null}
|
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||||
</div>
|
event.preventDefault()
|
||||||
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
onOpenDeepSearch()
|
||||||
<span>{formatDateTime(session.updatedAt)}</span>
|
}
|
||||||
<span>{session.messages.length} 条</span>
|
}}
|
||||||
</div>
|
placeholder="搜索会话"
|
||||||
</button>
|
title="普通搜索匹配标题;按 Enter 深度搜索"
|
||||||
<div className="pointer-events-none absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
className="pl-7"
|
||||||
<Button
|
/>
|
||||||
type="button"
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
title="重命名会话"
|
|
||||||
onClick={() => onRenameSession(session.id)}
|
|
||||||
>
|
|
||||||
<Edit3 />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
title="删除会话"
|
|
||||||
onClick={() => onDeleteSession(session.id)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
</aside>
|
size="icon-sm"
|
||||||
|
variant="outline"
|
||||||
|
title="清空搜索输入"
|
||||||
|
aria-label="清空搜索输入"
|
||||||
|
disabled={!search}
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="session-list min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={cn(
|
||||||
|
"group relative mb-1 rounded-lg border border-transparent",
|
||||||
|
session.id === currentSession.id &&
|
||||||
|
"border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full min-w-0 px-2.5 py-2 text-left"
|
||||||
|
onClick={() => onSwitchSession(session.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||||
|
{session.title}
|
||||||
|
</span>
|
||||||
|
{isBusy(session.status) ? (
|
||||||
|
<span className="size-2 rounded-full bg-amber-500" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{formatDateTime(session.updatedAt)}</span>
|
||||||
|
<span>{session.messages.length} 条</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="pointer-events-none absolute top-4 right-1 flex gap-1 opacity-0 transition-opacity -translate-y-1/2 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="重命名会话"
|
||||||
|
onClick={() => onRenameSession(session.id)}
|
||||||
|
>
|
||||||
|
<Edit3 />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
title="删除会话"
|
||||||
|
onClick={() => onDeleteSession(session.id)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{deepSearch.open ? (
|
||||||
|
<DeepSearchDialog
|
||||||
|
deepSearch={deepSearch}
|
||||||
|
onOpenChange={onDeepSearchOpenChange}
|
||||||
|
onQueryChange={onDeepSearchQueryChange}
|
||||||
|
onRunSearch={onRunDeepSearch}
|
||||||
|
onSwitchSession={onSwitchSession}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeepSearchDialogProps = {
|
||||||
|
deepSearch: DeepSearchState
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onQueryChange: (value: string) => void
|
||||||
|
onRunSearch: (query: string) => void
|
||||||
|
onSwitchSession: (sessionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeepSearchDialog({
|
||||||
|
deepSearch,
|
||||||
|
onOpenChange,
|
||||||
|
onQueryChange,
|
||||||
|
onRunSearch,
|
||||||
|
onSwitchSession,
|
||||||
|
}: DeepSearchDialogProps) {
|
||||||
|
const progress = deepSearch.total
|
||||||
|
? Math.round((deepSearch.scanned / deepSearch.total) * 100)
|
||||||
|
: deepSearch.status === "done"
|
||||||
|
? 100
|
||||||
|
: 0
|
||||||
|
const isSearching = deepSearch.status === "searching"
|
||||||
|
|
||||||
|
function openResult(sessionId: string) {
|
||||||
|
onSwitchSession(sessionId)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="deep-search-title"
|
||||||
|
className="flex max-h-[min(760px,92svh)] w-full max-w-2xl flex-col rounded-lg border bg-background text-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 id="deep-search-title" className="text-base font-semibold">
|
||||||
|
深度搜索历史记录
|
||||||
|
</h2>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{isSearching
|
||||||
|
? `已扫描 ${deepSearch.scanned} / ${deepSearch.total} 条消息`
|
||||||
|
: `${deepSearch.results.length} 个会话匹配`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
title="关闭"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={deepSearch.query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||||
|
event.preventDefault()
|
||||||
|
onRunSearch(deepSearch.query)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
placeholder="搜索历史记录"
|
||||||
|
className="pl-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onRunSearch(deepSearch.query)}
|
||||||
|
disabled={isSearching}
|
||||||
|
>
|
||||||
|
<Search />
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{!deepSearch.query.trim() ? (
|
||||||
|
<div className="rounded-lg border bg-muted/30 px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
输入关键词后开始搜索
|
||||||
|
</div>
|
||||||
|
) : deepSearch.results.length === 0 ? (
|
||||||
|
<div className="rounded-lg border bg-muted/30 px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{isSearching ? "正在扫描历史记录" : "没有找到匹配内容"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{deepSearch.results.map((result) => (
|
||||||
|
<button
|
||||||
|
key={result.sessionId}
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border bg-background px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 focus-visible:outline-none"
|
||||||
|
onClick={() => openResult(result.sessionId)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||||
|
{result.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{result.matchCount} 条匹配
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(result.updatedAt)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid gap-1.5">
|
||||||
|
{result.snippets.map((snippet) => (
|
||||||
|
<div
|
||||||
|
key={snippet.messageId}
|
||||||
|
className="rounded-md bg-muted/60 px-2 py-1.5 text-xs leading-5 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{ROLE_TEXT[snippet.role]}:
|
||||||
|
</span>
|
||||||
|
<HighlightedText
|
||||||
|
text={snippet.excerpt}
|
||||||
|
query={deepSearch.query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||||
|
const needle = query.trim()
|
||||||
|
|
||||||
|
if (!needle) {
|
||||||
|
return <>{text}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase()
|
||||||
|
const lowerNeedle = needle.toLowerCase()
|
||||||
|
const parts: React.ReactNode[] = []
|
||||||
|
let cursor = 0
|
||||||
|
let matchIndex = lowerText.indexOf(lowerNeedle)
|
||||||
|
|
||||||
|
while (matchIndex >= 0) {
|
||||||
|
if (matchIndex > cursor) {
|
||||||
|
parts.push(text.slice(cursor, matchIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCursor = matchIndex + needle.length
|
||||||
|
parts.push(
|
||||||
|
<mark
|
||||||
|
key={`${matchIndex}-${nextCursor}`}
|
||||||
|
className="rounded bg-amber-300/60 px-0.5 text-foreground dark:bg-amber-400/30"
|
||||||
|
>
|
||||||
|
{text.slice(matchIndex, nextCursor)}
|
||||||
|
</mark>
|
||||||
|
)
|
||||||
|
cursor = nextCursor
|
||||||
|
matchIndex = lowerText.indexOf(lowerNeedle, cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
parts.push(text.slice(cursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{parts}</>
|
||||||
|
}
|
||||||
|
|||||||
265
src/index.css
265
src/index.css
@ -6,128 +6,128 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-sans: 'Inter Variable', sans-serif;
|
--font-sans: "Inter Variable", sans-serif;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-size: calc(16px * var(--app-font-scale, 1));
|
font-size: calc(16px * var(--app-font-scale, 1));
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
@ -137,6 +137,14 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell-sidebar-collapsed {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-sidebar-collapsed .sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@ -156,6 +164,14 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.content-view {
|
.content-view {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -189,7 +205,13 @@
|
|||||||
border-radius: 0.875rem;
|
border-radius: 0.875rem;
|
||||||
background: #f3f3f3;
|
background: #f3f3f3;
|
||||||
color: #171717;
|
color: #171717;
|
||||||
box-shadow:rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2px -1px
|
box-shadow:
|
||||||
|
rgba(0, 0, 0, 0) 0px 0px 0px 0px,
|
||||||
|
rgba(0, 0, 0, 0) 0px 0px 0px 0px,
|
||||||
|
rgba(0, 0, 0, 0) 0px 0px 0px 0px,
|
||||||
|
rgba(0, 0, 0, 0) 0px 0px 0px 0px,
|
||||||
|
rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
|
||||||
|
rgba(0, 0, 0, 0.1) 0px 1px 2px -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble-assistant {
|
.message-bubble-assistant {
|
||||||
@ -314,19 +336,34 @@
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
height: auto;
|
height: 100svh;
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
max-height: 42svh;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
max-height: none;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:not(.app-shell-sidebar-collapsed) .sidebar-header {
|
||||||
|
padding-left: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:not(.app-shell-sidebar-collapsed) .sidebar-toggle-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-panel {
|
.main-panel {
|
||||||
min-height: 58svh;
|
height: 100svh;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
@ -335,6 +372,10 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-header-actions {
|
.chat-header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/types.ts
23
src/types.ts
@ -118,6 +118,29 @@ export type ChatMessage = {
|
|||||||
toolCalls?: ChatToolCall[]
|
toolCalls?: ChatToolCall[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeepSearchSnippet = {
|
||||||
|
messageId: string
|
||||||
|
role: MessageRole
|
||||||
|
excerpt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeepSearchResult = {
|
||||||
|
sessionId: string
|
||||||
|
title: string
|
||||||
|
updatedAt: string
|
||||||
|
matchCount: number
|
||||||
|
snippets: DeepSearchSnippet[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeepSearchState = {
|
||||||
|
open: boolean
|
||||||
|
query: string
|
||||||
|
status: "idle" | "searching" | "done"
|
||||||
|
scanned: number
|
||||||
|
total: number
|
||||||
|
results: DeepSearchResult[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ServiceConfig = {
|
export type ServiceConfig = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user