diff --git a/src/assets/token-dark.svg b/src/assets/token-dark.svg new file mode 100644 index 000000000..09afc3ede --- /dev/null +++ b/src/assets/token-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/token-light.svg b/src/assets/token-light.svg new file mode 100644 index 000000000..441e18e5f --- /dev/null +++ b/src/assets/token-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/ChatBox/HeaderBox/index.tsx b/src/components/ChatBox/HeaderBox/index.tsx index eead12444..3c12df89d 100644 --- a/src/components/ChatBox/HeaderBox/index.tsx +++ b/src/components/ChatBox/HeaderBox/index.tsx @@ -12,35 +12,39 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import tokenDarkIcon from '@/assets/token-dark.svg'; +import tokenLightIcon from '@/assets/token-light.svg'; import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/authStore'; import { PlayCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { AnimatedTokenNumber } from '../TokenUtils'; interface HeaderBoxProps { - /** Token count to display */ - tokens: number; - /** Task status for determining what button to show */ + /** Total token count for the current project */ + totalTokens?: number; + /** Task status – controls visibility of replay button */ status?: 'running' | 'finished' | 'pending' | 'pause'; - /** Whether replay is loading */ + /** Whether the replay action is in a loading state */ replayLoading?: boolean; - /** Callback when replay button is clicked */ + /** Callback fired when the replay button is clicked */ onReplay?: () => void; - /** Optional class name */ + /** Optional extra class names for the outer container */ className?: string; } export function HeaderBox({ - tokens, + totalTokens = 0, status, replayLoading = false, onReplay, className, }: HeaderBoxProps) { const { t } = useTranslation(); + const { appearance } = useAuthStore(); + const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon; - // Replay button only appears when task is finished const showReplayButton = status === 'finished'; - // Replay button is disabled when task is running or pending const isReplayDisabled = status === 'running' || status === 'pending' || status === 'pause'; @@ -48,27 +52,34 @@ export function HeaderBox({
-
-
- Chat -
-
- # {(tokens || 0).toLocaleString()} -
+ {/* Left: title + replay button */} +
+ + {t('chat.chat-title')} + + + {showReplayButton && ( + + )}
- {showReplayButton && ( - - )} + {/* Right: project total token count */} +
+ + + {t('chat.token-total-label')}{' '} + + +
); } diff --git a/src/components/ChatBox/TokenUtils.tsx b/src/components/ChatBox/TokenUtils.tsx new file mode 100644 index 000000000..3464e2998 --- /dev/null +++ b/src/components/ChatBox/TokenUtils.tsx @@ -0,0 +1,81 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { animate, useMotionValue } from 'framer-motion'; +import React, { useEffect, useState } from 'react'; + +/** + * Format a raw token count into a compact human-readable string. + * < 1 000 → "950" + * 1 000 – 999 999 → "1.2K" + * ≥ 1 000 000 → "2.3M" + */ +const TOKEN_UNITS = [ + { threshold: 1_000_000_000_000, suffix: 'T' }, + { threshold: 1_000_000_000, suffix: 'B' }, + { threshold: 1_000_000, suffix: 'M' }, + { threshold: 1_000, suffix: 'K' }, +] as const; + +export function formatTokenCount(n: number): string { + if (!Number.isFinite(n)) return '0'; + + for (let index = 0; index < TOKEN_UNITS.length; index++) { + const unit = TOKEN_UNITS[index]; + + if (Math.abs(n) < unit.threshold) { + continue; + } + + const rounded = Number((n / unit.threshold).toFixed(1)); + const higherUnit = TOKEN_UNITS[index - 1]; + + if (Math.abs(rounded) >= 1000 && higherUnit) { + return `${(n / higherUnit.threshold).toFixed(1)}${higherUnit.suffix}`; + } + + return `${rounded.toFixed(1)}${unit.suffix}`; + } + + return String(Math.round(n)); +} + +interface AnimatedTokenNumberProps { + value: number; + className?: string; +} + +/** + * Renders a formatted token count that smoothly animates on change. + * The underlying integer interpolates via a spring so the formatted + * label updates fluidly without aggressive bouncing. + */ +export const AnimatedTokenNumber: React.FC = ({ + value, + className, +}) => { + const motionValue = useMotionValue(value); + const [display, setDisplay] = useState(value); + + useEffect(() => { + const controls = animate(motionValue, value, { + duration: 0.5, + ease: [0.16, 1, 0.3, 1], + onUpdate: (v) => setDisplay(Math.round(v)), + }); + return controls.stop; + }, [value, motionValue]); + + return {formatTokenCount(display)}; +}; diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 61de64e5e..08c106b8d 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -14,14 +14,10 @@ import { VanillaChatStore } from '@/store/chatStore'; import { AgentStep, ChatTaskStatus } from '@/types/constants'; -import { motion } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { FileText } from 'lucide-react'; -import React, { - useEffect, - useRef, - useState, - useSyncExternalStore, -} from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { AgentMessageCard } from './MessageItem/AgentMessageCard'; import { NoticeCard } from './MessageItem/NoticeCard'; import { TaskCompletionCard } from './MessageItem/TaskCompletionCard'; @@ -29,6 +25,7 @@ import { UserMessageCard } from './MessageItem/UserMessageCard'; import { StreamingTaskList } from './TaskBox/StreamingTaskList'; import { TaskCard } from './TaskBox/TaskCard'; import { TypeCardSkeleton } from './TaskBox/TypeCardSkeleton'; +import { AnimatedTokenNumber } from './TokenUtils'; interface QueryGroup { queryId: string; @@ -54,35 +51,25 @@ export const UserQueryGroup: React.FC = ({ onQueryActive, index, }) => { + const { t } = useTranslation(); const groupRef = useRef(null); const taskBoxRef = useRef(null); const [_isTaskBoxSticky, setIsTaskBoxSticky] = useState(false); const [isCompletionReady, setIsCompletionReady] = useState(false); const chatState = chatStore.getState(); const activeTaskId = chatState.activeTaskId; - - // Subscribe to streaming decompose text separately for efficient updates - const streamingDecomposeText = useSyncExternalStore( - (callback) => chatStore.subscribe(callback), - () => { - const state = chatStore.getState(); - const taskId = state.activeTaskId; - if (!taskId || !state.tasks[taskId]) return ''; - return state.tasks[taskId].streamingDecomposeText || ''; - } - ); + const activeTask = activeTaskId ? chatState.tasks[activeTaskId] : null; // Show task if this query group has a task message OR if it's the most recent user query during splitting // During splitting phase (no to_sub_tasks yet), show task for the most recent query only // Exclude human-reply scenarios (when user is replying to an activeAsk) const isHumanReply = queryGroup.userMessage && - activeTaskId && - chatState.tasks[activeTaskId] && - (chatState.tasks[activeTaskId].activeAsk || + activeTask && + (activeTask.activeAsk || // Check if this user message follows an 'ask' message in the message sequence (() => { - const messages = chatState.tasks[activeTaskId].messages; + const messages = activeTask.messages; const userMessageIndex = messages.findIndex( (m: any) => m.id === queryGroup.userMessage.id ); @@ -99,29 +86,27 @@ export const UserQueryGroup: React.FC = ({ const isLastUserQuery = !queryGroup.taskMessage && !isHumanReply && - activeTaskId && - chatState.tasks[activeTaskId] && + activeTask && queryGroup.userMessage && queryGroup.userMessage.id === - chatState.tasks[activeTaskId].messages - .filter((m: any) => m.role === 'user') - .pop()?.id && + activeTask.messages.filter((m: any) => m.role === 'user').pop()?.id && // Only show during active phases (not finished) - chatState.tasks[activeTaskId].status !== ChatTaskStatus.FINISHED; + activeTask.status !== ChatTaskStatus.FINISHED; // Only show the fallback task box for the newest query while the agent is still splitting work. // Simple Q&A sessions set hasWaitComfirm to true, so we should not render an empty task box there. // Also, do not show fallback task if we are currently decomposing (streaming text). + const streamingDecomposeText = activeTask?.streamingDecomposeText || ''; const isDecomposing = streamingDecomposeText.length > 0; const shouldShowFallbackTask = isLastUserQuery && - activeTaskId && - !chatState.tasks[activeTaskId].hasWaitComfirm && + activeTask && + !activeTask.hasWaitComfirm && !isDecomposing; const task = - (queryGroup.taskMessage || shouldShowFallbackTask) && activeTaskId - ? chatState.tasks[activeTaskId] + (queryGroup.taskMessage || shouldShowFallbackTask) && activeTask + ? activeTask : null; // Reset completion flag when active task or query group changes @@ -292,6 +277,25 @@ export const UserQueryGroup: React.FC = ({ )} + {/* Live token count – visible only while the task is running */} + + {task && task.status === ChatTaskStatus.RUNNING && ( + + {t('chat.current-task')} + · + + {t('chat.tokens')} + + )} + + {/* Other Messages */} {queryGroup.otherMessages.map((message) => { if (message.content.length > 0) { diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index ad44c9636..3e646d041 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -23,6 +23,7 @@ import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { generateUniqueId, replayActiveTask } from '@/lib'; import { proxyUpdateTriggerExecution } from '@/service/triggerApi'; import { useAuthStore } from '@/store/authStore'; +import type { VanillaChatStore } from '@/store/chatStore'; import { ExecutionStatus } from '@/types'; import { AgentStep, ChatTaskStatus } from '@/types/constants'; import { TriangleAlert } from 'lucide-react'; @@ -34,6 +35,15 @@ import BottomBox from './BottomBox'; import { HeaderBox } from './HeaderBox'; import { ProjectChatContainer } from './ProjectChatContainer'; +const getChatStoreTotalTokens = (chatStore: VanillaChatStore): number => { + const chatState = chatStore.getState(); + return Object.values(chatState.tasks).reduce( + (total, task) => + total + (typeof task.tokens === 'number' ? task.tokens : 0), + 0 + ); +}; + export default function ChatBox(): JSX.Element { const [message, setMessage] = useState(''); @@ -143,6 +153,7 @@ export default function ChatBox(): JSX.Element { const [loading, setLoading] = useState(false); const [isReplayLoading, setIsReplayLoading] = useState(false); const [isPauseResumeLoading, setIsPauseResumeLoading] = useState(false); + const [projectTotalTokens, setProjectTotalTokens] = useState(0); const activeTaskId = chatStore?.activeTaskId; const activeTaskMessages = chatStore?.tasks[activeTaskId as string]?.messages; @@ -192,6 +203,49 @@ export default function ChatBox(): JSX.Element { return projectStore.getAllChatStores(projectStore.activeProjectId); }, [projectStore]); + useEffect(() => { + if (!projectStore.activeProjectId) { + setProjectTotalTokens(0); + return; + } + + const chatTotals = new Map(); + let nextProjectTotalTokens = 0; + + getAllChatStoresMemoized.forEach(({ chatId, chatStore }) => { + const chatTotalTokens = getChatStoreTotalTokens(chatStore); + chatTotals.set(chatId, chatTotalTokens); + nextProjectTotalTokens += chatTotalTokens; + }); + + setProjectTotalTokens(nextProjectTotalTokens); + + const unsubscribers = getAllChatStoresMemoized.map( + ({ chatId, chatStore }) => + chatStore.subscribe((state) => { + const nextChatTotalTokens = Object.values(state.tasks).reduce( + (total, task) => + total + (typeof task.tokens === 'number' ? task.tokens : 0), + 0 + ); + const previousChatTotalTokens = chatTotals.get(chatId) ?? 0; + + if (nextChatTotalTokens === previousChatTotalTokens) { + return; + } + + chatTotals.set(chatId, nextChatTotalTokens); + nextProjectTotalTokens += + nextChatTotalTokens - previousChatTotalTokens; + setProjectTotalTokens(nextProjectTotalTokens); + }) + ); + + return () => { + unsubscribers.forEach((unsubscribe) => unsubscribe()); + }; + }, [projectStore.activeProjectId, getAllChatStoresMemoized]); + // Check if any chat store in the project has messages const hasAnyMessages = useMemo(() => { if (!chatStore) return false; @@ -984,7 +1038,7 @@ export default function ChatBox(): JSX.Element { {/* Header Box - Always visible */} {chatStore.activeTaskId && (