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..25fa5b5 --- /dev/null +++ b/src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx @@ -0,0 +1,94 @@ +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) { + const utils = render({ui}); + return { + ...utils, + // rerender 会替换整个渲染树,必须重新包裹 I18nProvider + rerender: (nextUi: React.ReactElement) => utils.rerender({nextUi}), + }; +} + +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').getAttribute('class')).toContain('text-warning'); + + rerender(); + expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('normal'); + expect(getByTestId('context-usage-ring-progress').getAttribute('class')).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..db8058f 100644 --- a/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx +++ b/src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx @@ -42,8 +42,44 @@ 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}); + const utils = render({ui}); + return { + ...utils, + // rerender 会替换整个渲染树,必须重新包裹 I18nProvider + rerender: (nextUi: React.ReactElement) => utils.rerender({nextUi}), + }; } describe('MessageInput', () => { @@ -341,4 +377,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": "收起思考过程",