feat(proxy): 探针结果结构化摘要,不再刷屏原始参数
Some checks are pending
Release / build (push) Waiting to run

- 显示:请求行/模型/messages数/stream、HTTP、事件帧数、模型、文本摘要、用量、错误
- 原始请求+响应仍完整保留,点“复制原始”一键复制
- 版本升至 1.7.4
This commit is contained in:
Liuxinyu176 2026-10-09 10:46:30 +08:00
parent 7de7111b18
commit fbbca4a03b
3 changed files with 166 additions and 20 deletions

View File

@ -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 {

View File

@ -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(

View File

@ -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<String?>(null)
val proxyDebug: StateFlow<String?> = _proxyDebug.asStateFlow()
/** 用 Trae CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */
/** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */
private val _proxyDebugRaw = MutableStateFlow<String?>(null)
val proxyDebugRaw: StateFlow<String?> = _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<String>()
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>,
): 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<String>()
val models = linkedSetOf<String>()
val usage = mutableListOf<String>()
val errors = mutableListOf<String>()
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<String>) {
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<String>) {
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<String>) {
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<CredentialStatusLine> = emptyList()