From 11b9065f8249627e074586ae851e0885d08be0ca Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:23:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20M1b=20WorkBuddy=20CN/Trae=20CN?= =?UTF-8?q?=20=E9=80=82=E9=85=8D=20+=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E7=BD=91=E5=85=B3=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KtorLocalProxyServer:修复审计阻断项(start/stop 加锁、apiKey Bearer 鉴权、IOException 502、请求体上限、错误 JSON 规范化) - WorkBuddyChatProxy:转发到 copilot.tencent.com/v2/chat/completions,Bearer accessToken + IDE 请求头 - TraeChatProxy:转发到 api.trae.cn/api/agent/v3/llm_utils_chat,Cloud-IDE-JWT 身份/设备头,最小 body 转换 - Credential.WorkBuddyCredential 增加 region 字段(默认 CN,兼容旧数据) - Settings 页新增代理网关开关(默认 127.0.0.1:8787,本机 Key: rainytoken) --- .../token/data/proxy/KtorLocalProxyServer.kt | 146 +++++++++++++----- .../rainy/token/data/proxy/TraeChatProxy.kt | 117 ++++++++++++++ .../token/data/proxy/WorkBuddyChatProxy.kt | 68 ++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 22 ++- .../rainy/token/domain/model/Credential.kt | 2 + .../rainy/token/ui/settings/SettingsScreen.kt | 49 ++++++ .../token/ui/settings/SettingsViewModel.kt | 17 +- 7 files changed, 382 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt diff --git a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt index fe90253..4e685ec 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -1,9 +1,11 @@ package com.rainy.token.data.proxy import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer @@ -15,6 +17,7 @@ import io.ktor.server.response.respondBytes import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow @@ -24,45 +27,63 @@ import kotlinx.coroutines.flow.asStateFlow /** * 基于 Ktor CIO 的本地 HTTP 反代服务。 * - * M1a 能力: + * M1b 能力: * - GET /health - * - GET /v1/models → Sub2API 透传 - * - POST /v1/chat/completions → Sub2API 透传 + * - GET /v1/models + * - POST /v1/chat/completions + * 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传 * - * 只绑定 127.0.0.1,默认端口 8787。 + * 安全: + * - 只绑定 127.0.0.1 + * - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401 */ @Singleton class KtorLocalProxyServer @Inject constructor( private val sub2ApiChatProxy: Sub2ApiChatProxy, + private val workBuddyChatProxy: WorkBuddyChatProxy, + private val traeChatProxy: TraeChatProxy, ) : LocalProxyServer { + private val lock = Any() + + @Volatile + private var server: EmbeddedServer<*, *>? = null + private val _isRunning = MutableStateFlow(false) override val isRunning: StateFlow = _isRunning.asStateFlow() - private var server: EmbeddedServer<*, *>? = null - - override fun start(config: ProxyServerConfig): Result = try { - if (_isRunning.value) return Result.success(Unit) - val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) { - proxyModule(config, sub2ApiChatProxy) + override fun start(config: ProxyServerConfig): Result { + synchronized(lock) { + if (_isRunning.value) return Result.success(Unit) + return try { + val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) { + proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy) + } + engine.start(wait = false) + server = engine + _isRunning.value = true + Result.success(Unit) + } catch (e: Throwable) { + server = null + _isRunning.value = false + Result.failure(e) + } } - engine.start(wait = false) - server = engine - _isRunning.value = true - Result.success(Unit) - } catch (e: Throwable) { - Result.failure(e) } override fun stop() { - runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) } - server = null - _isRunning.value = false + synchronized(lock) { + runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) } + server = null + _isRunning.value = false + } } private fun Application.proxyModule( - config: ProxyServerConfig, + apiKey: String?, sub2Api: Sub2ApiChatProxy, + workBuddy: WorkBuddyChatProxy, + trae: TraeChatProxy, ) { install(ContentNegotiation) { json() @@ -72,38 +93,91 @@ class KtorLocalProxyServer @Inject constructor( call.respond(mapOf("status" to "ok")) } get("/v1/models") { + if (!authorized(call, apiKey)) return@get val result = sub2Api.forwardModels() if (result == null) { - call.respondBytes( - errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"), - ContentType.Application.Json, - HttpStatusCode.BadRequest - ) + call.respond(HttpStatusCode.BadRequest, errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key")) } else { call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) } } post("/v1/chat/completions") { + if (!authorized(call, apiKey)) return@post val body = call.receiveText() - val result = sub2Api.forwardChat(body) - if (result == null) { - call.respondBytes( - errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"), - ContentType.Application.Json, - HttpStatusCode.BadRequest + if (body.length > MAX_REQUEST_BYTES) { + call.respond(HttpStatusCode(413, ""), errorBody("请求体过大")) + return@post + } + val model = extractModel(body) + val provider = routeProvider(model) + try { + val result = when (provider) { + ChatRoute.WORKBUDDY_CN -> workBuddy.forwardChat(body) + ChatRoute.TRAE_CN -> trae.forwardChat(body) + ChatRoute.SUB2API -> sub2Api.forwardChat(body) + } + if (result == null) { + call.respond( + HttpStatusCode.BadRequest, + errorBody("${provider.displayName} 未配置或未登录,请先在设置中配置") + ) + } else { + call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) + } + } catch (e: IOException) { + val detail = e.message ?: "未知错误" + call.respond( + HttpStatusCode.BadGateway, + errorBody("上游网络错误:$detail") + ) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + val detail = e.message ?: "未知错误" + call.respond( + HttpStatusCode.InternalServerError, + errorBody("网关内部错误:$detail") ) - } else { - call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) } } } } - private fun errorBody(message: String): ByteArray { - val safe = message.replace("\"", "'") - return "{\"error\":{\"message\":\"$safe\",\"type\":\"invalid_request_error\"}}".toByteArray() + private fun authorized(call: ApplicationCall, apiKey: String?): Boolean { + if (apiKey.isNullOrBlank()) return true + val header = call.request.headers[HttpHeaders.Authorization] ?: return false + val expect = "Bearer $apiKey" + return header.trim() == expect } + private fun errorBody(message: String): Map = + mapOf("error" to mapOf("message" to message, "type" to "invalid_request_error")) + private fun contentTypeOf(raw: String): ContentType = runCatching { ContentType.parse(raw) }.getOrDefault(ContentType.Application.Json) + + private fun extractModel(body: String): String = runCatching { + val el = kotlinx.serialization.json.Json.parseToJsonElement(body) + (el as? kotlinx.serialization.json.JsonObject) + ?.get("model") + ?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null } + }.getOrNull() ?: "" + + private fun routeProvider(model: String): ChatRoute { + val m = model.lowercase() + return when { + m.startsWith("wbcn-") -> ChatRoute.WORKBUDDY_CN + m.startsWith("traecn-") || m.startsWith("trae-") -> ChatRoute.TRAE_CN + else -> ChatRoute.SUB2API + } + } + + private enum class ChatRoute(val displayName: String) { + WORKBUDDY_CN("WorkBuddy 国内版"), + TRAE_CN("Trae CN"), + SUB2API("Sub2API"), + } + + companion object { + private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024 + } } diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt new file mode 100644 index 0000000..be4f8b8 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -0,0 +1,117 @@ +package com.rainy.token.data.proxy + +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Trae CN 上游 Chat 代理。 + * + * 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat, + * 请求头需要 Cloud-IDE-JWT / x-device-id / x-uid 等设备与身份指纹。 + * M1b 实现最小 body 转换:保留 messages/tools,注入 config_name/function/stream/session。 + */ +@Singleton +class TraeChatProxy @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, +) { + + private val json = Json { ignoreUnknownKeys = true } + + suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? = + withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.TRAE, accountId) + ?: return@withContext null + if (credential !is Credential.TraeCredential) return@withContext null + val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } + ?: return@withContext null + + val base = if (credential.region.equals("INTL", ignoreCase = true)) { + "https://grow-normal.trae.ai" + } else { + "https://api.trae.cn" + } + val upstreamBody = buildUpstreamBody(requestBody, credential) + val requestId = UUID.randomUUID().toString() + val sessionId = UUID.randomUUID().toString().replace("-", "") + + val builder = Request.Builder() + .url(base + "/api/agent/v3/llm_utils_chat") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Cloud-IDE-JWT \$jwt") + .addHeader("X-Cloudide-Token", jwt) + .addHeader("x-uid", credential.userId ?: "") + .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") + .addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "") + .addHeader("x-request-id", requestId) + .addHeader("x-app-id", "trae") + .addHeader("x-ide-version", "1.0.0") + .addHeader("x-ide-version-code", "1000000") + .addHeader("x-ide-version-type", "stable") + .addHeader("x-os-version", "Android") + .addHeader("x-system-type", "Android") + .addHeader("User-Agent", "RainyToken/1.0") + .post(upstreamBody.toRequestBody("application/json".toMediaType())) + + val response = try { + okHttpClient.newCall(builder.build()).execute() + } catch (e: java.io.IOException) { + throw e + } + val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) } + val contentType = response.header("Content-Type") ?: "application/json" + val status = response.code + response.close() + ProxyUpstreamResponse(status, contentType, bytes) + } + + private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { + val src = try { + json.parseToJsonElement(raw) as? JsonObject + } catch (_: Throwable) { + null + } ?: return raw + + val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + ?: "glm-5.2" + val messages = src["messages"] as? JsonArray ?: JsonArray(emptyList()) + val sessionId = UUID.randomUUID().toString().replace("-", "") + + return buildJsonObject { + put("conversation_id", "") + put("session_id", sessionId) + put("user_id", credential.userId ?: "") + put("device_id", credential.deviceId ?: credential.checkinDeviceId ?: "") + put("agent_type", "solo_work_lite") + put("model_name", model + "__dev") + put("config_name", model) + put("function", "solo_work_lite") + put("stream", true) + put("request_id", sessionId) + put("messages", messages) + src["tools"]?.let { put("tools", it) } + src["tool_choice"]?.let { put("tool_choice", it) } + (src["max_tokens"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { + put("max_tokens", it) + } + }.toString() + } +} diff --git a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt new file mode 100644 index 0000000..7ad1071 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt @@ -0,0 +1,68 @@ +package com.rainy.token.data.proxy + +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。 + * + * 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。 + * 这里保留客户端原始 body(M1b 最小实现),并补齐桌面端请求头;完整归一化在后续迭代。 + */ +@Singleton +class WorkBuddyChatProxy @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, +) { + + suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? = + withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId) + ?: return@withContext null + if (credential !is Credential.WorkBuddyCredential) return@withContext null + val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } + ?: return@withContext null + + val base = if (credential.region.equals("INTL", ignoreCase = true)) { + "https://www.workbuddy.ai" + } else { + "https://copilot.tencent.com" + } + val requestId = UUID.randomUUID().toString() + val builder = Request.Builder() + .url(base + "/v2/chat/completions") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Bearer \$accessToken") + .addHeader("User-Agent", "RainyToken/1.0") + .addHeader("X-IDE-Type", "VSCode") + .addHeader("X-IDE-Name", "CodeBuddy") + .addHeader("X-IDE-Version", "3.0.0") + .addHeader("X-Product", "CodeBuddy") + .addHeader("X-Agent-Intent", "craft") + .addHeader("X-Request-ID", requestId) + .addHeader("X-Conv-Request-ID", requestId) + .post(requestBody.toRequestBody("application/json".toMediaType())) + + val response = try { + okHttpClient.newCall(builder.build()).execute() + } catch (e: java.io.IOException) { + throw e + } + val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) } + val contentType = response.header("Content-Type") ?: "application/json" + val status = response.code + response.close() + ProxyUpstreamResponse(status, contentType, bytes) + } +} diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 9af3644..755416f 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -23,6 +23,8 @@ import com.rainy.token.data.repository.UpdateRepository import com.rainy.token.data.proxy.KtorLocalProxyServer import com.rainy.token.data.proxy.LocalProxyServer import com.rainy.token.data.proxy.Sub2ApiChatProxy +import com.rainy.token.data.proxy.TraeChatProxy +import com.rainy.token.data.proxy.WorkBuddyChatProxy import com.rainy.token.data.repository.WorkBuddyRepository import dagger.Module import dagger.Provides @@ -235,11 +237,27 @@ object NetworkModule { credentialRepository: CredentialRepository ): Sub2ApiChatProxy = Sub2ApiChatProxy(okHttpClient, credentialRepository) + @Provides + @Singleton + fun provideWorkBuddyChatProxy( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository + ): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository) + + @Provides + @Singleton + fun provideTraeChatProxy( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository + ): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository) + @Provides @Singleton fun provideLocalProxyServer( - sub2ApiChatProxy: Sub2ApiChatProxy - ): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy) + sub2ApiChatProxy: Sub2ApiChatProxy, + workBuddyChatProxy: WorkBuddyChatProxy, + traeChatProxy: TraeChatProxy + ): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy) /** 余额缓存 DataStore(计划 7.1) */ @Provides diff --git a/app/src/main/java/com/rainy/token/domain/model/Credential.kt b/app/src/main/java/com/rainy/token/domain/model/Credential.kt index 760b281..a2a9b1d 100644 --- a/app/src/main/java/com/rainy/token/domain/model/Credential.kt +++ b/app/src/main/java/com/rainy/token/domain/model/Credential.kt @@ -106,6 +106,8 @@ sealed class Credential { override val service: ServiceType, val accessToken: String, val refreshToken: String, + /** 区域:"CN"(copilot.tencent.com / codebuddy.cn)/ "INTL"(workbuddy.ai) */ + val region: String = "CN", /** 过期时间(epoch millis),0=未知 */ val expiresAt: Long = 0L, override val lastVerifiedAt: Long = 0L 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 b2f740f..9410cfe 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 @@ -28,6 +28,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -89,6 +90,7 @@ fun SettingsScreen( } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -159,6 +161,12 @@ fun SettingsScreen( item { LanguageCard(onClick = { showLanguageDialog = true }) } + item { + ProxyGatewayCard( + running = proxyRunning, + onToggle = viewModel::setProxyEnabled + ) + } item { TipsCard(onClick = { onOpenTips() }) } @@ -181,6 +189,47 @@ fun SettingsScreen( } } +@Composable +private fun ProxyGatewayCard( + running: Boolean, + onToggle: (Boolean) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "🛰️", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "代理网关", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = if (running) "运行中 · http://127.0.0.1:8787/v1" else "已停止", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 2.dp) + ) + } + Switch( + checked = running, + onCheckedChange = onToggle + ) + } + } +} + @Composable private fun LanguageCard(onClick: () -> Unit) { val context = LocalContext.current 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 a742351..475743c 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 @@ -2,6 +2,8 @@ package com.rainy.token.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.data.proxy.LocalProxyServer +import com.rainy.token.data.proxy.ProxyServerConfig import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.service.ServiceType @@ -19,12 +21,25 @@ import javax.inject.Inject */ @HiltViewModel class SettingsViewModel @Inject constructor( - private val credentialRepository: CredentialRepository + private val credentialRepository: CredentialRepository, + private val localProxyServer: LocalProxyServer ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** 本地反代服务运行状态。 */ + val proxyRunning: StateFlow = localProxyServer.isRunning + + /** 启动/停止本地反代服务。M1b 使用固定本地 Key,后续改为可配置。 */ + fun setProxyEnabled(enabled: Boolean) { + if (enabled) { + localProxyServer.start(ProxyServerConfig(apiKey = "rainytoken")) + } else { + localProxyServer.stop() + } + } + init { refresh() }