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 小时提示缓存