From 2e50f20b524512784ba9a76bbfa7178fdf57b88d Mon Sep 17 00:00:00 2001
From: devcxl <64475363+devcxl@users.noreply.github.com>
Date: Mon, 10 Aug 2026 18:43:51 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat(sidepanel):=20=E8=BE=93=E5=85=A5?=
=?UTF-8?q?=E6=A1=86=E6=97=81=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8D=A0=E7=94=A8?=
=?UTF-8?q?=E8=BF=9B=E5=BA=A6=E7=8E=AF=EF=BC=88ContextUsageRing=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新组件 ContextUsageRing:内联 SVG 20px 环形(SIZE=20/STROKE=2.5/RADIUS=8.75),
进度弧 stroke-dashoffset=CIRC·(1-percent/100)+rotate(-90);
导出纯函数 contextUsagePercent(0-100 整数 clamp + limit<=0 防御)
- 隐藏条件 used<=0||limit<=0;占用率≥80% data-state=warning + text-warning,
否则 normal + text-primary;title/aria-label 复用 formatNum 千位分隔
- MessageInput 新增可选 props tokenUsage/contextWindowTokens,上限解析链
activeModel.limit.context ?? contextWindowTokens ?? 128000,
渲染位置:hasConfigRow 之后、micButton 之前
- App inputProps 透传 tokenUsage 与 agentSettings.contextWindowTokens
- i18n:chat.contextUsage.tooltip(zh/en)
- 测试:ContextUsageRing 6 项行为 + MessageInput 4 项集成 + ChatContext
会话切换 tokenUsage 重置回归
---
src/entrypoints/sidepanel/App.tsx | 2 +
.../sidepanel/__tests__/ChatContext.test.tsx | 75 ++++++++++
.../__tests__/ContextUsageRing.test.tsx | 89 ++++++++++++
.../sidepanel/__tests__/MessageInput.test.tsx | 136 ++++++++++++++++++
.../sidepanel/components/ContextUsageRing.tsx | 80 +++++++++++
.../sidepanel/components/MessageInput.tsx | 12 ++
src/entrypoints/sidepanel/i18n/types.ts | 3 +
src/entrypoints/sidepanel/locales/en.json | 3 +
src/entrypoints/sidepanel/locales/zh-CN.json | 3 +
9 files changed, 403 insertions(+)
create mode 100644 src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx
create mode 100644 src/entrypoints/sidepanel/components/ContextUsageRing.tsx
diff --git a/src/entrypoints/sidepanel/App.tsx b/src/entrypoints/sidepanel/App.tsx
index 053e5f3..7adcf70 100644
--- a/src/entrypoints/sidepanel/App.tsx
+++ b/src/entrypoints/sidepanel/App.tsx
@@ -312,6 +312,8 @@ function ChatLayout() {
onSelectModel: handleSelectModel,
reasoningEffort,
onReasoningEffortChange: handleReasoningEffortChange,
+ tokenUsage,
+ contextWindowTokens: agentSettings.contextWindowTokens,
};
return (
diff --git a/src/entrypoints/sidepanel/__tests__/ChatContext.test.tsx b/src/entrypoints/sidepanel/__tests__/ChatContext.test.tsx
index 576db53..91e1b36 100644
--- a/src/entrypoints/sidepanel/__tests__/ChatContext.test.tsx
+++ b/src/entrypoints/sidepanel/__tests__/ChatContext.test.tsx
@@ -38,6 +38,33 @@ vi.mock('@/conversation', () => ({
ConversationManager: vi.fn(() => mockConversationManager),
}));
+// ── useAgent mock:捕获 ChatProvider 注册的回调(onTokenUsage 等),供回归测试驱动 ──
+
+const mockAgent = vi.hoisted(() => {
+ const callbacks: {
+ onMessage?: (msg: unknown) => void;
+ onConfirm?: (req: unknown) => void;
+ onTokenUsage?: (usage: { prompt: number; completion: number }) => void;
+ onConversationTitle?: (conversationId: string, title: string) => void;
+ } = {};
+ return {
+ status: 'idle',
+ error: null,
+ runningConversationId: null,
+ callbacks,
+ setCallbacks: (cb: typeof callbacks) => {
+ Object.assign(callbacks, cb);
+ },
+ run: vi.fn(),
+ abort: vi.fn(),
+ resolveConfirm: vi.fn(),
+ };
+});
+
+vi.mock('../hooks/useAgent', () => ({
+ useAgent: () => mockAgent,
+}));
+
import { ChatProvider, useChat } from '../ChatContext';
function ContextInspector() {
@@ -320,4 +347,52 @@ describe('ChatContext message loading', () => {
expect(screen.getByTestId('messages-count').textContent).toBe('1');
expect(screen.getByTestId('active-id').textContent).toBe('conv-B');
});
+
+ it('切换 activeId 后 tokenUsage 重置为 {0,0}(会话切换无残留旧值)', async () => {
+ const now = Date.now();
+ mockConversationManager.list.mockResolvedValue([
+ { id: 'conv-A', title: 'Conv A', titleGenerated: true, updatedAt: now, createdAt: now, messages: [], sensitiveDataGranted: false },
+ { id: 'conv-B', title: 'Conv B', titleGenerated: true, updatedAt: now, createdAt: now, messages: [], sensitiveDataGranted: false },
+ ]);
+ mockConversationManager.get.mockResolvedValue({
+ id: 'conv-A', title: 'Conv A', titleGenerated: true, createdAt: now, updatedAt: now, messages: [], sensitiveDataGranted: false,
+ });
+
+ function TokenUsageInspector() {
+ const chat = useChat();
+ return (
+
+
{chat.tokenUsage.prompt}
+
{chat.conversations.activeId ?? '(none)'}
+
+
+
+ );
+ }
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByTestId('active-id').textContent).toBe('conv-A'));
+
+ // 模拟一次请求返回 usage → tokenUsage 变为非零
+ await userEvent.click(screen.getByTestId('fire-usage'));
+ await waitFor(() => expect(screen.getByTestId('token-usage-prompt').textContent).toBe('5000'));
+
+ // 切换会话 → 重置为 {0,0}
+ await userEvent.click(screen.getByTestId('select-conv-b'));
+ await waitFor(() => expect(screen.getByTestId('active-id').textContent).toBe('conv-B'));
+ expect(screen.getByTestId('token-usage-prompt').textContent).toBe('0');
+ });
});
diff --git a/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx b/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx
new file mode 100644
index 0000000..343cb4e
--- /dev/null
+++ b/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { describe, it, expect, beforeEach } from 'vitest';
+import { ContextUsageRing, contextUsagePercent } from '../components/ContextUsageRing';
+import { I18nProvider } from '../i18n/I18nProvider';
+import { mockBrowserStorage } from './test-utils';
+
+// 与组件实现同一几何公式的独立期望值(CIRC = 2π·((20-2.5)/2))
+const SIZE = 20;
+const STROKE = 2.5;
+const CIRCUMFERENCE = 2 * Math.PI * ((SIZE - STROKE) / 2);
+
+beforeEach(() => {
+ mockBrowserStorage();
+});
+
+function wrappedRender(ui: React.ReactElement) {
+ return render({ui});
+}
+
+describe('contextUsagePercent', () => {
+ it('返回 0-100 整数(含恰好 100% 边界)', () => {
+ expect(contextUsagePercent(45200, 128000)).toBe(35);
+ expect(contextUsagePercent(128000, 128000)).toBe(100);
+ });
+
+ it('clamp 上限:占用超过上限时返回 100', () => {
+ expect(contextUsagePercent(150000, 128000)).toBe(100);
+ });
+
+ it('clamp 下限与 limit<=0 防御:返回 0 且不抛错', () => {
+ expect(contextUsagePercent(0, 128000)).toBe(0);
+ expect(contextUsagePercent(-1, 128000)).toBe(0);
+ expect(contextUsagePercent(100, 0)).toBe(0);
+ });
+});
+
+describe('ContextUsageRing', () => {
+ it('used<=0 或 limit<=0 时隐藏,有效值时显示', () => {
+ const { queryByTestId, rerender } = wrappedRender();
+ expect(queryByTestId('context-usage-ring')).toBeNull();
+
+ rerender();
+ expect(queryByTestId('context-usage-ring')).toBeNull();
+
+ rerender();
+ expect(queryByTestId('context-usage-ring')).not.toBeNull();
+ });
+
+ it('占用率 ≥80% 时 wrapper data-state=warning 且进度弧 text-warning,<80% 为 normal', () => {
+ const { getByTestId, rerender } = wrappedRender();
+ expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('warning');
+ expect(getByTestId('context-usage-ring-progress').className).toContain('text-warning');
+
+ rerender();
+ expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('normal');
+ expect(getByTestId('context-usage-ring-progress').className).toContain('text-primary');
+
+ // 100% 仍为 warning
+ rerender();
+ expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('warning');
+ });
+
+ it('hover title/aria-label 显示千位分隔的上下文占用文案', () => {
+ const { getByTestId, rerender } = wrappedRender();
+ let wrapper = getByTestId('context-usage-ring');
+ expect(wrapper.getAttribute('title')).toBe('上下文占用:45,200 / 128,000');
+ expect(wrapper.getAttribute('aria-label')).toBe('上下文占用:45,200 / 128,000');
+
+ rerender();
+ wrapper = getByTestId('context-usage-ring');
+ expect(wrapper.getAttribute('title')).toContain('1,000');
+ expect(wrapper.getAttribute('title')).toContain('128,000');
+ });
+
+ it('进度弧 stroke-dashoffset 与 percent 几何闭环', () => {
+ const { getByTestId, rerender } = wrappedRender();
+ const offset = () => parseFloat(getByTestId('context-usage-ring-progress').getAttribute('stroke-dashoffset') ?? 'NaN');
+
+ // percent=25 → 0.75·CIRC
+ expect(Math.abs(offset() - 0.75 * CIRCUMFERENCE)).toBeLessThan(0.5);
+ // percent=100 → ≈0
+ rerender();
+ expect(Math.abs(offset() - 0)).toBeLessThan(0.5);
+ // percent=0 → ≈CIRC
+ rerender();
+ expect(Math.abs(offset() - CIRCUMFERENCE)).toBeLessThan(0.5);
+ });
+});
diff --git a/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx b/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx
index a326da4..bdccbc4 100644
--- a/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx
+++ b/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx
@@ -42,6 +42,37 @@ function getLatestMockReturn(): UseVoiceInputReturn & { __onTranscribed: (text:
const EMPTY_PROVIDERS: ProviderConfig[] = [];
+/** 模型未配置 limit.context → 上限回退 contextWindowTokens / 默认 128000 */
+const PROVIDER_NO_CONTEXT_LIMIT: ProviderConfig = {
+ id: 'local',
+ name: 'Local Gateway',
+ apiKey: '',
+ isLocalTrusted: true,
+ models: {
+ chat: { id: 'chat', name: 'Chat' },
+ },
+ defaultModelId: 'chat',
+};
+
+/** 模型配置 limit.context=32768 且支持思考等级 → reasoning-select 渲染 */
+const PROVIDER_CONTEXT_32768: ProviderConfig = {
+ id: 'local',
+ name: 'Local Gateway',
+ apiKey: '',
+ isLocalTrusted: true,
+ models: {
+ qwen: {
+ id: 'qwen',
+ name: 'Qwen',
+ limit: { context: 32768, output: 8192 },
+ reasoning: true,
+ reasoningEfforts: ['low', 'high'],
+ defaultReasoningEffort: 'high',
+ },
+ },
+ defaultModelId: 'qwen',
+};
+
function wrappedRender(ui: React.ReactElement) {
return render({ui});
}
@@ -341,4 +372,109 @@ describe('MessageInput', () => {
expect(screen.getByTestId('reasoning-unsupported')).toHaveTextContent('Think: Unsupported');
});
+
+ // ── 上下文占用进度环集成 ──
+
+ it('传入 tokenUsage 时显示上下文占用进度环(模型无 limit.context 时回退默认上限 128000)', () => {
+ wrappedRender(
+ ,
+ );
+
+ const ring = screen.getByTestId('context-usage-ring');
+ expect(ring.getAttribute('aria-label')).toContain('45,200 / 128,000');
+ });
+
+ it('tokenUsage 未传或 prompt=0 时不渲染进度环', () => {
+ const { rerender } = wrappedRender(
+ ,
+ );
+ expect(screen.queryByTestId('context-usage-ring')).toBeNull();
+
+ rerender(
+ ,
+ );
+ expect(screen.queryByTestId('context-usage-ring')).toBeNull();
+ });
+
+ it('activeModel.limit.context 优先于 contextWindowTokens 作为上限', () => {
+ wrappedRender(
+ ,
+ );
+
+ const ring = screen.getByTestId('context-usage-ring');
+ expect(ring.getAttribute('aria-label')).toContain('45,200');
+ expect(ring.getAttribute('aria-label')).toContain('32,768');
+ expect(ring.getAttribute('aria-label')).not.toContain('50,000');
+ });
+
+ it('DOM 顺序为 reasoning 下拉 → 进度环 → 麦克风按钮', () => {
+ wrappedRender(
+ ,
+ );
+
+ const reasoning = screen.getByRole('combobox', { name: '思考' });
+ const ring = screen.getByTestId('context-usage-ring');
+ const mic = screen.getByTestId('mic-button');
+
+ const follows = (a: Element, b: Element) =>
+ (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
+
+ expect(follows(reasoning, ring)).toBe(true);
+ expect(follows(ring, mic)).toBe(true);
+ });
});
diff --git a/src/entrypoints/sidepanel/components/ContextUsageRing.tsx b/src/entrypoints/sidepanel/components/ContextUsageRing.tsx
new file mode 100644
index 0000000..261e8ec
--- /dev/null
+++ b/src/entrypoints/sidepanel/components/ContextUsageRing.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { cn, formatNum } from '../utils';
+import { useI18n } from '../i18n/useI18n';
+
+/** 进度环几何常量(模块内私有) */
+const SIZE = 20;
+const STROKE = 2.5;
+const RADIUS = (SIZE - STROKE) / 2;
+const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
+/** 占用率 ≥ 80% 时进度弧变色警告 */
+const WARNING_THRESHOLD = 0.8;
+
+interface ContextUsageRingProps {
+ /** 最近一次请求发送给 LLM 的完整上下文 token 数(tokenUsage.prompt) */
+ used: number;
+ /** 上下文上限(模型 limit.context 或 agentSettings.contextWindowTokens) */
+ limit: number;
+}
+
+/** 占用率(0-100 整数),limit<=0 时防御性返回 0 */
+export function contextUsagePercent(used: number, limit: number): number {
+ if (limit <= 0) return 0;
+ const percent = Math.round((used / limit) * 100);
+ if (percent <= 0) return 0;
+ if (percent >= 100) return 100;
+ return percent;
+}
+
+export function ContextUsageRing({ used, limit }: ContextUsageRingProps) {
+ const { t, locale } = useI18n();
+
+ if (used <= 0 || limit <= 0) return null;
+
+ const percent = contextUsagePercent(used, limit);
+ const isWarning = percent >= WARNING_THRESHOLD * 100;
+ const dashOffset = CIRCUMFERENCE * (1 - percent / 100);
+ const tooltip = t('chat.contextUsage.tooltip', {
+ used: formatNum(used, locale),
+ limit: formatNum(limit, locale),
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/src/entrypoints/sidepanel/components/MessageInput.tsx b/src/entrypoints/sidepanel/components/MessageInput.tsx
index a712f9d..0f024fc 100644
--- a/src/entrypoints/sidepanel/components/MessageInput.tsx
+++ b/src/entrypoints/sidepanel/components/MessageInput.tsx
@@ -3,7 +3,9 @@ import { cn } from '../utils';
import type { ProviderConfig, ReasoningEffort } from '@/shared/types';
import { useVoiceInput } from '../hooks/useVoiceInput';
import { useI18n } from '../i18n/useI18n';
+import type { TokenUsage } from '../types';
import { ChatSelect } from './ChatSelect';
+import { ContextUsageRing } from './ContextUsageRing';
interface Props {
onSend: (text: string) => void;
@@ -19,6 +21,10 @@ interface Props {
onReasoningEffortChange?: (effort: ReasoningEffort | undefined) => void;
/** home: 居中大输入框;chat: 底部紧凑输入框 */
variant?: 'home' | 'chat';
+ /** 最近一次请求的 token 用量(未传视为 {0,0},进度环隐藏) */
+ tokenUsage?: TokenUsage;
+ /** 上下文窗口上限(agentSettings.contextWindowTokens;默认 128000) */
+ contextWindowTokens?: number;
}
const SpinnerIcon = (
@@ -33,6 +39,7 @@ export function MessageInput({
providers, selectedProviderId = '', onSelectProvider = () => {}, selectedModelId = '', onSelectModel = () => {},
reasoningEffort, onReasoningEffortChange = () => {},
variant = 'chat',
+ tokenUsage, contextWindowTokens,
}: Props) {
const { t } = useI18n();
const [text, setText] = useState('');
@@ -42,6 +49,9 @@ export function MessageInput({
const models = Object.values(activeProvider?.models ?? {});
const activeModel = activeProvider?.models?.[selectedModelId] ?? null;
+ // 上下文上限解析链:模型 limit.context → agentSettings.contextWindowTokens → 默认 128000
+ const contextLimit = activeModel?.limit?.context ?? contextWindowTokens ?? 128000;
+
const handleTranscribed = useCallback((transcribedText: string) => {
setText((prev) => {
const separator = prev.trim() ? ' ' : '';
@@ -242,6 +252,8 @@ export function MessageInput({
>
)}
+ {tokenUsage && }
+
{micButton}
diff --git a/src/entrypoints/sidepanel/i18n/types.ts b/src/entrypoints/sidepanel/i18n/types.ts
index b8f6495..03d0514 100644
--- a/src/entrypoints/sidepanel/i18n/types.ts
+++ b/src/entrypoints/sidepanel/i18n/types.ts
@@ -54,6 +54,9 @@ export interface MessageSchema {
transcribing: string;
voiceError: string;
};
+ contextUsage: {
+ tooltip: string;
+ };
message: {
showReasoning: string;
hideReasoning: string;
diff --git a/src/entrypoints/sidepanel/locales/en.json b/src/entrypoints/sidepanel/locales/en.json
index ab25a82..93898be 100644
--- a/src/entrypoints/sidepanel/locales/en.json
+++ b/src/entrypoints/sidepanel/locales/en.json
@@ -50,6 +50,9 @@
"transcribing": "Transcribing...",
"voiceError": "Voice recognition error"
},
+ "contextUsage": {
+ "tooltip": "Context usage: {used} / {limit}"
+ },
"message": {
"showReasoning": "Show reasoning",
"hideReasoning": "Hide reasoning",
diff --git a/src/entrypoints/sidepanel/locales/zh-CN.json b/src/entrypoints/sidepanel/locales/zh-CN.json
index 0c4af28..936f0a4 100644
--- a/src/entrypoints/sidepanel/locales/zh-CN.json
+++ b/src/entrypoints/sidepanel/locales/zh-CN.json
@@ -50,6 +50,9 @@
"transcribing": "正在识别语音...",
"voiceError": "语音识别出错"
},
+ "contextUsage": {
+ "tooltip": "上下文占用:{used} / {limit}"
+ },
"message": {
"showReasoning": "查看思考过程",
"hideReasoning": "收起思考过程",
From 5700365510041f91692eae06b9736843155fdc25 Mon Sep 17 00:00:00 2001
From: devcxl <64475363+devcxl@users.noreply.github.com>
Date: Mon, 10 Aug 2026 18:52:02 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ContextUsageRin?=
=?UTF-8?q?g=20=E6=B5=8B=E8=AF=95=20=E2=80=94=20rerender=20=E4=BF=9D?=
=?UTF-8?q?=E6=8C=81=20I18nProvider=20=E5=8C=85=E8=A3=B9=20+=20SVG=20class?=
=?UTF-8?q?=20=E6=96=AD=E8=A8=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- RTL rerender 替换整棵渲染树导致 useI18n Provider 丢失,wrappedRender 的 rerender 重新包裹 I18nProvider
- SVG 元素 className 为 SVGAnimatedString,断言改用 getAttribute('class')
---
.../sidepanel/__tests__/ContextUsageRing.test.tsx | 11 ++++++++---
.../sidepanel/__tests__/MessageInput.test.tsx | 7 ++++++-
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx b/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx
index 343cb4e..25fa5b5 100644
--- a/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx
+++ b/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx
@@ -15,7 +15,12 @@ beforeEach(() => {
});
function wrappedRender(ui: React.ReactElement) {
- return render({ui});
+ const utils = render({ui});
+ return {
+ ...utils,
+ // rerender 会替换整个渲染树,必须重新包裹 I18nProvider
+ rerender: (nextUi: React.ReactElement) => utils.rerender({nextUi}),
+ };
}
describe('contextUsagePercent', () => {
@@ -50,11 +55,11 @@ describe('ContextUsageRing', () => {
it('占用率 ≥80% 时 wrapper data-state=warning 且进度弧 text-warning,<80% 为 normal', () => {
const { getByTestId, rerender } = wrappedRender();
expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('warning');
- expect(getByTestId('context-usage-ring-progress').className).toContain('text-warning');
+ expect(getByTestId('context-usage-ring-progress').getAttribute('class')).toContain('text-warning');
rerender();
expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('normal');
- expect(getByTestId('context-usage-ring-progress').className).toContain('text-primary');
+ expect(getByTestId('context-usage-ring-progress').getAttribute('class')).toContain('text-primary');
// 100% 仍为 warning
rerender();
diff --git a/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx b/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx
index bdccbc4..db8058f 100644
--- a/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx
+++ b/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx
@@ -74,7 +74,12 @@ const PROVIDER_CONTEXT_32768: ProviderConfig = {
};
function wrappedRender(ui: React.ReactElement) {
- return render({ui});
+ const utils = render({ui});
+ return {
+ ...utils,
+ // rerender 会替换整个渲染树,必须重新包裹 I18nProvider
+ rerender: (nextUi: React.ReactElement) => utils.rerender({nextUi}),
+ };
}
describe('MessageInput', () => {