diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index e32d1a3477..1132778756 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -370,6 +370,13 @@ "noMatchingAgents": "No matching members found", "noMembersYet": "This group doesn't have any members yet. Click the + button to invite agents.", "noSelectedAgents": "No members selected yet", + "opStatusTray.status.compressing": "Compressing context", + "opStatusTray.status.generating": "Generating", + "opStatusTray.status.reasoning": "Thinking", + "opStatusTray.status.searching": "Searching", + "opStatusTray.status.toolCalling": "Calling tools", + "opStatusTray.steps": "steps", + "opStatusTray.tokens": "tokens", "openInNewWindow": "Open in New Window", "operation.contextCompression": "Context too long, compressing history...", "operation.execAgentRuntime": "Preparing response", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 16d9921c8c..ec736999fb 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -370,6 +370,13 @@ "noMatchingAgents": "未找到匹配的成员", "noMembersYet": "这个群组还没有成员。点击「+」邀请助理加入", "noSelectedAgents": "还未选择成员", + "opStatusTray.status.compressing": "压缩上下文中", + "opStatusTray.status.generating": "生成中", + "opStatusTray.status.reasoning": "思考中", + "opStatusTray.status.searching": "检索中", + "opStatusTray.status.toolCalling": "调用工具中", + "opStatusTray.steps": "步", + "opStatusTray.tokens": "tokens", "openInNewWindow": "在新窗口打开", "operation.contextCompression": "上下文过长,正在压缩历史记录……", "operation.execAgentRuntime": "准备响应中", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index 626fdb7a9d..02d56831e7 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -21,7 +21,7 @@ "builtins.codex.commandExecution.readFile": "读取文件", "builtins.codex.fileChange.editedFiles_one": "已编辑 {{count}} 个文件", "builtins.codex.fileChange.editedFiles_other": "已编辑 {{count}} 个文件", - "builtins.codex.fileChange.editing": "正在编辑文件", + "builtins.codex.fileChange.editing": "编辑文件中", "builtins.codex.fileChange.noChanges": "没有文件改动", "builtins.codex.fileChange.unknownFile": "未知文件", "builtins.codex.mcpTool.error": "错误", diff --git a/packages/builtin-tools/src/codex/FileChangeInspector.tsx b/packages/builtin-tools/src/codex/FileChangeInspector.tsx index cb757597da..b610d0d024 100644 --- a/packages/builtin-tools/src/codex/FileChangeInspector.tsx +++ b/packages/builtin-tools/src/codex/FileChangeInspector.tsx @@ -35,29 +35,24 @@ const FileChangeInspector = memo 0 || stats.linesDeleted > 0; - const summary = - stats.total > 0 + const isEditing = isArgumentsStreaming || isLoading; + const summary = isEditing + ? t('builtins.codex.fileChange.editing', { defaultValue: 'Editing files' }) + : stats.total > 0 ? t('builtins.codex.fileChange.editedFiles', { count: stats.total, defaultValue: stats.total === 1 ? 'Edited {{count}} file' : 'Edited {{count}} files', }) - : isArgumentsStreaming || isLoading - ? t('builtins.codex.fileChange.editing', { defaultValue: 'Editing files' }) - : t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' }); + : t('builtins.codex.fileChange.noChanges', { defaultValue: 'No file changes' }); - if (isArgumentsStreaming && !stats.firstPath) { + if (isEditing && !stats.firstPath) { return (
{summary}
); } return ( -
+
{stats.firstPath ? ( <> {summary}: diff --git a/packages/locales/src/default/chat.ts b/packages/locales/src/default/chat.ts index c8933c6deb..452ed492b8 100644 --- a/packages/locales/src/default/chat.ts +++ b/packages/locales/src/default/chat.ts @@ -386,6 +386,15 @@ export default { 'newPlatformAgent': 'Connect Agent', 'newGroupChat': 'Create Group', + // Op status tray (floating panel above the chat input during a run) + 'opStatusTray.status.compressing': 'Compressing context', + 'opStatusTray.status.generating': 'Generating', + 'opStatusTray.status.reasoning': 'Thinking', + 'opStatusTray.status.searching': 'Searching', + 'opStatusTray.status.toolCalling': 'Calling tools', + 'opStatusTray.steps': 'steps', + 'opStatusTray.tokens': 'tokens', + // Connect agent: per-agent descriptions shown in step 0 of the connect modal 'platformAgent.create.desc.openclaw': 'Connect to OpenClaw running on one of your devices', 'platformAgent.create.desc.hermes': 'Connect to Hermes running on one of your devices', diff --git a/src/features/Conversation/ChatInput/OpStatusTray.tsx b/src/features/Conversation/ChatInput/OpStatusTray.tsx new file mode 100644 index 0000000000..6966f42f86 --- /dev/null +++ b/src/features/Conversation/ChatInput/OpStatusTray.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { Flexbox, Icon, Tooltip } from '@lobehub/ui'; +import { createStaticStyles, cx } from 'antd-style'; +import { CircleDollarSignIcon, CoinsIcon, FootprintsIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Fragment, memo, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useChatStore } from '@/store/chat'; +import { operationSelectors } from '@/store/chat/selectors'; +import { + AI_RUNTIME_OPERATION_TYPES, + type OperationType, +} from '@/store/chat/slices/operation/types'; +import { shinyTextStyles } from '@/styles'; + +import { contextSelectors, dataSelectors, useConversationStore } from '../store'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding-block: 8px; + padding-inline: 14px; + border: 1px solid ${cssVar.colorFillSecondary}; + border-block-end: none; + border-start-start-radius: 12px; + border-start-end-radius: 12px; + + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + + background: ${cssVar.colorBgElevated}; + `, + containerTopAttached: css` + border-start-start-radius: 0; + border-start-end-radius: 0; + `, + divider: css` + width: 1px; + height: 12px; + background: ${cssVar.colorBorderSecondary}; + `, + metric: css` + display: inline-flex; + gap: 4px; + align-items: center; + font-variant-numeric: tabular-nums; + `, + metricGroup: css` + display: inline-flex; + gap: 10px; + align-items: center; + `, + metricIcon: css` + color: ${cssVar.colorTextTertiary}; + `, + statusText: css` + font-weight: 500; + white-space: nowrap; + `, + timerValue: css` + color: ${cssVar.colorTextTertiary}; + `, + activityGlyph: css` + overflow: visible; + flex: none; + + width: 16px; + height: 16px; + + color: ${cssVar.colorPrimary}; + + @keyframes op-status-tray-glyph-spin { + to { + transform: rotate(360deg); + } + } + + @keyframes op-status-tray-glyph-core { + 0%, + 100% { + transform: scale(0.86); + opacity: 0.9; + } + + 50% { + transform: scale(1); + opacity: 1; + } + } + `, + glyphCore: css` + transform-origin: center; + transform-box: fill-box; + fill: ${cssVar.colorPrimary}; + animation: op-status-tray-glyph-core 1.5s ease-in-out infinite; + `, + glyphOrbit: css` + transform-origin: center; + transform-box: fill-box; + + fill: none; + stroke: color-mix(in srgb, ${cssVar.colorPrimary} 76%, transparent); + stroke-dasharray: 9 18; + stroke-linecap: round; + stroke-width: 1.5; + + animation: op-status-tray-glyph-spin 2s linear infinite; + `, +})); + +const ActivityGlyph = memo(() => ( + + + + +)); + +ActivityGlyph.displayName = 'ActivityGlyph'; + +const formatDuration = (ms: number) => { + if (ms < 0) ms = 0; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +}; + +const formatTokens = (n: number) => { + if (n < 1000) return String(n); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(2)}M`; +}; + +const formatCost = (cost: number) => { + if (cost < 0.01) return cost.toFixed(4); + return cost.toFixed(2); +}; + +const normalizeStepCount = (stepCount: unknown) => { + if (typeof stepCount !== 'number' || !Number.isFinite(stepCount)) return 0; + return Math.max(0, Math.floor(stepCount)); +}; + +type ActivityKey = 'compressing' | 'generating' | 'reasoning' | 'searching' | 'toolCalling'; + +/** + * Map a running sub-operation type to the streaming phase shown on the left. + * Container ops (AI_RUNTIME) and bookkeeping ops return undefined. + */ +const resolveActivity = (type: OperationType): ActivityKey | undefined => { + if (type === 'reasoning') return 'reasoning'; + if ( + type === 'toolCalling' || + type === 'executeToolCall' || + type === 'createToolMessage' || + type === 'pluginApi' || + type.startsWith('builtinTool') + ) + return 'toolCalling'; + if (type === 'rag' || type === 'searchWorkflow') return 'searching'; + if (type === 'contextCompression' || type === 'generateSummary') return 'compressing'; + if ( + type === 'callLLM' || + type === 'groupAgentStream' || + type === 'createAssistantMessage' || + type === 'supervisorDecision' + ) + return 'generating'; + return undefined; +}; + +interface OpStatusTrayProps { + /** + * Square the top corners when another panel sits flush above this one. + */ + topAttached?: boolean; +} + +const OpStatusTray = memo(({ topAttached }) => { + const { t } = useTranslation('chat'); + const context = useConversationStore(contextSelectors.context); + const dbMessages = useConversationStore(dataSelectors.dbMessages); + + // Detect any running AI-runtime op (excludes sub-ops like callLLM/toolCalling) + // and capture the earliest start time as the op's anchor. + const startTime = useChatStore((s) => { + const ops = operationSelectors.getOperationsByContext(context)(s); + let earliest: number | undefined; + for (const op of ops) { + if ( + op.status !== 'running' || + op.metadata.isAborting || + !AI_RUNTIME_OPERATION_TYPES.includes(op.type) + ) { + continue; + } + if (earliest === undefined || op.metadata.startTime < earliest) { + earliest = op.metadata.startTime; + } + } + return earliest; + }); + + // The most recently started running sub-op decides the streaming phase. + // Server-side runtimes surface no sub-ops on the client, so fall back to + // 'generating' — the dominant phase for plain server-streamed chat. + const activity = useChatStore((s): ActivityKey => { + const ops = operationSelectors.getOperationsByContext(context)(s); + let current: ActivityKey | undefined; + let latest = -1; + for (const op of ops) { + if (op.status !== 'running' || op.metadata.isAborting) continue; + const mapped = resolveActivity(op.type); + if (!mapped) continue; + if (op.metadata.startTime > latest) { + latest = op.metadata.startTime; + current = mapped; + } + } + return current ?? 'generating'; + }); + + const steps = useChatStore((s) => { + const ops = operationSelectors.getOperationsByContext(context)(s); + let stepCount = 0; + + for (const op of ops) { + if ( + op.status !== 'running' || + op.metadata.isAborting || + !AI_RUNTIME_OPERATION_TYPES.includes(op.type) + ) { + continue; + } + + stepCount = Math.max(stepCount, normalizeStepCount(op.metadata.stepCount)); + } + + return stepCount; + }); + + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!startTime) return; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [startTime]); + + // Aggregate tokens / cost across the current conversation. + // New code reads usage only from the top-level message field. + const { totalCost, totalTokens } = useMemo(() => { + let tokens = 0; + let cost = 0; + for (const m of dbMessages) { + if (m.role !== 'assistant') continue; + const usage = m.usage; + if (!usage) continue; + tokens += usage.totalTokens ?? 0; + cost += usage.cost ?? 0; + } + return { totalCost: cost, totalTokens: tokens }; + }, [dbMessages]); + + if (!startTime) return null; + + const elapsed = now - startTime; + const tokenLabel = t('opStatusTray.tokens', { defaultValue: 'tokens' }); + + // Zero-valued metrics render nothing; steps only matter for long-running + // multi-step tasks, so a single step stays hidden too. + const metrics: ReactNode[] = []; + if (steps > 1) + metrics.push( + + + + {steps} + + , + ); + if (totalTokens > 0) + metrics.push( + + + + {formatTokens(totalTokens)} + + , + ); + if (totalCost > 0) + metrics.push( + + + {formatCost(totalCost)} + , + ); + + return ( + + + + + {t(`opStatusTray.status.${activity}`)}... + + {formatDuration(elapsed)} + + + {metrics.length > 0 && ( + + {metrics.map((node, i) => ( + + {i > 0 && } + {node} + + ))} + + )} + + ); +}); + +OpStatusTray.displayName = 'OpStatusTray'; + +export default OpStatusTray; diff --git a/src/features/Conversation/ChatInput/index.tsx b/src/features/Conversation/ChatInput/index.tsx index 86cf0c76da..2a4b19a638 100644 --- a/src/features/Conversation/ChatInput/index.tsx +++ b/src/features/Conversation/ChatInput/index.tsx @@ -23,6 +23,7 @@ import { } from '@/features/ChatInput/store/initialState'; import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; +import { selectCurrentTurnTodosFromMessages } from '@/store/chat/slices/message/selectors/dbMessage'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { fileChatSelectors, useFileStore } from '@/store/file'; @@ -30,6 +31,7 @@ import WideScreenContainer from '../../WideScreenContainer'; import InterventionBar from '../InterventionBar'; import { dataSelectors, messageStateSelectors, useConversationStore } from '../store'; import TodoProgress from '../TodoProgress'; +import OpStatusTray from './OpStatusTray'; import QueueTray from './QueueTray'; import { getConversationChatInputUiState } from './utils'; @@ -276,6 +278,10 @@ const ChatInput = memo( (s) => operationSelectors.queuedMessageCount(context)(s) > 0, ); + // Detect whether TodoProgress will render (mirrors its own gating) so we + // can square the top corners of OpStatusTray when it sits flush below. + const hasTodos = (selectCurrentTurnTodosFromMessages(dbMessages)?.items.length ?? 0) > 0; + // Computed state const isInputEmpty = !inputMessage.trim() && fileList.length === 0 && contextList.length === 0; const { placeholderVariant, showSendMenu, showStopButton } = getConversationChatInputUiState({ @@ -375,6 +381,7 @@ const ChatInput = memo( > {!disableQueue && hasQueuedMessages && } + ({ `, })); +// Cheap messages don't need a cost callout — only surface it once it's +// expensive enough to matter. +const MIN_DISPLAY_COST = 0.2; + +const formatCost = (cost: number) => cost.toFixed(2); + interface UsageProps { model: string; performance?: ModelPerformance; @@ -33,6 +42,8 @@ interface UsageProps { const Usage = memo(({ model, usage, performance, provider }) => { const onboardingAgentId = useAgentStore(builtinAgentSelectors.webOnboardingAgentId); const conversationAgentId = useConversationStore(contextSelectors.agentId); + // Credit mode already expresses cost in credits — showing USD alongside would conflict. + const isShowCredit = useGlobalStore(systemStatusSelectors.isShowCredit); if (!isDev && onboardingAgentId && conversationAgentId === onboardingAgentId) return null; @@ -64,14 +75,22 @@ const Usage = memo(({ model, usage, performance, provider }) => { )} - {!!usage?.totalTokens && ( - - )} +
+ {!!usage?.totalTokens && ( + + )} + {!isShowCredit && !!usage?.cost && usage.cost >= MIN_DISPLAY_COST && ( +
+ + {formatCost(usage.cost)} +
+ )} +
); }, isEqual); diff --git a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts index 7be6a61607..8e030b1ddf 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts @@ -47,6 +47,7 @@ function createMockStore() { operationId: `op-reasoning-${reasoningCounter}`, }; }), + updateOperationMetadata: vi.fn(), }; } diff --git a/src/store/chat/slices/aiChat/actions/gateway.ts b/src/store/chat/slices/aiChat/actions/gateway.ts index 55dedaee1f..e6ecc40e4d 100644 --- a/src/store/chat/slices/aiChat/actions/gateway.ts +++ b/src/store/chat/slices/aiChat/actions/gateway.ts @@ -597,11 +597,21 @@ export class GatewayActionImpl { topicId, }; + // Anchor the operation to the run's real start: the assistant message was + // created when the run began. Defaulting to Date.now() here would reset + // elapsed-time displays (OpStatusTray) to zero on every page refresh. + const assistantMessage = Object.values(this.#get().messagesMap) + .flat() + .find((m) => m.id === assistantMessageId); + // Create a local operation for UI loading state, stashing the server op id // so intervention flows can find it after reconnect as well. const { operationId: gatewayOpId } = this.#get().startOperation({ context, - metadata: { serverOperationId: operationId }, + metadata: { + serverOperationId: operationId, + ...(assistantMessage?.createdAt ? { startTime: assistantMessage.createdAt } : {}), + }, type: 'execServerAgentRuntime', }); diff --git a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts index ea7de067e2..ef49cadb0d 100644 --- a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts +++ b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts @@ -440,6 +440,13 @@ export const createGatewayEventHandler = ( uiMessages?: UIChatMessage[]; }; + // The server's stepIndex is the authoritative step counter — mirror it + // onto the operation so step-based UI (OpStatusTray) stays correct + // even across page-refresh reconnects. + if (typeof event.stepIndex === 'number') { + get().updateOperationMetadata(operationId, { stepCount: event.stepIndex + 1 }); + } + // Server attaches the canonical UIChatMessage[] snapshot at every // step boundary (agent-runtime #15152). Use it as Source of Truth // instead of issuing a DB refetch — the refetch returns a stale