diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62e5020..72370b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 23 - versionName = "1.7.3" + versionCode = 24 + versionName = "1.7.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index 1ddd92e..6440d04 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -104,6 +104,7 @@ fun SettingsScreen( val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle() val proxyError by viewModel.proxyError.collectAsStateWithLifecycle() val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() + val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -180,6 +181,7 @@ fun SettingsScreen( apiKey = proxyApiKey, error = proxyError, debug = proxyDebug, + debugRaw = proxyDebugRaw, onToggle = viewModel::setProxyEnabled, onTestTrae = viewModel::testTraeStream, onTestWorkBuddy = viewModel::testWorkBuddyStream @@ -213,6 +215,7 @@ private fun ProxyGatewayCard( apiKey: String, error: String?, debug: String?, + debugRaw: String?, onToggle: (Boolean) -> Unit, onTestTrae: () -> Unit, onTestWorkBuddy: () -> Unit @@ -271,17 +274,17 @@ private fun ProxyGatewayCard( Spacer(modifier = Modifier.padding(top = 8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "原始输出", + text = "探针摘要", style = MaterialTheme.typography.labelSmall, color = InkMuted, modifier = Modifier.weight(1f) ) TextButton(onClick = { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("代理网关探针输出", debug)) + clipboard.setPrimaryClip(ClipData.newPlainText("代理网关探针输出", debugRaw ?: debug)) Toast.makeText(context, "已复制全部原始输出", Toast.LENGTH_SHORT).show() }) { - Text("复制全部") + Text("复制原始") } } Box( diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index 335a600..167f30f 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -20,6 +20,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.coroutines.launch +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.Json import javax.inject.Inject /** @@ -59,46 +66,68 @@ class SettingsViewModel @Inject constructor( } } - /** 代理调试输出(原始 SSE 帧)。 */ + /** 代理调试输出(结构化摘要)。 */ private val _proxyDebug = MutableStateFlow(null) val proxyDebug: StateFlow = _proxyDebug.asStateFlow() - /** 用 Trae CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */ + /** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */ + private val _proxyDebugRaw = MutableStateFlow(null) + val proxyDebugRaw: StateFlow = _proxyDebugRaw.asStateFlow() + + /** 用 Trae CN 默认账号发一条 stream=true 测试。 */ fun testTraeStream() { viewModelScope.launch { _proxyDebug.value = "正在请求 Trae CN 流..." - val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" + _proxyDebugRaw.value = null + val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\""" try { val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { _proxyDebug.value = "Trae CN 未配置或未登录" return@launch } - _proxyDebug.value = readStreamSample(stream, "Trae CN") + val result = readStreamSample( + stream, + "Trae CN", + body, + "POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat" + ) + _proxyDebug.value = result.summary + _proxyDebugRaw.value = result.raw } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { _proxyDebug.value = "Trae CN 异常:" + (e.message ?: "未知错误") + _proxyDebugRaw.value = null } } } - /** 用 WorkBuddy CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */ + /** 用 WorkBuddy CN 默认账号发一条 stream=true 测试。 */ fun testWorkBuddyStream() { viewModelScope.launch { _proxyDebug.value = "正在请求 WorkBuddy CN 流..." - val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" + _proxyDebugRaw.value = null + val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\""" try { val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { _proxyDebug.value = "WorkBuddy CN 未配置或未登录" return@launch } - _proxyDebug.value = readStreamSample(stream, "WorkBuddy CN") + val result = readStreamSample( + stream, + "WorkBuddy CN", + body, + "POST https://copilot.tencent.com/v2/chat/completions" + ) + _proxyDebug.value = result.summary + _proxyDebugRaw.value = result.raw } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { _proxyDebug.value = "WorkBuddy CN 异常:" + (e.message ?: "未知错误") + _proxyDebugRaw.value = null } } } @@ -106,24 +135,133 @@ class SettingsViewModel @Inject constructor( private suspend fun readStreamSample( stream: ProxyUpstreamStream, label: String, - ): String = withContext(Dispatchers.IO) { - val sb = StringBuilder() - sb.append(label).append(" HTTP ").append(stream.status) - sb.append(" Content-Type=").append(stream.contentType).append("\n") + requestBody: String, + requestLine: String, + ): StreamProbeResult = withContext(Dispatchers.IO) { + val lines = mutableListOf() + val raw = StringBuilder() + raw.append(label).append(" HTTP ").append(stream.status) + raw.append(" Content-Type=").append(stream.contentType).append("\n") + raw.append("请求: ").append(requestLine).append("\n") + raw.append("请求体: ").append(requestBody).append("\n") + raw.append("---- 上游响应原始帧 ----\n") try { val reader = stream.input.bufferedReader() - repeat(80) { + repeat(120) { val line = reader.readLine() ?: return@repeat - sb.append(line).append("\n") + lines.add(line) + raw.append(line).append("\n") if (line.trim() == "data: [DONE]") return@repeat - if (sb.length > 6000) return@repeat + if (raw.length > 12000) return@repeat } } finally { stream.close() } - sb.toString() + StreamProbeResult( + summary = summarizeProbe(label, stream.status, stream.contentType, requestBody, requestLine, lines), + raw = raw.toString() + ) } + private fun summarizeProbe( + label: String, + status: Int, + contentType: String, + requestBody: String, + requestLine: String, + lines: List, + ): String { + val sb = StringBuilder() + sb.append(label).append(" 探针摘要") + sb.append("\n请求: ").append(requestLine) + runCatching { + val body = json.parseToJsonElement(requestBody) as? JsonObject + if (body != null) { + val model = (body["model"] as? JsonPrimitive)?.contentOrNull ?: "?" + val count = (body["messages"] as? JsonArray)?.size ?: 0 + val stream = (body["stream"] as? JsonPrimitive)?.contentOrNull ?: "?" + sb.append("\n 模型=").append(model) + sb.append(" · messages=").append(count) + sb.append(" · stream=").append(stream) + (body["tools"] as? JsonArray)?.let { sb.append(" · tools=").append(it.size) } + } + } + sb.append("\n回复: HTTP ").append(status).append(" · ").append(contentType) + var events = 0 + val texts = mutableListOf() + val models = linkedSetOf() + val usage = mutableListOf() + val errors = mutableListOf() + for (line in lines) { + val trimmed = line.trim() + if (!trimmed.startsWith("data:")) continue + val payload = trimmed.removePrefix("data:").trim() + if (payload == "[DONE]") continue + val el = runCatching { json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: continue + events++ + collectProbeText(el, texts) + collectProbeModel(el, models) + collectProbeUsage(el, usage) + (el["error"] as? JsonObject)?.let { e -> + val msg = (e["message"] as? JsonPrimitive)?.contentOrNull ?: e.toString() + errors.add(msg) + } + } + if (events > 0) sb.append("\n事件帧: ").append(events) + if (models.isNotEmpty()) sb.append("\n模型: ").append(models.joinToString(" / ")) + if (texts.isNotEmpty()) { + val joined = texts.joinToString("").trim().replace("\n", " ").take(200) + sb.append("\n文本(截断): ").append(if (joined.isEmpty()) "(空)" else joined) + } + if (usage.isNotEmpty()) sb.append("\n用量: ").append(usage.distinct().joinToString(" · ")) + if (errors.isNotEmpty()) sb.append("\n错误: ").append(errors.take(2).joinToString(" | ")) + return sb.toString() + } + + private fun collectProbeText(el: JsonObject, out: MutableList) { + fun add(v: JsonElement?) { + when (v) { + is JsonPrimitive -> if (v.isString) out.add(v.content) + is JsonObject -> { + (v["content"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) } + (v["text"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) } + (v["delta"] as? JsonObject)?.let { add(it) } + } + is JsonArray -> v.forEach { add(it) } + else -> Unit + } + } + (el["data"] as? JsonObject)?.let { add(it) } + (el["content"] as? JsonElement)?.let { add(it) } + (el["text"] as? JsonElement)?.let { add(it) } + (el["message"] as? JsonObject)?.let { add(it) } + (el["choices"] as? JsonArray)?.forEach { c -> + val co = c as? JsonObject ?: return@forEach + (co["delta"] as? JsonObject)?.let { add(it) } + (co["message"] as? JsonObject)?.let { add(it) } + } + } + + private fun collectProbeModel(el: JsonObject, out: MutableSet) { + for (key in listOf("model_name", "modelName", "config_name", "model", "provider_model_name", "modelProviderName")) { + (el[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }?.let { out.add(it) } + } + (el["data"] as? JsonObject)?.let { collectProbeModel(it, out) } + (el["message"] as? JsonObject)?.let { collectProbeModel(it, out) } + } + + private fun collectProbeUsage(el: JsonObject, out: MutableList) { + val usage = (el["usage"] as? JsonObject) ?: (el["token_usage"] as? JsonObject) ?: return + fun num(key: String, label: String) { + (usage[key] as? JsonPrimitive)?.let { out.add(label + "=" + it.content) } + } + num("prompt_tokens", "输入") + num("completion_tokens", "输出") + num("total_tokens", "合计") + } + + private val json = Json { ignoreUnknownKeys = true } + private fun generateProxyKey(): String = "rainy-" + UUID.randomUUID().toString().replace("-", "").take(16) @@ -151,6 +289,11 @@ class SettingsViewModel @Inject constructor( } } +data class StreamProbeResult( + val summary: String, + val raw: String, +) + data class SettingsUiState( val loading: Boolean = false, val credentialStatuses: List = emptyList()