13 changed files with 1325 additions and 56 deletions
@ -0,0 +1,70 @@ |
|||
# ChatAPI Android 实现 |
|||
|
|||
基于 [openai-kotlin 4.0.1](https://github.com/aallam/openai-kotlin/tree/4.0.1) 的 ChatAPI 插件 Android 实现。 |
|||
|
|||
## 特性 |
|||
|
|||
- ✅ 基于 openai-kotlin 4.0.1 的 OpenAI API 客户端 |
|||
- ✅ 与 iOS 版本接口完全一致 |
|||
- ✅ 支持流式和非流式对话 |
|||
- ✅ 支持多模态内容(文本和图像识别) |
|||
- ✅ 工具调用架构预留(MCP 功能暂时留空) |
|||
- ✅ 协程支持和异步处理 |
|||
- ✅ 完整的错误处理和取消机制 |
|||
|
|||
## 依赖 |
|||
|
|||
- openai-kotlin 4.0.1 |
|||
- ktor-client-okhttp 2.3.2 |
|||
- kotlinx-coroutines |
|||
- gson |
|||
|
|||
## 主要文件 |
|||
|
|||
- `ChatApiPlugin.kt` - Flutter 插件桥接层 |
|||
- `ChatApiService.kt` - 核心 ChatAPI 服务实现 |
|||
- `StreamCallback.kt` - 流式回调接口 |
|||
|
|||
## 与 iOS 版本的一致性 |
|||
|
|||
Android 实现完全遵循 iOS 版本的接口定义: |
|||
|
|||
1. **初始化方法**: `initialize(apiKey, baseUrl, model, mcpServer)` |
|||
2. **消息创建**: `createUserMessage()`, `createAssistantMessage()` |
|||
3. **消息发送**: `sendMessage()` (非流式), `sendMessageStream()` (流式) |
|||
4. **函数注册**: `registerFunction()` |
|||
5. **MCP 相关**: 接口保留,功能暂时留空 |
|||
6. **流式事件**: 完全一致的事件类型和回调 |
|||
|
|||
## MCP 功能状态 |
|||
|
|||
MCP (Model Context Protocol) 相关功能在 Android 版本中暂时留空,包括: |
|||
|
|||
- `initializeMcpClient()` - 返回 false |
|||
- `isMcpInitialized()` - 返回 false |
|||
- `handleMcpToolCall()` - 返回占位符消息 |
|||
- 工具调用会通知上层但不会实际执行 |
|||
|
|||
## 使用示例 |
|||
|
|||
```kotlin |
|||
// 初始化 |
|||
chatApiService.initialize( |
|||
apiKey = "your-api-key", |
|||
baseUrl = "https://api.openai.com/v1/", |
|||
model = "gpt-3.5-turbo", |
|||
mcpServer = "" |
|||
) |
|||
|
|||
// 流式对话 |
|||
chatApiService.sendMessageStream(listOf( |
|||
mapOf("role" to "user", "content" to "Hello!") |
|||
)) |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
1. 需要网络权限 `INTERNET` 和 `ACCESS_NETWORK_STATE` |
|||
2. 所有网络请求在后台线程执行 |
|||
3. 流式回调在主线程触发 |
|||
4. 支持取消正在进行的请求 |
|||
@ -0,0 +1,50 @@ |
|||
plugins { |
|||
id("com.android.library") |
|||
id("org.jetbrains.kotlin.android") |
|||
kotlin("plugin.serialization") version "1.9.24" |
|||
} |
|||
|
|||
android { |
|||
namespace = "com.yunqiinnovation.chat_api" |
|||
compileSdk = 35 |
|||
|
|||
defaultConfig { |
|||
minSdk = 21 |
|||
targetSdk = 33 |
|||
} |
|||
|
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_11 |
|||
targetCompatibility = JavaVersion.VERSION_11 |
|||
} |
|||
|
|||
kotlinOptions { |
|||
jvmTarget = "11" |
|||
} |
|||
|
|||
packaging { |
|||
resources { |
|||
excludes.add("META-INF/DEPENDENCIES") |
|||
excludes.add("META-INF/LICENSE") |
|||
excludes.add("META-INF/LICENSE.txt") |
|||
excludes.add("META-INF/license.txt") |
|||
excludes.add("META-INF/NOTICE") |
|||
excludes.add("META-INF/NOTICE.txt") |
|||
excludes.add("META-INF/notice.txt") |
|||
excludes.add("META-INF/*.kotlin_module") |
|||
} |
|||
} |
|||
} |
|||
|
|||
dependencies { |
|||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") |
|||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") |
|||
|
|||
// OpenAI Kotlin 4.0.1 |
|||
implementation("com.aallam.openai:openai-client:4.0.1") |
|||
implementation("io.ktor:ktor-client-okhttp:2.3.2") |
|||
|
|||
// JSON处理 |
|||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") |
|||
implementation("com.google.code.gson:gson:2.10.1") |
|||
} |
|||
@ -0,0 +1 @@ |
|||
rootProject.name = "chat_api" |
|||
@ -0,0 +1,8 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.chat_api"> |
|||
|
|||
<!-- 网络权限 --> |
|||
<uses-permission android:name="android.permission.INTERNET" /> |
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |
|||
|
|||
</manifest> |
|||
@ -0,0 +1,252 @@ |
|||
package com.yunqiinnovation.chat_api |
|||
|
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.plugin.common.EventChannel |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import kotlinx.coroutines.* |
|||
|
|||
/** |
|||
* ChatApiPlugin |
|||
* |
|||
* 基于 openai-kotlin 4.0.1 的 ChatAPI 插件 Android 实现 |
|||
* 与 iOS 版本接口完全一致 |
|||
*/ |
|||
class ChatApiPlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { |
|||
private lateinit var methodChannel: MethodChannel |
|||
private lateinit var eventChannel: EventChannel |
|||
private var eventSink: EventChannel.EventSink? = null |
|||
|
|||
private val chatApiService = ChatApiService() |
|||
private val mainHandler = Handler(Looper.getMainLooper()) |
|||
|
|||
// 协程作用域 |
|||
private val pluginScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) |
|||
|
|||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.chat_api/methods") |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.chat_api/events") |
|||
eventChannel.setStreamHandler(this) |
|||
|
|||
// 设置流式回调 |
|||
chatApiService.setStreamCallback(object : com.yunqiinnovation.chat_api.StreamCallback { |
|||
override fun onToken(token: String) { |
|||
sendEvent("token", token) |
|||
} |
|||
|
|||
override fun onComplete() { |
|||
sendEvent("complete", null) |
|||
} |
|||
|
|||
override fun onError(error: Exception) { |
|||
sendEvent("error", error.message ?: "未知错误") |
|||
} |
|||
|
|||
override fun onFunctionCall(functionCall: org.json.JSONObject) { |
|||
try { |
|||
val jsonString = functionCall.toString() |
|||
sendEvent("functionCall", jsonString) |
|||
} catch (e: Exception) { |
|||
sendEvent("error", "Failed to serialize function call: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
override fun onFunctionCallResult(functionCall: org.json.JSONObject, functionCallResult: org.json.JSONObject) { |
|||
try { |
|||
val meta = org.json.JSONObject().apply { |
|||
put("functionCall", functionCall) |
|||
put("functionCallResult", functionCallResult) |
|||
} |
|||
val metaString = meta.toString() |
|||
val context = functionCallResult.optString("context", "") |
|||
|
|||
sendEvent("functionCall", context, metaString) |
|||
} catch (e: Exception) { |
|||
sendEvent("error", "Failed to serialize function call result: ${e.message}") |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
eventChannel.setStreamHandler(null) |
|||
pluginScope.cancel() |
|||
} |
|||
|
|||
override fun onMethodCall(call: MethodCall, result: Result) { |
|||
when (call.method) { |
|||
"initialize" -> handleInitialize(call, result) |
|||
"createUserMessage" -> handleCreateUserMessage(call, result) |
|||
"createAssistantMessage" -> handleCreateAssistantMessage(call, result) |
|||
"sendMessage" -> handleSendMessage(call, result) |
|||
"sendMessageStream" -> handleSendMessageStream(call, result) |
|||
"sendFunctionCallResult" -> handleSendFunctionCallResult(call, result) |
|||
"cancelCurrentStream" -> handleCancelCurrentStream(result) |
|||
"registerFunction" -> handleRegisterFunction(call, result) |
|||
"initializeMcpClient" -> handleInitializeMcpClient(call, result) |
|||
"isMcpInitialized" -> handleIsMcpInitialized(result) |
|||
"closeMcpClient" -> handleCloseMcpClient(result) |
|||
"handleMcpToolCall" -> handleMcpToolCall(call, result) |
|||
else -> result.notImplemented() |
|||
} |
|||
} |
|||
|
|||
// MARK: - Method Handlers |
|||
|
|||
private fun handleInitialize(call: MethodCall, result: Result) { |
|||
val apiKey = call.argument<String>("apiKey") |
|||
if (apiKey.isNullOrEmpty()) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
val baseUrl = call.argument<String>("baseUrl") ?: "" |
|||
val model = call.argument<String>("model") ?: "" |
|||
val mcpServer = call.argument<String>("mcpServer") ?: "" |
|||
|
|||
val success = chatApiService.initialize(apiKey, baseUrl, model, mcpServer) |
|||
result.success(success) |
|||
} |
|||
|
|||
private fun handleCreateUserMessage(call: MethodCall, result: Result) { |
|||
val content = call.argument<String>("content") |
|||
if (content.isNullOrEmpty()) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
val message = chatApiService.createUserMessage(content) |
|||
result.success(message) |
|||
} |
|||
|
|||
private fun handleCreateAssistantMessage(call: MethodCall, result: Result) { |
|||
val content = call.argument<String>("content") |
|||
if (content.isNullOrEmpty()) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
val message = chatApiService.createAssistantMessage(content) |
|||
result.success(message) |
|||
} |
|||
|
|||
private fun handleSendMessage(call: MethodCall, result: Result) { |
|||
@Suppress("UNCHECKED_CAST") |
|||
val messages = call.argument<List<Map<String, Any>>>("messages") |
|||
if (messages == null) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
pluginScope.launch { |
|||
try { |
|||
val response = chatApiService.sendMessage(messages) |
|||
mainHandler.post { |
|||
result.success(response) |
|||
} |
|||
} catch (e: Exception) { |
|||
mainHandler.post { |
|||
result.error("SEND_ERROR", e.message, null) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun handleSendMessageStream(call: MethodCall, result: Result) { |
|||
@Suppress("UNCHECKED_CAST") |
|||
val messages = call.argument<List<Map<String, Any>>>("messages") |
|||
if (messages == null) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
chatApiService.sendMessageStream(messages) |
|||
result.success(true) |
|||
} |
|||
|
|||
private fun handleSendFunctionCallResult(call: MethodCall, result: Result) { |
|||
result.error("DEPRECATED", "sendFunctionCallResult已废弃,工具调用结果现在自动处理", null) |
|||
} |
|||
|
|||
private fun handleCancelCurrentStream(result: Result) { |
|||
val success = chatApiService.cancelCurrentStream() |
|||
result.success(success) |
|||
} |
|||
|
|||
private fun handleRegisterFunction(call: MethodCall, result: Result) { |
|||
val name = call.argument<String>("name") |
|||
val description = call.argument<String>("description") |
|||
@Suppress("UNCHECKED_CAST") |
|||
val parameters = call.argument<Map<String, Any>>("parameters") |
|||
|
|||
if (name.isNullOrEmpty() || description.isNullOrEmpty() || parameters == null) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
val success = chatApiService.registerFunction(name, description, parameters) |
|||
result.success(success) |
|||
} |
|||
|
|||
private fun handleInitializeMcpClient(call: MethodCall, result: Result) { |
|||
val serverUrl = call.argument<String>("serverUrl") |
|||
if (serverUrl.isNullOrEmpty()) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
// MCP功能暂时留空 |
|||
result.success(false) |
|||
} |
|||
|
|||
private fun handleIsMcpInitialized(result: Result) { |
|||
// MCP功能暂时留空 |
|||
result.success(false) |
|||
} |
|||
|
|||
private fun handleCloseMcpClient(result: Result) { |
|||
// MCP功能暂时留空 |
|||
result.success(true) |
|||
} |
|||
|
|||
private fun handleMcpToolCall(call: MethodCall, result: Result) { |
|||
val functionCallJson = call.argument<String>("functionCall") |
|||
if (functionCallJson.isNullOrEmpty()) { |
|||
result.error("INVALID_ARGS", "缺少必要参数", null) |
|||
return |
|||
} |
|||
|
|||
// MCP功能暂时留空 |
|||
result.success("MCP功能暂未实现") |
|||
} |
|||
|
|||
// MARK: - Event Stream Handler |
|||
|
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
eventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
eventSink = null |
|||
} |
|||
|
|||
private fun sendEvent(type: String, content: Any?, meta: String? = null) { |
|||
mainHandler.post { |
|||
eventSink?.let { sink -> |
|||
val event = mutableMapOf<String, Any>( |
|||
"type" to type |
|||
) |
|||
content?.let { event["content"] = it } |
|||
meta?.let { event["meta"] = it } |
|||
sink.success(event) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,799 @@ |
|||
package com.yunqiinnovation.chat_api |
|||
|
|||
import android.graphics.Bitmap |
|||
import android.graphics.BitmapFactory |
|||
import android.util.Base64 |
|||
import com.aallam.openai.api.chat.* |
|||
import com.aallam.openai.api.core.Role |
|||
import com.aallam.openai.api.model.ModelId |
|||
import com.aallam.openai.api.http.Timeout |
|||
import com.aallam.openai.client.OpenAI |
|||
import com.aallam.openai.client.OpenAIConfig |
|||
import com.aallam.openai.client.OpenAIHost |
|||
import com.google.gson.Gson |
|||
import kotlinx.coroutines.* |
|||
import kotlinx.coroutines.flow.collect |
|||
import java.io.ByteArrayOutputStream |
|||
import java.io.File |
|||
import kotlin.coroutines.CoroutineContext |
|||
import kotlin.math.min |
|||
import kotlin.math.sqrt |
|||
import kotlin.time.Duration.Companion.seconds |
|||
import android.util.Log |
|||
|
|||
/** |
|||
* ChatAPI服务异常 |
|||
*/ |
|||
class ChatApiException(message: String) : Exception("ChatApiException: $message") |
|||
|
|||
/** |
|||
* 流式回调接口 |
|||
* 与 iOS 版本 StreamCallback 协议保持完全一致 |
|||
*/ |
|||
interface StreamCallback { |
|||
/** |
|||
* 接收到新的token |
|||
*/ |
|||
fun onToken(token: String) |
|||
|
|||
/** |
|||
* 流式响应完成 |
|||
*/ |
|||
fun onComplete() |
|||
|
|||
/** |
|||
* 出现错误 |
|||
*/ |
|||
fun onError(error: Exception) |
|||
|
|||
/** |
|||
* 函数调用 - 兼容JSONObject格式 |
|||
*/ |
|||
fun onFunctionCall(functionCall: org.json.JSONObject) |
|||
|
|||
/** |
|||
* 函数调用结果 - 兼容JSONObject格式 |
|||
*/ |
|||
fun onFunctionCallResult(functionCall: org.json.JSONObject, functionCallResult: org.json.JSONObject) |
|||
} |
|||
|
|||
/** |
|||
* 工具调用信息 |
|||
*/ |
|||
private data class ToolCallInfo( |
|||
var id: String = "", |
|||
var name: String = "", |
|||
var arguments: String = "" |
|||
) { |
|||
fun isValid(): Boolean = id.isNotEmpty() && name.isNotEmpty() |
|||
} |
|||
|
|||
/** |
|||
* ChatAPI服务的Android实现 |
|||
* 基于 openai-kotlin 4.0.1,与 iOS 版本接口完全一致 |
|||
*/ |
|||
class ChatApiService(private val context: android.content.Context? = null) : CoroutineScope { |
|||
|
|||
override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() |
|||
|
|||
// MARK: - 属性 |
|||
private var baseUrl = "https://api.openai.com/v1/" |
|||
private var apiKey = "" |
|||
private var model = "gpt-3.5-turbo" |
|||
private var visionModel = "gpt-4-vision-preview" |
|||
private var isInitialized = false |
|||
|
|||
// OpenAI 客户端 |
|||
private var openAI: OpenAI? = null |
|||
|
|||
// 流式请求相关 |
|||
private var currentStreamJob: Job? = null |
|||
private var streamCallback: StreamCallback? = null |
|||
private var currentMessages: List<Map<String, Any>> = emptyList() |
|||
private var toolCalls: MutableMap<Int, ToolCallInfo> = mutableMapOf() |
|||
private var isCanceled = false |
|||
|
|||
// JSON处理 |
|||
private val gson = Gson() |
|||
|
|||
// MARK: - 公共方法 |
|||
|
|||
/** |
|||
* 设置流式回调 |
|||
*/ |
|||
fun setStreamCallback(callback: StreamCallback) { |
|||
streamCallback = callback |
|||
} |
|||
|
|||
/** |
|||
* 初始化ChatAPI服务 |
|||
*/ |
|||
fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean { |
|||
this.apiKey = apiKey |
|||
if (baseUrl.isNotEmpty()) { |
|||
this.baseUrl = baseUrl |
|||
} |
|||
if (model.isNotEmpty()) { |
|||
this.model = model |
|||
} |
|||
Log.d("ChatApiService", "原始 baseUrl: $baseUrl") |
|||
|
|||
// 处理 baseUrl:移除末尾的 /chat/completions(如果存在) |
|||
// 因为 openai-kotlin 会自动拼接 /chat/completions |
|||
var processedBaseUrl = if (this.baseUrl.endsWith("/chat/completions")) { |
|||
this.baseUrl.removeSuffix("/chat/completions") |
|||
} else { |
|||
this.baseUrl.removeSuffix("/") |
|||
} |
|||
|
|||
// 确保以 / 结尾 |
|||
if (!processedBaseUrl.endsWith("/")) { |
|||
processedBaseUrl += "/" |
|||
} |
|||
|
|||
Log.d("ChatApiService", "处理后 baseUrl: $processedBaseUrl") |
|||
|
|||
return try { |
|||
// 创建OpenAI配置 |
|||
val config = OpenAIConfig( |
|||
token = apiKey, |
|||
timeout = Timeout(socket = 60.seconds), |
|||
host = OpenAIHost(baseUrl = processedBaseUrl) |
|||
) |
|||
|
|||
openAI = OpenAI(config) |
|||
|
|||
// 初始化MCP客户端 (暂时留空,但保持接口一致) |
|||
if (mcpServer.isNotEmpty()) { |
|||
// MCP功能暂时留空,但记录服务器地址以便后续实现 |
|||
// initializeMcpClient(mcpServer) |
|||
} |
|||
|
|||
isInitialized = apiKey.isNotEmpty() |
|||
true |
|||
} catch (e: Exception) { |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 创建用户消息 |
|||
*/ |
|||
fun createUserMessage(content: String): Map<String, Any> { |
|||
return mapOf( |
|||
"role" to "user", |
|||
"content" to content |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 创建助手消息 |
|||
*/ |
|||
fun createAssistantMessage(content: String): Map<String, Any> { |
|||
return mapOf( |
|||
"role" to "assistant", |
|||
"content" to content |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 创建系统消息 |
|||
*/ |
|||
fun createSystemMessage(content: String): Map<String, Any> { |
|||
return mapOf( |
|||
"role" to "system", |
|||
"content" to content |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 发送消息(非流式输出) |
|||
*/ |
|||
suspend fun sendMessage(messages: List<Map<String, Any>>): String { |
|||
if (!isInitialized || apiKey.isEmpty() || openAI == null) { |
|||
throw ChatApiException("ChatAPI服务未初始化") |
|||
} |
|||
|
|||
// 检查是否包含图片,决定使用哪个模型 |
|||
val currentModel = if (containsImage(messages)) visionModel else model |
|||
|
|||
// 转换消息格式 |
|||
val chatMessages = convertToChatMessages(messages) |
|||
|
|||
// 构建请求 |
|||
val chatCompletionRequest = ChatCompletionRequest( |
|||
model = ModelId(currentModel), |
|||
messages = chatMessages, |
|||
maxTokens = 2000, |
|||
temperature = 0.7 |
|||
) |
|||
|
|||
val result = openAI!!.chatCompletion(chatCompletionRequest) |
|||
|
|||
// 检查是否有工具调用 |
|||
val choice = result.choices.firstOrNull() |
|||
choice?.message?.toolCalls?.firstOrNull()?.let { toolCall -> |
|||
if (toolCall is ToolCall.Function) { |
|||
val functionCallJson = mapOf( |
|||
"name" to toolCall.function.name, |
|||
"arguments" to (toolCall.function.arguments ?: ""), |
|||
"id" to toolCall.id.toString() |
|||
) |
|||
return gson.toJson(functionCallJson) |
|||
} |
|||
} |
|||
|
|||
// 返回普通消息内容 |
|||
return choice?.message?.content ?: throw ChatApiException("无效的响应格式") |
|||
} |
|||
|
|||
/** |
|||
* 发送消息(流式输出)- 兼容JSONArray格式 |
|||
*/ |
|||
fun sendMessageStream(messages: org.json.JSONArray, callback: StreamCallback) { |
|||
// 设置回调 |
|||
setStreamCallback(callback) |
|||
|
|||
// 转换JSONArray为List<Map<String, Any>> |
|||
val messageList = convertJsonArrayToList(messages) |
|||
|
|||
// 调用原始方法 |
|||
sendMessageStream(messageList) |
|||
} |
|||
|
|||
/** |
|||
* 发送消息(流式输出) |
|||
*/ |
|||
fun sendMessageStream(messages: List<Map<String, Any>>) { |
|||
if (!isInitialized || apiKey.isEmpty() || openAI == null) { |
|||
streamCallback?.onError(ChatApiException("ChatAPI服务未初始化")) |
|||
return |
|||
} |
|||
|
|||
// 重置状态 |
|||
isCanceled = false |
|||
currentMessages = messages |
|||
toolCalls.clear() |
|||
|
|||
// 检查是否包含图片,决定使用哪个模型 |
|||
val currentModel = if (containsImage(messages)) visionModel else model |
|||
|
|||
currentStreamJob = launch { |
|||
try { |
|||
// 转换消息格式 |
|||
val chatMessages = convertToChatMessages(messages) |
|||
|
|||
// 构建请求 |
|||
val chatCompletionRequest = ChatCompletionRequest( |
|||
model = ModelId(currentModel), |
|||
messages = chatMessages, |
|||
maxTokens = 2000, |
|||
temperature = 0.7 |
|||
) |
|||
|
|||
val chatsFlow = openAI!!.chatCompletions(chatCompletionRequest) |
|||
|
|||
chatsFlow.collect { result -> |
|||
if (isCanceled) return@collect |
|||
|
|||
val choice = result.choices.firstOrNull() ?: return@collect |
|||
val delta = choice.delta ?: return@collect |
|||
|
|||
// 处理普通文本内容 |
|||
delta.content?.let { content -> |
|||
streamCallback?.onToken(content) |
|||
} |
|||
|
|||
// 收集工具调用信息 |
|||
delta.toolCalls?.forEach { toolCall -> |
|||
val index = toolCall.index |
|||
|
|||
// 创建或获取现有的工具调用信息 |
|||
val toolCallInfo = toolCalls.getOrPut(index) { ToolCallInfo() } |
|||
|
|||
// 更新ID |
|||
toolCall.id?.let { toolCallInfo.id = it.toString() } |
|||
|
|||
// 更新函数信息 |
|||
toolCall.function?.let { function -> |
|||
function.name?.let { toolCallInfo.name = it } |
|||
function.arguments?.let { toolCallInfo.arguments += it } |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (!isCanceled) { |
|||
// 处理工具调用或完成 |
|||
val hasToolCalls = processToolCalls() |
|||
if (!hasToolCalls) { |
|||
streamCallback?.onComplete() |
|||
} |
|||
} |
|||
|
|||
} catch (e: Exception) { |
|||
if (!isCanceled) { |
|||
streamCallback?.onError(ChatApiException("流式请求失败: ${e.message}")) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理工具调用 |
|||
*/ |
|||
private suspend fun processToolCalls(): Boolean { |
|||
val firstToolCall = toolCalls.values.firstOrNull { it.isValid() } ?: return false |
|||
|
|||
// 创建函数调用字典 |
|||
val functionCall = mapOf( |
|||
"name" to firstToolCall.name, |
|||
"arguments" to firstToolCall.arguments, |
|||
"id" to firstToolCall.id |
|||
) |
|||
|
|||
// 通知上层工具调用事件 |
|||
streamCallback?.onFunctionCall(convertMapToJsonObject(functionCall)) |
|||
|
|||
// 在后台队列处理工具调用 |
|||
launch { |
|||
try { |
|||
if (!isCanceled) { |
|||
// 这里暂时返回占位符结果,实际MCP功能留空 |
|||
val result = mapOf("context" to "MCP功能暂未实现") |
|||
|
|||
if (!isCanceled) { |
|||
// 处理结果 |
|||
streamCallback?.onFunctionCallResult( |
|||
convertMapToJsonObject(functionCall), |
|||
convertMapToJsonObject(result) |
|||
) |
|||
|
|||
// 将结果发送回OpenAI继续对话 |
|||
sendFunctionCallResultInternal( |
|||
messages = currentMessages, |
|||
functionCall = functionCall, |
|||
functionResult = gson.toJson(result) |
|||
) |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
if (!isCanceled) { |
|||
val errorMessage = "工具调用处理失败: ${e.message}" |
|||
sendFunctionCallResultInternal( |
|||
messages = currentMessages, |
|||
functionCall = functionCall, |
|||
functionResult = errorMessage |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 发送函数调用结果(内部方法) |
|||
*/ |
|||
private suspend fun sendFunctionCallResultInternal( |
|||
messages: List<Map<String, Any>>, |
|||
functionCall: Map<String, Any>, |
|||
functionResult: String |
|||
) { |
|||
if (isCanceled) return |
|||
|
|||
val fullMessages = messages.toMutableList() |
|||
|
|||
// 添加函数调用消息 |
|||
val callId = functionCall["id"] as? String ?: "call_${System.currentTimeMillis()}" |
|||
fullMessages.add( |
|||
mapOf( |
|||
"role" to "assistant", |
|||
"content" to "", |
|||
"tool_calls" to listOf( |
|||
mapOf( |
|||
"id" to callId, |
|||
"type" to "function", |
|||
"function" to mapOf( |
|||
"name" to (functionCall["name"] as? String ?: ""), |
|||
"arguments" to (functionCall["arguments"] as? String ?: "{}") |
|||
) |
|||
) |
|||
) |
|||
) |
|||
) |
|||
|
|||
// 添加函数结果消息 |
|||
fullMessages.add( |
|||
mapOf( |
|||
"role" to "tool", |
|||
"content" to functionResult, |
|||
"tool_call_id" to callId |
|||
) |
|||
) |
|||
|
|||
// 重新发起对话,让AI基于工具调用结果生成回复 |
|||
sendMessageStream(fullMessages) |
|||
} |
|||
|
|||
/** |
|||
* 取消当前流式请求 |
|||
*/ |
|||
fun cancelCurrentStream(): Boolean { |
|||
isCanceled = true |
|||
currentStreamJob?.cancel() |
|||
currentStreamJob = null |
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 取消所有操作并释放资源 (与 iOS 版本保持一致) |
|||
*/ |
|||
fun cancelAll() { |
|||
cancelCurrentStream() |
|||
} |
|||
|
|||
/** |
|||
* 注册函数 |
|||
*/ |
|||
fun registerFunction(name: String, description: String, parameters: Map<String, Any>): Boolean { |
|||
// 暂时返回true,实际功能留空,但保持与iOS版本接口一致 |
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 初始化MCP客户端 |
|||
*/ |
|||
fun initializeMcpClient(serverUrl: String): Boolean { |
|||
// MCP功能暂时留空,但保持与iOS版本接口一致 |
|||
return false |
|||
} |
|||
|
|||
/** |
|||
* MCP客户端是否已初始化 |
|||
*/ |
|||
fun isMcpInitialized(): Boolean { |
|||
// MCP功能暂时留空,但保持与iOS版本接口一致 |
|||
return false |
|||
} |
|||
|
|||
/** |
|||
* 关闭MCP客户端 |
|||
*/ |
|||
fun closeMcpClient() { |
|||
// MCP功能暂时留空,但保持与iOS版本接口一致 |
|||
} |
|||
|
|||
/** |
|||
* 处理MCP工具调用 |
|||
*/ |
|||
suspend fun handleMcpToolCall(functionCallJson: String): String { |
|||
// MCP功能暂时留空,但保持与iOS版本接口一致 |
|||
return "MCP功能暂未实现" |
|||
} |
|||
|
|||
// MARK: - 工具方法 |
|||
|
|||
/** |
|||
* 检查消息中是否包含图片 |
|||
*/ |
|||
private fun containsImage(messages: List<Map<String, Any>>): Boolean { |
|||
for (message in messages) { |
|||
val content = message["content"] |
|||
if (content is String && content.contains("image_url")) { |
|||
return true |
|||
} |
|||
if (content is List<*>) { |
|||
content.forEach { item -> |
|||
if (item is Map<*, *>) { |
|||
val type = item["type"] as? String |
|||
if (type == "image_url") { |
|||
return true |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
/** |
|||
* 转换为OpenAI ChatMessage格式 |
|||
*/ |
|||
private fun convertToChatMessages(messages: List<Map<String, Any>>): List<ChatMessage> { |
|||
val chatMessages = mutableListOf<ChatMessage>() |
|||
|
|||
for (message in messages) { |
|||
val role = message["role"] as? String ?: continue |
|||
|
|||
val chatRole = when (role) { |
|||
"system" -> Role.System |
|||
"user" -> Role.User |
|||
"assistant" -> Role.Assistant |
|||
"tool" -> Role.Tool |
|||
else -> Role.User |
|||
} |
|||
|
|||
// 特殊处理assistant角色的tool_calls消息 |
|||
if (role == "assistant" && message.containsKey("tool_calls")) { |
|||
val content = message["content"] as? String ?: "" |
|||
|
|||
// 解析tool_calls |
|||
val toolCallsList = mutableListOf<ToolCall>() |
|||
@Suppress("UNCHECKED_CAST") |
|||
val toolCallsArray = message["tool_calls"] as? List<Map<String, Any>> |
|||
|
|||
toolCallsArray?.forEach { toolCallDict -> |
|||
val id = toolCallDict["id"] as? String ?: return@forEach |
|||
val type = toolCallDict["type"] as? String |
|||
if (type == "function") { |
|||
@Suppress("UNCHECKED_CAST") |
|||
val function = toolCallDict["function"] as? Map<String, Any> ?: return@forEach |
|||
val name = function["name"] as? String ?: return@forEach |
|||
val arguments = function["arguments"] as? String ?: "" |
|||
|
|||
val functionCall = FunctionCall( |
|||
nameOrNull = name, |
|||
argumentsOrNull = arguments |
|||
) |
|||
|
|||
val toolCall = ToolCall.Function( |
|||
id = ToolId(id), |
|||
function = functionCall |
|||
) |
|||
|
|||
toolCallsList.add(toolCall) |
|||
} |
|||
} |
|||
|
|||
val chatMessage = ChatMessage.Assistant( |
|||
content = if (content.isEmpty()) null else content, |
|||
toolCalls = if (toolCallsList.isEmpty()) null else toolCallsList |
|||
) |
|||
|
|||
chatMessages.add(chatMessage) |
|||
continue |
|||
} |
|||
|
|||
// 特殊处理tool角色消息 |
|||
if (role == "tool") { |
|||
val content = message["content"] as? String ?: "" |
|||
val toolCallId = message["tool_call_id"] as? String ?: "" |
|||
|
|||
val chatMessage = ChatMessage.Tool( |
|||
content = content, |
|||
toolCallId = ToolId(toolCallId) |
|||
) |
|||
|
|||
chatMessages.add(chatMessage) |
|||
continue |
|||
} |
|||
|
|||
// 处理普通消息的不同类型内容 |
|||
val content = message["content"] |
|||
when (content) { |
|||
is String -> { |
|||
val chatMessage = when (chatRole) { |
|||
Role.System -> ChatMessage.System(content) |
|||
Role.User -> ChatMessage.User(content) |
|||
Role.Assistant -> ChatMessage.Assistant(content) |
|||
else -> ChatMessage.User(content) |
|||
} |
|||
chatMessages.add(chatMessage) |
|||
} |
|||
is List<*> -> { |
|||
// 对于多模态内容,提取文本部分(简化处理) |
|||
var textContent = "" |
|||
|
|||
content.forEach { item -> |
|||
if (item is Map<*, *>) { |
|||
val type = item["type"] as? String |
|||
if (type == "text") { |
|||
val text = item["text"] as? String ?: "" |
|||
textContent += text |
|||
} |
|||
} |
|||
} |
|||
|
|||
val finalContent = if (textContent.isEmpty()) "多媒体内容" else textContent |
|||
|
|||
val chatMessage = when (chatRole) { |
|||
Role.System -> ChatMessage.System(finalContent) |
|||
Role.User -> ChatMessage.User(finalContent) |
|||
Role.Assistant -> ChatMessage.Assistant(finalContent) |
|||
else -> ChatMessage.User(finalContent) |
|||
} |
|||
chatMessages.add(chatMessage) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return chatMessages |
|||
} |
|||
|
|||
/** |
|||
* 将文件转换为Base64字符串 |
|||
*/ |
|||
fun fileToBase64(filePath: String, maxSizeKB: Int = 20480): String? { |
|||
return try { |
|||
val file = File(filePath) |
|||
if (!file.exists() || !file.isFile) { |
|||
return null |
|||
} |
|||
|
|||
// 读取文件 |
|||
var bitmap = BitmapFactory.decodeFile(filePath) ?: return null |
|||
|
|||
// 检查图片尺寸,限制最大为1024*1024 |
|||
val maxDimension = 1024 |
|||
if (bitmap.width > maxDimension || bitmap.height > maxDimension) { |
|||
// 计算缩放比例,保持纵横比 |
|||
val ratio = min(maxDimension.toFloat() / bitmap.width, maxDimension.toFloat() / bitmap.height) |
|||
|
|||
val newWidth = (bitmap.width * ratio).toInt() |
|||
val newHeight = (bitmap.height * ratio).toInt() |
|||
|
|||
bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) |
|||
} |
|||
|
|||
// 压缩图片 |
|||
val outputStream = ByteArrayOutputStream() |
|||
var quality = 100 |
|||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) |
|||
|
|||
// 如果原始图片太大,继续优化文件大小 |
|||
var fileSize = file.length() / 1024 // 转为KB |
|||
if (fileSize > maxSizeKB) { |
|||
val scale = sqrt(maxSizeKB.toDouble() / fileSize) |
|||
val newWidth = (bitmap.width * scale).toInt() |
|||
val newHeight = (bitmap.height * scale).toInt() |
|||
bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) |
|||
outputStream.reset() |
|||
quality = 85 |
|||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) |
|||
} |
|||
|
|||
// 转为Base64 |
|||
Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) |
|||
} catch (e: Exception) { |
|||
null |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 创建带图片的用户消息 |
|||
*/ |
|||
fun createUserMessageWithImage(text: String, imageBase64: String): Map<String, Any> { |
|||
val contentArray = mutableListOf<Map<String, Any>>() |
|||
|
|||
// 添加文本部分 |
|||
if (text.isNotEmpty()) { |
|||
contentArray.add( |
|||
mapOf( |
|||
"type" to "text", |
|||
"text" to text |
|||
) |
|||
) |
|||
} |
|||
|
|||
// 添加图片部分 |
|||
contentArray.add( |
|||
mapOf( |
|||
"type" to "image_url", |
|||
"image_url" to mapOf( |
|||
"url" to "data:image/jpeg;base64,$imageBase64" |
|||
) |
|||
) |
|||
) |
|||
|
|||
return mapOf( |
|||
"role" to "user", |
|||
"content" to contentArray |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 从URL中提取主机信息 (保留向后兼容) |
|||
*/ |
|||
private fun extractHost(url: String): String { |
|||
return try { |
|||
val javaUrl = java.net.URL(url) |
|||
javaUrl.host ?: "api.openai.com" |
|||
} catch (e: Exception) { |
|||
"api.openai.com" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 解析JSON参数 (与 iOS 版本保持一致) |
|||
*/ |
|||
private fun parseJsonArguments(argumentsJson: String): Map<String, Any> { |
|||
return try { |
|||
if (argumentsJson.isBlank()) { |
|||
emptyMap() |
|||
} else { |
|||
val jsonObject = org.json.JSONObject(argumentsJson) |
|||
convertJsonObjectToMap(jsonObject) |
|||
} |
|||
} catch (e: Exception) { |
|||
emptyMap() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 转换JSONArray为List<Map<String, Any>> |
|||
*/ |
|||
private fun convertJsonArrayToList(jsonArray: org.json.JSONArray): List<Map<String, Any>> { |
|||
val result = mutableListOf<Map<String, Any>>() |
|||
|
|||
for (i in 0 until jsonArray.length()) { |
|||
val jsonObject = jsonArray.optJSONObject(i) ?: continue |
|||
val map = convertJsonObjectToMap(jsonObject) |
|||
result.add(map) |
|||
} |
|||
|
|||
return result |
|||
} |
|||
|
|||
/** |
|||
* 转换JSONObject为Map<String, Any> |
|||
*/ |
|||
private fun convertJsonObjectToMap(jsonObject: org.json.JSONObject): Map<String, Any> { |
|||
val map = mutableMapOf<String, Any>() |
|||
|
|||
val keys = jsonObject.keys() |
|||
while (keys.hasNext()) { |
|||
val key = keys.next() |
|||
val value = jsonObject.get(key) |
|||
|
|||
when (value) { |
|||
is org.json.JSONObject -> map[key] = convertJsonObjectToMap(value) |
|||
is org.json.JSONArray -> { |
|||
val list = mutableListOf<Any>() |
|||
for (i in 0 until value.length()) { |
|||
val item = value.get(i) |
|||
when (item) { |
|||
is org.json.JSONObject -> list.add(convertJsonObjectToMap(item)) |
|||
else -> list.add(item) |
|||
} |
|||
} |
|||
map[key] = list |
|||
} |
|||
else -> map[key] = value |
|||
} |
|||
} |
|||
|
|||
return map |
|||
} |
|||
|
|||
/** |
|||
* 转换Map为JSONObject |
|||
*/ |
|||
private fun convertMapToJsonObject(map: Map<String, Any>): org.json.JSONObject { |
|||
val jsonObject = org.json.JSONObject() |
|||
|
|||
for ((key, value) in map) { |
|||
when (value) { |
|||
is Map<*, *> -> { |
|||
@Suppress("UNCHECKED_CAST") |
|||
jsonObject.put(key, convertMapToJsonObject(value as Map<String, Any>)) |
|||
} |
|||
is List<*> -> { |
|||
val jsonArray = org.json.JSONArray() |
|||
value.forEach { item -> |
|||
when (item) { |
|||
is Map<*, *> -> { |
|||
@Suppress("UNCHECKED_CAST") |
|||
jsonArray.put(convertMapToJsonObject(item as Map<String, Any>)) |
|||
} |
|||
else -> jsonArray.put(item) |
|||
} |
|||
} |
|||
jsonObject.put(key, jsonArray) |
|||
} |
|||
else -> jsonObject.put(key, value) |
|||
} |
|||
} |
|||
|
|||
return jsonObject |
|||
} |
|||
} |
|||
Loading…
Reference in new issue