diff --git a/package.json b/package.json index ff483aa..aafc795 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,10 @@ { "command": "deepseek-copilot.openRequestDumpsFolder", "title": "%deepseek-copilot.command.openRequestDumpsFolder%" + }, + { + "command": "deepseek-copilot.clearCloudCache", + "title": "%deepseek-copilot.command.clearCloudCache%" } ], "walkthroughs": [ @@ -135,6 +139,25 @@ "experimental" ] }, + "deepseek-copilot.experimental.filesApi": { + "type": "boolean", + "default": false, + "markdownDescription": "%deepseek-copilot.config.experimental.filesApi.description%", + "tags": [ + "experimental" + ] + }, + "deepseek-copilot.cacheExpiresDays": { + "type": "number", + "default": 7, + "minimum": -1, + "maximum": 30, + "description": "%deepseek-copilot.config.cacheExpiresDays.description%", + "markdownDescription": "%deepseek-copilot.config.cacheExpiresDays.markdownDescription%", + "tags": [ + "experimental" + ] + }, "deepseek-copilot.modelIdOverrides": { "type": "object", "default": { diff --git a/package.nls.json b/package.nls.json index af9acdb..4cb45e9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -6,6 +6,7 @@ "deepseek-copilot.command.openSettings": "DeepSeek: Open Settings", "deepseek-copilot.command.showLogs": "DeepSeek: Show Logs", "deepseek-copilot.command.openRequestDumpsFolder": "DeepSeek: Open Request Dumps Folder", + "deepseek-copilot.command.clearCloudCache": "DeepSeek: Clear Cloud Cache", "deepseek-copilot.walkthrough.title": "DeepSeek V4 for Copilot Chat", "deepseek-copilot.walkthrough.description": "Set up DeepSeek V4 models in Copilot Chat.", "deepseek-copilot.walkthrough.setApiKey.title": "Set your DeepSeek API key", @@ -18,6 +19,9 @@ "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.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.experimental.filesApi.description": "**Experimental**: upload images to the DeepSeek Files API and reference them by `file_id` instead of embedding base64 data URLs.\n\nApplies only to the `deepseek-v4-flash-vision-exp` model and only on the official DeepSeek API endpoint. Images are content-addressed and cached locally, so repeated images reuse the same `file_id` while it is valid, and expired files are re-uploaded automatically.\n\nWhen enabled, image payloads are smaller and cost fewer tokens; when disabled, images embed as base64.", + "deepseek-copilot.config.cacheExpiresDays.description": "How long image files uploaded to the DeepSeek Files API are kept. Set to 0 for 1 hour, or -1 to keep files permanently. Positive values are days and are clamped to the API's allowed range (1-30 days).", + "deepseek-copilot.config.cacheExpiresDays.markdownDescription": "How long uploaded image files stay cached in the DeepSeek Files API.\n\n- **-1** — keep files permanently (never expire).\n- **0** — 1 hour (the API's minimum).\n- **1-30** — number of days, clamped to the API's allowed range.\n\nFiles are content-addressed and deduplicated, so re-uploading the same image reuses the existing `file_id` while it is still valid.\n\nOnly applies when `#deepseek-copilot.experimental.filesApi#` is enabled.", "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", "deepseek-copilot.config.debugMode.minimal.description": "Token usage only. No diagnostic logs or dumps.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index d3e6a02..df5f446 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -6,6 +6,7 @@ "deepseek-copilot.command.openSettings": "DeepSeek: 打开设置", "deepseek-copilot.command.showLogs": "DeepSeek: 显示日志", "deepseek-copilot.command.openRequestDumpsFolder": "DeepSeek: 打开请求 Dump 目录", + "deepseek-copilot.command.clearCloudCache": "DeepSeek: 清空云端缓存", "deepseek-copilot.walkthrough.title": "DeepSeek V4 for Copilot Chat", "deepseek-copilot.walkthrough.description": "在 Copilot Chat 中配置 DeepSeek V4 模型。", "deepseek-copilot.walkthrough.setApiKey.title": "设置你的 DeepSeek API Key", @@ -18,6 +19,9 @@ "deepseek-copilot.config.baseUrl.description": "DeepSeek API 基础 URL,默认为官方 DeepSeek API 端点。", "deepseek-copilot.config.maxTokens.description": "每次请求的最大输出 Token 数,设为 0 则不限制,可用于控制成本。", "deepseek-copilot.config.experimental.stabilizeToolList.description": "**实验性功能**:通过预先激活可用的工具来提升 DeepSeek 上下文缓存命中率。\n- 当已启用工具列表跨轮次变化时,这可能提高 DeepSeek 上下文缓存命中率;\n- 请求中将包含更多函数工具定义,input tokens 可能增加,虽然缓存命中的 input tokens 单价更低,但仍会计入用量;\n- 此设置可能会在当前 Copilot 对话历史中加入内部预检工具调用。在同一对话中切换到其他模型时,部分模型提供方可能无法正确重放这段历史;如果切换模型后请求异常,请新建对话后重试。\n\n 通过 [配置工具](command:workbench.action.chat.configureTools) **查看和管理**工具列表:\n\n- 64 个或更少已启用工具时通常无需开启,除非工具列表仍在跨轮次变化;\n- 超过 128 个已启用工具时不建议开启:DeepSeek 单次 `tools` 请求最多支持 128 个 functions。考虑禁用部分不常用工具。", + "deepseek-copilot.config.experimental.filesApi.description": "**实验性功能**:将图片上传到 DeepSeek Files API,并通过 `file_id` 引用,而不是嵌入 base64 data URL。\n\n仅适用于 `deepseek-v4-flash-vision-exp` 模型,且只在官方 DeepSeek API 端点生效。图片按内容寻址并在本地缓存,重复图片在有效期内复用同一个 `file_id`,过期文件自动重新上传。\n\n开启后图片 payload 更小、token 更省;关闭时图片以 base64 嵌入。", + "deepseek-copilot.config.cacheExpiresDays.description": "上传到 DeepSeek Files API 的图片文件保留时长。设为 0 表示 1 小时,或设为 -1 永久保留。正数表示天数,并会被钳制到 API 允许范围内(1-30 天)。", + "deepseek-copilot.config.cacheExpiresDays.markdownDescription": "上传的图片文件在 DeepSeek Files API 中的缓存保留时长。\n\n- **-1** — 永久保留(永不过期)。\n- **0** — 1 小时(API 允许的最小值)。\n- **1-30** — 天数,钳制到 API 允许范围内。\n\n文件按内容寻址并去重,因此重复上传同一张图片时,只要 `file_id` 仍有效就会复用。\n\n仅在启用 `#deepseek-copilot.experimental.filesApi#` 时生效。", "deepseek-copilot.config.debugMode.description": "控制 DeepSeek Copilot 写入的诊断信息量。无论此设置如何,token 用量始终上报给 Copilot。\n\n- **基本** — 仅上报 token 用量,不输出诊断日志或请求 dump。\n- **元数据** — 隐私安全的诊断元数据(请求哈希、前缀重合度、工具定义变更)。不含提示词原文,可安全附在公开 issue 中反馈问题。使用 [`DeepSeek: 显示日志`](command:deepseek-copilot.showLogs) 查看。\n- **详细** — 将完整请求体写入磁盘,供本地调试。**警告:包含敏感的提示词内容。** 使用 [`DeepSeek: 打开请求 Dump 目录`](command:deepseek-copilot.openRequestDumpsFolder) 浏览。", "deepseek-copilot.config.debugMode.minimal.label": "基本", "deepseek-copilot.config.debugMode.minimal.description": "仅上报 token 用量,不输出诊断日志或 dump。", diff --git a/src/client/error/index.ts b/src/client/error/index.ts index 7df85d2..082454e 100644 --- a/src/client/error/index.ts +++ b/src/client/error/index.ts @@ -315,10 +315,17 @@ function getContentChars(content: DeepSeekMessage['content']): number { if (typeof content === 'string') { return content.length; } - return content.reduce( - (total, part) => total + (part.type === 'text' ? part.text.length : part.image_url.url.length), - 0, - ); + return content.reduce((total, part) => { + if (part.type === 'text') { + return total + part.text.length; + } + if (part.type === 'image_url') { + // data URLs can dominate request size, so count them. + return total + part.image_url.url.length; + } + // file (Files API) refs are short; treat as negligible. + return total; + }, 0); } /** @@ -330,7 +337,7 @@ function countImageParts(content: DeepSeekMessage['content']): number { if (typeof content === 'string') { return 0; } - return content.filter((part) => part.type === 'image_url').length; + return content.filter((part) => part.type === 'image_url' || part.type === 'file').length; } function joinDiagnosticParts(...parts: (string | undefined)[]): string { diff --git a/src/config.ts b/src/config.ts index 477ca09..906cc2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,10 @@ import vscode from 'vscode'; import { CONFIG_SECTION } from './consts'; +import { + CLOUD_CACHE_SECONDS, + MAX_CACHE_SECONDS, + MIN_CACHE_SECONDS, +} from './provider/vision/filesApi'; export type DebugMode = 'minimal' | 'metadata' | 'verbose'; @@ -69,6 +74,61 @@ export function getStabilizeToolListEnabled(): boolean { return config.get('experimental.stabilizeToolList', false); } +/** + * Whether the experimental DeepSeek Files API native-vision route is enabled. + * + * When enabled (and the model supports it and the endpoint is the official + * DeepSeek API), images are uploaded via POST /files and referenced by file_id. + */ +export function getFilesApiEnabled(): boolean { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + return config.get('experimental.filesApi', false); +} + +/** + * Get the configured cloud cache lifetime (in seconds) for Files API uploads. + * + * Reads `cacheExpiresDays` from settings: + * - `-1` → permanent (0, no expires_after) + * - `0` → 1 hour (the API's minimum) + * - a plain number `N` → N days + * + * The result is clamped to the API's valid range [MIN_CACHE_SECONDS, MAX_CACHE_SECONDS]. + * Returns 0 only when the user explicitly wants permanent files (never expires). + */ +export function getCacheExpiresSeconds(): number { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + // 兜底为 7(天),与 package.json 的 schema default 保持一致, + // 避免配置读取失败时落入 0 → 1 小时的语义。 + const days = config.get('cacheExpiresDays', 7); + return parseCacheExpiresSeconds(days); +} + +/** + * Parse a `cacheExpiresDays` numeric value into seconds. Clamps to the API's + * valid range, or returns 0 for permanent. + */ +export function parseCacheExpiresSeconds(raw: number | undefined | null): number { + if (raw == null) { + return CLOUD_CACHE_SECONDS; // 默认 7 天 + } + // 永久:-1 或任意负数 + if (raw < 0) { + return 0; + } + // 0 → 1 小时(API 允许的最小值) + if (raw === 0) { + return MIN_CACHE_SECONDS; + } + // 正数 → 天,并钳制到 API 允许范围 + return clampToValidCacheRange(raw * 24 * 60 * 60); +} + +/** Clamp a raw duration (seconds) to the API's valid range. */ +function clampToValidCacheRange(seconds: number): number { + return Math.min(Math.max(Math.round(seconds), MIN_CACHE_SECONDS), MAX_CACHE_SECONDS); +} + /** * Migrate the legacy boolean `deepseek-copilot.debug` setting to `debugMode`. * diff --git a/src/consts.ts b/src/consts.ts index 7e52ed4..291ed7d 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -110,6 +110,7 @@ export const MODELS: ModelDefinition[] = [ toolCalling: DEEPSEEK_TOOLS_LIMIT, imageInput: true, nativeImageInput: true, + visionNative: true, thinking: { supportedEfforts: ['low', 'high', 'max'], defaultEffort: 'high', diff --git a/src/i18n.ts b/src/i18n.ts index 20062f3..9be4c05 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -35,6 +35,15 @@ const zh: Translations = { 'auth.removed': 'API Key 已移除。', 'auth.notConfigured': 'API Key 未配置,请在命令面板运行 "DeepSeek: 设置 API Key"。', + // Cloud cache (Files API) + 'cloudCache.noApiKey': '需要先配置 DeepSeek API Key 才能清空云端缓存。', + 'cloudCache.clearConfirm': + '确定要清空 DeepSeek Files API 云端缓存吗?这将永久删除所有已上传的图片文件。', + 'cloudCache.clearConfirmAction': '清空', + 'cloudCache.clearing': '正在清空云端缓存...', + 'cloudCache.cleared': '已清空云端缓存,共删除 {0} 个文件。', + 'cloudCache.clearFailed': '清空云端缓存失败,请查看日志。', + // Thinking Effort — short labels for model picker dropdown 'status.thinking': '思考模式', 'thinking.none': '停用', @@ -230,6 +239,15 @@ const en: Translations = { 'auth.notConfigured': 'DeepSeek API key not configured. Run "DeepSeek: Set API Key" from the Command Palette.', + // Cloud cache (Files API) + 'cloudCache.noApiKey': 'A DeepSeek API key is required to clear the cloud cache.', + 'cloudCache.clearConfirm': + 'Clear the DeepSeek Files API cloud cache? This permanently deletes all uploaded image files.', + 'cloudCache.clearConfirmAction': 'Clear', + 'cloudCache.clearing': 'Clearing cloud cache...', + 'cloudCache.cleared': 'Cloud cache cleared. Deleted {0} file(s).', + 'cloudCache.clearFailed': 'Failed to clear cloud cache. Check the logs.', + // Thinking Effort 'status.thinking': 'Thinking Effort', 'thinking.none': 'None', diff --git a/src/provider/convert.ts b/src/provider/convert.ts index cfb15b9..a77de3d 100644 --- a/src/provider/convert.ts +++ b/src/provider/convert.ts @@ -1,4 +1,5 @@ import vscode from 'vscode'; +import * as crypto from 'crypto'; import { safeStringify } from '../json'; import type { DeepSeekContentPart, @@ -8,6 +9,14 @@ import type { } from '../types'; import { parseFirstReplayMarker } from './replay'; +/** + * Options controlling multimodal conversion for the DeepSeek Files API route. + */ +export interface ConvertMessageOptions { + /** Keyed by the sha256 content hash of an image; value is its file_id. */ + filesApiFileIdByHash?: ReadonlyMap; +} + /** * Convert VS Code chat messages to DeepSeek format. * Injects marker-replayed reasoning_content for assistant messages. @@ -16,7 +25,9 @@ export function convertMessages( messages: readonly vscode.LanguageModelChatRequestMessage[], isThinkingModel: boolean, nativeImageInput: boolean, + options?: ConvertMessageOptions, ): DeepSeekMessage[] { + const filesApiFileIdByHash = options?.filesApiFileIdByHash; const result: DeepSeekMessage[] = []; for (const message of messages) { @@ -38,12 +49,20 @@ export function convertMessages( }); } } else if (nativeImageInput && role === 'user' && isImageDataPart(part)) { - nativeVisionContentParts.push({ - type: 'image_url', - image_url: { - url: toImageDataUrl(part), - }, - }); + const fileId = resolveFileId(part, filesApiFileIdByHash); + if (fileId) { + nativeVisionContentParts.push({ + type: 'file', + file_id: fileId, + }); + } else { + nativeVisionContentParts.push({ + type: 'image_url', + image_url: { + url: toImageDataUrl(part), + }, + }); + } } else if (isLanguageModelThinkingPart(part)) { thinkingContent += normalizeThinkingPartText(part.value); } else if (part instanceof vscode.LanguageModelToolCallPart) { @@ -122,6 +141,21 @@ function toImageDataUrl(part: vscode.LanguageModelDataPart): string { return `data:${part.mimeType};base64,${Buffer.from(part.data).toString('base64')}`; } +/** + * Resolve the Files API file_id for an image part by its sha256 content hash, + * or return undefined when no Files API mapping exists (falls back to base64). + */ +function resolveFileId( + part: vscode.LanguageModelDataPart, + fileIdByHash: ReadonlyMap | undefined, +): string | undefined { + if (!fileIdByHash) { + return undefined; + } + const hash = crypto.createHash('sha256').update(part.data).digest('hex'); + return fileIdByHash.get(hash); +} + function getReasoningContent( replayMarker: ReturnType, thinkingContent: string, @@ -201,9 +235,10 @@ function getMessageContentChars(content: DeepSeekMessage['content']): number { for (const part of content) { if (part.type === 'text') { total += part.text.length; - } else if (part.type === 'image_url') { - // Do not count base64 URL chars. Native-image requests are excluded from - // adaptive charsPerToken updates, and image cost is handled separately. + } else if (part.type === 'image_url' || part.type === 'file') { + // Do not count base64 URL chars (image_url) or file_id refs (file). + // Native-image requests are excluded from adaptive charsPerToken + // updates, and image cost is handled separately. total += 0; } } diff --git a/src/provider/request.ts b/src/provider/request.ts index 057d1fe..071bd31 100644 --- a/src/provider/request.ts +++ b/src/provider/request.ts @@ -1,7 +1,14 @@ import vscode from 'vscode'; +import * as crypto from 'crypto'; import { AuthManager } from '../auth'; import { DeepSeekClient } from '../client'; -import { getApiModelId, getBaseUrl, getMaxTokens } from '../config'; +import { + getApiModelId, + getBaseUrl, + getCacheExpiresSeconds, + getFilesApiEnabled, + getMaxTokens, +} from '../config'; import { MODELS } from '../consts'; import { isOfficialDeepSeekBaseUrl } from '../endpoint'; import { t } from '../i18n'; @@ -12,6 +19,7 @@ import { type CacheDiagnosticsRecorder, type CacheDiagnosticsRun, } from './debug'; +import { ensureFileId } from './vision/filesApi'; import { getConfiguredThinkingEffort, type ModelConfigurationOptions } from './models'; import type { ReplayMarkerMetadata } from './replay'; import { classifyDeepSeekRequest, shouldForceThinkingNone, type RequestKind } from './routing'; @@ -71,6 +79,36 @@ export async function prepareChatRequest({ const nativeImageInput = modelDef?.capabilities.nativeImageInput === true; const maxTokens = getMaxTokens(); + // Experimental Files API route: enabled only when the user opts in, the model + // declares visionNative (Files API capability), and the endpoint is official. + const filesApiEnabled = getFilesApiEnabled(); + const filesApiAvailable = + modelDef?.capabilities.visionNative === true && isOfficialDeepSeekBaseUrl(baseUrl); + + let filesApiFileIdByHash: Map | undefined; + let effectiveFilesApi = false; + if (filesApiEnabled && filesApiAvailable && nativeImageInput) { + try { + filesApiFileIdByHash = await uploadNativeImages( + globalStorageUri, + apiKey, + baseUrl, + messages, + token, + ); + effectiveFilesApi = true; + } catch (error) { + // Upload failed — fall back to the base64 native route so the + // conversation is not interrupted. Log the reason for diagnosis. + console.warn( + '[deepseek-copilot] Files API upload failed, falling back to base64 native vision', + error, + ); + filesApiFileIdByHash = undefined; + effectiveFilesApi = false; + } + } + // Flash/Pro are declared as non-native vision models and therefore resolve // image inputs through the configured/default proxy route (Vision Exp in auto mode). const visionResolution: VisionResolutionResult = nativeImageInput @@ -79,7 +117,9 @@ export async function prepareChatRequest({ const resolvedMessages = visionResolution.messages; - const deepseekMessages = convertMessages(resolvedMessages, isThinkingModel, nativeImageInput); + const deepseekMessages = convertMessages(resolvedMessages, isThinkingModel, nativeImageInput, { + filesApiFileIdByHash: effectiveFilesApi ? filesApiFileIdByHash : undefined, + }); if (nativeImageInput) { // For native-image models, count images after conversion so diagnostics reflect // what is actually forwarded in the DeepSeek payload. @@ -177,7 +217,7 @@ function hasNativeImageParts(messages: DeepSeekMessage[]): boolean { continue; } for (const part of message.content) { - if (part.type === 'image_url') { + if (part.type === 'image_url' || part.type === 'file') { return true; } } @@ -239,7 +279,7 @@ function createNativeVisionResolutionStats(): VisionResolutionStats { }; } -/** Count native image parts that survived conversion into image_url content. */ +/** Count native image parts that survived conversion into image_url/file content. */ function countNativeForwardedImageParts(messages: readonly DeepSeekMessage[]): number { let total = 0; for (const message of messages) { @@ -247,10 +287,55 @@ function countNativeForwardedImageParts(messages: readonly DeepSeekMessage[]): n continue; } for (const part of message.content) { - if (part.type === 'image_url') { + if (part.type === 'image_url' || part.type === 'file') { total += 1; } } } return total; } +/** + * Experimental Files API route: scan all image parts in the conversation, upload + * each unique image to the DeepSeek Files API, and return a map from the image's + * sha256 content hash to the resulting file_id. Deduped by content hash so the + * same image only uploads once per request. + */ +async function uploadNativeImages( + globalStorageUri: vscode.Uri, + apiKey: string, + baseUrl: string, + messages: readonly vscode.LanguageModelChatRequestMessage[], + token?: vscode.CancellationToken, +): Promise> { + const fileIdByHash = new Map(); + + for (const message of messages) { + for (const part of message.content) { + if (isImageDataPart(part)) { + const hash = hashBytes(part.data); + if (!fileIdByHash.has(hash)) { + const fileId = await ensureFileId( + globalStorageUri, + apiKey, + baseUrl, + part.data, + part.mimeType, + getCacheExpiresSeconds(), + token, + ); + fileIdByHash.set(hash, fileId); + } + } + } + } + + return fileIdByHash; +} + +function isImageDataPart(part: unknown): part is vscode.LanguageModelDataPart { + return part instanceof vscode.LanguageModelDataPart && part.mimeType.startsWith('image/'); +} + +function hashBytes(data: Uint8Array): string { + return crypto.createHash('sha256').update(data).digest('hex'); +} diff --git a/src/provider/vision/filesApi.ts b/src/provider/vision/filesApi.ts new file mode 100644 index 0000000..28fce2c --- /dev/null +++ b/src/provider/vision/filesApi.ts @@ -0,0 +1,385 @@ +import vscode from 'vscode'; +import * as crypto from 'crypto'; + +/** DeepSeek Files API 上传返回的文件对象。 */ +export interface DeepSeekFile { + id: string; + object: string; + bytes: number; + created_at: number; + filename: string; + purpose: string; + expires_at?: number; +} + +/** 云端缓存默认时长:7 天(秒)。 */ +export const CLOUD_CACHE_SECONDS = 7 * 24 * 60 * 60; +/** 云端缓存最小时长:1 小时(秒,DeepSeek Files API 允许的最小值)。 */ +export const MIN_CACHE_SECONDS = 60 * 60; +/** 云端缓存最大时长:30 天(秒,DeepSeek 上限)。 */ +export const MAX_CACHE_SECONDS = 30 * 24 * 60 * 60; + +/** 文件过期前多久视为即将失效,需要重传(秒 = 1 小时)。 */ +const EXPIRY_WARN_SECONDS = 60 * 60; + +/** 本地缓存保留时长:超过该时长的缓存文件将被清理(秒 = 7 天)。 */ +export const LOCAL_CACHE_RETENTION_SECONDS = 7 * 24 * 60 * 60; + +/** 两次清理之间的最小间隔,避免每次上传都遍历目录(秒 = 1 小时)。 */ +const PRUNE_INTERVAL_SECONDS = 60 * 60; + +/** 本地缓存元数据。 */ +interface LocalCacheEntry { + contentHash: string; + fileId?: string; + expiresAt?: number; +} + +/** 从 MIME 推断扩展名。 */ +function extFromMime(mime: string): string { + const map: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + }; + return map[mime] || '.img'; +} + +function sha256(data: Uint8Array): string { + return crypto.createHash('sha256').update(data).digest('hex'); +} + +/** + * 上传图片字节到 DeepSeek Files API。 + * @param apiKey DeepSeek API Key + * @param baseUrl API 基础地址 + * @param data 图片字节 + * @param filename 文件名 + * @param expiresSeconds 云端过期秒数(0 = 永久) + * @param token 可选的取消令牌;请求会响应取消并通过 AbortController 中断 + */ +export async function uploadBytes( + apiKey: string, + baseUrl: string, + data: Uint8Array, + filename: string, + expiresSeconds: number, + token?: vscode.CancellationToken, +): Promise { + const MAX_BYTES = 64 * 1024 * 1024; + if (data.byteLength > MAX_BYTES) { + throw new Error( + `图片大小超过 64 MiB 上限(当前 ${(data.byteLength / 1024 / 1024).toFixed(2)} MiB)。`, + ); + } + + // 用可选的取消令牌构建 AbortController,支持用户按 Esc 中断上传。 + const controller = new AbortController(); + const onCancel = () => controller.abort(); + let cancelDisposable: vscode.Disposable | undefined; + if (token) { + if (token.isCancellationRequested) { + throw new Error('Files API upload cancelled'); + } + cancelDisposable = token.onCancellationRequested(onCancel, undefined); + } + + const form = new FormData(); + form.append('file', new Blob([Buffer.from(data)]), filename); + form.append('purpose', 'user_data'); + if (expiresSeconds > 0) { + // 防御性钳制到 API 允许范围(3600 ~ 2592000 秒),避免非法值导致 400。 + const safeExpires = Math.min(Math.max(expiresSeconds, MIN_CACHE_SECONDS), MAX_CACHE_SECONDS); + form.append('expires_after[anchor]', 'created_at'); + form.append('expires_after[seconds]', String(safeExpires)); + } + + try { + const response = await fetch(`${baseUrl}/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body: form, + signal: controller.signal, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Files API 上传失败(HTTP ${response.status}):${body}`); + } + + return (await response.json()) as DeepSeekFile; + } finally { + cancelDisposable?.dispose(); + } +} + +/** 官方 `GET /files` 的分页响应。 */ +interface DeepSeekFileListResponse { + object: string; + data: DeepSeekFile[]; + first_id?: string; + last_id?: string; + has_more?: boolean; +} + +/** + * 列出当前账号在 DeepSeek Files API 上已上传的文件(默认 1000 个,覆盖官方上限)。 + * @param apiKey DeepSeek API Key + * @param baseUrl API 基础地址 + */ +export async function listCloudFiles(apiKey: string, baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/files?limit=1000`, { + method: 'GET', + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Files API 列出文件失败(HTTP ${response.status}):${body}`); + } + + const list = (await response.json()) as DeepSeekFileListResponse; + return list.data ?? []; +} + +/** + * 删除 DeepSeek Files API 上的单个文件。 + * @param apiKey DeepSeek API Key + * @param baseUrl API 基础地址 + * @param fileId 要删除的文件 ID(形如 `file-api-...`) + */ +export async function deleteCloudFile( + apiKey: string, + baseUrl: string, + fileId: string, +): Promise { + const response = await fetch(`${baseUrl}/files/${encodeURIComponent(fileId)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Files API 删除文件失败(HTTP ${response.status}):${body}`); + } +} + +/** + * 清空当前账号在 DeepSeek Files API 上已上传的所有文件。 + * 遍历列出全部文件并逐个删除,返回删除的文件数量。 + * @param apiKey DeepSeek API Key + * @param baseUrl API 基础地址 + */ +export async function clearCloudFiles(apiKey: string, baseUrl: string): Promise { + const files = await listCloudFiles(apiKey, baseUrl); + let deleted = 0; + for (const file of files) { + try { + await deleteCloudFile(apiKey, baseUrl, file.id); + deleted += 1; + } catch (error) { + // 单个文件删除失败时继续尝试删除其他文件,最后汇总结果。 + console.warn(`Failed to delete cloud file ${file.id}`, error); + } + } + return deleted; +} + +/** + * 确保图片已上传到 Files API 并返回有效 file_id。 + * 本地按内容哈希缓存;云端缓存(默认 7 天);云端缓存失效时自动重传。 + * + * @param globalStorageUri 扩展全局存储根(用于本地缓存目录) + * @param apiKey DeepSeek API Key + * @param baseUrl API 基础地址 + * @param imageData 图片字节 + * @param mimeType 图片 MIME 类型 + * @param expiresSeconds 云端过期秒数(0 = 永久;默认使用 CLOUD_CACHE_SECONDS) + * @param token 可选的取消令牌;上传过程会响应取消 + */ +export async function ensureFileId( + globalStorageUri: vscode.Uri, + apiKey: string, + baseUrl: string, + imageData: Uint8Array, + mimeType: string, + expiresSeconds: number = CLOUD_CACHE_SECONDS, + token?: vscode.CancellationToken, +): Promise { + if (token?.isCancellationRequested) { + throw new Error('Files API upload cancelled'); + } + + const contentHash = sha256(imageData); + const cacheDir = vscode.Uri.joinPath(globalStorageUri, 'image-cache'); + await vscode.workspace.fs.createDirectory(cacheDir); + + // 每次上传都顺带做一次过期清理(带时间门槛,不会每次都遍历目录)。 + // 永久文件(expiresSeconds <= 0)不触发本地清理,避免删除仍在复用的本地 meta。 + // 清理失败不影响主流程,只记录状态。 + if (expiresSeconds > 0) { + void pruneLocalCache(globalStorageUri, expiresSeconds).catch(() => { + // 非关键路径,忽略清理错误。 + }); + } + + const localPath = vscode.Uri.joinPath(cacheDir, `${contentHash}${extFromMime(mimeType)}`); + const metaPath = vscode.Uri.joinPath(cacheDir, `${contentHash}.json`); + + // 写本地字节缓存(内容寻址,天然去重) + await vscode.workspace.fs.writeFile(localPath, imageData); + + const entry = await readCacheEntry(metaPath); + if (entry && entry.fileId) { + const nowSec = Math.floor(Date.now() / 1000); + // 永久文件(未设置 expires_at)视为永远有效,直接复用 + if (!entry.expiresAt) { + // 重写元数据以刷新 mtime,避免被本地保留期清理误删。 + await writeCacheEntry(metaPath, entry); + return entry.fileId; + } + // 云端缓存仍有效(且离过期还有 1 小时以上),直接复用 + if (entry.expiresAt - nowSec > EXPIRY_WARN_SECONDS) { + // 重写元数据以刷新 mtime,避免被本地保留期清理误删。 + await writeCacheEntry(metaPath, entry); + return entry.fileId; + } + // 即将过期或已失效:重新上传 + } + + const uploaded = await uploadBytes( + apiKey, + baseUrl, + imageData, + contentHash + extFromMime(mimeType), + expiresSeconds, + token, + ); + + const newEntry: LocalCacheEntry = { + contentHash, + fileId: uploaded.id, + expiresAt: uploaded.expires_at, + }; + await writeCacheEntry(metaPath, newEntry); + + return uploaded.id; +} + +async function readCacheEntry(metaPath: vscode.Uri): Promise { + try { + const raw = await vscode.workspace.fs.readFile(metaPath); + return JSON.parse(Buffer.from(raw).toString('utf8')) as LocalCacheEntry; + } catch { + return undefined; + } +} + +async function writeCacheEntry(metaPath: vscode.Uri, entry: LocalCacheEntry): Promise { + await vscode.workspace.fs.writeFile( + metaPath, + Buffer.from(JSON.stringify(entry, null, 2), 'utf8'), + ); +} + +/** 记录上一次清理的时间(Unix 秒),避免每次上传都触发目录遍历。 */ +let lastPruneAt = 0; + +/** + * 清理本地图片缓存:删除超过保留期的 `.json` 元数据及其对应的图片文件。 + * 通过 `retentionSeconds` 控制保留时长(默认 7 天)。清理是幂等的,且带时间门槛。 + * + * @param globalStorageUri 扩展全局存储根(用于定位 image-cache 目录) + * @param retentionSeconds 保留时长(秒)。默认使用 LOCAL_CACHE_RETENTION_SECONDS。 + * @param force 为 true 时忽略时间门槛,立即执行(用于测试/手动触发)。 + * @returns 删除的缓存条目数量。 + */ +export async function pruneLocalCache( + globalStorageUri: vscode.Uri, + retentionSeconds: number = LOCAL_CACHE_RETENTION_SECONDS, + force = false, +): Promise { + if (!force) { + const nowSec = Math.floor(Date.now() / 1000); + // 距上次清理不足 PRUNE_INTERVAL_SECONDS 则跳过。 + if (nowSec - lastPruneAt < PRUNE_INTERVAL_SECONDS) { + return 0; + } + } + + const cacheDir = vscode.Uri.joinPath(globalStorageUri, 'image-cache'); + let entries: [string, vscode.FileType][]; + try { + entries = await vscode.workspace.fs.readDirectory(cacheDir); + } catch { + // 目录不存在(尚未上传过图片),无需清理。 + lastPruneAt = Math.floor(Date.now() / 1000); + return 0; + } + + const nowSec = Math.floor(Date.now() / 1000); + let removed = 0; + + for (const [name, type] of entries) { + if (type !== vscode.FileType.File || !name.endsWith('.json')) { + continue; + } + + const metaUri = vscode.Uri.joinPath(cacheDir, name); + + // 用元数据文件的修改时间(mtime)作为“最后使用时间”。 + // 每次 ensureFileId 命中复用都会重写 meta,因此 mtime 能反映最近使用。 + const stat = await vscode.workspace.fs.stat(metaUri); + const mtimeSec = Math.floor(stat.mtime / 1000); + // 超过保留期(默认 7 天)未使用的条目视为过期。 + if (nowSec - mtimeSec <= retentionSeconds) { + continue; + } + + // meta 文件名形如 `.json`,对应图片文件为 `.`。 + // 图片扩展名未存进 meta,因此通过扫描同目录中同前缀(去掉 .json)的图片文件来定位。 + const imagePrefix = name.slice(0, -'.json'.length); + try { + await vscode.workspace.fs.delete(metaUri, { recursive: false }); + for (const [candidate, candidateType] of entries) { + if ( + candidateType === vscode.FileType.File && + candidate.startsWith(imagePrefix) && + candidate !== name && + !candidate.endsWith('.json') + ) { + const imageUri = vscode.Uri.joinPath(cacheDir, candidate); + try { + await vscode.workspace.fs.delete(imageUri, { recursive: false }); + } catch { + // 单张图片删除失败时继续尝试删除其他匹配文件。 + } + } + } + removed += 1; + } catch { + // 删除失败时跳过,不影响其他条目。 + } + } + + lastPruneAt = Math.floor(Date.now() / 1000); + return removed; +} + +/** + * 清空本地图片缓存目录(递归删除整个 image-cache 并重建)。 + * 在清空云端缓存后调用,确保本地 meta 不会引用已删除的 file_id。 + * + * @param globalStorageUri 扩展全局存储根 + */ +export async function clearLocalCache(globalStorageUri: vscode.Uri): Promise { + const cacheDir = vscode.Uri.joinPath(globalStorageUri, 'image-cache'); + try { + await vscode.workspace.fs.delete(cacheDir, { recursive: true }); + } catch { + // 目录不存在(尚未上传过图片),无需处理。 + } + await vscode.workspace.fs.createDirectory(cacheDir); + lastPruneAt = Math.floor(Date.now() / 1000); +} diff --git a/src/runtime/commands.ts b/src/runtime/commands.ts index f640e5b..9dd3eca 100644 --- a/src/runtime/commands.ts +++ b/src/runtime/commands.ts @@ -1,8 +1,11 @@ import vscode from 'vscode'; +import { AuthManager } from '../auth'; import { EXTERNAL_URLS } from '../consts'; +import { getBaseUrl } from '../config'; import { t } from '../i18n'; import { logger } from '../logger'; import { ensureRequestDumpRoot } from '../provider/debug'; +import { clearCloudFiles, clearLocalCache } from '../provider/vision/filesApi'; export function registerCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( @@ -16,7 +19,50 @@ export function registerCommands(context: vscode.ExtensionContext): void { vscode.commands.registerCommand('deepseek-copilot.openSettings', () => vscode.commands.executeCommand('workbench.action.openSettings', 'deepseek-copilot'), ), + vscode.commands.registerCommand('deepseek-copilot.clearCloudCache', () => + clearCloudCacheCommand(context), + ), + ); +} + +/** + * 清空 DeepSeek Files API 云端缓存(永久删除所有已上传文件)。 + * 破坏性操作,先弹确认框,再执行。 + */ +async function clearCloudCacheCommand(context: vscode.ExtensionContext): Promise { + const authManager = new AuthManager(context); + const apiKey = await authManager.getApiKey(); + if (!apiKey) { + void vscode.window.showWarningMessage(t('cloudCache.noApiKey')); + return; + } + + const choice = await vscode.window.showWarningMessage( + t('cloudCache.clearConfirm'), + { modal: true }, + t('cloudCache.clearConfirmAction'), ); + if (choice !== t('cloudCache.clearConfirmAction')) { + return; + } + + const baseUrl = getBaseUrl(); + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: t('cloudCache.clearing') }, + async () => { + const deleted = await clearCloudFiles(apiKey, baseUrl); + // 云端已清空,同步清空本地 image-cache,避免本地 meta 仍指向已删除的 + // file_id,导致后续聊天复用时服务器 400 "file not found"。 + await clearLocalCache(context.globalStorageUri); + logger.info(`Cleared ${deleted} cloud cache file(s)`); + void vscode.window.showInformationMessage(t('cloudCache.cleared', String(deleted))); + }, + ); + } catch (error) { + logger.warn('Failed to clear cloud cache', error); + void vscode.window.showErrorMessage(t('cloudCache.clearFailed')); + } } async function openRequestDumpsFolder(context: vscode.ExtensionContext): Promise { diff --git a/src/types.ts b/src/types.ts index 50decd4..46dd3ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,7 +19,19 @@ export interface DeepSeekImageUrlContentPart { }; } -export type DeepSeekContentPart = DeepSeekTextContentPart | DeepSeekImageUrlContentPart; +/** + * File block used by the DeepSeek Files API native-vision path. + * Images are uploaded first via POST /files, then referenced by file_id. + */ +export interface DeepSeekFileContentPart { + type: 'file'; + file_id: string; +} + +export type DeepSeekContentPart = + | DeepSeekTextContentPart + | DeepSeekImageUrlContentPart + | DeepSeekFileContentPart; export interface DeepSeekMessage { role: 'system' | 'user' | 'assistant' | 'tool'; @@ -139,6 +151,12 @@ export interface ModelDefinition { toolCalling: boolean | number; imageInput: boolean; nativeImageInput?: boolean; + /** + * True when the model natively accepts images via the DeepSeek Files API + * (file_id blocks uploaded through POST /files). Used by the experimental + * Files API route, distinct from `nativeImageInput` (base64 data URL). + */ + visionNative?: boolean; thinking: ThinkingCapability | false; }; requiresThinkingParam: boolean;