From 22567b11860ad0c5980890e8ffff36c95247a3e6 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:45:03 +0000 Subject: [PATCH 1/7] feat(deepseek): upload images via Files API for file_id references --- .../api/chat/llmprovider/DeepseekProvider.kt | 120 ++++++++++++++++++ .../api/chat/llmprovider/OpenAIProvider.kt | 38 ++++-- .../sections/ModelApiSettingsSection.kt | 6 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-ms/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ro/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 11 files changed, 164 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt index 2fec4cffc8..d764bc4846 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt @@ -11,9 +11,17 @@ import com.ai.assistance.operit.data.model.ToolPrompt import com.ai.assistance.operit.util.ChatUtils import com.ai.assistance.operit.util.stream.Stream import okhttp3.OkHttpClient +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.Request import okhttp3.RequestBody import org.json.JSONArray import org.json.JSONObject +import java.net.URL +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * 针对DeepSeek模型的特定API Provider。 @@ -48,6 +56,118 @@ class DeepseekProvider( thinkingOptionId = thinkingOptionId ) { private val configuredApiEndpoint = apiEndpoint + // 基类中的 client / apiKeyProvider / customHeaders 为 private,这里保存自有引用供 Files API 上传使用 + private val httpClient = client + private val keyProvider = apiKeyProvider + private val headerOverrides = customHeaders + + /** 图片 id -> DeepSeek Files API file_id 的会话级缓存(避免同一图片重复上传) */ + private val uploadedImageFileIds = ConcurrentHashMap() + + /** DeepSeek Files API 支持上传的图片 MIME 类型 */ + private val supportedUploadMimeTypes = setOf("image/jpeg", "image/png", "image/gif", "image/webp") + + /** DeepSeek Files API 单文件上传上限(64 MiB) */ + private val maxUploadBytes = 64L * 1024L * 1024L + + /** + * 发送前把消息中的图片上传至 DeepSeek Files API 换取 file_id。 + * 上传失败的图片保持原样,构建请求时回退为 base64 内嵌(见 buildImageContentPart)。 + */ + override suspend fun prepareMediaForRequest(context: Context, chatHistory: List) { + if (!supportsVision) return + val seenIds = mutableSetOf() + val imageLinks = mutableListOf() + for (turn in chatHistory) { + MediaLinkParser.extractImageLinks(turn.content).forEach { link -> + if (seenIds.add(link.id)) imageLinks.add(link) + } + } + for (link in imageLinks) { + if (uploadedImageFileIds.containsKey(link.id)) continue + if (link.mimeType.lowercase() !in supportedUploadMimeTypes) continue + val bytes = + runCatching { Base64.getDecoder().decode(link.base64Data) }.getOrNull() ?: continue + if (bytes.isEmpty() || bytes.size > maxUploadBytes) continue + val fileId = uploadImageToFilesApi(bytes, link.mimeType, link.id) + if (fileId != null) { + uploadedImageFileIds[link.id] = fileId + AppLogger.d("DeepseekProvider", "图片 ${link.id} 已上传至 DeepSeek Files API: $fileId") + } + } + } + + /** 上传单张图片到 DeepSeek Files API,返回 file_id;失败返回 null */ + private suspend fun uploadImageToFilesApi( + bytes: ByteArray, + mimeType: String, + linkId: String + ): String? { + val filesEndpoint = buildFilesEndpoint() ?: return null + return runCatching { + val body = + MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("purpose", "user_data") + .addFormDataPart( + "file", + "image_${linkId.take(24)}.${extensionForMime(mimeType)}", + RequestBody.create(mimeType.toMediaType(), bytes) + ) + .build() + val builder = Request.Builder().url(filesEndpoint).post(body) + applyAuthenticationHeaders(builder, keyProvider.getApiKey().trim()) + headerOverrides.forEach { (key, value) -> + if (!key.equals("Content-Type", ignoreCase = true)) builder.addHeader(key, value) + } + withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response -> + if (!response.isSuccessful) { + AppLogger.w( + "DeepseekProvider", + "Files API 上传失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}" + ) + return@use null + } + val json = JSONObject(response.body?.string().orEmpty()) + json.optString("id").takeIf { it.startsWith("file-") } + } + }.getOrElse { e -> + AppLogger.w("DeepseekProvider", "Files API 上传异常: ${e.message}") + null + } + } + + /** 由聊天 API 端点推导 Files API 上传地址(官方为 https://api.deepseek.com/files) */ + private fun buildFilesEndpoint(): String? { + val raw = configuredApiEndpoint.trim().removeSuffix("#").trim() + return runCatching { + val url = URL(raw) + val portPart = if (url.port in 1..65535) ":${url.port}" else "" + "${url.protocol}://${url.host}${portPart}/files" + }.getOrNull() + } + + private fun extensionForMime(mimeType: String): String { + return when (mimeType.lowercase()) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + else -> "bin" + } + } + + /** 已有 file_id 时用 DeepSeek 文件块引用,否则回退基类的 base64 内嵌 */ + override fun buildImageContentPart(link: ImageLink): JSONObject { + val fileId = uploadedImageFileIds[link.id] + if (fileId != null) { + return JSONObject().apply { + put("type", "file") + put("file_id", fileId) + } + } + return super.buildImageContentPart(link) + } companion object { fun create( diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index 62fc69b8cd..7fdd7b7fcf 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -963,12 +963,7 @@ open class OpenAIProvider( if ((allowUserRichContent || allowResponsesToolImages) && supportsVision) { imageLinks.forEach { link -> - contentArray.put(JSONObject().apply { - put("type", "image_url") - put("image_url", JSONObject().apply { - put("url", "data:${link.mimeType};base64,${link.base64Data}") - }) - }) + contentArray.put(buildImageContentPart(link)) } } @@ -978,10 +973,36 @@ open class OpenAIProvider( put("text", textWithoutLinks) }) } - return contentArray } + /** + * 构建单张图片的内容块(OpenAI兼容格式:image_url + base64 data URL)。 + * 子类可覆写以改用其他传输方式(例如 DeepSeek Files API 的 file_id 引用)。 + * @param link 图片链接 + * @return 图片内容块 JSONObject + */ + protected open fun buildImageContentPart(link: ImageLink): JSONObject { + return JSONObject().apply { + put("type", "image_url") + put("image_url", JSONObject().apply { + put("url", "data:${link.mimeType};base64,${link.base64Data}") + }) + } + } + + /** + * 在构建请求体之前预处理消息中的媒体内容(基类为空实现)。 + * 子类可覆写以执行媒体上传等前置操作(例如 DeepSeek 将图片上传至 Files API)。 + * 此方法在 sendMessage 的重试循环之前调用一次,位于挂起上下文中。 + * @param context Android上下文 + * @param chatHistory 聊天历史 + */ + protected open suspend fun prepareMediaForRequest( + context: Context, + chatHistory: List + ) { + } /** * 构建消息列表并计算token(核心逻辑) * @param message 用户消息 @@ -3150,7 +3171,8 @@ open class OpenAIProvider( "AIService", "【发送消息】开始处理sendMessage请求,历史记录数量: ${chatHistory.size},最后一条长度: ${chatHistory.lastOrNull()?.content?.length ?: 0}" ) - + // 预处理媒体内容(例如 DeepSeek 将图片上传至 Files API 换取 file_id) + prepareMediaForRequest(context, chatHistory) val maxRetries = LlmRetryPolicy.MAX_RETRY_ATTEMPTS var retryCount = 0 var lastException: Exception? = null diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt index 4590975e27..d7fa548b1b 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt @@ -899,6 +899,12 @@ fun ModelApiSettingsSection( checked = enableDeepSeekWebSearchInput, onCheckedChange = { enableDeepSeekWebSearchInput = it } ) + Text( + text = stringResource(R.string.deepseek_files_api_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) } // Claude 1小时提示缓存开关 (仅Claude支持) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index da3ae8664a..a43b6fda06 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -4011,6 +4011,7 @@ When enabled, Gemini will use Google Search to enhance responses (may incur additional charges) Enable DeepSeek web search When using a DeepSeek Responses endpoint, allow the model to use server-side web search. + Images are automatically uploaded to the DeepSeek Files API and referenced by file_id; on failure, falls back to inline Base64. Enable Codex web search When signed in to Codex, allow the model to use server-side web search. Enable Claude 1-hour prompt cache diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 91d19cc84e..8fcd3232b1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7587,6 +7587,7 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. %1$s, %2$s, %3$s, total %4$s Habilitar búsqueda web de DeepSeek Al usar el endpoint de DeepSeek Responses, permite que el modelo utilice la búsqueda web del servidor. + Las imágenes se suben automáticamente a la API de archivos de DeepSeek y se referencian por file_id; si falla, se vuelve a Base64 en línea. Habilitar búsqueda web de Codex Después de iniciar sesión en Codex, permite que el modelo utilice la búsqueda web del servidor. Consulta diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 864f535afd..cc58f250af 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -7519,6 +7519,7 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. %1$s, %2$s, %3$s, total %4$s Aktifkan pencarian web DeepSeek Saat menggunakan endpoint DeepSeek Responses, izinkan model menggunakan pencarian web sisi server. + Gambar otomatis diunggah ke DeepSeek Files API dan direferensikan dengan file_id; jika gagal, kembali ke Base64 inline. Aktifkan pencarian web Codex Setelah masuk ke Codex, izinkan model menggunakan pencarian web sisi server. Kueri diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bef17c9d23..d820179d0c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -7366,6 +7366,7 @@ %1$s, %2$s, %3$s, 합계 %4$s DeepSeek 웹 검색 활성화 DeepSeek Responses 엔드포인트 사용 시 모델이 서버 측 웹 검색을 사용할 수 있습니다. + 이미지는 DeepSeek Files API에 자동 업로드되고 file_id로 참조됩니다. 실패 시 인라인 Base64로 대체됩니다. Codex 웹 검색 활성화 Codex에 로그인하면 모델이 서버 측 웹 검색을 사용할 수 있습니다. 쿼리 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index e471122d20..52baf9b496 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -7516,6 +7516,7 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. %1$s, %2$s, %3$s, jumlah %4$s Dayakan carian web DeepSeek Apabila menggunakan titik akhir DeepSeek Responses, benarkan model menggunakan carian web pelayan. + Imej dimuat naik secara automatik ke API Files DeepSeek dan dirujuk dengan file_id; jika gagal, kembali ke Base64 sebaris. Dayakan carian web Codex Selepas log masuk ke Codex, benarkan model menggunakan carian web pelayan. Pertanyaan diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 31e3e667ec..ea3f93a1ce 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -7357,6 +7357,7 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. %1$s, %2$s, %3$s, total %4$s Ativar pesquisa na web DeepSeek Ao usar o endpoint DeepSeek Responses, permite que o modelo use a pesquisa na web do servidor. + As imagens são enviadas automaticamente para a API de arquivos do DeepSeek e referenciadas por file_id; em caso de falha, volta para Base64 embutido. Ativar pesquisa na web Codex Após fazer login no Codex, permite que o modelo use a pesquisa na web do servidor. Consulta diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 25f57b895c..4ea62d405c 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -8114,6 +8114,7 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.%1$s, %2$s, %3$s, total %4$s Activați căutarea web DeepSeek Când utilizați punctul final DeepSeek Responses, permiteți modelului să folosească căutarea web pe server. + Imaginile sunt încărcate automat în API-ul Files DeepSeek și referențiate prin file_id; în caz de eșec, se revine la Base64 inline. Activați căutarea web Codex După autentificarea la Codex, permiteți modelului să folosească căutarea web pe server. Interogare diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54019940f0..b8b99aa747 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4003,6 +4003,7 @@ 启用后,Gemini 将使用 Google 搜索来增强回答(可能产生额外费用) 启用 DeepSeek 网络搜索 使用 DeepSeek Responses 端点时,允许模型使用服务端网络搜索。 + 发送图片时将自动上传至 DeepSeek Files API 并以 file_id 引用;上传失败自动回退为内嵌 Base64。 启用 Codex 网络搜索 登录 Codex 后,允许模型使用服务端网络搜索。 启用 Claude 1 小时提示缓存 From 0742aac9992d9585397f6c5fb456b0df62abb388 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:08:39 +0000 Subject: [PATCH 2/7] feat(deepseek): share Files API uploader across chat completions & responses --- .../chat/llmprovider/DeepseekFileUploader.kt | 131 +++++++++++++++++ .../api/chat/llmprovider/DeepseekProvider.kt | 136 ++++-------------- .../llmprovider/OpenAIResponsesProvider.kt | 12 ++ 3 files changed, 172 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt new file mode 100644 index 0000000000..d0dfbc3720 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt @@ -0,0 +1,131 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import com.ai.assistance.operit.core.chat.hooks.PromptTurn +import com.ai.assistance.operit.util.AppLogger +import java.net.URL +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import org.json.JSONObject + +/** + * DeepSeek Files API 图片上传器,供 Chat Completions 与 Responses 两种协议共用。 + * + * 职责:发送请求前把消息里的图片上传至 DeepSeek Files API 换取 file_id, + * 并按图片 id 做会话级缓存,避免同一图片重复上传。 + * 上传失败的图片不缓存,由调用方回退为 base64 内嵌。 + */ +internal class DeepseekFileUploader( + private val apiEndpoint: String, + private val apiKeyProvider: ApiKeyProvider, + private val httpClient: OkHttpClient, + private val customHeaders: Map +) { + /** 图片 id -> DeepSeek Files API file_id 的会话级缓存(避免同一图片重复上传) */ + private val uploadedImageFileIds = ConcurrentHashMap() + /** DeepSeek Files API 支持上传的图片 MIME 类型 */ + private val supportedUploadMimeTypes = setOf("image/jpeg", "image/png", "image/gif", "image/webp") + /** DeepSeek Files API 单文件上传上限(64 MiB) */ + private val maxUploadBytes = 64L * 1024L * 1024L + + /** + * 扫描消息历史中的图片并上传至 DeepSeek Files API 换取 file_id。 + * 上传失败的图片保持原样,构建请求时回退为 base64 内嵌(见各 Provider 的 buildImageContentPart)。 + * @param supportsVision 当前配置是否开启"直接图片处理";关闭时不触发上传 + * @param logTag 日志标签(区分 Chat Completions / Responses 两条调用线) + */ + suspend fun prepareMedia(supportsVision: Boolean, chatHistory: List, logTag: String) { + if (!supportsVision) return + val seenIds = mutableSetOf() + val imageLinks = mutableListOf() + for (turn in chatHistory) { + MediaLinkParser.extractImageLinks(turn.content).forEach { link -> + if (seenIds.add(link.id)) imageLinks.add(link) + } + } + for (link in imageLinks) { + if (uploadedImageFileIds.containsKey(link.id)) continue + if (link.mimeType.lowercase() !in supportedUploadMimeTypes) continue + val bytes = + runCatching { Base64.getDecoder().decode(link.base64Data) }.getOrNull() ?: continue + if (bytes.isEmpty() || bytes.size > maxUploadBytes) continue + val fileId = uploadImageToFilesApi(bytes, link.mimeType, link.id) + if (fileId != null) { + uploadedImageFileIds[link.id] = fileId + AppLogger.d(logTag, "图片 ${link.id} 已上传至 DeepSeek Files API: $fileId") + } + } + } + + /** 已上传图片对应的 file_id;未上传返回 null(调用方回退 base64) */ + fun fileIdFor(linkId: String): String? = uploadedImageFileIds[linkId] + + /** 上传单张图片到 DeepSeek Files API,返回 file_id;失败返回 null */ + private suspend fun uploadImageToFilesApi( + bytes: ByteArray, + mimeType: String, + linkId: String + ): String? { + val filesEndpoint = buildFilesEndpoint() ?: return null + return runCatching { + val body = + MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("purpose", "user_data") + .addFormDataPart( + "file", + "image_${linkId.take(24)}.${extensionForMime(mimeType)}", + RequestBody.create(mimeType.toMediaType(), bytes) + ) + .build() + val builder = Request.Builder().url(filesEndpoint).post(body) + val apiKey = apiKeyProvider.getApiKey().trim() + if (apiKey.isNotEmpty()) { + builder.addHeader("Authorization", "Bearer $apiKey") + } + customHeaders.forEach { (key, value) -> + if (!key.equals("Content-Type", ignoreCase = true)) builder.addHeader(key, value) + } + withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response -> + if (!response.isSuccessful) { + AppLogger.w( + "DeepseekFileUploader", + "Files API 上传失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}" + ) + return@use null + } + val json = JSONObject(response.body?.string().orEmpty()) + json.optString("id").takeIf { it.startsWith("file-") } + } + }.getOrElse { e -> + AppLogger.w("DeepseekFileUploader", "Files API 上传异常: ${e.message}") + null + } + } + + /** 由聊天 API 端点推导 Files API 上传地址(官方为 https://api.deepseek.com/files) */ + private fun buildFilesEndpoint(): String? { + val raw = apiEndpoint.trim().removeSuffix("#").trim() + return runCatching { + val url = URL(raw) + val portPart = if (url.port in 1..65535) ":" + url.port else "" + "${url.protocol}://${url.host}${portPart}/files" + }.getOrNull() + } + + private fun extensionForMime(mimeType: String): String { + return when (mimeType.lowercase()) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + else -> "bin" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt index d764bc4846..9c0955363e 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt @@ -11,17 +11,9 @@ import com.ai.assistance.operit.data.model.ToolPrompt import com.ai.assistance.operit.util.ChatUtils import com.ai.assistance.operit.util.stream.Stream import okhttp3.OkHttpClient -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.MultipartBody -import okhttp3.Request import okhttp3.RequestBody import org.json.JSONArray import org.json.JSONObject -import java.net.URL -import java.util.Base64 -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext /** * 针对DeepSeek模型的特定API Provider。 @@ -56,110 +48,17 @@ class DeepseekProvider( thinkingOptionId = thinkingOptionId ) { private val configuredApiEndpoint = apiEndpoint - // 基类中的 client / apiKeyProvider / customHeaders 为 private,这里保存自有引用供 Files API 上传使用 - private val httpClient = client - private val keyProvider = apiKeyProvider - private val headerOverrides = customHeaders + /** DeepSeek Files API 图片上传器(与 Responses 协议线共用的抽取实现) */ + private val fileUploader = DeepseekFileUploader(apiEndpoint, apiKeyProvider, client, customHeaders) - /** 图片 id -> DeepSeek Files API file_id 的会话级缓存(避免同一图片重复上传) */ - private val uploadedImageFileIds = ConcurrentHashMap() - - /** DeepSeek Files API 支持上传的图片 MIME 类型 */ - private val supportedUploadMimeTypes = setOf("image/jpeg", "image/png", "image/gif", "image/webp") - - /** DeepSeek Files API 单文件上传上限(64 MiB) */ - private val maxUploadBytes = 64L * 1024L * 1024L - - /** - * 发送前把消息中的图片上传至 DeepSeek Files API 换取 file_id。 - * 上传失败的图片保持原样,构建请求时回退为 base64 内嵌(见 buildImageContentPart)。 - */ + /** 发送前把消息中的图片上传至 DeepSeek Files API 换取 file_id,失败回退 base64 */ override suspend fun prepareMediaForRequest(context: Context, chatHistory: List) { - if (!supportsVision) return - val seenIds = mutableSetOf() - val imageLinks = mutableListOf() - for (turn in chatHistory) { - MediaLinkParser.extractImageLinks(turn.content).forEach { link -> - if (seenIds.add(link.id)) imageLinks.add(link) - } - } - for (link in imageLinks) { - if (uploadedImageFileIds.containsKey(link.id)) continue - if (link.mimeType.lowercase() !in supportedUploadMimeTypes) continue - val bytes = - runCatching { Base64.getDecoder().decode(link.base64Data) }.getOrNull() ?: continue - if (bytes.isEmpty() || bytes.size > maxUploadBytes) continue - val fileId = uploadImageToFilesApi(bytes, link.mimeType, link.id) - if (fileId != null) { - uploadedImageFileIds[link.id] = fileId - AppLogger.d("DeepseekProvider", "图片 ${link.id} 已上传至 DeepSeek Files API: $fileId") - } - } - } - - /** 上传单张图片到 DeepSeek Files API,返回 file_id;失败返回 null */ - private suspend fun uploadImageToFilesApi( - bytes: ByteArray, - mimeType: String, - linkId: String - ): String? { - val filesEndpoint = buildFilesEndpoint() ?: return null - return runCatching { - val body = - MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("purpose", "user_data") - .addFormDataPart( - "file", - "image_${linkId.take(24)}.${extensionForMime(mimeType)}", - RequestBody.create(mimeType.toMediaType(), bytes) - ) - .build() - val builder = Request.Builder().url(filesEndpoint).post(body) - applyAuthenticationHeaders(builder, keyProvider.getApiKey().trim()) - headerOverrides.forEach { (key, value) -> - if (!key.equals("Content-Type", ignoreCase = true)) builder.addHeader(key, value) - } - withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response -> - if (!response.isSuccessful) { - AppLogger.w( - "DeepseekProvider", - "Files API 上传失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}" - ) - return@use null - } - val json = JSONObject(response.body?.string().orEmpty()) - json.optString("id").takeIf { it.startsWith("file-") } - } - }.getOrElse { e -> - AppLogger.w("DeepseekProvider", "Files API 上传异常: ${e.message}") - null - } - } - - /** 由聊天 API 端点推导 Files API 上传地址(官方为 https://api.deepseek.com/files) */ - private fun buildFilesEndpoint(): String? { - val raw = configuredApiEndpoint.trim().removeSuffix("#").trim() - return runCatching { - val url = URL(raw) - val portPart = if (url.port in 1..65535) ":${url.port}" else "" - "${url.protocol}://${url.host}${portPart}/files" - }.getOrNull() - } - - private fun extensionForMime(mimeType: String): String { - return when (mimeType.lowercase()) { - "image/jpeg" -> "jpg" - "image/png" -> "png" - "image/gif" -> "gif" - "image/webp" -> "webp" - else -> "bin" - } + fileUploader.prepareMedia(supportsVision, chatHistory, "DeepseekProvider") } /** 已有 file_id 时用 DeepSeek 文件块引用,否则回退基类的 base64 内嵌 */ override fun buildImageContentPart(link: ImageLink): JSONObject { - val fileId = uploadedImageFileIds[link.id] + val fileId = fileUploader.fileIdFor(link.id) if (fileId != null) { return JSONObject().apply { put("type", "file") @@ -168,7 +67,6 @@ class DeepseekProvider( } return super.buildImageContentPart(link) } - companion object { fun create( config: ModelConfigData, @@ -718,6 +616,30 @@ private class DeepseekResponsesProvider( ) { override val useResponsesApi: Boolean = true override val bufferResponsesOutputTextUntilItemDone: Boolean = true + /** DeepSeek Files API 图片上传器(与 Chat Completions 协议线共用的抽取实现) */ + private val fileUploader = + DeepseekFileUploader(responsesApiEndpoint, apiKeyProvider, client, customHeaders) + + /** 发送前把消息中的图片上传至 DeepSeek Files API 换取 file_id,失败回退 base64 */ + override suspend fun prepareMediaForRequest(context: Context, chatHistory: List) { + fileUploader.prepareMedia(supportsVision, chatHistory, "DeepseekResponsesProvider") + } + + /** + * Responses 线同样先输出 DeepSeek 文件块,随后由 + * OpenAIResponsesPayloadAdapter.convertMessageContentForResponses 转换为 + * Responses 协议的 input_image + file_id 引用;未上传成功则回退 base64。 + */ + override fun buildImageContentPart(link: ImageLink): JSONObject { + val fileId = fileUploader.fileIdFor(link.id) + if (fileId != null) { + return JSONObject().apply { + put("type", "file") + put("file_id", fileId) + } + } + return super.buildImageContentPart(link) + } override fun isResponsesCommentaryMessage(item: JSONObject): Boolean { return item.optString("phase", "").trim().equals("commentary", ignoreCase = true) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt index 0f490ce333..88dfb3178e 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt @@ -549,6 +549,18 @@ object OpenAIResponsesPayloadAdapter { ) } } + "file" -> { + // DeepSeek Files API 文件块(file_id 引用),转换为 Responses 的 input_image part + val fileId = part.optString("file_id", "") + if (fileId.isNotEmpty()) { + convertedParts.put( + JSONObject().apply { + put("type", "input_image") + put("file_id", fileId) + } + ) + } + } "input_file" -> { val fileData = part.optString("file_data", "") From 37c30126fa223d8c6fe20f1231d945d5a19b220e Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:24:43 +0000 Subject: [PATCH 3/7] feat(deepseek): support list/retrieve/delete Files API endpoints with content-addressed dedup --- .../chat/llmprovider/DeepseekFileUploader.kt | 192 ++++++++++++++++-- 1 file changed, 177 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt index d0dfbc3720..f40480b0ff 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt @@ -3,6 +3,8 @@ package com.ai.assistance.operit.api.chat.llmprovider import com.ai.assistance.operit.core.chat.hooks.PromptTurn import com.ai.assistance.operit.util.AppLogger import java.net.URL +import java.net.URLEncoder +import java.security.MessageDigest import java.util.Base64 import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.Dispatchers @@ -14,11 +16,27 @@ import okhttp3.Request import okhttp3.RequestBody import org.json.JSONObject +/** DeepSeek Files API 返回的文件对象(4 个端点共用) */ +internal data class DeepseekFileInfo( + val id: String, + val filename: String, + val bytes: Long, + val createdAt: Long, + val purpose: String +) + /** - * DeepSeek Files API 图片上传器,供 Chat Completions 与 Responses 两种协议共用。 + * DeepSeek Files API 客户端,供 Chat Completions 与 Responses 两种协议共用。 + * + * 覆盖官方 4 个端点: + * - POST /files 上传图片(换取 file_id,用于 chat 请求引用) + * - GET /files 列出文件(用于跨会话同图复用 file_id,避免重复上传堆积) + * - GET /files/{id} 查询单个文件信息 + * - DELETE /files/{id} 删除文件(保留给后续清理策略挂载) * - * 职责:发送请求前把消息里的图片上传至 DeepSeek Files API 换取 file_id, - * 并按图片 id 做会话级缓存,避免同一图片重复上传。 + * 上传文件名采用图片内容 SHA-256 前缀命名,跨会话恒定: + * 图片 id 是每次添加随机生成的 UUID(见 ImagePoolManager),不能作为稳定键, + * 内容寻址命名使"列出->按文件名匹配->复用 file_id"在任意会话下都成立。 * 上传失败的图片不缓存,由调用方回退为 base64 内嵌。 */ internal class DeepseekFileUploader( @@ -35,7 +53,8 @@ internal class DeepseekFileUploader( private val maxUploadBytes = 64L * 1024L * 1024L /** - * 扫描消息历史中的图片并上传至 DeepSeek Files API 换取 file_id。 + * 扫描消息历史中的图片,优先复用远端已存在的同名文件(跨会话免重传), + * 否则上传至 DeepSeek Files API 换取 file_id。 * 上传失败的图片保持原样,构建请求时回退为 base64 内嵌(见各 Provider 的 buildImageContentPart)。 * @param supportsVision 当前配置是否开启"直接图片处理";关闭时不触发上传 * @param logTag 日志标签(区分 Chat Completions / Responses 两条调用线) @@ -49,13 +68,28 @@ internal class DeepseekFileUploader( if (seenIds.add(link.id)) imageLinks.add(link) } } + // 候选上传项:本地缓存未命中 + 通过格式/大小校验 + val candidates = mutableListOf>() for (link in imageLinks) { if (uploadedImageFileIds.containsKey(link.id)) continue if (link.mimeType.lowercase() !in supportedUploadMimeTypes) continue val bytes = runCatching { Base64.getDecoder().decode(link.base64Data) }.getOrNull() ?: continue if (bytes.isEmpty() || bytes.size > maxUploadBytes) continue - val fileId = uploadImageToFilesApi(bytes, link.mimeType, link.id) + val contentName = "image_${sha256Prefix(bytes)}.${extensionForMime(link.mimeType)}" + candidates.add(Triple(link, bytes, contentName)) + } + if (candidates.isEmpty()) return + // 列出远端文件,按文件名匹配复用已有 file_id(列出失败则全部走直传) + val remoteByName = listFiles()?.associate { it.filename to it.id } ?: emptyMap() + for ((link, bytes, contentName) in candidates) { + val existingFileId = remoteByName[contentName] + if (existingFileId != null) { + uploadedImageFileIds[link.id] = existingFileId + AppLogger.d(logTag, "图片 ${link.id} 已在 DeepSeek Files API 存在($existingFileId),复用免重传") + continue + } + val fileId = uploadImageToFilesApi(bytes, link.mimeType, contentName) if (fileId != null) { uploadedImageFileIds[link.id] = fileId AppLogger.d(logTag, "图片 ${link.id} 已上传至 DeepSeek Files API: $fileId") @@ -66,11 +100,90 @@ internal class DeepseekFileUploader( /** 已上传图片对应的 file_id;未上传返回 null(调用方回退 base64) */ fun fileIdFor(linkId: String): String? = uploadedImageFileIds[linkId] + /** 列出当前 API key 下的所有文件(自动分页);失败返回 null */ + suspend fun listFiles(): List? { + val filesEndpoint = buildFilesEndpoint() ?: return null + val result = mutableListOf() + var after: String? = null + while (true) { + val base = "${filesEndpoint}?purpose=user_data&limit=1000&order=desc" + val pageUrl = if (after == null) base else "$base&after=${URLEncoder.encode(after, "UTF-8")}" + val page = runCatching { + val builder = Request.Builder().url(pageUrl).get() + applyAuthAndCustomHeaders(builder) + withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response -> + if (!response.isSuccessful) { + AppLogger.w( + "DeepseekFileUploader", + "Files API 列表失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}" + ) + return@use null + } + response.body?.string() + }?.let { parseFileListPage(it) } + }.getOrElse { e -> + AppLogger.w("DeepseekFileUploader", "Files API 列表异常: ${e.message}") + null + } ?: return null + result.addAll(page.files) + after = if (page.hasMore) page.lastId else null + if (after == null) break + } + return result + } + + /** 查询单个文件信息;失败或不存在返回 null */ + suspend fun retrieveFile(fileId: String): DeepseekFileInfo? { + val filesEndpoint = buildFilesEndpoint() ?: return null + val url = "$filesEndpoint/${URLEncoder.encode(fileId, "UTF-8")}" + return runCatching { + val builder = Request.Builder().url(url).get() + applyAuthAndCustomHeaders(builder) + withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response -> + if (!response.isSuccessful) { + AppLogger.w( + "DeepseekFileUploader", + "Files API 查询失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}" + ) + return@use null + } + response.body?.string()?.let { parseFileObject(JSONObject(it)) } + } + }.getOrElse { e -> + AppLogger.w("DeepseekFileUploader", "Files API 查询异常: ${e.message}") + null + } + } + + /** 删除单个文件;成功返回 true,失败返回 false */ + suspend fun deleteFile(fileId: String): Boolean { + val filesEndpoint = buildFilesEndpoint() ?: return false + val url = "$filesEndpoint/${URLEncoder.encode(fileId, "UTF-8")}" + return runCatching { + val builder = Request.Builder().url(url).delete() + applyAuthAndCustomHeaders(builder) + withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response -> + if (!response.isSuccessful) { + AppLogger.w( + "DeepseekFileUploader", + "Files API 删除失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}" + ) + return@use false + } + val json = response.body?.string()?.let { runCatching { JSONObject(it) }.getOrNull() } + json?.optBoolean("deleted", false) == true + } + }.getOrElse { e -> + AppLogger.w("DeepseekFileUploader", "Files API 删除异常: ${e.message}") + false + } + } + /** 上传单张图片到 DeepSeek Files API,返回 file_id;失败返回 null */ private suspend fun uploadImageToFilesApi( bytes: ByteArray, mimeType: String, - linkId: String + contentName: String ): String? { val filesEndpoint = buildFilesEndpoint() ?: return null return runCatching { @@ -80,18 +193,12 @@ internal class DeepseekFileUploader( .addFormDataPart("purpose", "user_data") .addFormDataPart( "file", - "image_${linkId.take(24)}.${extensionForMime(mimeType)}", + contentName, RequestBody.create(mimeType.toMediaType(), bytes) ) .build() val builder = Request.Builder().url(filesEndpoint).post(body) - val apiKey = apiKeyProvider.getApiKey().trim() - if (apiKey.isNotEmpty()) { - builder.addHeader("Authorization", "Bearer $apiKey") - } - customHeaders.forEach { (key, value) -> - if (!key.equals("Content-Type", ignoreCase = true)) builder.addHeader(key, value) - } + applyAuthAndCustomHeaders(builder) withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response -> if (!response.isSuccessful) { AppLogger.w( @@ -109,7 +216,50 @@ internal class DeepseekFileUploader( } } - /** 由聊天 API 端点推导 Files API 上传地址(官方为 https://api.deepseek.com/files) */ + /** 给请求附加认证头与自定义头(排除会破坏 multipart 边界的 Content-Type 覆写) */ + private fun applyAuthAndCustomHeaders(builder: Request.Builder) { + val apiKey = apiKeyProvider.getApiKey().trim() + if (apiKey.isNotEmpty()) { + builder.addHeader("Authorization", "Bearer $apiKey") + } + customHeaders.forEach { (key, value) -> + if (!key.equals("Content-Type", ignoreCase = true)) builder.addHeader(key, value) + } + } + + /** 解析列表端点的一页响应;失败返回 null */ + private fun parseFileListPage(body: String): FileListPage? { + return runCatching { + val json = JSONObject(body) + val data = json.optJSONArray("data") ?: return null + val files = mutableListOf() + for (i in 0 until data.length()) { + data.optJSONObject(i)?.let { obj -> + parseFileObject(obj)?.let { files.add(it) } + } + } + FileListPage( + files = files, + hasMore = json.optBoolean("has_more", false), + lastId = json.optString("last_id").takeIf { it.isNotEmpty() } + ) + }.getOrNull() + } + + /** 解析单个 file object;失败返回 null */ + private fun parseFileObject(json: JSONObject): DeepseekFileInfo? { + val id = json.optString("id") + if (!id.startsWith("file-")) return null + return DeepseekFileInfo( + id = id, + filename = json.optString("filename"), + bytes = json.optLong("bytes"), + createdAt = json.optLong("created_at"), + purpose = json.optString("purpose") + ) + } + + /** 由聊天 API 端点推导 Files API 地址(官方为 https://api.deepseek.com/files) */ private fun buildFilesEndpoint(): String? { val raw = apiEndpoint.trim().removeSuffix("#").trim() return runCatching { @@ -119,6 +269,12 @@ internal class DeepseekFileUploader( }.getOrNull() } + /** 图片内容 SHA-256 的十六进制前 24 字符(作为跨会话稳定的文件名键) */ + private fun sha256Prefix(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + return digest.take(12).joinToString("") { "%02x".format(it.toInt() and 0xFF) } + } + private fun extensionForMime(mimeType: String): String { return when (mimeType.lowercase()) { "image/jpeg" -> "jpg" @@ -128,4 +284,10 @@ internal class DeepseekFileUploader( else -> "bin" } } + + private data class FileListPage( + val files: List, + val hasMore: Boolean, + val lastId: String? + ) } \ No newline at end of file From 701bcd25f0a5d0c0df44e5bbbcb317822a8fe49f Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:51:28 +0000 Subject: [PATCH 4/7] fix(deepseek): mark applyAuthAndCustomHeaders as suspend (getApiKey is suspend) --- .../operit/api/chat/llmprovider/DeepseekFileUploader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt index f40480b0ff..d5fcd12058 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt @@ -217,7 +217,7 @@ internal class DeepseekFileUploader( } /** 给请求附加认证头与自定义头(排除会破坏 multipart 边界的 Content-Type 覆写) */ - private fun applyAuthAndCustomHeaders(builder: Request.Builder) { + private suspend fun applyAuthAndCustomHeaders(builder: Request.Builder) { val apiKey = apiKeyProvider.getApiKey().trim() if (apiKey.isNotEmpty()) { builder.addHeader("Authorization", "Bearer $apiKey") From 3cfc3081936fd0b0554630fd1844a7f1e0e5882f Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:07:40 +0000 Subject: [PATCH 5/7] fix(ui): move deepseek files hint under image toggle; prompt when attaching with no active chat --- .../ui/features/chat/viewmodel/ChatViewModel.kt | 12 +++++++++++- .../settings/sections/ModelApiSettingsSection.kt | 15 +++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt index 0cc5412d01..51795e04ca 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt @@ -1730,7 +1730,11 @@ class ChatViewModel(private val context: Context) : ViewModel() { try { // 获取当前会话ID并绑定 val currentChatId = chatHistoryDelegate.currentChatId.value - if (currentChatId == null) return@launch + if (currentChatId == null) { + // 与发消息保持一致:无活跃对话时给出明确提示,而不是静默失败 + uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + return@launch + } // 显示附件处理进度 messageProcessingDelegate.setInputProcessingStateForChat( @@ -1922,6 +1926,12 @@ class ChatViewModel(private val context: Context) : ViewModel() { /** Handles a photo taken by the camera */ fun handleTakenPhoto(uri: Uri) { viewModelScope.launch { + val currentChatId = chatHistoryDelegate.currentChatId.value + if (currentChatId == null) { + // 与文件/图片附件入口保持一致:无活跃对话时给出明确提示 + uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + return@launch + } attachmentDelegate.handleTakenPhoto(uri) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt index d7fa548b1b..ad46c626e0 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/sections/ModelApiSettingsSection.kt @@ -867,6 +867,15 @@ fun ModelApiSettingsSection( onCheckedChange = { enableDirectImageProcessingInput = it } ) + if (selectedApiProvider == ApiProviderType.DEEPSEEK) { + Text( + text = stringResource(R.string.deepseek_files_api_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + SettingsSwitchRow( title = stringResource(R.string.enable_direct_audio_processing), subtitle = stringResource(R.string.enable_direct_audio_processing_desc), @@ -899,12 +908,6 @@ fun ModelApiSettingsSection( checked = enableDeepSeekWebSearchInput, onCheckedChange = { enableDeepSeekWebSearchInput = it } ) - Text( - text = stringResource(R.string.deepseek_files_api_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) - ) } // Claude 1小时提示缓存开关 (仅Claude支持) From ae6835fba50859ca8a26343f3ed7ee4c87dfc087 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:23:47 +0000 Subject: [PATCH 6/7] fix(chat): show no-active-chat prompt for all attachment entry points --- .../features/chat/viewmodel/ChatViewModel.kt | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt index 51795e04ca..97c17c2670 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt @@ -1805,7 +1805,11 @@ class ChatViewModel(private val context: Context) : ViewModel() { try { // 获取当前会话ID并绑定 val currentChatId = chatHistoryDelegate.currentChatId.value - if (currentChatId == null) return@launch + if (currentChatId == null) { + // 无活跃对话时给出明确提示,而不是静默失败 + uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + return@launch + } // 显示屏幕内容获取进度 messageProcessingDelegate.setInputProcessingStateForChat( @@ -1836,7 +1840,11 @@ class ChatViewModel(private val context: Context) : ViewModel() { try { // 获取当前会话ID并绑定 val currentChatId = chatHistoryDelegate.currentChatId.value - if (currentChatId == null) return@launch + if (currentChatId == null) { + // 无活跃对话时给出明确提示,而不是静默失败 + uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + return@launch + } // 显示通知获取进度 messageProcessingDelegate.setInputProcessingStateForChat( @@ -1867,7 +1875,11 @@ class ChatViewModel(private val context: Context) : ViewModel() { try { // 获取当前会话ID并绑定 val currentChatId = chatHistoryDelegate.currentChatId.value - if (currentChatId == null) return@launch + if (currentChatId == null) { + // 无活跃对话时给出明确提示,而不是静默失败 + uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + return@launch + } // 显示位置获取进度 messageProcessingDelegate.setInputProcessingStateForChat( @@ -1899,7 +1911,11 @@ class ChatViewModel(private val context: Context) : ViewModel() { viewModelScope.launch { try { val currentChatId = chatHistoryDelegate.currentChatId.value - if (currentChatId == null) return@launch + if (currentChatId == null) { + // 无活跃对话时给出明确提示,而不是静默失败 + uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + return@launch + } // 显示记忆文件夹附着进度 messageProcessingDelegate.setInputProcessingStateForChat( currentChatId, @@ -1940,6 +1956,12 @@ class ChatViewModel(private val context: Context) : ViewModel() { fun attachPackage(packageName: String) { viewModelScope.launch { try { + val currentChatId = chatHistoryDelegate.currentChatId.value + if (currentChatId == null) { + // 与文件/图片附件入口保持一致:无活跃对话时给出明确提示 + uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + return@launch + } attachmentDelegate.attachPackage(packageName) } catch (e: Exception) { AppLogger.e(TAG, "添加包附件失败", e) From 5a2f284e142874e2b4d5ea5e1fdffdfc622d98ba Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:49:47 +0000 Subject: [PATCH 7/7] fix(chat): use native toast for no-active-chat prompt, matching send path --- .../core/MessageCoordinationDelegate.kt | 3 ++- .../features/chat/viewmodel/ChatViewModel.kt | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt index a8805a7778..150f17cf61 100644 --- a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt +++ b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt @@ -1,6 +1,7 @@ package com.ai.assistance.operit.services.core import android.content.Context +import android.widget.Toast import com.ai.assistance.operit.R import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.api.chat.EnhancedAIService @@ -546,7 +547,7 @@ class MessageCoordinationDelegate( // 获取当前聊天ID和工作区路径 val chatId = chatIdOverride ?: chatHistoryDelegate.currentChatId.value if (chatId == null) { - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return } if (!isAutoContinuation) { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt index 97c17c2670..f95901af5f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/viewmodel/ChatViewModel.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.ui.features.chat.viewmodel import android.Manifest import android.content.Context import android.content.Intent +import android.widget.Toast import android.provider.Settings import com.ai.assistance.operit.util.AppLogger import androidx.activity.result.ActivityResultLauncher @@ -856,7 +857,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { // 获取当前会话ID并绑定 val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { - uiStateDelegate.showToast(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } if (message.sender != "user" && message.sender != "ai") { @@ -974,7 +975,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { AppLogger.d(TAG, "准备批量删除消息,索引: $indices") val chatIdSnapshot = chatHistoryDelegate.currentChatId.value if (chatIdSnapshot == null) { - uiStateDelegate.showToast(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } @@ -1732,7 +1733,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { // 与发消息保持一致:无活跃对话时给出明确提示,而不是静默失败 - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } @@ -1807,7 +1808,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { // 无活跃对话时给出明确提示,而不是静默失败 - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } @@ -1842,7 +1843,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { // 无活跃对话时给出明确提示,而不是静默失败 - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } @@ -1877,7 +1878,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { // 无活跃对话时给出明确提示,而不是静默失败 - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } @@ -1913,7 +1914,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { // 无活跃对话时给出明确提示,而不是静默失败 - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } // 显示记忆文件夹附着进度 @@ -1945,7 +1946,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { // 与文件/图片附件入口保持一致:无活跃对话时给出明确提示 - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } attachmentDelegate.handleTakenPhoto(uri) @@ -1959,7 +1960,7 @@ class ChatViewModel(private val context: Context) : ViewModel() { val currentChatId = chatHistoryDelegate.currentChatId.value if (currentChatId == null) { // 与文件/图片附件入口保持一致:无活跃对话时给出明确提示 - uiStateDelegate.showErrorMessage(context.getString(R.string.chat_no_active_conversation)) + Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show() return@launch } attachmentDelegate.attachPackage(packageName)