feat(proxy): M1b WorkBuddy CN/Trae CN 适配 + 设置页网关开关
- 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)
This commit is contained in:
parent
85c6cf4283
commit
11b9065f82
@ -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<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
override fun start(config: ProxyServerConfig): Result<Unit> = try {
|
||||
override fun start(config: ProxyServerConfig): Result<Unit> {
|
||||
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, sub2ApiChatProxy)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
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 (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.respondBytes(
|
||||
errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.BadRequest
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<String, Any> =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
117
app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
Normal file
117
app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** 本地反代服务运行状态。 */
|
||||
val proxyRunning: StateFlow<Boolean> = localProxyServer.isRunning
|
||||
|
||||
/** 启动/停止本地反代服务。M1b 使用固定本地 Key,后续改为可配置。 */
|
||||
fun setProxyEnabled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
localProxyServer.start(ProxyServerConfig(apiKey = "rainytoken"))
|
||||
} else {
|
||||
localProxyServer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user