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)