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', () => {