From b264e7553cdfa678b195403d375a9ad4d54290ec Mon Sep 17 00:00:00 2001 From: ushaio Date: Sun, 2 Aug 2026 14:03:40 +0800 Subject: [PATCH] feat(agent-gui): add context usage ring with manual compaction - Show a context usage ring in the composer based on the latest assistant round usage relative to the model context window (GUI + WebUI) - Clicking the ring (usage > 50%) opens a confirm popover to manually compact the current conversation - CompactionController gains compactNow() with a force mode and a "manual" trigger, reusing the existing compaction flow while idle - ConfirmActionPopover description is now optional --- .../agent-gateway/web/src/app/GatewayApp.tsx | 17 ++ .../web/src/pages/chat/ChatComposerBar.tsx | 84 +++++++++- .../components/ui/confirm-action-popover.tsx | 10 +- crates/agent-gui/src/i18n/config.ts | 6 + .../src/lib/chat/compaction/controller.ts | 38 ++++- .../src/lib/chat/compaction/types.ts | 2 +- crates/agent-gui/src/pages/ChatPage.tsx | 153 ++++++++++++++++++ .../pages/chat/components/ChatComposerBar.tsx | 133 ++++++++++++++- 8 files changed, 434 insertions(+), 9 deletions(-) diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index a8dce2858..90e69b8e3 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -4405,6 +4405,21 @@ export default function GatewayApp() { return item?.title ?? ""; }, [selectedHistoryId, sidebarConversationsById]); const transcriptRows = displayedTranscript.rows; + const contextUsageTokens = useMemo(() => { + for (let rowIndex = transcriptRows.length - 1; rowIndex >= 0; rowIndex -= 1) { + const row = transcriptRows[rowIndex]; + if (row.kind === "checkpoint") return undefined; + if (row.kind !== "assistant") continue; + + for (let roundIndex = row.rounds.length - 1; roundIndex >= 0; roundIndex -= 1) { + const totalTokens = row.rounds[roundIndex].meta?.usageTotalTokens; + if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) { + return totalTokens; + } + } + } + return undefined; + }, [transcriptRows]); // 当前会话的待审批工具:遍历渲染中的 transcript,筛出带 __toolApprovalPending 标记 // 且尚无结果的 tool call(与 ToolCallItem 判定同源)。用于输入框上方的集中审批栏, // 取代埋在各折叠项里的分散卡片。快照 revision 变化时经 useConversationChat 重渲染, @@ -4981,6 +4996,8 @@ export default function GatewayApp() { chatRuntimeControls={chatRuntimeControlsForCurrentProvider} reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} + contextUsageTokens={contextUsageTokens} + contextWindow={currentModelContextWindow} gitClient={gitClient} gitWriteEnabled={settings.remote.enableWebGit} gitDisabledMessage={gitDisabledMessage} diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index dbd3f1cd0..0c36e153a 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -101,6 +101,77 @@ function RuntimeControlTooltip(props: { label: string; children: ReactNode }) { ); } +function ContextUsageRing(props: { + totalTokens?: number; + contextWindow?: number; + locale: string; + totalLabel: string; + contextWindowLabel: string; +}) { + const { totalTokens, contextWindow, locale, totalLabel, contextWindowLabel } = props; + if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) { + return null; + } + + const normalizedTokens = + typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0 + ? Math.floor(totalTokens) + : 0; + const rawPercentage = (normalizedTokens / contextWindow) * 100; + const displayedPercentage = Math.min(999, Math.round(rawPercentage)); + const ringPercentage = Math.min(100, Math.max(0, rawPercentage)); + const formattedTokens = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format( + normalizedTokens, + ); + const formattedWindow = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format( + contextWindow, + ); + const label = `${displayedPercentage}% · ${totalLabel} ${formattedTokens} · ${contextWindowLabel} ${formattedWindow}`; + const progressClass = + rawPercentage >= 90 + ? "stroke-red-500 dark:stroke-red-400" + : rawPercentage >= 70 + ? "stroke-amber-500 dark:stroke-amber-400" + : "stroke-emerald-500 dark:stroke-emerald-400"; + + return ( + +
+ + + + + {displayedPercentage}% +
+
+ ); +} + function useComposerUploadedImagePreview( file: PendingUploadedFile, workdir: string, @@ -230,6 +301,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { chatRuntimeControls: ChatRuntimeControls; reasoningOptions: ReasoningLevel[]; thinkingAlwaysOn: boolean; + contextUsageTokens?: number; + contextWindow?: number; gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; @@ -266,6 +339,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { chatRuntimeControls, reasoningOptions, thinkingAlwaysOn, + contextUsageTokens, + contextWindow, gitClient, gitWriteEnabled = true, gitDisabledMessage, @@ -288,7 +363,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onRemoveQueuedTurn, approvalBar, } = props; - const { t } = useLocale(); + const { t, locale } = useLocale(); const [composerIsEmpty, setComposerIsEmpty] = useState(true); const [isComposerExpanded, setIsComposerExpanded] = useState(false); const isComposerExpandedRef = useRef(false); @@ -1035,6 +1110,13 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
+

{title}

-
- {description} -
+ {description ? ( +
+ {description} +
+ ) : null}
diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 1d5a9014a..17c54962d 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -198,6 +198,9 @@ export const translations: Record> = { "chat.changedFiles.expand": "展开文件列表", "chat.compactingContext": "正在压缩上下文", "chat.compactingContextWait": "正在压缩上下文,请稍候...", + "chat.manualCompactTitle": "手动压缩上下文?", + + "chat.manualCompactConfirm": "压缩", "chat.editMessage": "编辑消息", "chat.cancel": "取消", "chat.queue.title": "等待队列 {count}", @@ -2467,6 +2470,9 @@ export const translations: Record> = { "chat.changedFiles.expand": "Expand file list", "chat.compactingContext": "Compressing context", "chat.compactingContextWait": "Compressing context, please wait...", + "chat.manualCompactTitle": "Compact context manually?", + + "chat.manualCompactConfirm": "Compact", "chat.editMessage": "Edit Message", "chat.cancel": "Cancel", "chat.queue.title": "Queue {count}", diff --git a/crates/agent-gui/src/lib/chat/compaction/controller.ts b/crates/agent-gui/src/lib/chat/compaction/controller.ts index 77db0d8d1..275f963c1 100644 --- a/crates/agent-gui/src/lib/chat/compaction/controller.ts +++ b/crates/agent-gui/src/lib/chat/compaction/controller.ts @@ -244,6 +244,7 @@ export class CompactionController { async compactDuringRun(params: { trigger: Exclude; state: ConversationViewState; + force?: boolean; budgetContext?: Context; tools?: Context["tools"]; includeAbortedMessages?: boolean; @@ -292,10 +293,14 @@ export class CompactionController { : binding.buildPreparedContext(workingState, params.tools, buildOptions); this.ledger.rebase(budgetContext); this.updateTurnMeta(workingState); - const decision = this.decide("protection", this.ledger.total(), now); + const decision = this.decide( + params.force ? "optimization" : "protection", + this.ledger.total(), + now, + ); this.logDecision(decision); - if (!decision.shouldCompact) { + if (!params.force && !decision.shouldCompact) { if (pruned) { binding.sinks.applyStateMidRun?.(pruned.state); return { @@ -378,6 +383,35 @@ export class CompactionController { } // 用户中止后的统一善后:有快照则回滚(恢复状态/输入框/可选持久化)并返回 true。 + /** + * 用户手动触发的上下文压缩(点击输入区上下文用量环 → 确认):临时绑定一轮并复用主压缩流程, + * 强制跳过阈值判断。仅限空闲时调用;若有进行中的回合或绑定则直接返回 false。 + */ + async compactNow(params: { + state: ConversationViewState; + providerId: ProviderId; + model: string; + runtime: ProviderRuntimeConfig; + cancellation: TurnCancellation; + debugLogger?: StreamDebugLogger; + sinks: CompactionSinks; + buildPreparedContext: CompactionTurnBinding["buildPreparedContext"]; + buildResumeContext: CompactionTurnBinding["buildResumeContext"]; + }): Promise { + if (this.binding || this.inFlight) return false; + this.bindTurn({ ...params }); + try { + const result = await this.compactDuringRun({ + trigger: "manual", + state: params.state, + force: true, + }); + return result.context !== null; + } finally { + this.unbindTurn(); + } + } + async handleTurnAbort(): Promise { const binding = this.binding; const snapshot = this.rollbackSnapshot; diff --git a/crates/agent-gui/src/lib/chat/compaction/types.ts b/crates/agent-gui/src/lib/chat/compaction/types.ts index 87fed85cc..03fbe75cb 100644 --- a/crates/agent-gui/src/lib/chat/compaction/types.ts +++ b/crates/agent-gui/src/lib/chat/compaction/types.ts @@ -1,6 +1,6 @@ export type { ProviderRuntimeConfig } from "../../providers/runtime/types"; -export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool"; +export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool" | "manual"; // optimization = 发送前的从容压缩(阈值更宽),protection = 运行中的保护性压缩(阈值更紧)。 export type CompactionIntent = "optimization" | "protection"; diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 6974cae64..98f6a7465 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -34,6 +34,7 @@ import { useLocale } from "../i18n"; import type { AppUpdateController } from "../lib/appUpdates"; import { getAutomationState, useAutomation } from "../lib/automation"; import type { ChatFileLink } from "../lib/chat/chatFileLinks"; +import type { CompactionSinks } from "../lib/chat/compaction/controller"; import type { CompactionStatus } from "../lib/chat/compaction/types"; import { buildRequestContext, @@ -41,6 +42,8 @@ import { createConversationStateFromContext, type RenderTimelineItem, } from "../lib/chat/conversation/conversationState"; +import { createGatewayBridgeEventController } from "../lib/chat/conversation/run/gatewayBridgeEvents"; +import { createTurnCancellation } from "../lib/chat/conversation/turnCancellation"; import type { ChatHistorySummary } from "../lib/chat/history/chatHistory"; import { memoryExtraction } from "../lib/chat/memory/extractionController"; import type { CodeMentionReference } from "../lib/chat/messages/mentionReferences"; @@ -54,6 +57,7 @@ import { import type { ScrollFollowHandle } from "../lib/chat-scroll/useScrollFollow"; import { tauriGitClient } from "../lib/git/tauriGitClient"; import { setPreferredMonacoNlsLocale } from "../lib/monacoNls"; +import { createProviderRuntimeConfig } from "../lib/providers/llm"; import { type AppSettings, getRightDockFileTreeState, @@ -127,6 +131,7 @@ import { appendManagedSkillSelections } from "./chat/chatPageUtils"; import { ChatFileDropOverlay } from "./chat/components/ChatFileDropOverlay"; import { WorkspaceOverlayHost } from "./chat/components/WorkspaceOverlayHost"; import { useComposerDraftCache } from "./chat/composer/useComposerDraftCache"; +import { createLocalGatewayChatRunId } from "./chat/gateway/gatewayRuntimeStatusModel"; import { useGatewayBridgeReadiness } from "./chat/gateway/useGatewayBridgeReadiness"; import { useGatewayRunMirrorCoordinator } from "./chat/gateway/useGatewayRunMirrorCoordinator"; import { useGatewayStatus } from "./chat/gateway/useGatewayStatus"; @@ -139,6 +144,11 @@ import { removeQueuedChatTurnsForConversation, } from "./chat/queue/chatTurnQueue"; import { useChatTurnQueue } from "./chat/queue/useChatTurnQueue"; +import { buildCompactionContext } from "./chat/runtime/conversationContextBuilders"; +import { + type EffectiveChatModelSelection, + resolveEffectiveChatModelSelection, +} from "./chat/runtime/modelSelection"; import { useChatModelSelection } from "./chat/runtime/useChatModelSelection"; import { useSendChatTurn } from "./chat/runtime/useSendChatTurn"; import { ChatSidebarContainer } from "./chat/sidebar/ChatSidebarContainer"; @@ -368,6 +378,21 @@ export function ChatPage(props: ChatPageProps) { () => conversationState.transcript.items, [conversationState], ); + const contextUsageTokens = useMemo(() => { + for (let itemIndex = transcriptItems.length - 1; itemIndex >= 0; itemIndex -= 1) { + const item = transcriptItems[itemIndex]; + if (item.kind === "summary") return undefined; + if (item.kind !== "assistant") continue; + + for (let roundIndex = item.rounds.length - 1; roundIndex >= 0; roundIndex -= 1) { + const totalTokens = item.rounds[roundIndex].meta?.usageTotalTokens; + if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) { + return totalTokens; + } + } + } + return undefined; + }, [transcriptItems]); // Sent-prompt history for the composer's ↑/↓ recall. Read lazily through a // ref so the memoized composer bar never re-renders on transcript growth. const transcriptItemsRef = useRef(transcriptItems); @@ -1087,6 +1112,131 @@ export function ChatPage(props: ChatPageProps) { setHydrationFailedConversationId, }); + /** + * 手动压缩当前会话上下文(由输入区上下文用量环的确认弹窗触发)。 + * 空闲时复用压缩主流程强制压缩;发送中/压缩中返回 false。 + */ + const handleManualCompact = useCallback(async (): Promise => { + const conversationId = currentConversationIdRef.current; + if (isConversationRunning(conversationId)) return false; + const runtimeEntry = buildRuntimeEntryFromVisibleState(); + if (runtimeEntry.compactionStatus.phase !== "idle") return false; + // 手动压缩仅在上下文占用超过 50% 时可用(与用量环展示口径一致)。 + const usageRatio = + typeof contextUsageTokens === "number" && + Number.isFinite(contextUsageTokens) && + typeof currentModelContextWindow === "number" && + Number.isFinite(currentModelContextWindow) && + currentModelContextWindow > 0 + ? contextUsageTokens / currentModelContextWindow + : 0; + if (usageRatio <= 0.5) return false; + + let effectiveSelectedModel: EffectiveChatModelSelection; + try { + effectiveSelectedModel = resolveEffectiveChatModelSelection({ + settings, + conversationSelectedModel: runtimeEntry.selectedModel, + }); + } catch { + setErrorMessage("当前模型配置不可用,请重新选择模型后重试。"); + return false; + } + const { selectedModel, provider, providerId, model } = effectiveSelectedModel; + const providerConfig = createProviderRuntimeConfig( + provider, + model, + chatRuntimeControlsForCurrentProvider, + settings.customSettings.providerIdentities, + ); + + const hasRemoteGatewayTarget = + settings.remote.enabled && + settings.remote.gatewayUrl.trim() !== "" && + settings.remote.token.trim() !== ""; + const gatewayBridgeEvents = createGatewayBridgeEventController({ + conversationId, + requestId: createLocalGatewayChatRunId(conversationId), + workerId: "gui-live", + enabled: hasRemoteGatewayTarget, + sendEvent: queueGatewayBridgeEventForRequest, + flushEvents: flushGatewayBridgeEventsForRequest, + resolveErrorConversationId: () => currentConversationIdRef.current, + }); + const setBridgeToolStatus = (status: string | null, isCompaction = false) => { + gatewayBridgeEvents.queueToolStatus(status, isCompaction); + updateToolStatus(status, liveTranscriptStore); + }; + + const controller = getCompactionController(conversationId); + const sinks: CompactionSinks = { + applyState: (state) => + updateConversationRuntimeEntry(conversationId, (prev) => ({ ...prev, state })), + applyStateMidRun: (state) => { + updateConversationRuntimeEntry(conversationId, (prev) => ({ ...prev, state })); + resetLiveTranscript(liveTranscriptStore); + }, + publishStatus: (status) => + updateConversationRuntimeEntry(conversationId, (prev) => ({ + ...prev, + compactionStatus: status, + })), + setBridgeToolStatus, + queueCheckpoint: (state) => gatewayBridgeEvents.queueCheckpoint(state), + persist: (state) => + persistConversation({ + conversationId, + sessionId: runtimeEntry.sessionId, + providerId, + model, + selectedModel, + cwd: displayedConversationWorkdir || undefined, + state, + fallbackTitle: t("chat.pendingTitle"), + createdAt: runtimeEntry.createdAt, + titlePromise: null, + }), + }; + + try { + return await controller.compactNow({ + state: runtimeEntry.state, + providerId, + model, + runtime: providerConfig, + cancellation: createTurnCancellation(), + sinks, + buildPreparedContext: (state, tools, options) => + buildCompactionContext(state, tools, options), + buildResumeContext: (state, resumeMessage, tools, options) => { + const base = buildCompactionContext(state, tools, options); + if (!resumeMessage) return base; + return { ...base, messages: [...base.messages, resumeMessage] }; + }, + }); + } finally { + await gatewayBridgeEvents.close(); + } + }, [ + buildRuntimeEntryFromVisibleState, + chatRuntimeControlsForCurrentProvider, + contextUsageTokens, + currentConversationIdRef, + currentModelContextWindow, + displayedConversationWorkdir, + flushGatewayBridgeEventsForRequest, + getCompactionController, + isConversationRunning, + liveTranscriptStore, + persistConversation, + queueGatewayBridgeEventForRequest, + resetLiveTranscript, + settings, + t, + updateConversationRuntimeEntry, + updateToolStatus, + ]); + startNewConversationActionRef.current = startNewConversation; openInitialActionRef.current = openConversationInitial; loadEarlierHistoryActionRef.current = loadEarlierConversationHistory; @@ -2037,6 +2187,9 @@ export function ChatPage(props: ChatPageProps) { chatRuntimeControls={chatRuntimeControlsForCurrentProvider} reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} + contextUsageTokens={contextUsageTokens} + contextWindow={currentModelContextWindow} + onManualCompactConfirm={handleManualCompact} gitClient={tauriGitClient} workspaceActivityClient={tauriWorkspaceActivityClient} onSend={handleSend} diff --git a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx index 1d03b5fdd..226c0ae4a 100644 --- a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx +++ b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx @@ -38,6 +38,7 @@ import { Trash2, } from "../../../components/icons"; import { Button } from "../../../components/ui/button"; +import { ConfirmActionPopover } from "../../../components/ui/confirm-action-popover"; import { Select, SelectContent, @@ -96,6 +97,118 @@ function RuntimeControlTooltip(props: { label: string; children: ReactNode }) { ); } +function ContextUsageRing(props: { + totalTokens?: number; + contextWindow?: number; + locale: string; + totalLabel: string; + contextWindowLabel: string; + confirmTitle: string; + confirmLabel: string; + disabled?: boolean; + onConfirm?: (() => void) | (() => Promise); +}) { + const { + totalTokens, + contextWindow, + locale, + totalLabel, + contextWindowLabel, + confirmTitle, + confirmLabel, + disabled, + onConfirm, + } = props; + if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) { + return null; + } + + const normalizedTokens = + typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0 + ? Math.floor(totalTokens) + : 0; + const rawPercentage = (normalizedTokens / contextWindow) * 100; + const displayedPercentage = Math.min(999, Math.round(rawPercentage)); + const ringPercentage = Math.min(100, Math.max(0, rawPercentage)); + const formattedTokens = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format( + normalizedTokens, + ); + const formattedWindow = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format( + contextWindow, + ); + const label = `${displayedPercentage}% · ${totalLabel} ${formattedTokens} · ${contextWindowLabel} ${formattedWindow}`; + const progressClass = + rawPercentage >= 90 + ? "stroke-red-500 dark:stroke-red-400" + : rawPercentage >= 70 + ? "stroke-amber-500 dark:stroke-amber-400" + : "stroke-emerald-500 dark:stroke-emerald-400"; + // 手动压缩仅在上下文占用超过 50% 时可用。 + const manualCompactAvailable = rawPercentage > 50; + + return ( + + onConfirm?.()} + > + {(open) => ( + + )} + + + ); +} let pendingComposerImagePreviewSequence = 0; const pendingComposerImagePreviewKeys = new WeakMap(); @@ -184,6 +297,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { chatRuntimeControls: ChatRuntimeControls; reasoningOptions: ReasoningLevel[]; thinkingAlwaysOn: boolean; + contextUsageTokens?: number; + contextWindow?: number; + /** 点击上下文用量环触发手动压缩(确认弹窗由组件内部展示)。 */ + onManualCompactConfirm?: (() => void) | (() => Promise); gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; @@ -219,6 +336,9 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { chatRuntimeControls, reasoningOptions, thinkingAlwaysOn, + contextUsageTokens, + contextWindow, + onManualCompactConfirm, gitClient, gitWriteEnabled = true, gitDisabledMessage, @@ -240,7 +360,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onHeightChange, approvalBar, } = props; - const { t } = useLocale(); + const { t, locale } = useLocale(); const rootRef = useRef(null); const attachmentListRef = useRef(null); const previousPendingUploadCountRef = useRef(0); @@ -981,6 +1101,17 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
+