feat(web): 对齐 deepseek-harness 设计语言,重构前端视觉与三栏布局

- 设计 token 重映射为 DeepSeek 调色板(深浅双主题),正文改无衬线字体栈,全面去霓虹化
- 移除顶部 Header,控件迁入左栏(Logo/连接/通道/会话/tabs/底部操作区),左右栏支持拖拽调宽并持久化,折叠为图标 rail
- 对话区对齐 DeepSeek 聊天风:hero/docked 状态机、736px 居中列、用户右对齐柔和气泡、助手无气泡平铺、composer 卡片与反差发送键;子智能体点击导航完整保留
- 全功能区同步换肤:侧栏列表、右栏面板、ConfigPage、弹窗、选择器等
- fix: 修复 virtual-core 3.17.x 陈旧行高导致消息行重叠(宽度变化/滚动停止后强制 resizeItem 重测)
- fix: 修复 ConfigPage/Modal 引用不存在的 fadeIn/scaleIn keyframes 致入场动画失效
This commit is contained in:
oudecheng 2026-08-14 19:15:45 +08:00
parent 4c21add43b
commit eaee29841d
30 changed files with 868 additions and 696 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
import {
Zap,
ArrowLeft,
@ -11,6 +11,8 @@ import {
Brain,
Settings as SettingsIcon,
ChevronRight,
MessageSquare,
Calendar,
} from 'lucide-react';
import { ChatContainer } from './components/Chat/ChatContainer';
import { TopicList } from './components/Sidebar/TopicList';
@ -200,6 +202,75 @@ function App() {
// 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态
const [settingsClosedTick, setSettingsClosedTick] = useState(0);
// ---- 三栏列宽(拖拽调宽,对齐 harness AppFrame----
const [sidebarWidth, setSidebarWidth] = useState(() => {
const parsed = parseInt(localStorage.getItem('picobot-sidebar-width') ?? '', 10);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 216), 400) : 288;
});
const [rightPanelWidth, setRightPanelWidth] = useState(() => {
const parsed = parseInt(localStorage.getItem('picobot-right-panel-width') ?? '', 10);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 240), 420) : 320;
});
// 拖拽期间禁用 width transition避免面板"追鼠标"延迟感
const [isResizing, setIsResizing] = useState(false);
const startSidebarDrag = useCallback(
(e: ReactMouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = sidebarWidth;
setIsResizing(true);
const onMove = (ev: MouseEvent) => {
setSidebarWidth(Math.min(Math.max(startWidth + ev.clientX - startX, 216), 400));
};
const onUp = (ev: MouseEvent) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setIsResizing(false);
localStorage.setItem(
'picobot-sidebar-width',
String(Math.min(Math.max(startWidth + ev.clientX - startX, 216), 400)),
);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[sidebarWidth],
);
const startRightPanelDrag = useCallback(
(e: ReactMouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = rightPanelWidth;
setIsResizing(true);
const onMove = (ev: MouseEvent) => {
setRightPanelWidth(Math.min(Math.max(startWidth - (ev.clientX - startX), 240), 420));
};
const onUp = (ev: MouseEvent) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setIsResizing(false);
localStorage.setItem(
'picobot-right-panel-width',
String(Math.min(Math.max(startWidth - (ev.clientX - startX), 240), 420)),
);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[rightPanelWidth],
);
const handleSaveConnection = useCallback((host: string, port: number) => {
setGatewaySettings({ host, port });
}, []);
@ -615,189 +686,222 @@ function App() {
}, [schedulerView, subAgentView, selectedTopic]);
return (
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
{/* Header */}
<header className="flex items-center justify-between border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md px-6 py-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Zap className="h-6 w-6 text-[var(--accent-cyan)]" />
<h1 className="text-xl font-bold tracking-tight">
<span className="text-[var(--text-primary)]">Pico</span>
<span className="text-[var(--accent-cyan)] glow-text">Bot</span>
</h1>
{appVersion && (
<span className="text-xs font-mono text-[var(--text-muted)] select-none">
v{appVersion}
</span>
)}
</div>
<div className="h-4 w-px bg-[var(--divider-color)]" />
<ConnectionStatus status={status} />
{/* 主题切换按钮 */}
<div className="h-4 w-px bg-[var(--divider-color)]" />
<button
onClick={() => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all"
title={theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
onClick={() => setShowThinking((prev) => !prev)}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
showThinking
? 'text-purple-400 hover:text-purple-300 bg-purple-500/10 hover:bg-purple-500/20'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
title={showThinking ? '隐藏思考过程' : '显示思考过程'}
aria-label="Toggle thinking display"
>
<Brain className="h-4 w-4" />
</button>
<button
onClick={() => {
setConfigInitialTab('providers');
setConfigPageOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all"
title="系统配置"
aria-label="System config"
>
<SettingsIcon className="h-4 w-4" />
</button>
<div className="flex h-screen overflow-hidden bg-[var(--bg-primary)] text-[var(--text-primary)]">
{/* 左栏 — harness 风格 shellLogo/连接/通道/会话/列表/底部操作区) */}
<aside
className={`relative shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col overflow-hidden ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}
style={{
width: sidebarCollapsed ? 56 : sidebarWidth,
transition: isResizing ? 'none' : 'width 200ms ease-out',
willChange: 'width',
}}
>
{/* Logo 行 */}
<div className="flex h-12 shrink-0 items-center gap-2 px-3">
{sidebarCollapsed ? (
<button
onClick={toggleSidebar}
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-[var(--overlay-hover)] transition-colors"
title="展开侧栏"
aria-label="Expand sidebar"
>
<Zap className="h-5 w-5 text-[var(--accent-cyan)]" />
</button>
) : (
<>
<Zap className="h-5 w-5 shrink-0 text-[var(--accent-cyan)]" />
<h1 className="min-w-0 flex-1 truncate text-[15px] font-semibold tracking-tight">
<span className="text-[var(--text-primary)]">Pico</span>
<span className="text-[var(--accent-cyan)]">Bot</span>
</h1>
{appVersion && (
<span className="shrink-0 text-xs text-[var(--text-muted)] select-none">
v{appVersion}
</span>
)}
<button
onClick={toggleSidebar}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--text-primary)] transition-colors"
title="收起侧栏"
aria-label="Collapse sidebar"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</>
)}
</div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
<ChannelSelector
channels={channels}
selectedChannel={selectedChannel}
onSelectChannel={handleSwitchChannel}
/>
<SessionSelector
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelectSession={handleSelectSession}
/>
</div>
</header>
{/* Main Content */}
<div className="flex flex-1 overflow-hidden relative">
{/* Left Sidebar — smooth width animation via will-change + overflow-hidden */}
{sidebarCollapsed ? (
/* 折叠 rail */
<div className="flex flex-1 flex-col items-center gap-1 px-1 pt-1">
<button
onClick={() => {
setSidebarTab('topics');
setSidebarCollapsed(false);
localStorage.setItem('picobot-sidebar-collapsed', 'false');
}}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
sidebarTab === 'topics'
? 'text-[var(--accent-cyan)] bg-[var(--overlay-subtle)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)]'
}`}
title="话题"
>
<MessageSquare className="h-4 w-4" />
</button>
<button
onClick={() => {
setSidebarTab('scheduler');
setSidebarCollapsed(false);
localStorage.setItem('picobot-sidebar-collapsed', 'false');
}}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] bg-[var(--overlay-subtle)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)]'
}`}
title="定时任务"
>
<Calendar className="h-4 w-4" />
</button>
<div className="flex-1" />
<button
onClick={() => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title={theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
onClick={() => {
setConfigInitialTab('providers');
setConfigPageOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="系统配置"
>
<SettingsIcon className="h-4 w-4" />
</button>
<div className="pb-3 pt-2">
<ConnectionStatus status={status} compact />
</div>
</div>
) : (
<>
<div className="shrink-0 px-3 pb-2">
<ConnectionStatus status={status} />
</div>
<div className="flex shrink-0 flex-col gap-1.5 px-3 pb-2 text-sm text-[var(--text-secondary)]">
<ChannelSelector
channels={channels}
selectedChannel={selectedChannel}
onSelectChannel={handleSwitchChannel}
/>
<SessionSelector
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelectSession={handleSelectSession}
/>
</div>
{/* Tab 栏 */}
<div className="flex shrink-0 border-b border-[var(--border-color)] px-3">
<button
onClick={() => setSidebarTab('topics')}
className={`flex-1 border-b-2 py-2 text-center text-sm transition-colors whitespace-nowrap ${
sidebarTab === 'topics'
? 'border-[var(--accent-cyan)] font-medium text-[var(--text-primary)]'
: 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button
onClick={() => setSidebarTab('scheduler')}
className={`flex-1 border-b-2 py-2 text-center text-sm transition-colors whitespace-nowrap ${
sidebarTab === 'scheduler'
? 'border-[var(--accent-cyan)] font-medium text-[var(--text-primary)]'
: 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
</div>
<div className="flex-1 overflow-hidden">
{sidebarTab === 'topics' ? (
<TopicList
sessionId={sessionId}
topics={topics}
currentTopicId={selectedTopic}
isReadOnly={isReadOnly}
onCreateTopic={handleCreateTopic}
onRefresh={handleRefreshTopics}
onSwitchTopic={handleSwitchTopic}
onDeleteTopic={handleDeleteTopic}
onRenameTopic={handleRenameTopic}
/>
) : (
<SchedulerJobList
jobs={schedulerJobs}
onRefresh={handleRefreshSchedulerJobs}
onViewJob={handleViewSchedulerJob}
sessionId={sessionId}
/>
)}
</div>
{/* 底部操作区:思考 / 主题 / 配置 */}
<div className="flex shrink-0 items-center gap-1 border-t border-[var(--border-color)] px-3 py-2">
<button
onClick={() => setShowThinking((prev) => !prev)}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
showThinking
? 'text-[var(--accent-purple)] bg-[var(--accent-purple)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)]'
}`}
title={showThinking ? '隐藏思考过程' : '显示思考过程'}
aria-label="Toggle thinking display"
>
<Brain className="h-4 w-4" />
</button>
<button
onClick={() => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title={theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<div className="flex-1" />
<button
onClick={() => {
setConfigInitialTab('providers');
setConfigPageOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="系统配置"
aria-label="System config"
>
<SettingsIcon className="h-4 w-4" />
</button>
</div>
</>
)}
</aside>
{/* 左栏拖拽调宽手柄 */}
{!sidebarCollapsed && (
<div
className={`shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${sidebarCollapsed ? 'w-11' : 'w-72'} ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}
style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
>
{/* Tab 栏 + collapse toggle */}
<div
className="flex border-b border-[var(--border-color)]"
style={{ minWidth: sidebarCollapsed ? 0 : '288px' }}
>
{sidebarCollapsed ? (
/* 收起态:紧凑竖排 tab 按钮 */
<div className="flex flex-col w-full">
<button
onClick={() => {
setSidebarTab('topics');
setSidebarCollapsed(false);
localStorage.setItem('picobot-sidebar-collapsed', 'false');
}}
className={`py-2 text-xs font-medium text-center transition-colors ${
sidebarTab === 'topics'
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
title="话题"
>
<br />
</button>
<button
onClick={() => {
setSidebarTab('scheduler');
setSidebarCollapsed(false);
localStorage.setItem('picobot-sidebar-collapsed', 'false');
}}
className={`py-2 text-xs font-medium text-center transition-colors ${
sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
title="定时任务"
>
<br />
</button>
{/* 展开按钮 */}
<button
onClick={toggleSidebar}
className="flex items-center justify-center py-2 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="展开侧栏"
>
<PanelLeftOpen className="h-3.5 w-3.5" />
</button>
</div>
) : (
<>
<button
onClick={() => setSidebarTab('topics')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors whitespace-nowrap ${
sidebarTab === 'topics'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button
onClick={() => setSidebarTab('scheduler')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors whitespace-nowrap ${
sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
{/* 收起按钮 */}
<button
onClick={toggleSidebar}
className="flex items-center justify-center px-2 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors shrink-0"
title="收起侧栏"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</>
)}
</div>
onMouseDown={startSidebarDrag}
className="w-1.5 shrink-0 cursor-col-resize transition-colors hover:bg-[var(--accent-cyan)]/40 active:bg-[var(--accent-cyan)]/60"
role="separator"
aria-orientation="vertical"
/>
)}
<div
className="flex-1 overflow-hidden"
style={{ minWidth: sidebarCollapsed ? 0 : '288px' }}
>
{sidebarCollapsed ? null : sidebarTab === 'topics' ? (
<TopicList
sessionId={sessionId}
topics={topics}
currentTopicId={selectedTopic}
isReadOnly={isReadOnly}
onCreateTopic={handleCreateTopic}
onRefresh={handleRefreshTopics}
onSwitchTopic={handleSwitchTopic}
onDeleteTopic={handleDeleteTopic}
onRenameTopic={handleRenameTopic}
/>
) : (
<SchedulerJobList
jobs={schedulerJobs}
onRefresh={handleRefreshSchedulerJobs}
onViewJob={handleViewSchedulerJob}
sessionId={sessionId}
/>
)}
</div>
</div>
{/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[var(--bg-primary)] flex flex-col">
{/* 中栏 — 对话区 */}
<div className="flex min-w-0 flex-1 flex-col bg-[var(--bg-primary)]">
{/* Scheduler job view back bar */}
{schedulerView && (
<div className="shrink-0 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 px-4 py-2 flex items-center gap-4">
@ -810,7 +914,7 @@ function App() {
</button>
<div className="h-4 w-px bg-[var(--divider-color)]" />
<div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]">
<Clock className="h-4 w-4 text-amber-400" />
<Clock className="h-4 w-4 text-[var(--accent-amber)]" />
<span className="text-[var(--text-muted)]">:</span>
<span className="text-[var(--text-primary)] font-medium font-mono text-xs truncate max-w-[250px]">
{schedulerView.description}
@ -861,13 +965,13 @@ function App() {
: level.status;
const statusColor =
level.status === 'completed'
? 'text-emerald-400'
? 'text-[var(--accent-green)]'
: level.status === 'failed'
? 'text-red-400'
? 'text-[rgb(242,90,90)]'
: level.status === 'timeout'
? 'text-amber-400'
? 'text-[var(--accent-amber)]'
: level.status === 'running'
? 'text-amber-400'
? 'text-[var(--accent-amber)]'
: 'text-[var(--text-secondary)]';
return (
<div key={level.taskId} className="flex items-center gap-2 min-w-0">
@ -935,11 +1039,25 @@ function App() {
</div>
</div>
{/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */}
{/* 右栏拖拽调宽手柄 */}
{!rightSidebarCollapsed && (
<div
className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${rightSidebarCollapsed ? 'w-11' : 'w-80'}`}
style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
>
onMouseDown={startRightPanelDrag}
className="w-1.5 shrink-0 cursor-col-resize transition-colors hover:bg-[var(--accent-cyan)]/40 active:bg-[var(--accent-cyan)]/60"
role="separator"
aria-orientation="vertical"
/>
)}
{/* 右栏 — Token 统计 / 待办 / 记忆 / 技能(可折叠) */}
<div
className="relative shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col overflow-hidden"
style={{
width: rightSidebarCollapsed ? 44 : rightPanelWidth,
transition: isResizing ? 'none' : 'width 200ms ease-out',
willChange: 'width',
}}
>
{rightSidebarCollapsed ? (
// 折叠态:窄条 + 展开按钮
<button
@ -950,7 +1068,7 @@ function App() {
<PanelLeftOpen className="h-4 w-4 rotate-180" />
</button>
) : (
<div className="w-80 h-full flex flex-col">
<div className="flex h-full min-w-0 flex-col">
{/* Token 用量面板(主代理/子代理共用,按视图切换数据源) */}
<TopicTokenStatsPanel
tokenStats={subAgentView ? subAgentView.tokenStats : currentTopic?.tokenStats}
@ -1020,7 +1138,6 @@ function App() {
</div>
)}
</div>
</div>
{/* 系统配置页面 */}
{configPageOpen && (

View File

@ -3,6 +3,7 @@ import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector';
import { ModelSelector } from './ModelSelector';
import { Zap } from 'lucide-react';
import type { ChatMessage, Attachment } from '../../types/protocol';
interface ChatContainerProps {
@ -49,36 +50,72 @@ export function ChatContainer({
description: string;
} | null>(null);
return (
<div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative">
<MessageList
messages={messages}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
/>
</div>
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
<ExpertSelector
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick}
/>
<ModelSelector sessionId={sessionId ?? null} settingsClosedTick={settingsClosedTick} />
</div>
<MessageInput
onSend={onSendMessage}
onStop={onStop}
disabled={isLoading}
isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={channelName}
selectedExpert={selectedExpert}
topicId={topicId}
const selectors = (
<div className="flex flex-wrap items-center gap-1 px-3 pt-2">
<ExpertSelector
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick}
/>
<ModelSelector sessionId={sessionId ?? null} settingsClosedTick={settingsClosedTick} />
</div>
);
const input = (
<MessageInput
onSend={onSendMessage}
onStop={onStop}
disabled={isLoading}
isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={channelName}
selectedExpert={selectedExpert}
topicId={topicId}
/>
);
/* Hero + +
composer remount
稿 */
const isHero = messages.length === 0 && !isLoading && !isReadOnly;
return (
<div className="relative flex h-full w-full flex-col">
{isHero && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(closest-side at 50% 42%, color-mix(in srgb, var(--accent-cyan) 9%, transparent), transparent)',
}}
/>
)}
{isHero ? (
<div className="relative flex flex-1 flex-col items-center justify-center px-6 pb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]">
<Zap className="h-6 w-6 text-[var(--accent-cyan)]" />
</div>
<h1 className="mt-4 text-[26px] font-semibold text-[var(--text-primary)]">PicoBot</h1>
<p className="mt-1 text-sm text-[var(--text-muted)]"></p>
</div>
) : (
<div className="relative flex-1 overflow-hidden">
<MessageList
messages={messages}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
/>
</div>
)}
<div className="relative shrink-0 px-6 pb-4">
<div className={`mx-auto w-full ${isHero ? 'max-w-[680px]' : 'max-w-[736px]'}`}>
{selectors}
{input}
</div>
</div>
</div>
);
}

View File

@ -256,7 +256,7 @@ export function ExpertSelector({
</div>
)}
</div>
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
{error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>}
</div>
);
}

View File

@ -1,28 +1,23 @@
import { useState, useEffect, memo } from 'react';
import {
User,
Bot,
Wrench,
CheckCircle,
AlertCircle,
Terminal,
File,
Image,
FileText,
Music,
Video,
Download,
ChevronDown,
ChevronRight,
Copy,
Check,
Loader2,
XCircle,
ChevronDown,
Clock,
Loader,
X,
Loader2,
XCircle,
Copy,
Check,
Brain,
Maximize2,
Download,
File,
FileText,
Image,
Music,
Video,
X,
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@ -289,7 +284,7 @@ function CopyButton({ text, className = '' }: { text: string; className?: string
title="复制"
>
{copied ? (
<Check className="h-3 w-3 text-emerald-400" />
<Check className="h-3 w-3 text-[var(--accent-green)]" />
) : (
<Copy className="h-3 w-3 text-[var(--text-muted)]" />
)}
@ -385,25 +380,25 @@ export const MessageBubble = memo(function MessageBubble({
const statusConfig = {
calling: {
dot: 'bg-amber-400 animate-pulse',
fullBorder: 'border-amber-500/30',
iconColor: 'text-amber-400',
avatarBg: 'bg-amber-500/20',
avatarIcon: 'text-amber-400',
dot: 'bg-[var(--accent-cyan)] animate-pulse',
fullBorder: 'border-[var(--accent-cyan)]/30',
iconColor: 'text-[var(--accent-cyan)]',
avatarBg: 'bg-[var(--accent-cyan)]/10',
avatarIcon: 'text-[var(--accent-cyan)]',
},
result: {
dot: 'bg-emerald-400',
fullBorder: 'border-emerald-500/30',
iconColor: 'text-emerald-400',
avatarBg: 'bg-emerald-500/20',
avatarIcon: 'text-emerald-400',
dot: 'bg-[var(--accent-green)]',
fullBorder: 'border-[var(--accent-green)]/30',
iconColor: 'text-[var(--accent-green)]',
avatarBg: 'bg-[var(--accent-green)]/10',
avatarIcon: 'text-[var(--accent-green)]',
},
pending: {
dot: 'bg-orange-400 animate-pulse',
fullBorder: 'border-orange-500/30',
iconColor: 'text-orange-400',
avatarBg: 'bg-orange-500/20',
avatarIcon: 'text-orange-400',
dot: 'bg-[var(--accent-amber)] animate-pulse',
fullBorder: 'border-[var(--accent-amber)]/30',
iconColor: 'text-[var(--accent-amber)]',
avatarBg: 'bg-[var(--accent-amber)]/10',
avatarIcon: 'text-[var(--accent-amber)]',
},
}[status];
@ -450,35 +445,39 @@ export const MessageBubble = memo(function MessageBubble({
// - interrupted: 子代理因服务器重启被中断
const taskStatusConfig: Record<string, { dot: string; borderColor: string; iconColor: string }> = {
running: {
dot: 'bg-amber-400 animate-pulse',
borderColor: 'border-amber-500/40',
iconColor: 'text-amber-400',
dot: 'bg-[var(--accent-amber)] animate-pulse',
borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-[var(--accent-amber)]',
},
success: {
dot: 'bg-emerald-400',
borderColor: 'border-emerald-500/40',
iconColor: 'text-emerald-400',
dot: 'bg-[var(--accent-green)]',
borderColor: 'border-[var(--accent-green)]/40',
iconColor: 'text-[var(--accent-green)]',
},
completed: {
dot: 'bg-emerald-400',
borderColor: 'border-emerald-500/40',
iconColor: 'text-emerald-400',
dot: 'bg-[var(--accent-green)]',
borderColor: 'border-[var(--accent-green)]/40',
iconColor: 'text-[var(--accent-green)]',
},
failed: {
dot: 'bg-[rgb(242,90,90)]',
borderColor: 'border-[rgb(242,90,90)]/40',
iconColor: 'text-[rgb(242,90,90)]',
},
failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' },
timeout: {
dot: 'bg-amber-400',
borderColor: 'border-amber-500/40',
iconColor: 'text-amber-400',
dot: 'bg-[var(--accent-amber)]',
borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-[var(--accent-amber)]',
},
cancelled: {
dot: 'bg-zinc-400',
borderColor: 'border-zinc-500/40',
iconColor: 'text-zinc-400',
dot: 'bg-[var(--text-muted)]',
borderColor: 'border-[var(--border-color)]',
iconColor: 'text-[var(--text-muted)]',
},
interrupted: {
dot: 'bg-orange-400',
borderColor: 'border-orange-500/40',
iconColor: 'text-orange-400',
dot: 'bg-[var(--accent-amber)]',
borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-[var(--accent-amber)]',
},
};
@ -486,38 +485,13 @@ export const MessageBubble = memo(function MessageBubble({
const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null;
return (
<div data-message-id={message.id} className="flex gap-3 animate-slide-in">
<div data-message-id={message.id} className="animate-slide-in">
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${
isTaskTool ? 'bg-violet-500/20' : statusConfig.avatarBg
onClick={() => setToolExpanded(!toolExpanded)}
className={`group w-full cursor-pointer rounded-xl border bg-[var(--bg-secondary)]/60 transition-colors duration-300 hover:bg-[var(--bg-secondary)] ${
taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
}`}
>
{isTaskTool ? (
<Bot className="h-3.5 w-3.5 text-violet-400" />
) : (
<Terminal className={`h-3.5 w-3.5 ${statusConfig.avatarIcon}`} />
)}
</div>
<div className="max-w-[80%] min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-[var(--text-secondary)]">
{message.toolName || 'Tool'}
</span>
{isTaskTool && (
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400">
·{subagentType}
</span>
)}
<span className="text-xs text-[var(--text-muted)]">
{formatTime(message.timestamp)}
</span>
</div>
<div
onClick={() => setToolExpanded(!toolExpanded)}
className={`cursor-pointer rounded-xl border bg-[var(--bg-tertiary)]/60 w-full transition-all duration-500 hover:bg-[var(--bg-tertiary)]/80 group ${
taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
}`}
>
{/* Header row */}
<div className="flex items-center gap-2 px-3 py-2">
<span
@ -660,10 +634,10 @@ export const MessageBubble = memo(function MessageBubble({
<div
className={`rounded-lg px-3 py-2 ${
taskResult.status === 'success'
? 'bg-emerald-500/10 border border-emerald-500/30'
? 'bg-[var(--accent-green)]/10 border border-[var(--accent-green)]/30'
: taskResult.status === 'failed'
? 'bg-red-500/10 border border-red-500/30'
: 'bg-amber-500/10 border border-amber-500/30'
? 'bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/30'
: 'bg-[var(--accent-amber)]/10 border border-[var(--accent-amber)]/30'
}`}
>
<div className="text-xs font-medium text-[var(--text-muted)] mb-0.5">
@ -742,7 +716,15 @@ export const MessageBubble = memo(function MessageBubble({
)}
</div>
)}
</div>
</div>
<div className="mt-1 flex items-center gap-2 px-1">
<span className="text-xs text-[var(--text-muted)]">{message.toolName || 'Tool'}</span>
{isTaskTool && (
<span className="rounded bg-[var(--accent-purple)]/10 px-1.5 py-0.5 text-xs text-[var(--accent-purple)]">
·{subagentType}
</span>
)}
<span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
</div>
{lightboxElement}
{showDetailModal && (
@ -776,76 +758,39 @@ export const MessageBubble = memo(function MessageBubble({
return null;
}
const getIcon = () => {
if (isUser) return <User className="h-4 w-4" />;
if (isTool) {
if (message.type === 'tool_call') return <Terminal className="h-4 w-4" />;
if (message.type === 'tool_result') return <CheckCircle className="h-4 w-4" />;
if (message.type === 'tool_pending') return <AlertCircle className="h-4 w-4" />;
return <Wrench className="h-4 w-4" />;
}
return <Bot className="h-4 w-4" />;
};
const getContainerStyles = () => {
if (isUser) {
return 'bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 border-[var(--accent-cyan)]/30 text-[var(--text-primary)]';
}
if (isTool) {
if (message.type === 'tool_call') return 'bg-amber-500/10 border-amber-500/30 text-amber-100';
if (message.type === 'tool_result')
return 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100';
if (message.type === 'tool_pending')
return 'bg-orange-500/10 border-orange-500/30 text-orange-100';
return 'bg-[var(--bg-hover)] border-[var(--border-color)] text-[var(--text-secondary)]';
}
return 'bg-[var(--bg-tertiary)] border-[var(--border-color)] text-[var(--text-primary)]';
};
const getAvatarStyles = () => {
if (isUser) return 'bg-gradient-to-br from-[var(--accent-cyan)] to-[var(--accent-blue)]';
if (isTool) {
if (message.type === 'tool_call') return 'bg-amber-500';
if (message.type === 'tool_result') return 'bg-emerald-500';
if (message.type === 'tool_pending') return 'bg-orange-500';
return 'bg-zinc-700';
}
return 'bg-gradient-to-br from-[var(--accent-purple)] to-[#ec4899]';
};
return (
<div
data-message-id={message.id}
className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}
className={`animate-slide-in group ${isUser ? 'flex flex-col items-end' : ''}`}
>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
>
{getIcon()}
</div>
<div className={`max-w-[80%] ${isUser ? 'text-right' : 'text-left'}`}>
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs font-medium ${isUser ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'}`}
>
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
{!isUser && (
<div className="mb-1 flex items-center gap-2">
{isTool && message.toolName ? (
<span className="text-xs font-medium text-[var(--text-secondary)]">
{message.toolName}
</span>
) : null}
<span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
{formatTime(message.timestamp)}
</span>
<span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
<CopyButton text={message.content} />
</div>
<div
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
>
{isTool && message.toolName && (
<div className="mb-2 text-xs font-semibold opacity-70 flex items-center gap-1">
<Terminal className="h-3 w-3" />
{message.toolName}
</div>
)}
{isUser ? (
// 用户消息保持纯文本
<div className="whitespace-pre-wrap text-sm leading-relaxed">{message.content}</div>
) : (
)}
<div
className={
isUser
? 'max-w-[min(525px,82%)] rounded-[22px] bg-[var(--bubble-user)] px-4 py-2.5 text-left'
: 'w-full min-w-0'
}
>
{isUser ? (
// 用户消息保持纯文本(右对齐柔和气泡)
<div className="whitespace-pre-wrap text-[15px] leading-6 text-[var(--text-primary)]">
{message.content}
</div>
) : (
// 模型思考内容(仅助手消息,非工具消息)
<>
{showThinking && !isTool && message.reasoningContent && (
@ -855,7 +800,7 @@ export const MessageBubble = memo(function MessageBubble({
)}
{/* AI 和工具消息使用 Markdown 渲染 */}
{message.content.trim() && (
<div className="markdown-content text-sm leading-relaxed">
<div className="markdown-content text-[15px] leading-6">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@ -991,8 +936,15 @@ export const MessageBubble = memo(function MessageBubble({
))}
</div>
)}
</div>
</div>
{isUser && (
<div className="mt-1 flex items-center gap-2 px-1">
<span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
{formatTime(message.timestamp)}
</span>
<CopyButton text={message.content} />
</div>
)}
{lightboxElement}
</div>
);

View File

@ -2,7 +2,6 @@ import {
Send,
Loader2,
Square,
Sparkles,
Eye,
Paperclip,
X,
@ -284,23 +283,21 @@ export function MessageInput({
// 只读模式:显示提示占位符
if (isReadOnly) {
return (
<div className="border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md p-4">
<div className="max-w-5xl mx-auto">
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/50 px-4 py-5 text-center">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
<Eye className="h-5 w-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-[var(--text-muted)]">
{channelName ? (
<>{channelName} </>
) : (
'当前通道仅支持查看历史消息'
)}
</p>
<p className="text-xs text-[var(--text-muted)]"> WebSocket </p>
<div className="pt-2">
<div className="rounded-2xl border border-[var(--border-color)] bg-[var(--surface-input)] px-4 py-5 text-center shadow-sm">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
<Eye className="h-5 w-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-[var(--text-muted)]">
{channelName ? (
<>{channelName} </>
) : (
'当前通道仅支持查看历史消息'
)}
</p>
<p className="text-xs text-[var(--text-muted)]"> WebSocket </p>
</div>
</div>
</div>
@ -308,70 +305,87 @@ export function MessageInput({
}
return (
<div className="border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md p-4">
<div className="max-w-5xl mx-auto">
{/* 错误提示 */}
{error && (
<div className="mb-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
<div className="pt-2">
{/* 错误提示 */}
{error && (
<div className="mb-2 rounded-lg bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/25 px-3 py-2 text-sm text-[rgb(242,90,90)]">
{error}
</div>
)}
{/* 附件预览列表 */}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att, index) => (
<div
key={att.id}
className="flex items-center gap-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm"
{/* 附件预览列表 */}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att, index) => (
<div
key={att.id}
className="flex items-center gap-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm"
>
{att.preview ? (
<img
src={`data:${att.attachment.mime_type};base64,${att.preview}`}
alt={att.attachment.file_name}
className="h-8 w-8 rounded object-cover"
/>
) : (
<div className="h-8 w-8 rounded bg-[var(--bg-hover)] flex items-center justify-center text-[var(--text-secondary)]">
{getAttachmentIcon(att.attachment.media_type)}
</div>
)}
<span className="text-[var(--text-secondary)] max-w-[120px] truncate">
{att.attachment.file_name}
</span>
<button
onClick={() => handleRemoveAttachment(index)}
className="text-[var(--text-muted)] hover:text-[rgb(242,90,90)] transition-colors"
>
{att.preview ? (
<img
src={`data:${att.attachment.mime_type};base64,${att.preview}`}
alt={att.attachment.file_name}
className="h-8 w-8 rounded object-cover"
/>
) : (
<div className="h-8 w-8 rounded bg-[var(--bg-hover)] flex items-center justify-center text-[var(--text-secondary)]">
{getAttachmentIcon(att.attachment.media_type)}
</div>
)}
<span className="text-[var(--text-secondary)] max-w-[120px] truncate">
{att.attachment.file_name}
</span>
<button
onClick={() => handleRemoveAttachment(index)}
className="text-[var(--text-muted)] hover:text-red-400 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
))}
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
{/* Composer 卡片 */}
<div
className={`relative rounded-2xl border bg-[var(--surface-input)] shadow-sm transition-colors ${
isDragging
? 'border-[var(--border-accent)]'
: 'border-[var(--border-color)] focus-within:border-[var(--border-accent)]'
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 拖拽提示 */}
{isDragging && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl border border-[var(--border-accent)] bg-[var(--accent-cyan)]/10">
<div className="text-sm font-medium text-[var(--accent-cyan)]"></div>
</div>
)}
{/* 输入区域 */}
<div
className={`flex gap-3 items-center relative ${isDragging ? 'ring-2 ring-[var(--accent-cyan)]/50 rounded-xl' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 拖拽提示 */}
{isDragging && (
<div className="absolute inset-0 rounded-xl bg-[var(--accent-cyan)]/10 border-2 border-[var(--accent-cyan)]/50 flex items-center justify-center z-10">
<div className="text-[var(--accent-cyan)] text-sm font-medium"></div>
</div>
)}
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={effectivePlaceholder}
disabled={disabled}
rows={1}
className="composer-area w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[15px] leading-6 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] disabled:opacity-50 scrollbar-hide"
/>
{/* 附件按钮 */}
{/* 底部操作行 */}
<div className="flex items-center gap-1 px-2 pb-2">
<button
onClick={handleAttachClick}
disabled={disabled}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-color)] disabled:opacity-50 transition-all"
title="添加附件"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--text-primary)] disabled:opacity-50 transition-colors"
>
<Paperclip className="h-5 w-5" />
<Paperclip className="h-4 w-4" />
</button>
<input
ref={fileInputRef}
@ -380,51 +394,37 @@ export function MessageInput({
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
{/* 输入框 */}
<div className="flex-1 relative flex items-center">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={effectivePlaceholder}
disabled={disabled}
rows={1}
className="w-full resize-none rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-4 py-3 pr-12 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--focus-ring)] disabled:opacity-50 transition-all self-center scrollbar-hide"
/>
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--text-muted)] pointer-events-none" />
</div>
{/* 发送/停止按钮 */}
<div className="flex-1" />
{isLoading && onStop ? (
<button
onClick={onStop}
title="停止执行"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 hover:text-red-300 transition-all"
aria-label="Stop generating"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[var(--border-color)] text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[rgb(242,90,90)] transition-colors"
>
<Square className="h-4 w-4" />
<Square className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleSend}
disabled={disabled || (!content.trim() && attachments.length === 0)}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[var(--accent-cyan)] to-[var(--accent-blue)] text-white shadow-lg shadow-[var(--accent-cyan)]/20 hover:shadow-xl hover:shadow-[var(--accent-cyan)]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
title="发送"
aria-label="Send message"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--btn-contrast)] text-[var(--btn-contrast-fg)] hover:opacity-85 disabled:opacity-35 disabled:cursor-not-allowed transition-opacity"
>
{disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-4 w-4" />
<Send className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
</div>
{/* 提示 */}
<div className="mt-2 text-center text-xs text-[var(--text-muted)]">
Enter Shift+Enter · / · 50MB
</div>
{/* 提示 */}
<div className="mt-2 text-center text-xs text-[var(--text-muted)]">
Enter Shift+Enter · / · 50MB
</div>
</div>
);

View File

@ -182,25 +182,57 @@ export function MessageList({
});
}, [highlightedMessageId, virtualizer]);
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
// 滚动停止后或对话列宽度变化(拖拽调宽/折叠侧栏)时,换行行可能保留 120px 估值导致行重叠。
// 在这两个时机绕过门控,直接对已渲染行调用 resizeItem 强制纠正。
const remeasureRows = useCallback(() => {
const container = containerRef.current;
if (!container) return;
for (const vi of virtualizer.getVirtualItems()) {
const node = container.querySelector<HTMLElement>(`[data-index="${vi.index}"]`);
if (node) {
const h = Math.round(node.getBoundingClientRect().height);
if (h > 0) virtualizer.resizeItem(vi.index, h);
}
}
}, [virtualizer]);
// 宽度变化后强制重测(含一次 rAF 补测同提交内的重 stamped 行)
useEffect(() => {
const container = containerRef.current;
if (!container || typeof ResizeObserver === 'undefined') return;
let lastWidth = container.clientWidth;
const ro = new ResizeObserver(() => {
const w = container.clientWidth;
if (w === lastWidth) return;
lastWidth = w;
remeasureRows();
requestAnimationFrame(remeasureRows);
});
ro.observe(container);
return () => ro.disconnect();
}, [virtualizer, remeasureRows]);
// 滚动停止后补测一次(滚动期间被门控跳过的行在此纠正)
const isScrolling = virtualizer.isScrolling;
useEffect(() => {
if (isScrolling) return;
const id = requestAnimationFrame(remeasureRows);
return () => cancelAnimationFrame(id);
}, [isScrolling, remeasureRows]);
// ---- empty state ----
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center animate-fade-in">
<div className="mb-6 inline-flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 shadow-2xl shadow-[var(--accent-cyan)]/20">
<Sparkles className="h-10 w-10 text-[var(--accent-cyan)]" />
</div>
<h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]"></h2>
<p className="text-[var(--text-muted)]"> AI </p>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]">
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
/new
</span>
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
/list
</span>
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]">
<Sparkles className="h-8 w-8 text-[var(--accent-cyan)]" />
</div>
<h2 className="mb-2 text-xl font-semibold text-[var(--text-primary)]"></h2>
<p className="text-sm text-[var(--text-muted)]"> AI </p>
</div>
</div>
);
@ -237,11 +269,13 @@ export function MessageList({
transform: `translateY(${vi.start}px)`,
}}
>
<MessageBubble
message={message}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
/>
<div className="mx-auto w-full max-w-[736px]">
<MessageBubble
message={message}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
/>
</div>
</div>
);
})}
@ -256,11 +290,9 @@ export function MessageList({
onClick={scrollToTop}
className="flex items-center gap-1.5 px-3 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
border border-[var(--border-color)] shadow-sm
text-[var(--text-muted)] hover:text-[var(--text-primary)]
transition-colors duration-200 ease-out
animate-fade-in"
aria-label="回到顶部"
>
@ -273,11 +305,9 @@ export function MessageList({
onClick={() => scrollToBottom('smooth')}
className="flex items-center gap-2 px-4 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
border border-[var(--border-color)] shadow-sm
text-[var(--text-muted)] hover:text-[var(--text-primary)]
transition-colors duration-200 ease-out
animate-fade-in"
aria-label="回到底部"
>

View File

@ -229,7 +229,7 @@ export function ModelSelector({
</label>
</div>
{error && <div className="text-xs text-red-400 truncate">{error}</div>}
{error && <div className="text-xs text-[rgb(242,90,90)] truncate">{error}</div>}
<div className="flex items-center justify-between gap-2 pt-1">
<button
@ -260,7 +260,7 @@ export function ModelSelector({
</div>
)}
</div>
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
{error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>}
</div>
);
}

View File

@ -59,7 +59,7 @@ export function ToolDetailModal({
: status === 'result'
? 'var(--accent-green)'
: status === 'pending'
? '#f59e0b'
? 'var(--accent-amber)'
: 'var(--text-muted)';
return (
@ -81,9 +81,9 @@ export function ToolDetailModal({
<span
className="px-2.5 py-1 rounded-full text-sm font-medium"
style={{
backgroundColor: `${statusColor}15`,
backgroundColor: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
color: statusColor,
border: `1px solid ${statusColor}30`,
border: `1px solid color-mix(in srgb, ${statusColor} 25%, transparent)`,
}}
>
{statusLabel}

View File

@ -3,34 +3,40 @@ import type { ConnectionStatus } from '../types/protocol';
interface ConnectionStatusProps {
status: ConnectionStatus;
/** rail 紧凑模式:仅显示图标 */
compact?: boolean;
}
export function ConnectionStatus({ status }: ConnectionStatusProps) {
export function ConnectionStatus({ status, compact = false }: ConnectionStatusProps) {
const getStatusConfig = () => {
switch (status) {
case 'connecting':
return {
icon: <Loader2 className="h-3 w-3 animate-spin" />,
icon: <Loader2 className={`animate-spin ${compact ? 'h-4 w-4' : 'h-3 w-3'}`} />,
text: '连接中',
className: 'text-amber-400 bg-amber-400/10 border-amber-400/30',
className:
'text-[var(--accent-amber)] bg-[var(--accent-amber)]/10 border-[var(--accent-amber)]/30',
};
case 'connected':
return {
icon: <Wifi className="h-3 w-3" />,
icon: <Wifi className={compact ? 'h-4 w-4' : 'h-3 w-3'} />,
text: '已连接',
className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30',
className:
'text-[var(--accent-green)] bg-[var(--accent-green)]/10 border-[var(--accent-green)]/30',
};
case 'disconnected':
return {
icon: <WifiOff className="h-3 w-3" />,
icon: <WifiOff className={compact ? 'h-4 w-4' : 'h-3 w-3'} />,
text: '已断开',
className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30',
className:
'text-[var(--text-muted)] bg-[var(--overlay-dim)] border-[var(--border-color)]',
};
case 'error':
return {
icon: <WifiOff className="h-3 w-3" />,
icon: <WifiOff className={compact ? 'h-4 w-4' : 'h-3 w-3'} />,
text: '连接错误',
className: 'text-red-400 bg-red-400/10 border-red-400/30',
className:
'text-[rgb(242,90,90)] bg-[rgb(242,90,90)]/10 border-[rgb(242,90,90)]/30',
};
}
};
@ -39,9 +45,21 @@ export function ConnectionStatus({ status }: ConnectionStatusProps) {
if (!config) return null;
if (compact) {
return (
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg border ${config.className}`}
title={config.text}
aria-label={config.text}
>
{config.icon}
</div>
);
}
return (
<div
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs border ${config.className}`}
className={`inline-flex w-fit items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${config.className}`}
>
{config.icon}
<span>{config.text}</span>

View File

@ -110,11 +110,11 @@ export function ChannelSelector({
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
className={`
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
transition-all duration-200 ease-out
group flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-sm justify-between
transition-colors duration-200 ease-out
${
isOpen
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
? 'border-[var(--border-accent)] bg-[var(--overlay-subtle)]'
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]'
}
`}
@ -128,7 +128,7 @@ export function ChannelSelector({
</span>
{/* Channel name */}
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center">
<span className="flex-1 min-w-0 text-[var(--text-primary)] text-[13px] truncate text-left">
{selected?.name || '选择通道'}
</span>
@ -137,9 +137,7 @@ export function ChannelSelector({
{selected ? (
<span
className={`h-1.5 w-1.5 rounded-full ${
selected.isWritable
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
: 'bg-zinc-500'
selected.isWritable ? 'bg-[var(--accent-green)]' : 'bg-[var(--text-muted)]'
}`}
/>
) : (
@ -203,7 +201,7 @@ export function ChannelSelector({
className={`
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
transition-all duration-200
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
${isActive ? 'bg-[var(--accent-cyan)]' : 'bg-transparent'}
`}
/>
@ -243,10 +241,9 @@ export function ChannelSelector({
transition-all duration-200
${
channel.isWritable
? 'bg-emerald-400/10 text-emerald-400'
: 'bg-zinc-500/10 text-zinc-500'
? 'bg-[var(--accent-green)]/10 text-[var(--accent-green)]'
: 'bg-[var(--overlay-dim)] text-[var(--text-muted)]'
}
${isActive && channel.isWritable ? 'bg-emerald-400/15' : ''}
`}
>
{channel.isWritable ? (

View File

@ -80,19 +80,19 @@ export function SessionSelector({
onClick={() => setIsOpen(!isOpen)}
disabled={sessions.length <= 1}
className={`
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
transition-all duration-200 ease-out
group flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-sm justify-between
transition-colors duration-200 ease-out
${
sessions.length <= 1
? 'border-[var(--border-color)] cursor-default'
: isOpen
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
? 'border-[var(--border-accent)] bg-[var(--overlay-subtle)]'
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
}
`}
>
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-cyan)]" />
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center">
<span className="flex-1 min-w-0 text-[var(--text-primary)] text-[13px] truncate text-left">
{selected?.title || '无会话'}
</span>
{sessions.length > 1 ? (
@ -151,7 +151,7 @@ export function SessionSelector({
className={`
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
transition-all duration-200
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
${isActive ? 'bg-[var(--accent-cyan)]' : 'bg-transparent'}
`}
/>
<MessageSquare

View File

@ -42,44 +42,44 @@ const NS: Record<string, NamespaceConfig> = {
user: {
label: '用户记忆',
icon: User,
accent: 'text-cyan-400',
accentBorder: 'border-cyan-400/40',
accent: 'text-[var(--accent-cyan)]',
accentBorder: 'border-[var(--accent-cyan)]/40',
},
semantic: {
label: '语义记忆',
icon: Library,
accent: 'text-amber-400',
accentBorder: 'border-amber-400/40',
accent: 'text-[var(--accent-amber)]',
accentBorder: 'border-[var(--accent-amber)]/40',
},
episodic: {
label: '情景记忆',
icon: History,
accent: 'text-purple-400',
accentBorder: 'border-purple-400/40',
accent: 'text-[var(--accent-purple)]',
accentBorder: 'border-[var(--accent-purple)]/40',
},
skill: {
label: '技能记忆',
icon: Cpu,
accent: 'text-green-400',
accentBorder: 'border-green-400/40',
accent: 'text-[var(--accent-green)]',
accentBorder: 'border-[var(--accent-green)]/40',
},
environment: {
label: '环境记忆',
icon: Globe,
accent: 'text-sky-400',
accentBorder: 'border-sky-400/40',
accent: 'text-[var(--accent-blue)]',
accentBorder: 'border-[var(--accent-blue)]/40',
},
reflection: {
label: '反思记忆',
icon: Star,
accent: 'text-rose-400',
accentBorder: 'border-rose-400/40',
accent: 'text-[rgb(242,90,90)]',
accentBorder: 'border-[rgb(242,90,90)]/40',
},
other: {
label: '其他',
icon: Package,
accent: 'text-stone-400',
accentBorder: 'border-stone-400/40',
accent: 'text-[var(--text-muted)]',
accentBorder: 'border-[var(--border-color)]',
},
};
@ -159,7 +159,7 @@ function MemoryCard({
</button>
<button
onClick={handleDelete}
className={`p-1 rounded hover:bg-red-500/10 ${confirmDelete ? 'text-red-400' : 'text-[var(--text-muted)]'} hover:text-red-400 transition-colors`}
className={`p-1 rounded hover:bg-[rgb(242,90,90)]/10 ${confirmDelete ? 'text-[rgb(242,90,90)]' : 'text-[var(--text-muted)]'} hover:text-[rgb(242,90,90)] transition-colors`}
title={confirmDelete ? '再次点击确认删除' : '删除'}
>
<Trash2 className="h-3 w-3" />

View File

@ -24,12 +24,12 @@ interface SourceConfig {
}
const SOURCE_CONFIG: Record<string, SourceConfig> = {
user: { label: '用户技能', icon: User, accent: 'text-cyan-400' },
useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' },
useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' },
project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' },
projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-amber-400' },
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' },
user: { label: '用户技能', icon: User, accent: 'text-[var(--accent-cyan)]' },
useragent: { label: '用户 Agent', icon: User, accent: 'text-[var(--accent-cyan)]' },
useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-[var(--accent-cyan)]' },
project: { label: '项目技能', icon: Folder, accent: 'text-[var(--accent-amber)]' },
projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-[var(--accent-amber)]' },
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-[var(--accent-amber)]' },
};
function sourceConfig(source: string): SourceConfig {

View File

@ -18,9 +18,9 @@ interface StatusCfg {
}
const STATUS: Record<string, StatusCfg> = {
in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' },
in_progress: { label: '进行中', color: 'text-[var(--accent-amber)]', dot: 'bg-[var(--accent-amber)]' },
pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' },
completed: { label: '已完成', color: 'text-[var(--accent-green)]', dot: 'bg-[var(--accent-green)]' },
cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
};
@ -45,8 +45,8 @@ function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
function PulseDot() {
return (
<span className="relative flex h-1.5 w-1.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" />
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--accent-amber)] opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[var(--accent-amber)]" />
</span>
);
}

View File

@ -107,29 +107,29 @@ export function ToolPanel({ messages }: ToolPanelProps) {
case 'calling':
return {
icon: Play,
iconColor: 'text-amber-400',
bgClass: 'bg-amber-400',
borderClass: 'border-amber-500/30',
iconColor: 'text-[var(--accent-amber)]',
bgClass: 'bg-[var(--accent-amber)]',
borderClass: 'border-[var(--accent-amber)]/30',
label: '执行中',
labelClass: 'text-amber-400',
labelClass: 'text-[var(--accent-amber)]',
};
case 'result':
return {
icon: Check,
iconColor: 'text-emerald-400',
bgClass: 'bg-emerald-400',
borderClass: 'border-emerald-500/30',
iconColor: 'text-[var(--accent-green)]',
bgClass: 'bg-[var(--accent-green)]',
borderClass: 'border-[var(--accent-green)]/30',
label: '已完成',
labelClass: 'text-emerald-400',
labelClass: 'text-[var(--accent-green)]',
};
case 'pending':
return {
icon: AlertTriangle,
iconColor: 'text-orange-400',
bgClass: 'bg-orange-400',
borderClass: 'border-orange-500/30',
iconColor: 'text-[var(--accent-amber)]',
bgClass: 'bg-[var(--accent-amber)]',
borderClass: 'border-[var(--accent-amber)]/30',
label: '待确认',
labelClass: 'text-orange-400',
labelClass: 'text-[var(--accent-amber)]',
};
}
};

View File

@ -77,10 +77,10 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps)
<div
className={`h-full rounded-full transition-all ${
pct >= 80
? 'bg-red-400'
? 'bg-[rgb(242,90,90)]'
: pct >= 50
? 'bg-amber-400'
: 'bg-emerald-400'
? 'bg-[var(--accent-amber)]'
: 'bg-[var(--accent-green)]'
}`}
style={{ width: `${pct}%` }}
/>

View File

@ -175,7 +175,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
if (!config)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="text-red-400 text-center space-y-3">
<div className="text-[rgb(242,90,90)] text-center space-y-3">
<AlertTriangle className="h-10 w-10 mx-auto" />
<p>{error || '加载失败'}</p>
<button
@ -227,9 +227,9 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in">
<div
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
@ -237,7 +237,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<Settings className="h-5 w-5 text-[var(--accent-cyan)]" />
<span className="text-lg font-semibold text-[var(--text-primary)]"></span>
{dirty && (
<span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full">
<span className="text-xs text-[var(--accent-amber)] bg-[var(--accent-amber)]/10 px-2 py-0.5 rounded-full">
</span>
)}
@ -267,9 +267,9 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm transition-all relative ${
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm rounded-lg transition-colors relative ${
active
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/5'
? 'text-[var(--text-primary)] bg-[var(--overlay-subtle)] font-medium'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
>
@ -293,7 +293,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{/* Footer */}
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md flex items-center gap-3">
{error && <span className="text-sm text-red-400 truncate max-w-xs">{error}</span>}
{error && <span className="text-sm text-[rgb(242,90,90)] truncate max-w-xs">{error}</span>}
<div className="flex-1" />
<button
onClick={handleClose}
@ -304,7 +304,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<button
onClick={handleSave}
disabled={saving || restarting || !dirty}
className="flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
className="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)] hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? '保存中...' : '保存配置'}
@ -322,7 +322,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{/* Toast */}
{toast && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-sm shadow-lg backdrop-blur-md animate-[fadeIn_0.2s_ease-out]">
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-[var(--accent-green)]/15 border border-[var(--accent-green)]/30 text-[var(--accent-green)] text-sm shadow-lg backdrop-blur-md animate-fade-in">
{restarting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
@ -335,7 +335,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{/* Restart confirmation dialog */}
{restartDialogMode && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 max-w-sm mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 max-w-sm mx-4 shadow-2xl animate-scale-in">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-[var(--accent-cyan)]/10">
<RefreshCw className="h-5 w-5 text-[var(--accent-cyan)]" />
@ -362,7 +362,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
</button>
<button
onClick={handleRestart}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all"
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)] hover:opacity-90 transition-opacity"
>
<RefreshCw className="h-4 w-4" />

View File

@ -68,7 +68,7 @@ export const TAB_GROUPS: TabGroup[] = [
export const TABS: TabDef[] = TAB_GROUPS.flatMap((g) => g.tabs);
export const inputCls =
'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors';
'w-full px-3 py-2 rounded-xl bg-[var(--surface-input)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--border-accent)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors';
export const selectCls = inputCls;
export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [

View File

@ -145,7 +145,7 @@ export function ExpertModal({ draft, onClose, onSaved, setToast }: Props) {
onClick={onClose}
>
<div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-scale-in max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<ModalHeader

View File

@ -173,7 +173,7 @@ export function SubagentModal({ draft, onClose, onSaved, setToast }: Props) {
onClick={onClose}
>
<div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-scale-in max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<ModalHeader

View File

@ -30,7 +30,7 @@ export function AddButton({ label, onClick }: { label: string; onClick: () => vo
export function ErrorBanner({ children }: { children: ReactNode }) {
if (!children) return null;
return (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
<div className="text-sm text-[rgb(242,90,90)] bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/25 rounded-lg px-3 py-2">
{children}
</div>
);

View File

@ -138,7 +138,7 @@ export function ExpertsTab({ config, update, setToast }: Props) {
</button>
<button
onClick={() => handleDelete(expert.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
className="p-1 rounded text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />

View File

@ -84,7 +84,7 @@ export function McpTab({ config, update, setToast }: Props) {
</span>
</div>
{mcpStatus.failed_servers > 0 && (
<span className="text-red-400">{mcpStatus.failed_servers} </span>
<span className="text-[rgb(242,90,90)]">{mcpStatus.failed_servers} </span>
)}
<span className="text-[var(--text-muted)]">{mcpStatus.total_tools} </span>
<button
@ -111,10 +111,10 @@ export function McpTab({ config, update, setToast }: Props) {
</span>
) : st.error ? (
<span
className="inline-flex items-center gap-1 text-xs text-red-400"
className="inline-flex items-center gap-1 text-xs text-[rgb(242,90,90)]"
title={st.error}
>
<span className="w-2 h-2 rounded-full bg-red-400" />
<span className="w-2 h-2 rounded-full bg-[rgb(242,90,90)]" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-gray-400">

View File

@ -52,8 +52,8 @@ function ToolTag({
if (!tools || tools.length === 0) return null;
const tagCls =
tone === 'allow'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400';
? 'bg-[var(--accent-green)]/10 text-[var(--accent-green)]'
: 'bg-[rgb(242,90,90)]/10 text-[rgb(242,90,90)]';
return (
<div className="flex items-center gap-1 flex-wrap mt-1">
<span className="text-[10px] text-[var(--text-muted)]">{label}:</span>
@ -235,7 +235,7 @@ export function SubagentsTab({ config, update, setToast }: Props) {
</button>
<button
onClick={() => handleDelete(subagent.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
className="p-1 rounded text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />

View File

@ -401,7 +401,7 @@ export function MapEntryHeader({
<div className="flex-1" />
<button
onClick={onDelete}
className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"
className="p-1 rounded text-[rgb(242,90,90)]/60 hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>

View File

@ -23,31 +23,31 @@ function stateBadge(state: string): { label: string; color: string; pulse: boole
case 'running':
return {
label: '执行中',
color: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
color: 'bg-[var(--accent-amber)]/15 text-[var(--accent-amber)] border-[var(--accent-amber)]/30',
pulse: true,
};
case 'scheduled':
return {
label: '已调度',
color: 'bg-amber-500/15 text-amber-300 border-amber-500/20',
color: 'bg-[var(--accent-amber)]/10 text-[var(--accent-amber)] border-[var(--accent-amber)]/20',
pulse: false,
};
case 'paused':
return {
label: '已暂停',
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30',
color: 'bg-[var(--overlay-dim)] text-[var(--text-muted)] border-[var(--border-color)]',
pulse: false,
};
case 'completed':
return {
label: '已完成',
color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
color: 'bg-[var(--accent-green)]/15 text-[var(--accent-green)] border-[var(--accent-green)]/30',
pulse: false,
};
default:
return {
label: state,
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30',
color: 'bg-[var(--overlay-dim)] text-[var(--text-muted)] border-[var(--border-color)]',
pulse: false,
};
}
@ -84,9 +84,9 @@ function scheduleDescription(schedule: unknown): string {
function lastStatusIcon(lastStatus: string | undefined) {
switch (lastStatus) {
case 'ok':
return <Check className="h-3 w-3 text-emerald-400" />;
return <Check className="h-3 w-3 text-[var(--accent-green)]" />;
case 'error':
return <X className="h-3 w-3 text-red-400" />;
return <X className="h-3 w-3 text-[rgb(242,90,90)]" />;
default:
return <Minus className="h-3 w-3 text-[var(--text-muted)]" />;
}
@ -157,7 +157,7 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-zinc-600'}`}
className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-[var(--accent-green)]' : 'bg-[var(--text-muted)]'}`}
/>
{hasLookup && (
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
@ -171,7 +171,7 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}
>
{badge.pulse && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--accent-amber)] animate-pulse" />
)}
{badge.label}
</span>
@ -180,9 +180,9 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
<span
className={
job.last_status === 'error'
? 'text-red-400'
? 'text-[rgb(242,90,90)]'
: job.last_status === 'ok'
? 'text-emerald-400'
? 'text-[var(--accent-green)]'
: 'text-[var(--text-muted)]'
}
>
@ -199,7 +199,7 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
{/* Row 3: Error message (if any) */}
{job.last_error && (
<div className="mb-1.5 text-xs text-red-400/80 truncate bg-red-500/10 rounded px-1.5 py-0.5">
<div className="mb-1.5 text-xs text-[rgb(242,90,90)] truncate bg-[rgb(242,90,90)]/10 rounded px-1.5 py-0.5">
{job.last_error}
</div>
)}

View File

@ -12,7 +12,7 @@ export function SessionInfo({ session, connectionId }: SessionInfoProps) {
<div className="flex items-center gap-2 mb-3">
<Wifi className="h-4 w-4 text-[var(--accent-cyan)]" />
<span className="text-sm font-medium text-[var(--text-primary)]">WebSocket</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-green)]/15 text-[var(--accent-green)]">
线
</span>
</div>

View File

@ -158,10 +158,10 @@ export function TopicList({
<button
onClick={onCreateTopic}
disabled={!sessionId}
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-all ${
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
!sessionId
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
: 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30'
: 'bg-[var(--accent-cyan)] text-white hover:opacity-90'
}`}
>
<Plus className="h-4 w-4" />
@ -215,7 +215,7 @@ export function TopicList({
<button
type="submit"
onMouseDown={(e) => e.preventDefault()}
className="flex items-center justify-center h-6 w-6 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors shrink-0"
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors shrink-0"
title="确认 (Enter)"
>
<Check className="h-3.5 w-3.5" />
@ -224,7 +224,7 @@ export function TopicList({
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={cancelEdit}
className="flex items-center justify-center h-6 w-6 rounded-md bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors shrink-0"
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors shrink-0"
title="取消 (Esc)"
>
<X className="h-3.5 w-3.5" />
@ -234,21 +234,21 @@ export function TopicList({
<>
<button
onClick={() => onSwitchTopic(topic.id)}
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-all ${
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-colors ${
topic.id === currentTopicId
? 'bg-gradient-to-r from-[var(--accent-cyan)]/20 to-transparent border border-[var(--accent-cyan)]/30'
? 'bg-[var(--overlay-subtle)] border border-[var(--border-color)]'
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-[var(--text-muted)] font-mono w-4">
<span className="mt-0.5 text-xs text-[var(--text-muted)] w-4">
{currentPage * pageSize + index + 1}
</span>
<div className="min-w-0 flex-1">
<div
className={`truncate font-medium ${
topic.id === currentTopicId
? 'text-[var(--accent-cyan)]'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
}`}
>
@ -266,7 +266,7 @@ export function TopicList({
</div>
</div>
{topic.id === currentTopicId && (
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] shadow-lg shadow-[var(--shadow-glow-soft)] mt-1.5" />
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] mt-1.5" />
)}
</div>
</button>
@ -275,14 +275,16 @@ export function TopicList({
<div className="absolute top-2.5 right-2.5">
{confirmDeleteId === topic.id ? (
<span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in">
<span className="text-xs text-red-400 whitespace-nowrap">?</span>
<span className="text-xs text-[rgb(242,90,90)] whitespace-nowrap">
?
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTopic(topic.id);
setConfirmDeleteId(null);
}}
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors"
title="确认"
>
<Check className="h-3 w-3" />
@ -292,7 +294,7 @@ export function TopicList({
e.stopPropagation();
setConfirmDeleteId(null);
}}
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors"
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors"
title="取消"
>
<X className="h-3 w-3" />
@ -305,7 +307,7 @@ export function TopicList({
e.stopPropagation();
startEdit(topic);
}}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors"
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="重命名话题"
>
<Edit2 className="h-3.5 w-3.5" />
@ -315,7 +317,7 @@ export function TopicList({
e.stopPropagation();
setConfirmDeleteId(topic.id);
}}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除话题"
>
<Trash2 className="h-3.5 w-3.5" />

View File

@ -2,54 +2,62 @@
/* ============================================
PicoBot Theme Dark (default)
对齐 deepseek-harness 设计语言
--accent-cyan 语义为品牌主色 DeepSeek blue
============================================ */
:root {
/* Core colors */
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a25;
--bg-hover: #252535;
--bg-primary: rgb(35, 35, 36);
--bg-secondary: rgb(27, 27, 28);
--bg-tertiary: rgb(44, 44, 46);
--bg-hover: rgba(255, 255, 255, 0.08);
/* Accent colors */
--accent-cyan: #00f0ff;
--accent-blue: #3b82f6;
--accent-purple: #8b5cf6;
--accent-green: #10b981;
--accent-amber: #f59e0b;
--accent-cyan: rgb(103, 158, 254);
--accent-blue: rgb(96, 165, 250);
--accent-purple: rgb(183, 200, 254);
--accent-green: rgb(78, 209, 126);
--accent-amber: rgb(247, 173, 49);
/* Text colors */
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--text-primary: rgb(249, 250, 251);
--text-secondary: rgb(207, 211, 214);
--text-muted: rgb(151, 157, 166);
--text-accent: var(--accent-cyan);
/* Border */
--border-color: rgba(255, 255, 255, 0.08);
--border-accent: rgba(0, 240, 255, 0.3);
--border-color: rgba(255, 255, 255, 0.12);
--border-accent: rgba(103, 158, 254, 0.4);
/* Overlay — for hover / subtle surface effects */
--overlay-hover: rgba(255, 255, 255, 0.05);
--overlay-subtle: rgba(255, 255, 255, 0.1);
--overlay-medium: rgba(255, 255, 255, 0.2);
--overlay-hover: rgba(255, 255, 255, 0.08);
--overlay-subtle: rgba(255, 255, 255, 0.14);
--overlay-medium: rgba(255, 255, 255, 0.24);
--overlay-dim: rgba(0, 0, 0, 0.2);
--overlay-dim-strong: rgba(0, 0, 0, 0.3);
--overlay-dim-heavy: rgba(0, 0, 0, 0.4);
--overlay-code: rgba(0, 0, 0, 0.3);
/* Shadows */
--shadow-glow-sm: rgba(0, 240, 255, 0.12);
--shadow-glow: rgba(0, 240, 255, 0.2);
--shadow-glow-strong: rgba(0, 240, 255, 0.3);
--shadow-glow-soft: rgba(0, 240, 255, 0.5);
--shadow-glow-sm: rgba(103, 158, 254, 0.1);
--shadow-glow: rgba(103, 158, 254, 0.16);
--shadow-glow-strong: rgba(103, 158, 254, 0.24);
--shadow-glow-soft: rgba(103, 158, 254, 0.32);
/* Focus ring */
--focus-ring: rgba(0, 240, 255, 0.2);
--focus-ring: rgba(103, 158, 254, 0.3);
/* Selection / code highlight */
--selection-bg: rgba(0, 240, 255, 0.3);
--selection-bg: rgba(103, 158, 254, 0.28);
/* Divider */
--divider-color: rgba(255, 255, 255, 0.2);
--divider-color: rgba(255, 255, 255, 0.08);
/* Chat composer / user bubble surfaces */
--bubble-user: rgb(44, 44, 46);
--surface-input: rgb(44, 44, 46);
--btn-contrast: rgb(249, 250, 251);
--btn-contrast-fg: rgb(27, 27, 28);
color-scheme: dark;
}
@ -59,51 +67,57 @@
============================================ */
html.light {
/* Backgrounds */
--bg-primary: #f5f5f7;
--bg-secondary: #ffffff;
--bg-tertiary: #ebecf0;
--bg-hover: #dfe0e5;
--bg-primary: rgb(255, 255, 255);
--bg-secondary: rgb(249, 250, 251);
--bg-tertiary: rgb(241, 243, 245);
--bg-hover: rgba(38, 49, 72, 0.06);
/* Accents — slightly darker for readability on white */
--accent-cyan: #008899;
--accent-blue: #2563eb;
--accent-purple: #7c3aed;
--accent-green: #059669;
--accent-amber: #d97706;
/* Accents */
--accent-cyan: rgb(65, 118, 230);
--accent-blue: rgb(59, 130, 246);
--accent-purple: rgb(65, 118, 230);
--accent-green: rgb(34, 197, 94);
--accent-amber: rgb(245, 158, 11);
/* Text */
--text-primary: #1a1a2e;
--text-secondary: #52525b;
--text-muted: #a1a1aa;
--text-primary: rgb(15, 17, 21);
--text-secondary: rgb(97, 102, 107);
--text-muted: rgb(151, 157, 166);
--text-accent: var(--accent-cyan);
/* Borders */
--border-color: rgba(0, 0, 0, 0.08);
--border-accent: rgba(0, 136, 153, 0.3);
--border-color: rgba(0, 0, 0, 0.1);
--border-accent: rgba(65, 118, 230, 0.4);
/* Overlays */
--overlay-hover: rgba(0, 0, 0, 0.04);
--overlay-subtle: rgba(0, 0, 0, 0.06);
--overlay-medium: rgba(0, 0, 0, 0.1);
--overlay-hover: rgba(38, 49, 72, 0.06);
--overlay-subtle: rgba(38, 49, 72, 0.1);
--overlay-medium: rgba(38, 49, 72, 0.16);
--overlay-dim: rgba(0, 0, 0, 0.04);
--overlay-dim-strong: rgba(0, 0, 0, 0.06);
--overlay-dim-heavy: rgba(0, 0, 0, 0.08);
--overlay-code: rgba(0, 0, 0, 0.06);
/* Shadows — softer */
--shadow-glow-sm: rgba(0, 136, 153, 0.1);
--shadow-glow: rgba(0, 136, 153, 0.15);
--shadow-glow-strong: rgba(0, 136, 153, 0.22);
--shadow-glow-soft: rgba(0, 136, 153, 0.35);
--shadow-glow-sm: rgba(65, 118, 230, 0.08);
--shadow-glow: rgba(65, 118, 230, 0.12);
--shadow-glow-strong: rgba(65, 118, 230, 0.18);
--shadow-glow-soft: rgba(65, 118, 230, 0.25);
/* Focus ring */
--focus-ring: rgba(0, 136, 153, 0.2);
--focus-ring: rgba(65, 118, 230, 0.25);
/* Selection / code highlight */
--selection-bg: rgba(0, 136, 153, 0.2);
--selection-bg: rgba(65, 118, 230, 0.2);
/* Divider */
--divider-color: rgba(0, 0, 0, 0.1);
--divider-color: rgba(0, 0, 0, 0.08);
/* Chat composer / user bubble surfaces */
--bubble-user: rgb(237, 243, 254);
--surface-input: rgb(255, 255, 255);
--btn-contrast: rgb(67, 69, 74);
--btn-contrast-fg: rgb(255, 255, 255);
color-scheme: light;
}
@ -122,6 +136,11 @@ html.theme-transitioning *::after {
text-shadow 0.3s ease !important;
}
/* 虚拟列表行使用内联 transform 定位,主题过渡期间禁止对其做 transition避免行位移被动画化 */
html.theme-transitioning [data-index] {
transition: none !important;
}
/* ============================================
Custom scrollbar
============================================ */
@ -135,12 +154,20 @@ html.theme-transitioning *::after {
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
background: rgb(101, 103, 107);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-blue);
background: rgb(129, 133, 140);
}
html.light ::-webkit-scrollbar-thumb {
background: rgb(229, 229, 229);
}
html.light ::-webkit-scrollbar-thumb:hover {
background: rgb(212, 212, 212);
}
/* Hide scrollbar utility */
@ -163,12 +190,15 @@ html.theme-transitioning *::after {
body {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
font-size: 15px;
}
/* ============================================
@ -180,13 +210,10 @@ body {
}
/* ============================================
Glowing text effect
Glowing text effectDeepSeek 风格下仅保留品牌色无发光
============================================ */
.glow-text {
text-shadow:
0 0 10px var(--shadow-glow-soft),
0 0 20px var(--shadow-glow-strong),
0 0 30px var(--shadow-glow-sm);
color: var(--accent-cyan);
}
/* ============================================
@ -202,7 +229,7 @@ body {
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
background: var(--accent-cyan);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
@ -219,16 +246,10 @@ body {
@keyframes pulse-glow {
0%,
100% {
box-shadow:
0 0 5px var(--accent-cyan),
0 0 10px var(--accent-cyan),
0 0 20px var(--accent-cyan);
box-shadow: 0 0 0 1px var(--shadow-glow);
}
50% {
box-shadow:
0 0 10px var(--accent-cyan),
0 0 20px var(--accent-cyan),
0 0 40px var(--accent-cyan);
box-shadow: 0 0 0 3px var(--shadow-glow);
}
}
@ -330,7 +351,7 @@ body {
background-color: transparent;
}
50% {
background-color: rgba(0, 240, 255, 0.15);
background-color: color-mix(in srgb, var(--accent-cyan) 15%, transparent);
}
}
@ -405,9 +426,9 @@ body {
Code block styling
============================================ */
pre {
background: var(--bg-tertiary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: 12px;
padding: 12px;
overflow-x: auto;
}
@ -427,6 +448,12 @@ textarea:focus {
box-shadow: 0 0 0 2px var(--focus-ring);
}
/* Composer 内的无边框 textarea聚焦高亮由外层卡片 focus-within 提供 */
textarea.composer-area:focus {
border-color: transparent;
box-shadow: none;
}
/* ============================================
Button hover effects
============================================ */
@ -434,14 +461,6 @@ button {
transition: all 0.2s ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(0);
}
/* ============================================
Link styles
============================================ */
@ -452,5 +471,5 @@ a {
}
a:hover {
text-shadow: 0 0 8px var(--accent-cyan);
opacity: 0.85;
}

View File

@ -17,7 +17,7 @@ export function contextOccupancyPct(stats: TopicTokenStats): number | null {
/** 根据占用率返回颜色 class */
export function occupancyColor(pct: number): string {
if (pct >= 80) return 'text-red-400';
if (pct >= 50) return 'text-amber-400';
return 'text-emerald-400';
if (pct >= 80) return 'text-[rgb(242,90,90)]';
if (pct >= 50) return 'text-[var(--accent-amber)]';
return 'text-[var(--accent-green)]';
}