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..d5fcd12058
--- /dev/null
+++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekFileUploader.kt
@@ -0,0 +1,293 @@
+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
+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 返回的文件对象(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 两种协议共用。
+ *
+ * 覆盖官方 4 个端点:
+ * - POST /files 上传图片(换取 file_id,用于 chat 请求引用)
+ * - GET /files 列出文件(用于跨会话同图复用 file_id,避免重复上传堆积)
+ * - GET /files/{id} 查询单个文件信息
+ * - DELETE /files/{id} 删除文件(保留给后续清理策略挂载)
+ *
+ * 上传文件名采用图片内容 SHA-256 前缀命名,跨会话恒定:
+ * 图片 id 是每次添加随机生成的 UUID(见 ImagePoolManager),不能作为稳定键,
+ * 内容寻址命名使"列出->按文件名匹配->复用 file_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)
+ }
+ }
+ // 候选上传项:本地缓存未命中 + 通过格式/大小校验
+ 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 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")
+ }
+ }
+ }
+
+ /** 已上传图片对应的 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,
+ contentName: String
+ ): String? {
+ val filesEndpoint = buildFilesEndpoint() ?: return null
+ return runCatching {
+ val body =
+ MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart("purpose", "user_data")
+ .addFormDataPart(
+ "file",
+ contentName,
+ RequestBody.create(mimeType.toMediaType(), bytes)
+ )
+ .build()
+ val builder = Request.Builder().url(filesEndpoint).post(body)
+ 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
+ }
+ val json = JSONObject(response.body?.string().orEmpty())
+ json.optString("id").takeIf { it.startsWith("file-") }
+ }
+ }.getOrElse { e ->
+ AppLogger.w("DeepseekFileUploader", "Files API 上传异常: ${e.message}")
+ null
+ }
+ }
+
+ /** 给请求附加认证头与自定义头(排除会破坏 multipart 边界的 Content-Type 覆写) */
+ private suspend 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 {
+ val url = URL(raw)
+ val portPart = if (url.port in 1..65535) ":" + url.port else ""
+ "${url.protocol}://${url.host}${portPart}/files"
+ }.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"
+ "image/png" -> "png"
+ "image/gif" -> "gif"
+ "image/webp" -> "webp"
+ else -> "bin"
+ }
+ }
+
+ private data class FileListPage(
+ val files: List,
+ val hasMore: Boolean,
+ val lastId: String?
+ )
+}
\ 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 2fec4cffc8..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
@@ -48,7 +48,25 @@ class DeepseekProvider(
thinkingOptionId = thinkingOptionId
) {
private val configuredApiEndpoint = apiEndpoint
+ /** DeepSeek Files API 图片上传器(与 Responses 协议线共用的抽取实现) */
+ private val fileUploader = DeepseekFileUploader(apiEndpoint, apiKeyProvider, client, customHeaders)
+ /** 发送前把消息中的图片上传至 DeepSeek Files API 换取 file_id,失败回退 base64 */
+ override suspend fun prepareMediaForRequest(context: Context, chatHistory: List) {
+ fileUploader.prepareMedia(supportsVision, chatHistory, "DeepseekProvider")
+ }
+
+ /** 已有 file_id 时用 DeepSeek 文件块引用,否则回退基类的 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)
+ }
companion object {
fun create(
config: ModelConfigData,
@@ -598,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/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/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", "")
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 0cc5412d01..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
}
@@ -1730,7 +1731,11 @@ class ChatViewModel(private val context: Context) : ViewModel() {
try {
// 获取当前会话ID并绑定
val currentChatId = chatHistoryDelegate.currentChatId.value
- if (currentChatId == null) return@launch
+ if (currentChatId == null) {
+ // 与发消息保持一致:无活跃对话时给出明确提示,而不是静默失败
+ Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
// 显示附件处理进度
messageProcessingDelegate.setInputProcessingStateForChat(
@@ -1801,7 +1806,11 @@ class ChatViewModel(private val context: Context) : ViewModel() {
try {
// 获取当前会话ID并绑定
val currentChatId = chatHistoryDelegate.currentChatId.value
- if (currentChatId == null) return@launch
+ if (currentChatId == null) {
+ // 无活跃对话时给出明确提示,而不是静默失败
+ Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
// 显示屏幕内容获取进度
messageProcessingDelegate.setInputProcessingStateForChat(
@@ -1832,7 +1841,11 @@ class ChatViewModel(private val context: Context) : ViewModel() {
try {
// 获取当前会话ID并绑定
val currentChatId = chatHistoryDelegate.currentChatId.value
- if (currentChatId == null) return@launch
+ if (currentChatId == null) {
+ // 无活跃对话时给出明确提示,而不是静默失败
+ Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
// 显示通知获取进度
messageProcessingDelegate.setInputProcessingStateForChat(
@@ -1863,7 +1876,11 @@ class ChatViewModel(private val context: Context) : ViewModel() {
try {
// 获取当前会话ID并绑定
val currentChatId = chatHistoryDelegate.currentChatId.value
- if (currentChatId == null) return@launch
+ if (currentChatId == null) {
+ // 无活跃对话时给出明确提示,而不是静默失败
+ Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
// 显示位置获取进度
messageProcessingDelegate.setInputProcessingStateForChat(
@@ -1895,7 +1912,11 @@ class ChatViewModel(private val context: Context) : ViewModel() {
viewModelScope.launch {
try {
val currentChatId = chatHistoryDelegate.currentChatId.value
- if (currentChatId == null) return@launch
+ if (currentChatId == null) {
+ // 无活跃对话时给出明确提示,而不是静默失败
+ Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
// 显示记忆文件夹附着进度
messageProcessingDelegate.setInputProcessingStateForChat(
currentChatId,
@@ -1922,6 +1943,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) {
+ // 与文件/图片附件入口保持一致:无活跃对话时给出明确提示
+ Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
attachmentDelegate.handleTakenPhoto(uri)
}
}
@@ -1930,6 +1957,12 @@ class ChatViewModel(private val context: Context) : ViewModel() {
fun attachPackage(packageName: String) {
viewModelScope.launch {
try {
+ val currentChatId = chatHistoryDelegate.currentChatId.value
+ if (currentChatId == null) {
+ // 与文件/图片附件入口保持一致:无活跃对话时给出明确提示
+ Toast.makeText(context, context.getString(R.string.chat_please_create_new_chat), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
attachmentDelegate.attachPackage(packageName)
} catch (e: Exception) {
AppLogger.e(TAG, "添加包附件失败", e)
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..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),
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 小时提示缓存