Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 重渲染,
Expand Down Expand Up @@ -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}
Expand Down
84 changes: 83 additions & 1 deletion crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<RuntimeControlTooltip label={label}>
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.min(100, displayedPercentage)}
className="relative flex h-8 w-8 shrink-0 items-center justify-center text-[calc(7px*var(--zone-font-scale,1))] font-semibold leading-none tabular-nums text-foreground/75"
>
<svg aria-hidden viewBox="0 0 24 24" className="absolute inset-0 h-8 w-8 -rotate-90">
<circle
cx="12"
cy="12"
r="9.5"
fill="none"
strokeWidth="2.25"
className="stroke-foreground/10 dark:stroke-white/10"
/>
<circle
cx="12"
cy="12"
r="9.5"
fill="none"
pathLength="100"
strokeWidth="2.25"
strokeLinecap="round"
strokeDasharray="100"
strokeDashoffset={100 - ringPercentage}
className={cn("transition-[stroke-dashoffset,stroke] duration-300", progressClass)}
/>
</svg>
<span className="relative">{displayedPercentage}%</span>
</div>
</RuntimeControlTooltip>
);
}

function useComposerUploadedImagePreview(
file: PendingUploadedFile,
workdir: string,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -266,6 +339,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
chatRuntimeControls,
reasoningOptions,
thinkingAlwaysOn,
contextUsageTokens,
contextWindow,
gitClient,
gitWriteEnabled = true,
gitDisabledMessage,
Expand All @@ -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);
Expand Down Expand Up @@ -1035,6 +1110,13 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
</div>

<div className="flex shrink-0 items-center gap-1">
<ContextUsageRing
totalTokens={contextUsageTokens}
contextWindow={contextWindow}
locale={locale}
totalLabel={t("chat.usageTotal")}
contextWindowLabel={t("chat.contextWindow")}
/>
<Button
disabled={isSending ? false : sendDisabled}
onClick={() => {
Expand Down
10 changes: 6 additions & 4 deletions crates/agent-gui/src/components/ui/confirm-action-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Button } from "./button";

export function ConfirmActionPopover(props: {
title: string;
description: ReactNode;
description?: ReactNode;
confirmLabel: string;
onConfirm: () => void;
// Popover edge to align with the trigger; "end" suits right-aligned action
Expand Down Expand Up @@ -51,9 +51,11 @@ export function ConfirmActionPopover(props: {
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{title}</p>
<div className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{description}
</div>
{description ? (
<div className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{description}
</div>
) : null}
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
Expand Down
6 changes: 6 additions & 0 deletions crates/agent-gui/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.changedFiles.expand": "展开文件列表",
"chat.compactingContext": "正在压缩上下文",
"chat.compactingContextWait": "正在压缩上下文,请稍候...",
"chat.manualCompactTitle": "手动压缩上下文?",

"chat.manualCompactConfirm": "压缩",
"chat.editMessage": "编辑消息",
"chat.cancel": "取消",
"chat.queue.title": "等待队列 {count}",
Expand Down Expand Up @@ -2467,6 +2470,9 @@ export const translations: Record<Locale, Record<string, string>> = {
"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}",
Expand Down
38 changes: 36 additions & 2 deletions crates/agent-gui/src/lib/chat/compaction/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export class CompactionController {
async compactDuringRun(params: {
trigger: Exclude<CompactionTrigger, "pre-send">;
state: ConversationViewState;
force?: boolean;
budgetContext?: Context;
tools?: Context["tools"];
includeAbortedMessages?: boolean;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<boolean> {
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<boolean> {
const binding = this.binding;
const snapshot = this.rollbackSnapshot;
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-gui/src/lib/chat/compaction/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading
Loading