diff --git a/package-lock.json b/package-lock.json index 453d529..3bf20dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -430,9 +430,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -450,9 +447,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -470,9 +464,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -490,9 +481,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index e0c586c..0cb84d2 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,23 @@ "title": "%deepseek-copilot.command.openRequestDumpsFolder%" } ], + "views": { + "deepseek-copilot": [ + { + "id": "deepseek-copilot.panelView", + "name": "DeepSeek", + "type": "webview" + } + ] + }, + "viewsContainers": { + "panel": [ + { + "id": "deepseek-copilot", + "title": "DeepSeek" + } + ] + }, "walkthroughs": [ { "id": "deepseekGettingStarted", @@ -168,6 +185,12 @@ "default": "Describe all image attachments in this message.\n\nIf there is one image, describe it directly.\nIf there are multiple images:\n1. Describe each image separately, preserving their order.\n2. Then provide a combined description explaining the overall context and relationships across the images.\n\nReturn one concise factual description suitable for inserting into a text-only chat prompt. Include visible text, objects, UI elements, people, and relevant context. Do not invent details.", "markdownDescription": "%deepseek-copilot.config.visionPrompt.description%" }, + "deepseek-copilot.contextSize": { + "type": "number", + "default": 1000000, + "enum": [200000, 1000000], + "description": "Context window size (tokens). 200K or 1M." + }, "deepseek-copilot.debugMode": { "type": "string", "default": "minimal", diff --git a/package.nls.json b/package.nls.json index 34d81b6..f27a8c1 100644 --- a/package.nls.json +++ b/package.nls.json @@ -17,6 +17,11 @@ "deepseek-copilot.config.title": "DeepSeek Copilot", "deepseek-copilot.config.baseUrl.description": "DeepSeek API base URL. Defaults to official DeepSeek API endpoint.", "deepseek-copilot.config.maxTokens.description": "Maximum number of output tokens per request. Set to 0 to use the API default (no limit). Useful for controlling costs.", + "deepseek-copilot.config.contextSize.description": "Input context window size reported to VS Code. Larger context allows longer conversations without compaction but may increase cost.", + "deepseek-copilot.config.contextSize.200k.label": "200K", + "deepseek-copilot.config.contextSize.200k.description": "200K tokens — safe default for most sessions.", + "deepseek-copilot.config.contextSize.1m.label": "1M", + "deepseek-copilot.config.contextSize.1m.description": "1M tokens — longer sessions without compaction.", "deepseek-copilot.config.experimental.stabilizeToolList.description": "**Experimental**: improve DeepSeek context-cache hit rate by pre-activating available tools.\n- When the enabled tools list changes across turns, this may improve DeepSeek context-cache hit rate.\n- Requests will include more function definitions, so input tokens may increase. Cache-hit input tokens are billed at a lower price, but still count toward usage.\n- This may add internal preflight tool calls to the current Copilot chat history. If you switch to another model in the same conversation, that model provider may reject or mishandle the replayed history. Start a new chat if model switching behaves unexpectedly.\n\nUse [Configure Tools](command:workbench.action.chat.configureTools) to **view and manage** your tool list:\n\n- 64 or fewer enabled tools: usually no need to enable this unless the tool list still changes across turns.\n- More than 128 enabled tools: not recommended. DeepSeek supports at most 128 functions in one `tools` request. Consider disabling tools you rarely use.", "deepseek-copilot.config.debugMode.description": "Controls what diagnostic information DeepSeek Copilot writes. Token usage is always reported to Copilot regardless of this setting.\n\n- **Minimal** — Token usage only. No diagnostic logs or request dumps.\n- **Metadata** — Privacy-safe diagnostic metadata (request hashes, prefix overlap, tool schema changes). Does not contain prompt text — safe to share in public issue reports. View with [`DeepSeek: Show Logs`](command:deepseek-copilot.showLogs).\n- **Verbose** — Complete request payloads written to disk for local debugging. **Warning: contains sensitive prompt content.** View with [`DeepSeek: Open Request Dumps Folder`](command:deepseek-copilot.openRequestDumpsFolder).", "deepseek-copilot.config.debugMode.minimal.label": "Minimal", diff --git a/src/balance.ts b/src/balance.ts new file mode 100644 index 0000000..249b67f --- /dev/null +++ b/src/balance.ts @@ -0,0 +1,51 @@ +import vscode from 'vscode'; +import { getBaseUrl } from './config'; + +export interface BalanceInfo { + /** Single balance entry (DeepSeek returns array of these). */ + currency: string; + total_balance: string; + granted_balance: string; + topped_up_balance: string; +} + +export interface BalanceResponse { + is_available: boolean; + balance_infos: BalanceInfo[]; +} + +/** + * Fetch DeepSeek user balance from the API. + * Returns the raw response or undefined on failure. + */ +export async function fetchBalance(apiKey: string): Promise { + try { + const baseUrl = getBaseUrl(); + const response = await fetch(`${baseUrl}/user/balance`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + void vscode.window.showWarningMessage(`DeepSeek balance check failed: HTTP ${response.status}`); + return undefined; + } + + return (await response.json()) as BalanceResponse; + } catch (error) { + void vscode.window.showWarningMessage(`DeepSeek balance check failed: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +/** + * Format a balance entry for display. + */ +export function formatBalance(balance: BalanceInfo): string { + const total = parseFloat(balance.total_balance); + const granted = parseFloat(balance.granted_balance); + const toppedUp = parseFloat(balance.topped_up_balance); + return `${balance.currency} ${total.toFixed(2)}`; +} diff --git a/src/config.ts b/src/config.ts index 477ca09..472a3a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,15 @@ export function getMaxTokens(): number | undefined { return value > 0 ? value : undefined; } +/** + * Get the configured context window size in tokens. + * Returns the value set by the user (200K or 1M), defaulting to 1M. + */ +export function getContextSize(): number { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + return config.get('contextSize', 1000000); +} + /** * Diagnostic mode. `verbose` also enables metadata logs. * diff --git a/src/consts.ts b/src/consts.ts index b087544..d6d4de9 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -54,8 +54,8 @@ export const MODELS: ModelDefinition[] = [ family: 'deepseek', version: 'v4', detail: 'Fast, general-purpose model', - maxInputTokens: 655360, - maxOutputTokens: 393216, + maxInputTokens: 1000000, + maxOutputTokens: 128000, capabilities: { toolCalling: DEEPSEEK_TOOLS_LIMIT, imageInput: true, @@ -74,8 +74,8 @@ export const MODELS: ModelDefinition[] = [ family: 'deepseek', version: 'v4', detail: 'Most capable reasoning model', - maxInputTokens: 655360, - maxOutputTokens: 393216, + maxInputTokens: 1000000, + maxOutputTokens: 128000, capabilities: { toolCalling: DEEPSEEK_TOOLS_LIMIT, imageInput: true, diff --git a/src/i18n.ts b/src/i18n.ts index 847eac4..d95f377 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -42,6 +42,13 @@ const zh: Translations = { 'thinking.max': '深度', 'thinking.max.desc': '深度推理,适合复杂任务', + // Context Size — model picker dropdown + 'contextSize.title': '上下文窗口', + 'contextSize.200k': '200K', + 'contextSize.200k.desc': '200K token 上下文(更快)', + 'contextSize.1m': '1M', + 'contextSize.1m.desc': '1M token 上下文(更大容量)', + // Vision 'vision.proxyUsing': '视觉代理:{0}', 'vision.notFound': '未找到视觉模型 "{0}"', @@ -232,6 +239,13 @@ const en: Translations = { 'thinking.max': 'Max', 'thinking.max.desc': 'Maximum reasoning depth for complex agent tasks', + // Context Size — model picker dropdown + 'contextSize.title': 'Context Window', + 'contextSize.200k': '200K', + 'contextSize.200k.desc': '200K token context (faster)', + 'contextSize.1m': '1M', + 'contextSize.1m.desc': '1M token context (larger capacity)', + // Vision // NOTE: vision.unableToDescribe has been moved to consts.ts as // IMAGE_DESCRIPTION_UNAVAILABLE — it is prompt content, not UI text. diff --git a/src/provider/index.ts b/src/provider/index.ts index 0456a58..258e2f4 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,7 +1,7 @@ import vscode from 'vscode'; import { AuthManager } from '../auth'; -import { getStabilizeToolListEnabled } from '../config'; -import { MODELS } from '../consts'; +import { getContextSize, getStabilizeToolListEnabled } from '../config'; +import { CONFIG_SECTION, MODELS } from '../consts'; import { t } from '../i18n'; import { logger } from '../logger'; import { createCacheDiagnosticsRecorder, dumpProviderInput } from './debug'; @@ -133,10 +133,11 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider { const hasKey = await this.authManager.hasApiKey(); const pricingCurrency = this.balanceCurrencyResolver.getDisplayCurrency(); + const contextSize = getContextSize(); if (hasKey) { this.balanceCurrencyResolver.refreshInBackground(); } - return MODELS.map((model) => toChatInfo(model, hasKey, pricingCurrency)); + return MODELS.map((model) => toChatInfo(model, hasKey, pricingCurrency, contextSize)); } async provideLanguageModelChatResponse( @@ -184,6 +185,17 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider { getVisionDescriber: () => this.vision.get(), }); + // Sync context size back to VS Code setting when user changes it via the + // model-picker dropdown. The updated maxInputTokens takes effect on the + // next request after Copilot Chat re-queries model information. + const currentContextSize = getContextSize(); + if (prepared.configuredContextSize !== currentContextSize) { + await vscode.workspace + .getConfiguration(CONFIG_SECTION) + .update('contextSize', prepared.configuredContextSize, vscode.ConfigurationTarget.Global); + this.onDidChangeLanguageModelChatInformationEmitter.fire(); + } + return streamChatCompletion({ prepared, progress, diff --git a/src/provider/models.ts b/src/provider/models.ts index 65fcedd..c22a669 100644 --- a/src/provider/models.ts +++ b/src/provider/models.ts @@ -15,24 +15,27 @@ import { toModelCostInfo, type ModelCostInformation } from './pricing/costs'; export type ThinkingEffort = 'none' | 'high' | 'max'; +export type ContextSize = 200000 | 1000000; + export type ModelConfigurationOptions = vscode.ProvideLanguageModelChatResponseOptions & { readonly modelConfiguration?: Record; readonly configuration?: Record; }; -type ThinkingEffortConfigurationSchema = ReturnType; +type ModelConfigurationSchema = ReturnType; export type ModelPickerChatInformation = vscode.LanguageModelChatInformation & ModelCostInformation & { readonly isUserSelectable: boolean; readonly statusIcon?: vscode.ThemeIcon; - readonly configurationSchema?: ThinkingEffortConfigurationSchema; + readonly configurationSchema?: ModelConfigurationSchema; }; export function toChatInfo( m: ModelDefinition, hasApiKey: boolean, pricingCurrency?: PricingCurrency, + contextSize?: number, ): ModelPickerChatInformation { const modelDetail = resolveModelText(m, 'detail') ?? m.detail; const modelTooltip = resolveModelText(m, 'tooltip'); @@ -44,7 +47,7 @@ export function toChatInfo( detail: hasApiKey ? modelDetail : t('auth.apiKeyRequiredDetail'), tooltip: hasApiKey ? modelTooltip : t('auth.apiKeyRequiredDetail'), statusIcon: hasApiKey ? undefined : new vscode.ThemeIcon('warning'), - maxInputTokens: m.maxInputTokens, + maxInputTokens: contextSize ?? m.maxInputTokens, maxOutputTokens: m.maxOutputTokens, isUserSelectable: true, capabilities: { @@ -52,7 +55,7 @@ export function toChatInfo( imageInput: m.capabilities.imageInput, }, ...toModelCostInfo(m, pricingCurrency), - ...(m.capabilities.thinking ? { configurationSchema: buildThinkingEffortSchema() } : {}), + ...(hasApiKey ? { configurationSchema: buildModelConfigurationSchema(m) } : {}), }; } @@ -71,24 +74,49 @@ export function getConfiguredThinkingEffort(options: ModelConfigurationOptions): return configuredEffort === 'max' ? 'max' : 'high'; } -function buildThinkingEffortSchema() { - return { - properties: { - reasoningEffort: { - type: 'string', - title: t('status.thinking'), - enum: ['none', 'high', 'max'], - enumItemLabels: [t('thinking.none'), t('thinking.high'), t('thinking.max')], - enumDescriptions: [ - t('thinking.none.desc'), - t('thinking.high.desc'), - t('thinking.max.desc'), - ], - default: 'high', - group: 'navigation', - }, - }, - } as const; +/** + * Read the context size selected by the user via the model-picker dropdown. + * Falls back to the VS Code setting when the dropdown hasn't been used yet. + */ +export function getConfiguredContextSize(options: ModelConfigurationOptions): ContextSize { + const configured = + options.modelConfiguration?.contextSize ?? options.configuration?.contextSize; + if (configured === 200000) { + return 200000; + } + return 1000000; +} + +function buildModelConfigurationSchema(m: ModelDefinition) { + const properties: Record = {}; + + if (m.capabilities.thinking) { + properties.reasoningEffort = { + type: 'string', + title: t('status.thinking'), + enum: ['none', 'high', 'max'], + enumItemLabels: [t('thinking.none'), t('thinking.high'), t('thinking.max')], + enumDescriptions: [ + t('thinking.none.desc'), + t('thinking.high.desc'), + t('thinking.max.desc'), + ], + default: 'high', + group: 'navigation', + }; + } + + properties.contextSize = { + type: 'number', + title: t('contextSize.title'), + enum: [200000, 1000000], + enumItemLabels: [t('contextSize.200k'), t('contextSize.1m')], + enumDescriptions: [t('contextSize.200k.desc'), t('contextSize.1m.desc')], + default: 1000000, + group: 'tokens', + }; + + return { properties } as const; } function resolveModelText(m: ModelDefinition, field: 'detail' | 'tooltip'): string | undefined { diff --git a/src/provider/request.ts b/src/provider/request.ts index c143325..7ce7a81 100644 --- a/src/provider/request.ts +++ b/src/provider/request.ts @@ -5,23 +5,103 @@ import { getApiModelId, getBaseUrl, getMaxTokens } from '../config'; import { MODELS } from '../consts'; import { isOfficialDeepSeekBaseUrl } from '../endpoint'; import { t } from '../i18n'; +import { getCurrentCopilotSession } from '../session'; import type { DeepSeekRequest } from '../types'; import { convertMessages, countMessageChars } from './convert'; import { - dumpDeepSeekRequest, - type CacheDiagnosticsRecorder, - type CacheDiagnosticsRun, + dumpDeepSeekRequest, + type CacheDiagnosticsRecorder, + type CacheDiagnosticsRun, } from './debug'; -import { getConfiguredThinkingEffort, type ModelConfigurationOptions } from './models'; -import { classifyDeepSeekRequest, shouldForceThinkingNone, type RequestKind } from './routing'; +import { getConfiguredContextSize, getConfiguredThinkingEffort, type ContextSize, type ModelConfigurationOptions } from './models'; import type { ReplayMarkerMetadata } from './replay'; +import { classifyDeepSeekRequest, shouldForceThinkingNone, type RequestKind } from './routing'; import type { ConversationSegment } from './segment'; import { collectTrailingToolResultIds, prepareRequestTools } from './tools/request'; import { resolveImageMessages, type VisionDescriber } from './vision'; +function extractRawText(messages: readonly vscode.LanguageModelChatRequestMessage[]): string { + // Concatenate all user message text for debug display (first 500 chars). + let raw = ''; + for (const msg of messages) { + if (msg.role !== vscode.LanguageModelChatMessageRole.User) continue; + if (typeof msg.content === 'string') { + raw += msg.content + '\n---\n'; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part && typeof (part as { value?: unknown }).value === 'string') { + raw += (part as { value: string }).value + '\n---\n'; + } + } + } + } + return raw; +} + +function extractSessionTitle(messages: readonly vscode.LanguageModelChatRequestMessage[]): string { + // Collect text from user messages, joining all text parts per message. + let fullText = ''; + for (const msg of messages) { + if (msg.role !== vscode.LanguageModelChatMessageRole.User) continue; + if (typeof msg.content === 'string') { + fullText += msg.content + '\n'; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part && typeof (part as { value?: unknown }).value === 'string') { + fullText += (part as { value: string }).value + '\n'; + } + } + } + } + + // 1) is THE user message — always prefer it. + const urMatch = fullText.match(//gi); + if (urMatch) { + for (const block of urMatch) { + const inner = block.replace(/<\/?userRequest[^>]*>/gi, '').trim(); + if (inner.length >= 3) { + const clean = inner.replace(/[#*_`~]/g, '').trim().slice(0, 60); + if (clean) return clean; + } + } + } + + // 2) Fallback: search the rest of the text after stripping system blocks. + let cleaned = fullText + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/[\s\S]*?<\/context>/gi, '') + .replace(//gi, '') + .replace(/[\s\S]*?<\/userMemory>/gi, '') + .replace(/[\s\S]*?<\/repoMemory>/gi, '') + .replace(/[\s\S]*?<\/sessionMemory>/gi, '') + .replace(/<[^>]*>/g, ' '); + + const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length >= 3); + // Take the last non-system line (usually closest to the user's actual words). + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (/^(You are |The user|The current|Information about|#{1,6}\s)/.test(line)) continue; + const title = line.replace(/[#*_`~]/g, '').trim().slice(0, 60); + if (title) return title; + } + + return 'Untitled'; +} + export interface PreparedChatRequest { client: DeepSeekClient; request: DeepSeekRequest; + /** The VS Code model ID (e.g. "deepseek-v4-pro"). */ + modelId: string; isThinkingModel: boolean; totalRequestChars: number; trailingToolResultIds: string[]; @@ -31,6 +111,14 @@ export interface PreparedChatRequest { replayMarkerMetadata: ReplayMarkerMetadata; visionMarkerTextChars?: number; initialResponseNotice?: string; + /** The context size selected via the model-picker dropdown (if any). */ + configuredContextSize: ContextSize; + /** Stable Copilot Chat session ID used for grouping (falls back to title). */ + sessionId: string; + /** Title from Copilot Chat session, or extracted from user message. */ + sessionTitle: string; + /** Raw text from user messages (for debug). */ + sessionRawText: string; } export interface PrepareChatRequestOptions { @@ -88,6 +176,7 @@ export async function prepareChatRequest({ const configuredThinkingEffort = getConfiguredThinkingEffort( options as ModelConfigurationOptions, ); + const configuredContextSize = getConfiguredContextSize(options as ModelConfigurationOptions); // Only force helper requests into disabled thinking on the official API. // Custom endpoints keep their configured effort to preserve pre-#137 request shape. const forceNoneThinking = @@ -138,6 +227,7 @@ export async function prepareChatRequest({ return { client, request, + modelId: modelInfo.id, isThinkingModel, totalRequestChars, trailingToolResultIds: collectTrailingToolResultIds(deepseekMessages), @@ -147,5 +237,20 @@ export async function prepareChatRequest({ replayMarkerMetadata: visionResolution.replayMarkerMetadata, visionMarkerTextChars: visionResolution.stats.markerVisionTextChars || undefined, initialResponseNotice: visionResolution.initialResponseNotice, + configuredContextSize, + ...resolveSessionIdentity(messages), + sessionRawText: extractRawText(messages), }; } + +function resolveSessionIdentity( + messages: readonly vscode.LanguageModelChatRequestMessage[], +): { sessionId: string; sessionTitle: string } { + const copilot = getCurrentCopilotSession(); + if (copilot) { + const title = copilot.title && copilot.title !== 'New Chat' ? copilot.title : extractSessionTitle(messages); + return { sessionId: copilot.id, sessionTitle: title }; + } + const fallbackTitle = extractSessionTitle(messages); + return { sessionId: fallbackTitle, sessionTitle: fallbackTitle }; +} diff --git a/src/provider/stream.ts b/src/provider/stream.ts index a4c5477..bd430a5 100644 --- a/src/provider/stream.ts +++ b/src/provider/stream.ts @@ -1,19 +1,20 @@ import vscode from 'vscode'; import { createUserFacingError } from '../client'; import { logger } from '../logger'; +import { recordUsage } from '../tracker'; import type { DeepSeekToolCall, DeepSeekUsage } from '../types'; import { - observeCancellationToken, - type CacheDiagnosticsRun, - type ReplayMarkerReportTrigger, + observeCancellationToken, + type CacheDiagnosticsRun, + type ReplayMarkerReportTrigger, } from './debug'; -import { formatRequestLogLine, type RequestKind } from './routing'; import { - createReplayMarkerPart, - hasReplayMarkerMetadata, - type ReplayMarkerMetadata, + createReplayMarkerPart, + hasReplayMarkerMetadata, + type ReplayMarkerMetadata, } from './replay'; import type { PreparedChatRequest } from './request'; +import { formatRequestLogLine, type RequestKind } from './routing'; interface ResponseStreamState { accumulatedReasoning: string; @@ -82,6 +83,7 @@ export function streamChatCompletion({ }, onUsage: (usage) => { + recordUsage(usage, prepared.modelId, prepared.sessionTitle, prepared.sessionRawText, prepared.sessionId); const charsPerToken = updateCharsPerToken( prepared.totalRequestChars, usage, diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 707aa0c..1154e6c 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -1,7 +1,12 @@ import vscode from 'vscode'; +import { AuthManager } from '../auth'; +import { fetchBalance } from '../balance'; +import { EXTERNAL_URLS } from '../consts'; import { t } from '../i18n'; import { logger } from '../logger'; import { DeepSeekChatProvider } from '../provider'; +import { initSession } from '../session'; +import { clearTracker, getDailyHistory, getSessionGroups, getSessionRequests, getSessionTokens, initTracker } from '../tracker'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; @@ -12,9 +17,97 @@ let activeProvider: DeepSeekChatProvider | undefined; export async function activate(context: vscode.ExtensionContext): Promise { await initializeDiagnostics(context); + initTracker(context); + initSession(context); registerCommands(context); registerActionUrls(context); + // --- Status Bar Button --- + const statusBarButton = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100, + ); + statusBarButton.text = '$(hubot)'; + statusBarButton.tooltip = 'Open DeepSeek Copilot Panel'; + statusBarButton.command = 'deepseek-copilot.showPanel'; + statusBarButton.show(); + context.subscriptions.push(statusBarButton); + + context.subscriptions.push( + vscode.commands.registerCommand('deepseek-copilot.showPanel', () => { + void vscode.commands.executeCommand('deepseek-copilot.panelView.focus'); + }), + ); + + // --- Panel Webview --- + const authManager = new AuthManager(context); + const panelProvider = new (class implements vscode.WebviewViewProvider { + resolveWebviewView(webviewView: vscode.WebviewView): void { + webviewView.webview.options = { enableScripts: true }; + + webviewView.webview.onDidReceiveMessage(async (msg) => { + if (msg === 'refreshBalance') { + const key = await authManager.getApiKey(); + if (!key) { + webviewView.webview.postMessage({ type: 'balance', data: null, error: 'No API key configured' }); + return; + } + const balance = await fetchBalance(key); + webviewView.webview.postMessage({ type: 'balance', data: balance, error: null }); + } + if (msg === 'getTokens') { + const tokens = getSessionTokens(); + webviewView.webview.postMessage({ type: 'tokens', data: tokens }); + } + if (msg === 'getRequests') { + const reqs = getSessionRequests(); + webviewView.webview.postMessage({ type: 'requests', data: reqs }); + } + if (msg === 'getHistory') { + const hist = getDailyHistory(); + webviewView.webview.postMessage({ type: 'history', data: hist }); + } + if (msg === 'getSessions') { + const groups = getSessionGroups(); + webviewView.webview.postMessage({ type: 'sessions', data: groups }); + } + if (msg.type === 'openUrl' && msg.url) { + void vscode.env.openExternal(vscode.Uri.parse(msg.url)); + } + if (msg === 'clearData') { + clearTracker(); + webviewView.webview.postMessage({ type: 'tokens', data: getSessionTokens() }); + webviewView.webview.postMessage({ type: 'requests', data: getSessionRequests() }); + webviewView.webview.postMessage({ type: 'history', data: getDailyHistory() }); + webviewView.webview.postMessage({ type: 'sessions', data: getSessionGroups() }); + } + }); + + webviewView.webview.html = getPanelHtml(context.extension.packageJSON.version); + + // Auto-fetch on load + void (async () => { + const key = await authManager.getApiKey(); + if (!key) { + webviewView.webview.postMessage({ type: 'balance', data: null, error: 'No API key configured' }); + } else { + const balance = await fetchBalance(key); + webviewView.webview.postMessage({ type: 'balance', data: balance, error: null }); + } + webviewView.webview.postMessage({ type: 'tokens', data: getSessionTokens() }); + webviewView.webview.postMessage({ type: 'requests', data: getSessionRequests() }); + webviewView.webview.postMessage({ type: 'history', data: getDailyHistory() }); + webviewView.webview.postMessage({ type: 'sessions', data: getSessionGroups() }); + })(); + } + })(); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('deepseek-copilot.panelView', panelProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + try { const provider = await registerProvider(context); activeProvider = provider; @@ -43,3 +136,344 @@ export async function deactivate(): Promise { logger.dispose(); } } + +function getPanelHtml(version: string): string { + return ` + + + + + + + +
+ +
Loading...
+
+
+
+ +
Loading...
+
+
+ + +
+
+ +
+ + + +
+
+ + + +`; +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..75f88cc --- /dev/null +++ b/src/session.ts @@ -0,0 +1,157 @@ +/** + * Reads Copilot Chat session titles from VS Code's globalState database. + * This is a lightweight approach — we shell out to sqlite3 (available on macOS). + * Falls back to undefined if the DB can't be read. + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import vscode from 'vscode'; + +const SESSION_STORE_KEY = 'chat.ChatSessionStore.index'; + +let cachedDbPath: string | undefined; +let dbPathResolved = false; +let globalStorageUri: vscode.Uri | undefined; +let cachedTitles: Map | undefined; +let cacheTime = 0; +const CACHE_TTL = 5000; // 5 seconds + +/** + * Must be called once during activation with the extension context, so we can + * locate `state.vscdb` (sibling of our own globalStorage folder). + */ +export function initSession(context: vscode.ExtensionContext): void { + globalStorageUri = context.globalStorageUri; + // Reset cache so the path is recomputed with the new context. + cachedDbPath = undefined; + dbPathResolved = false; +} + +function getStateDbPath(): string | undefined { + if (dbPathResolved) return cachedDbPath; + dbPathResolved = true; + + // Preferred: our own globalStorageUri is `.../User/globalStorage//`. + // `state.vscdb` lives one level up in `.../User/globalStorage/`. + if (globalStorageUri) { + try { + const candidate = vscode.Uri.joinPath(globalStorageUri, '..', 'state.vscdb').fsPath; + if (fs.existsSync(candidate)) { + cachedDbPath = candidate; + return cachedDbPath; + } + } catch { + // fall through to other strategies + } + } + + // Fallback: try common absolute paths for both VS Code variants. + try { + const home = process.env.HOME || '~'; + const paths = [ + `${home}/Library/Application Support/Code - Insiders/User/globalStorage/state.vscdb`, + `${home}/Library/Application Support/Code/User/globalStorage/state.vscdb`, + ]; + for (const p of paths) { + if (fs.existsSync(p)) { + cachedDbPath = p; + return p; + } + } + } catch { + // ignore + } + + cachedDbPath = undefined; + return undefined; +} + +function loadSessionTitles(): Map { + const now = Date.now(); + if (cachedTitles && now - cacheTime < CACHE_TTL) { + return cachedTitles; + } + + const dbPath = getStateDbPath(); + if (!dbPath) { + cachedTitles = new Map(); + return cachedTitles; + } + + try { + const raw = execSync( + `sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key = '${SESSION_STORE_KEY}';"`, + { timeout: 2000, maxBuffer: 10 * 1024 * 1024, encoding: 'utf-8' }, + ); + if (!raw) { + cachedTitles = new Map(); + return cachedTitles; + } + + const data = JSON.parse(raw) as { entries?: Record }; + const entries = data.entries ?? {}; + const map = new Map(); + for (const [, v] of Object.entries(entries)) { + if (v.title && v.title !== 'New Chat') { + map.set(v.sessionId, v.title); + } + } + cachedTitles = map; + cacheTime = now; + return map; + } catch { + cachedTitles = new Map(); + cacheTime = now; + return new Map(); + } +} + +/** + * Returns the Copilot Chat session title for the most recently active session, + * or undefined if unavailable. + */ +export function getCurrentCopilotSessionTitle(): string | undefined { + return getCurrentCopilotSession()?.title; +} + +/** + * Returns the most recently active Copilot Chat session + * (both id and title), or undefined if unavailable. + * Does NOT filter by isEmpty — the freshest session by lastMessageDate wins. + */ +export function getCurrentCopilotSession(): { id: string; title: string } | undefined { + const dbPath = getStateDbPath(); + if (!dbPath) return undefined; + + try { + const raw = execSync( + `sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key = '${SESSION_STORE_KEY}';"`, + { timeout: 2000, maxBuffer: 10 * 1024 * 1024, encoding: 'utf-8' }, + ); + if (!raw) return undefined; + + const data = JSON.parse(raw) as { entries?: Record }; + const entries = Object.values(data.entries ?? {}); + // Find most recent session by lastMessageDate (don't filter isEmpty — the + // current session may not be marked non-empty yet when our request arrives). + let best: { sessionId: string; title: string; lastMessageDate: number } | undefined; + for (const e of entries) { + if (!best || e.lastMessageDate > best.lastMessageDate) { + best = e; + } + } + if (best) { + return { id: best.sessionId, title: best.title || 'Untitled' }; + } + } catch { + // ignore + } + return undefined; +} + +/** + * Returns all known Copilot session titles mapped by session ID. + */ +export function getAllCopilotSessionTitles(): ReadonlyMap { + return loadSessionTitles(); +} diff --git a/src/tracker.ts b/src/tracker.ts new file mode 100644 index 0000000..4e9a1cc --- /dev/null +++ b/src/tracker.ts @@ -0,0 +1,205 @@ +import vscode from 'vscode'; +import { MODELS } from './consts'; +import type { DeepSeekUsage, ModelDefinition } from './types'; + +/** + * Persistent session token tracker. + * Survives VS Code restarts via globalState. Resets daily. + */ +export interface SessionTokens { + inputTokens: number; + outputTokens: number; + requestCount: number; + /** Estimated cost in USD based on model pricing. */ + costUsd: number; +} + +export interface SessionRequest { + timestamp: number; + modelId: string; + modelName: string; + /** Stable session identifier used for grouping. */ + sessionId: string; + sessionTitle: string; + /** Raw text used for title extraction (debug). */ + rawTitleText: string; + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +export interface SessionGroup { + title: string; + requests: SessionRequest[]; + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +export interface DailyTokens extends SessionTokens { + date: string; // YYYY-MM-DD +} + +interface PersistedState { + date: string; + tokens: SessionTokens; + requests: SessionRequest[]; + history: DailyTokens[]; +} + +const STORAGE_KEY = 'deepseek-copilot.tokenTracker'; + +const emptySession = (): SessionTokens => ({ + inputTokens: 0, + outputTokens: 0, + requestCount: 0, + costUsd: 0, +}); + +let context: vscode.ExtensionContext | undefined; +let session: SessionTokens = emptySession(); +let requests: SessionRequest[] = []; +let history: DailyTokens[] = []; +let date: string = today(); + +// Build pricing lookup from MODELS const +const pricingMap = new Map(); +for (const m of MODELS as ModelDefinition[]) { + const usd = m.pricing?.USD; + if (usd) { + pricingMap.set(m.id, { inputPerM: usd.cacheMissInput, outputPerM: usd.output }); + } +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function calcCost(inputTokens: number, outputTokens: number, modelId: string): number { + const p = pricingMap.get(modelId); + if (!p) return 0; + return (inputTokens / 1_000_000) * p.inputPerM + (outputTokens / 1_000_000) * p.outputPerM; +} + +function findModelName(modelId: string): string { + const m = (MODELS as ModelDefinition[]).find(m => m.id === modelId); + return m?.name ?? modelId; +} + +function persist(): void { + if (!context) return; + const state: PersistedState = { date, tokens: { ...session }, requests: [...requests], history: [...history] }; + void context.globalState.update(STORAGE_KEY, state); +} + +export function initTracker(ctx: vscode.ExtensionContext): void { + context = ctx; + const saved = ctx.globalState.get(STORAGE_KEY); + if (saved?.date === today()) { + session = saved.tokens ?? emptySession(); + requests = saved.requests ?? []; + history = saved.history ?? []; + date = saved.date ?? today(); + } else { + // New day: save yesterday to history + if (saved && (saved.tokens?.requestCount ?? 0) > 0) { + history = [...(saved.history ?? []), { date: saved.date, ...saved.tokens }]; + } else if (saved?.history) { + history = saved.history; + } + session = emptySession(); + requests = []; + date = today(); + persist(); + } +} + +export function clearTracker(): void { + session = emptySession(); + requests = []; + history = []; + date = today(); + if (context) { + void context.globalState.update(STORAGE_KEY, undefined); + } +} + +export function recordUsage(usage: DeepSeekUsage, modelId?: string, sessionTitle?: string, rawTitleText?: string, sessionId?: string): void { + // Detect date change mid-session + const d = today(); + if (d !== date) { + if (session.requestCount > 0) { + history.push({ date, ...session }); + } + session = emptySession(); + requests = []; + date = d; + } + + const input = usage.prompt_tokens ?? 0; + const output = usage.completion_tokens ?? 0; + const cost = modelId ? calcCost(input, output, modelId) : 0; + + session.inputTokens += input; + session.outputTokens += output; + session.requestCount += 1; + session.costUsd += cost; + + requests.push({ + timestamp: Date.now(), + modelId: modelId ?? 'unknown', + modelName: findModelName(modelId ?? 'unknown'), + sessionId: sessionId ?? sessionTitle ?? 'Untitled', + sessionTitle: sessionTitle ?? 'Untitled', + rawTitleText: rawTitleText ?? '', + inputTokens: input, + outputTokens: output, + costUsd: cost, + }); + + persist(); +} + +export function getSessionTokens(): Readonly { + return session; +} + +export function getSessionRequests(): readonly SessionRequest[] { + return requests; +} + +export function getDailyHistory(): readonly DailyTokens[] { + // Include today if there's activity + const all = [...history]; + if (session.requestCount > 0) { + all.push({ date, ...session }); + } + return all; +} + +export function getSessionGroups(): readonly SessionGroup[] { + const groups = new Map(); + for (const req of requests) { + const key = req.sessionId || req.sessionTitle; + const existing = groups.get(key); + if (existing) { + existing.requests.push(req); + existing.inputTokens += req.inputTokens; + existing.outputTokens += req.outputTokens; + existing.costUsd += req.costUsd; + // Use the most informative (latest non-"Untitled") title for the group. + if (req.sessionTitle && req.sessionTitle !== 'Untitled') { + existing.title = req.sessionTitle; + } + } else { + groups.set(key, { + title: req.sessionTitle, + requests: [req], + inputTokens: req.inputTokens, + outputTokens: req.outputTokens, + costUsd: req.costUsd, + }); + } + } + return [...groups.values()]; +}