feat: 优化侧边栏,加入深度搜索

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
feie9456 2026-04-26 22:05:23 +08:00
parent 6c4c743e5a
commit 47e6da63e9
5 changed files with 784 additions and 245 deletions

View File

@ -4,6 +4,7 @@ import {
FileText,
Info,
MessageSquare,
PanelLeft,
Settings,
SlidersHorizontal,
} from "lucide-react"
@ -54,6 +55,8 @@ import type {
ChatMessage,
ChatSession,
ChatToolCall,
DeepSearchResult,
DeepSearchState,
ModelConfig,
ModelParameters,
PromptTemplate,
@ -106,10 +109,21 @@ const STATUS_TONE: Record<RequestState | "done", Notice["tone"]> = {
done: "success",
}
const DATA_SAVE_DELAY_MS = 800
const DEEP_SEARCH_BATCH_SIZE = 32
const DEEP_SEARCH_SNIPPET_LIMIT = 3
function isBusy(status: RequestState) {
return status === "requesting" || status === "generating"
}
function isMobileSidebarViewport() {
return (
typeof window !== "undefined" &&
window.matchMedia("(max-width: 900px)").matches
)
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
@ -118,6 +132,41 @@ function getErrorMessage(error: unknown) {
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(
source: AppData,
sessionId: string,
@ -249,8 +298,21 @@ function AppContent() {
const initialLoad = React.useMemo(() => loadAppData(), [])
const { setTheme } = useTheme()
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 [sidebarOpen, setSidebarOpen] = React.useState(
() => !isMobileSidebarViewport()
)
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 [editingMessageId, setEditingMessageId] = React.useState<string | null>(
null
@ -272,6 +334,7 @@ function AppContent() {
const settingsImportRef = React.useRef<HTMLInputElement>(null)
const allImportRef = React.useRef<HTMLInputElement>(null)
const activeRequestRef = React.useRef<ActiveRequest | null>(null)
const deepSearchRunRef = React.useRef(0)
const currentSession = React.useMemo(
() =>
@ -328,14 +391,63 @@ function AppContent() {
}, [data.prompts, promptSearch])
React.useEffect(() => {
const result = saveAppData(data)
latestDataRef.current = data
const hasBusySession = data.sessions.some((session) =>
isBusy(session.status)
)
if (!result.ok) {
const message = result.message
window.setTimeout(() => setStorageWarning(message), 0)
if (saveTimerRef.current !== undefined) {
window.clearTimeout(saveTimerRef.current)
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])
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(() => {
setTheme(data.settings.theme)
}, [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>) {
setData((previous) => ({
...previous,
@ -431,6 +656,10 @@ function AppContent() {
setChatInput("")
setEditingMessageId(null)
setActiveView("chat")
if (isMobileSidebarViewport()) {
setSidebarOpen(false)
}
}
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) {
setData((previous) => ({ ...previous, currentSessionId: sessionId }))
setEditingMessageId(null)
setActiveView("chat")
if (isMobileSidebarViewport()) {
setSidebarOpen(false)
}
}
function validateAndReport(session: ChatSession) {
@ -1437,6 +1657,7 @@ function AppContent() {
<div
className={cn(
"app-shell min-h-svh bg-background text-foreground",
!sidebarOpen && "app-shell-sidebar-collapsed",
data.settings.compactMode && "app-shell-compact"
)}
>
@ -1444,9 +1665,13 @@ function AppContent() {
sessions={filteredSessions}
currentSession={currentSession}
search={sessionSearch}
deepSearch={deepSearch}
onSearchChange={setSessionSearch}
onOpenDeepSearch={openDeepSearch}
onDeepSearchOpenChange={setDeepSearchOpen}
onDeepSearchQueryChange={updateDeepSearchQuery}
onRunDeepSearch={(query) => void runDeepSearch(query)}
onNewSession={() => createNewSession()}
onClearSessions={clearAllSessions}
onSwitchSession={switchSession}
onRenameSession={renameSession}
onDeleteSession={deleteSession}
@ -1454,23 +1679,37 @@ function AppContent() {
<main className="main-panel min-w-0">
<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">
{VIEW_ITEMS.map((item) => {
const Icon = item.icon
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 className="topbar-left">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="sidebar-toggle-button"
title={sidebarOpen ? "收起会话列表" : "展开会话列表"}
aria-label={sidebarOpen ? "收起会话列表" : "展开会话列表"}
aria-expanded={sidebarOpen}
onClick={() => setSidebarOpen((open) => !open)}
>
<PanelLeft />
</Button>
<nav className="flex min-w-0 flex-1 gap-1 overflow-x-auto">
{VIEW_ITEMS.map((item) => {
const Icon = item.icon
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">
<Badge tone={configIsValid ? "success" : "warning"}>
{configIsValid ? "配置有效" : "待配置"}

View File

@ -173,6 +173,18 @@ export function ChatView({
onOpenSettings,
}: ChatViewProps) {
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 (
<section className="content-view chat-view">
@ -279,7 +291,10 @@ export function ChatView({
</div>
) : 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 ? (
<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">

View File

@ -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 { Input } from "@/components/ui/input"
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) {
return new Intl.DateTimeFormat("zh-CN", {
@ -22,9 +34,13 @@ type AppSidebarProps = {
sessions: ChatSession[]
currentSession: ChatSession
search: string
deepSearch: DeepSearchState
onSearchChange: (value: string) => void
onOpenDeepSearch: () => void
onDeepSearchOpenChange: (open: boolean) => void
onDeepSearchQueryChange: (value: string) => void
onRunDeepSearch: (query: string) => void
onNewSession: () => void
onClearSessions: () => void
onSwitchSession: (sessionId: string) => void
onRenameSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
@ -34,107 +50,312 @@ export function AppSidebar({
sessions,
currentSession,
search,
deepSearch,
onSearchChange,
onOpenDeepSearch,
onDeepSearchOpenChange,
onDeepSearchQueryChange,
onRunDeepSearch,
onNewSession,
onClearSessions,
onSwitchSession,
onRenameSession,
onDeleteSession,
}: AppSidebarProps) {
return (
<aside className="sidebar border-r bg-sidebar text-sidebar-foreground">
<div className="flex items-center gap-2 border-b px-3 py-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Bot className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">
simple-llm-chat-ui
</div>
<div className="text-xs text-muted-foreground"> AI </div>
</div>
<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"
)}
<>
<aside className="sidebar border-r bg-sidebar text-sidebar-foreground">
<div className="sidebar-header flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-semibold tracking-wide text-sidebar-foreground/80">
AI
</span>
<Button
type="button"
size="icon-sm"
title="新建会话"
onClick={onNewSession}
>
<button
type="button"
className="w-full min-w-0 px-2.5 py-2 pr-16 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-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">
<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>
<Plus />
</Button>
</div>
<div className="flex items-center 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)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault()
onOpenDeepSearch()
}
}}
placeholder="搜索会话"
title="普通搜索匹配标题;按 Enter 深度搜索"
className="pl-7"
/>
</div>
))}
</div>
</aside>
<Button
type="button"
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}</>
}

View File

@ -6,128 +6,128 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-heading: var(--font-sans);
--font-sans: "Inter Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
}
body {
@apply bg-background text-foreground;
font-size: calc(16px * var(--app-font-scale, 1));
}
}
html {
@apply font-sans;
}
}
}
.app-shell {
@ -137,6 +137,14 @@
overflow: hidden;
}
.app-shell-sidebar-collapsed {
grid-template-columns: minmax(0, 1fr);
}
.app-shell-sidebar-collapsed .sidebar {
display: none;
}
.sidebar {
display: flex;
min-height: 0;
@ -156,6 +164,14 @@
gap: 0.75rem;
}
.topbar-left {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.5rem;
}
.content-view {
min-height: 0;
flex: 1;
@ -189,7 +205,13 @@
border-radius: 0.875rem;
background: #f3f3f3;
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 {
@ -314,19 +336,34 @@
@media (max-width: 900px) {
.app-shell {
grid-template-columns: minmax(0, 1fr);
height: auto;
height: 100svh;
min-height: 100svh;
overflow: visible;
overflow: hidden;
}
.sidebar {
max-height: 42svh;
position: fixed;
inset: 0;
z-index: 40;
max-height: none;
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 {
min-height: 58svh;
height: 100svh;
min-height: 0;
}
.topbar,
@ -335,6 +372,10 @@
flex-direction: column;
}
.topbar-left {
width: 100%;
}
.chat-header-actions {
width: 100%;
}
@ -346,4 +387,4 @@
.message-bubble {
max-width: 100%;
}
}
}

View File

@ -118,6 +118,29 @@ export type ChatMessage = {
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 = {
id: string
name: string