noulscorechoiceClassificationTriageVerification
Filter advertising information using the JEV model.
JevNoiseGate filters unwanted notifications and SMS on Android. Rather than matching keywords, an LLM decides what's noise — and only what it explicitly flags is blocked. Verification codes are matched on-device and never uploaded; anything uncertain passes through.
JevNoiseGate 可以过滤掉 Android 设备上不需要的通知和短信。该工具并非通过匹配关键词来识别垃圾信息,而是由大型语言模型来判断哪些信息属于垃圾信息。只有被明确标记为垃圾的信息才会被拦截。验证码会在设备上直接进行验证,而不会被上传到服务器;那些无法被确认的信息则会被直接传递过去。
Source (20 files)
package me.ethanxu.jevnoisegate.app.di
import android.os.Build
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import me.ethanxu.jevnoisegate.app.BuildConfig
import me.ethanxu.jevnoisegate.core.decision.DecisionBackend
import me.ethanxu.jevnoisegate.core.decision.TypeSafeBackend
import me.ethanxu.jevnoisegate.core.data.settings.SettingsRepository
import me.ethanxu.jevnoisegate.sdk.typesafe.ProxySpec
import me.ethanxu.jevnoisegate.sdk.typesafe.TypeSafeClient
import me.ethanxu.jevnoisegate.sdk.typesafe.TypeSafeConfig
/**
* 判断后端的装配。
*
* API key 来自 `local.properties`(`typesafe.apiKey=...`),经 BuildConfig 注入。
* 这里刻意**不**在 key 缺失时抛异常:那样应用会直接起不来,而"没有 key"是一个
* 正常状态(用户还没配置)。改为提供一个必然失败的[DecisionBackend],
* 让流水线走 fail-open 放行 —— 这与整个系统的失败哲学一致,也便于在诊断页显示原因。
*/
@Module
@InstallIn(SingletonComponent::class)
object BackendModule {
@Provides
@Singleton
fun provideTypeSafeClient(settings: SettingsRepository): TypeSafeClient {
// awaitReady 已在 JevNoiseGateApp.onCreate 里调用过,因此这里读到的是真实配置。
val prefs = settings.preferences.value
return TypeSafeClient(
TypeSafeConfig(
// 用户配置优先;其次回退到 local.properties 注入的 key;最后才是占位符。
apiKey = prefs.apiKey.ifBlank { BuildConfig.TYPESAFE_API_KEY }.ifBlank { PLACEHOLDER_KEY },
baseUrl = prefs.baseUrl,
defaultModel = prefs.model,
timeoutMs = prefs.timeoutMs,
// SDK 的默认值是中性占位(commonMain 读不到平台版本号),这里补上真实信息。
runtimeDescriptor = "android/${Build.VERSION.RELEASE}",
proxy = ProxySpec.fromSettings(prefs.proxyType, prefs.proxyHost, prefs.proxyPort),
),
)
}
@Provides
@Singleton
fun provideDecisionBackend(client: TypeSafeClient): DecisionBackend = TypeSafeBackend(client)
/** 仅用于让 [TypeSafeConfig] 通过构造校验;真实请求会因鉴权失败而归入 INVALID_RESPONSE。 */
private const val PLACEHOLDER_KEY = "not-configured"
}
package me.ethanxu.jevnoisegate.app
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.lifecycle.viewModelScope
import me.ethanxu.jevnoisegate.core.data.settings.AppPreferences
import me.ethanxu.jevnoisegate.core.data.settings.SettingsRepository
import me.ethanxu.jevnoisegate.sdk.typesafe.ModelCard
import me.ethanxu.jevnoisegate.sdk.typesafe.ProxySpec
import me.ethanxu.jevnoisegate.sdk.typesafe.TypeSafeClient
import me.ethanxu.jevnoisegate.sdk.typesafe.TypeSafeConfig
/**
* 连接测试的结果。
*
* `success` 是可空的:null 表示尚未测过,与"测过且失败"是两种不同状态 ——
* 界面需要区分"还没测"和"测了不通"。
*/
data class ConnectionTestState(
val running: Boolean = false,
val success: Boolean? = null,
val message: String? = null,
val models: List<ModelCard> = emptyList(),
)
/**
* 设置的状态持有者。
*
* 直接转发仓库的 `StateFlow`,**不做 `stateIn`**:
* 仓库是进程内单例且写入时同步更新内存,因此这里拿到的永远是即时值,
* 也不需要 `WhileSubscribed` 那种延迟订阅——主题切换必须立刻可见。
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: SettingsRepository,
) : ViewModel() {
val preferences: StateFlow<AppPreferences> = repository.preferences
private val _connectionTest = MutableStateFlow(ConnectionTestState())
val connectionTest: StateFlow<ConnectionTestState> = _connectionTest.asStateFlow()
fun setColorMode(value: Int) = launchSetting { repository.setColorMode(value) }
fun setKeyColor(value: Int) = launchSetting { repository.setKeyColor(value) }
fun setApiKey(value: String) = launchSetting { repository.setApiKey(value) }
fun setBaseUrl(value: String) = launchSetting { repository.setBaseUrl(value) }
fun setModel(value: String) = launchSetting { repository.setModel(value) }
fun setTimeoutMs(value: Long) = launchSetting { repository.setTimeoutMs(value) }
fun setAppMuted(packageName: String, muted: Boolean) =
launchSetting { repository.setAppMuted(packageName, muted) }
fun setLogLevel(value: Int) = launchSetting { repository.setLogLevel(value) }
fun setProxyType(value: String) = launchSetting { repository.setProxyType(value) }
fun setProxyHost(value: String) = launchSetting { repository.setProxyHost(value) }
fun setProxyPort(value: Int) = launchSetting { repository.setProxyPort(value) }
fun setProxyUsername(value: String) = launchSetting { repository.setProxyUsername(value) }
fun setProxyPassword(value: String) = launchSetting { repository.setProxyPassword(value) }
fun setPreferAllowRules(value: List<String>) =
launchSetting { repository.setPreferAllowRules(value) }
fun setPreferBlockRules(value: List<String>) =
launchSetting { repository.setPreferBlockRules(value) }
fun setSmsSenderBlacklist(value: List<String>) =
launchSetting { repository.setSmsSenderBlacklist(value) }
fun setDeveloperMode(value: Boolean) = launchSetting { repository.setDeveloperMode(value) }
/**
* 连接测试。
*
* 用 `GET /v1/models` 一次请求同时达成两件事:验证连通性、拿到模型列表。
* 不存在"只测连通"的轻量端点,而这个请求本来就是必需的 —— 合并比开两个入口更省事,
* 也避免用户先点"测试"再点"刷新模型"。
*
* **用表单当前值临时构造客户端**,而不是复用单例客户端:单例是按已保存的配置建的,
* 用它测等于在测旧配置 —— 用户改完代理点测试,结果反映的还是改动前,比不测更误导。
*/
fun testConnection(
apiKey: String,
baseUrl: String,
proxyType: String,
proxyHost: String,
proxyPort: Int,
proxyUsername: String,
proxyPassword: String,
) {
if (apiKey.isBlank()) {
_connectionTest.value = ConnectionTestState(
running = false,
success = false,
message = "请先填写 API Key",
)
return
}
_connectionTest.value = ConnectionTestState(running = true)
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
val client = TypeSafeClient(
TypeSafeConfig(
apiKey = apiKey,
baseUrl = baseUrl,
timeoutMs = repository.preferences.value.timeoutMs,
proxy = ProxySpec.fromSettings(
type = proxyType,
host = proxyHost,
port = proxyPort,
username = proxyUsername,
password = proxyPassword,
),
),
)
try {
client.listModels()
} finally {
// 临时客户端必须关掉,否则每次测试都泄漏一个连接池。
client.close()
}
}
}
_connectionTest.value = result.fold(
onSuccess = { models ->
ConnectionTestState(
running = false,
success = true,
message = "连接成功,账号可用模型 ${models.size} 个",
models = models,
)
},
onFailure = { error ->
ConnectionTestState(
running = false,
success = false,
message = "连接失败:${error.message ?: error::class.java.simpleName}",
)
},
)
}
}
private inline fun launchSetting(crossinline block: suspend () -> Unit) {
viewModelScope.launch { block() }
}
}
package me.ethanxu.jevnoisegate.ui.screen.settings
import me.ethanxu.jevnoisegate.sdk.typesafe.ModelCard
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Button
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.FilterChip
import me.ethanxu.jevnoisegate.app.SettingsViewModel
import me.ethanxu.jevnoisegate.core.data.settings.AppPreferences
import me.ethanxu.jevnoisegate.ui.component.SegmentedColumn
import me.ethanxu.jevnoisegate.ui.component.SegmentedField
import me.ethanxu.jevnoisegate.ui.component.SegmentedItemContainer
import me.ethanxu.jevnoisegate.ui.component.transparentFieldColors
import me.ethanxu.jevnoisegate.ui.component.SegmentedRadioItem
/**
* JevAPI 设置。
*
* ## 为什么用本地草稿而不是直接双向绑定
*
* `SettingsViewModel.preferences` 的初始值是占位默认值(空 key、默认 URL),
* DataStore 的真实读数要等一次磁盘 IO。若直接把 `prefs.apiKey` 作为输入框的值,
* 首次组合会把空串当成草稿,随后到达的真实值又被"用户已编辑"的语义挡住 ——
* 结果是已配置的 key 显示为空。
*
* 因此这里显式等待第一次真实发射再初始化草稿,之后由草稿驱动输入、逐次落盘。
*/
@Composable
fun JevApiSettingsScreen(viewModel: SettingsViewModel = viewModel()) {
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
val test by viewModel.connectionTest.collectAsStateWithLifecycle()
var initialized by remember { mutableStateOf(false) }
var apiKey by remember { mutableStateOf("") }
var baseUrl by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") }
var timeout by remember { mutableStateOf("") }
var keyVisible by remember { mutableStateOf(false) }
LaunchedEffect(prefs) {
if (!initialized) {
apiKey = prefs.apiKey
baseUrl = prefs.baseUrl
model = prefs.model
timeout = prefs.timeoutMs.toString()
initialized = true
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
SegmentedColumn {
item {
SegmentedItemContainer {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = if (prefs.isApiConfigured) "已配置" else "未配置",
style = MaterialTheme.typography.titleMedium,
color = if (prefs.isApiConfigured) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
)
Text(
text = if (prefs.isApiConfigured) {
"判断会走真实模型,可在「诊断」页看到分类结果与往返延迟。"
} else {
"没有 API Key 时每条消息都会因鉴权失败而降级放行 —— " +
"这是刻意的 fail-open:宁可漏放,不可误拦。"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
}
item {
SegmentedColumn(title = "凭据") {
item {
SegmentedField(
value = apiKey,
onValueChange = {
apiKey = it
viewModel.setApiKey(it.trim())
},
label = "API Key",
visualTransformation = if (keyVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { keyVisible = !keyVisible }) {
Icon(
imageVector = if (keyVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = if (keyVisible) "隐藏" else "显示",
)
}
},
)
}
}
}
item {
SegmentedColumn(title = "端点") {
item {
SegmentedField(
value = baseUrl,
onValueChange = {
baseUrl = it
viewModel.setBaseUrl(it.trim())
},
label = "Base URL",
)
}
item {
SegmentedModelField(
value = model,
onValueChange = {
model = it
viewModel.setModel(it.trim())
},
models = test.models,
)
}
item {
SegmentedField(
value = timeout,
onValueChange = { input ->
// 只接受数字:非法输入直接忽略,避免把异常值写进设置。
val digits = input.filter { it.isDigit() }.take(6)
timeout = digits
digits.toLongOrNull()?.takeIf { it > 0 }?.let(viewModel::setTimeoutMs)
},
label = "超时(毫秒)",
keyboardType = KeyboardType.Number,
)
}
}
}
item {
SegmentedItemContainer {
Column(modifier = Modifier.padding(16.dp)) {
Text("出网链路", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(2.dp))
Text(
text = prefs.proxyChainSummary(),
style = MaterialTheme.typography.bodySmall,
// 半配置是一条容易被忽略的警告:用户以为在用代理,实际是直连。
color = if (prefs.proxyType != AppPreferences.PROXY_NONE &&
!prefs.isProxyConfigured
) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Spacer(Modifier.height(6.dp))
Text(
text = "只读。修改请到「设置 → 网络代理」。",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item {
SegmentedColumn(title = "连接测试") {
item {
SegmentedItemContainer {
Column(modifier = Modifier.padding(16.dp)) {
Button(
onClick = {
viewModel.testConnection(
apiKey = apiKey,
baseUrl = baseUrl,
// 代理配置在「网络代理」页维护,这里用已保存的值。
proxyType = prefs.proxyType,
proxyHost = prefs.proxyHost,
proxyPort = prefs.proxyPort,
proxyUsername = prefs.proxyUsername,
proxyPassword = prefs.proxyPassword,
)
},
enabled = !test.running,
) {
Text(if (test.running) "测试中…" else "测试连接并获取模型")
}
test.message?.let { message ->
Spacer(Modifier.height(12.dp))
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = if (test.success == true) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
)
}
}
}
}
item {
SegmentedItemContainer {
Text(
text = "这一步用表单里的当前值临时建立连接,因此**测的是你刚填的配置**," +
"不需要先保存。请求的是 GET /v1/models —— 它同时也是模型列表接口," +
"所以一次请求既验证了连通性(含代理),又把可选模型取回来。\n\n" +
"修改配置后需要重启应用才会对实际判断生效。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
item {
SegmentedColumn(title = "说明") {
item {
SegmentedItemContainer {
Text(
text = "默认 Base URL 为 ${AppPreferences.DEFAULT_BASE_URL}," +
"模型 ${AppPreferences.DEFAULT_MODEL}," +
"超时 ${AppPreferences.DEFAULT_TIMEOUT_MS} 毫秒。\n\n" +
"超时是单次请求的时限。SDK 默认还会重试 2 次," +
"因此最坏情况下一条通知会占用约三倍的时间加退避。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
}
}
/**
* 当前出网链路的一句话描述。
*
* 存在的意义:代理配在独立页面,本页又依赖它 —— 连接失败时用户需要立刻看到
* "到底走没走代理",否则会跑去反复改 Key 和 Base URL。
*/
private fun AppPreferences.proxyChainSummary(): String = when {
proxyType == AppPreferences.PROXY_NONE -> "直连,未经代理"
!isProxyConfigured -> "代理未配置完整(缺主机或端口),当前按直连处理"
else -> buildString {
append(proxyType.uppercase())
append(' ')
append(proxyHost)
append(':')
append(proxyPort)
append(if (proxyUsername.isNotBlank()) " · 含认证" else " · 无认证")
}
}
/**
* 「模型」字段。
*
* 拉取到模型列表后变成**可输入的下拉框**:点开从列表选,也允许继续手输 ——
* 列表接口可能因鉴权或权限问题拿不到全部模型,硬锁成只读会把用户堵死。
*
* 没测过连接时退化为普通输入框,不额外占位。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SegmentedModelField(
value: String,
onValueChange: (String) -> Unit,
models: List<ModelCard>,
) {
if (models.isEmpty()) {
SegmentedField(
value = value,
onValueChange = onValueChange,
// 不写"上方/下方"这类位置词:布局一调整文案就错了。
// 直接引用按钮名,怎么排版都不会失真。
label = "模型(可点「测试连接并获取模型」拉取可选列表)",
)
return
}
var expanded by remember { mutableStateOf(false) }
SegmentedItemContainer {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
TextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryEditable)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
label = { Text("模型") },
singleLine = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = transparentFieldColors(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
models.forEach { card ->
DropdownMenuItem(
text = {
Column {
Text(card.name)
if (card.description.isNotBlank()) {
Text(
text = card.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
onClick = {
onValueChange(card.name)
expanded = false
},
)
}
}
}
}
}
package me.ethanxu.jevnoisegate.core.data.settings
/**
* 全部用户设置。
*
* 刻意用**原始类型**(String / Int / Set)而不是 UI 层的枚举:`:core:data` 不该反向依赖
* `:app`。枚举的解析放在 UI 层,存储层只负责如实保存。
*/
data class AppPreferences(
/** [me.ethanxu.jevnoisegate.ui.UiMode] 的值,默认 Miuix。 */
val uiMode: String = DEFAULT_UI_MODE,
/** [me.ethanxu.jevnoisegate.ui.theme.ColorMode] 的序号,默认跟随系统。 */
val colorMode: Int = DEFAULT_COLOR_MODE,
/** 主题色 ARGB;0 表示跟随系统动态取色。 */
val keyColor: Int = DEFAULT_KEY_COLOR,
// --- JevAPI ---
val apiKey: String = "",
val baseUrl: String = DEFAULT_BASE_URL,
val model: String = DEFAULT_MODEL,
val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
// --- 采集过滤 ---
/**
* 被用户关闭监听的包名集合(黑名单)。
*
* 采用黑名单而非白名单模型:默认全监听,用户只关掉不想看的。
* 白名单会让"装完什么都不知道该开哪个",开箱不可用。
*/
val mutedPackages: Set<String> = emptySet(),
// --- 网络代理 ---
/**
* 代理类型,取值见本文件底部的 `PROXY_*` 常量。
*
* 有些网络环境无法直连 Base URL(企业内网、部分运营商链路),
* 因此代理是可用性问题而非可选项。
*/
val proxyType: String = DEFAULT_PROXY_TYPE,
/** 代理主机。留空表示不启用代理,即使 [proxyType] 不是 none。 */
val proxyHost: String = DEFAULT_PROXY_HOST,
/** 代理端口。0 表示未设置。 */
val proxyPort: Int = DEFAULT_PROXY_PORT,
/** 代理认证用户名。留空表示代理不需要认证。 */
val proxyUsername: String = "",
/**
* 代理认证密码。
*
* 与 API key 一样以明文存在应用私有 DataStore 中 —— 本应用没有引入密钥库
* (Keystore)加解密的复杂度。这个取舍写在设置页说明里,不隐瞒。
*/
val proxyPassword: String = "",
// --- 用户偏好规则 ---
/**
* 用户声明的"应当放行"偏好,每条一句话。
*
* 与 [mutedPackages] 的区别:后者是"完全不处理某个 App"(事件不入库),
* 这里则是"照常处理,但把偏好告诉模型"。因此两者语义不同,不能互相替代。
*/
val preferAllowRules: List<String> = emptyList(),
/** 用户声明的"应当拦截"偏好。 */
val preferBlockRules: List<String> = emptyList(),
// --- 短信号码黑名单 ---
/**
* 短信发件人号码黑名单(**硬规则**)。
*
* 与 [preferBlockRules] 的区别:后者是注入 state 交给模型权衡的软约束,
* 这个命中即拦截、根本不调用模型。用户填的是精确标识,没有需要权衡的余地。
*/
val smsSenderBlacklist: List<String> = emptyList(),
// --- 开发者模式 ---
/**
* 开发者模式。
*
* 开启后:诊断页展示「自测」工具;且**本应用自己发出的通知按普通通知处理**
* (正常情况下自家通知会被排除,以免自产数据污染统计)。
*
* 这两件事必须绑在同一个开关上:自测通知要能验证完整链路,就必须被当作普通通知;
* 而既然开了开发者模式,用户已经知道自己在看调试数据,统计被污染是可接受的代价。
*/
val developerMode: Boolean = false,
// --- 运行日志 ---
/**
* 最小记录等级;[DEFAULT_LOG_LEVEL]。取 `LogLevel` 的数值,
* [me.ethanxu.jevnoisegate.core.common.log.AppLog.OFF](100)表示完全关闭。
*
* 存原始 Int 而非枚举,理由同本文件顶部说明。
*/
val logLevel: Int = DEFAULT_LOG_LEVEL,
) {
/** key 是否已配置。用于界面上提示"未配置"而不是静默失败。 */
val isApiConfigured: Boolean get() = apiKey.isNotBlank()
/**
* 代理是否已配置齐全。
*
* 主机与端口**都**要有才算配置完成 —— 只填其一多半是还没写完,
* 这时应当直连而不是带着半配置去连代理然后超时。
*/
val isProxyConfigured: Boolean
get() = proxyType != PROXY_NONE && proxyHost.isNotBlank() && proxyPort in 1..65535
companion object {
const val DEFAULT_UI_MODE: String = "miuix"
const val DEFAULT_COLOR_MODE: Int = 0
const val DEFAULT_KEY_COLOR: Int = 0
const val DEFAULT_BASE_URL: String = "https://api.typesafe.ai"
const val DEFAULT_MODEL: String = "jev-latest"
const val DEFAULT_TIMEOUT_MS: Long = 10_000L
/** 默认 INFO:日志功能开箱可见,又不至于把 VERBOSE 的噪音全记下来。 */
const val DEFAULT_LOG_LEVEL: Int = 2
const val PROXY_NONE: String = "none"
const val PROXY_HTTP: String = "http"
const val PROXY_HTTPS: String = "https"
const val PROXY_SOCKS5: String = "socks5"
const val DEFAULT_PROXY_TYPE: String = PROXY_NONE
const val DEFAULT_PROXY_HOST: String = ""
const val DEFAULT_PROXY_PORT: Int = 0
/** 界面上可选的全部代理类型,顺序即显示顺序。 */
val PROXY_TYPES: List<String> = listOf(PROXY_NONE, PROXY_HTTP, PROXY_HTTPS, PROXY_SOCKS5)
}
}
package me.ethanxu.jevnoisegate.core.decision
import me.ethanxu.jevnoisegate.core.model.Action
import me.ethanxu.jevnoisegate.core.model.DecidedBy
import me.ethanxu.jevnoisegate.core.model.FailureReason
import me.ethanxu.jevnoisegate.core.model.FinalDecision
/**
* 把后端结果映射成最终动作。
*
* **这里是整个系统唯一允许存在"策略"的地方**,而且它完全由配置驱动(阈值来自
* [me.ethanxu.jevnoisegate.core.model.CategoryConfig.minConfidence]),代码里没有任何
* "什么算广告"这类判断。TypeSafe 官方文档也把这一步明确划给调用方:
* "Your code encodes the risk tolerance."
*
* 同时,**fail-open 在这里落地** —— 所有无法确信的路径一律降级为 [Action.ALLOW]。
* 集中在一处的好处是它不可能被别的调用路径绕过。
*/
object DecisionMapper {
fun map(
input: DecisionInput,
outcome: BackendOutcome,
latencyMillis: Long = 0L,
): FinalDecision = when (outcome) {
is BackendOutcome.Failed -> allow(input, DecidedBy.FALLBACK, latencyMillis, outcome.reason)
is BackendOutcome.Decided -> {
val config = input.categories.firstOrNull { it.name == outcome.category }
when {
// 模型返回了配置里不存在的标签。正常不该发生(答案空间由 criteria 定义),
// 一旦发生说明契约被破坏,此时唯一安全的做法是放行。
config == null -> allow(
input = input,
decidedBy = DecidedBy.MODEL,
latencyMillis = latencyMillis,
category = outcome.category,
confidence = outcome.confidence,
)
// 置信度不足,或根本不是有限数(NaN / Infinity)。
// NaN 必须显式拦下:NaN 参与的任何比较都是 false,若只写
// `confidence < threshold` 就会让 NaN 掉进"执行动作"分支,
// 用垃圾数据去拦截用户的消息 —— 这是 fail-open 的反面。
!outcome.confidence.isFinite() || outcome.confidence < config.minConfidence ->
allow(
input = input,
decidedBy = DecidedBy.MODEL,
latencyMillis = latencyMillis,
category = outcome.category,
confidence = outcome.confidence,
)
else -> FinalDecision(
event = input.event,
action = config.action,
decidedBy = DecidedBy.MODEL,
category = outcome.category,
confidence = outcome.confidence,
latencyMillis = latencyMillis,
)
}
}
}
private fun allow(
input: DecisionInput,
decidedBy: DecidedBy,
latencyMillis: Long,
reason: FailureReason? = null,
category: String? = null,
confidence: Float? = null,
): FinalDecision = FinalDecision(
event = input.event,
action = Action.ALLOW,
decidedBy = decidedBy,
category = category,
confidence = confidence,
failureReason = reason,
latencyMillis = latencyMillis,
)
}
package me.ethanxu.jevnoisegate.core.decision
import me.ethanxu.jevnoisegate.core.model.Action
import me.ethanxu.jevnoisegate.core.model.CategoryConfig
import me.ethanxu.jevnoisegate.sdk.typesafe.ChoiceAnswer
import me.ethanxu.jevnoisegate.sdk.typesafe.QuestionId
import me.ethanxu.jevnoisegate.sdk.typesafe.choice
/**
* 把用户的分类配置编译成 TypeSafe 问题。
*
* 这是**「配置即答案空间」的落地处**:分类名直接成为 `Choice.criteria` 的 key,
* 因此增加一个分类不需要改任何代码,只多一个选项。
*
* 之所以还能顺手承载"提示词工程":用户的 `keywords` 属于分类级知识
* ("优惠/促销"是广告,"转账/账户异常"是诈骗),把它们渲染进对应选项的 rubric
* 比放在全局 state 更精确 —— 模型看到的是"广告 = 推销信息,典型用词:优惠、促销"。
*/
object QuestionFactory {
/** 分类问题的 id。答案也按此 id 取回。 */
const val CATEGORY_QUESTION_ID: String = "category"
/** 分类问题的提问。 */
const val CATEGORY_INSTRUCTIONS: String =
"这条消息属于哪一类?若无法确定,选择最接近「用户预期内」的那一类。"
/**
* 构造分类问题。
*
* @throws IllegalArgumentException 配置非法(见 [validate])。
*/
fun categoryQuestion(categories: List<CategoryConfig>): QuestionId<ChoiceAnswer> {
validate(categories)
return QuestionId(
CATEGORY_QUESTION_ID,
choice(
instructions = CATEGORY_INSTRUCTIONS,
criteria = categories.associate { it.name to renderCriteria(it) },
),
)
}
/**
* 渲染单个分类的 rubric 描述。
*
* 包名 `internal` 以便诊断页原样展示"模型到底看到了什么",让配置调优可解释。
*/
internal fun renderCriteria(category: CategoryConfig): String =
if (category.keywords.isEmpty()) {
category.description
} else {
"${category.description}(典型用词:${category.keywords.joinToString("、")})"
}
/**
* 校验分类配置。
*
* 前三条是结构约束,最后一条是**安全约束**:
* 若没有任何 [Action.ALLOW] 分类,所有消息都会被当作噪音处置 ——
* 配置错一个字就会让用户的所有通知消失。强制保留一个"放行"类别是必要的护栏。
*
* @throws IllegalArgumentException 任一约束不满足。
*/
fun validate(categories: List<CategoryConfig>) {
require(categories.isNotEmpty()) {
"At least one category is required; the choice question would have an empty answer space."
}
categories.forEach { category ->
require(category.name.isNotBlank()) {
"Category names must not be blank."
}
}
val duplicates = categories.groupBy { it.name }.filterValues { it.size > 1 }.keys
require(duplicates.isEmpty()) {
"Category names must be unique, but found duplicates: ${duplicates.joinToString("、")}. " +
"Duplicated names would silently collapse in the choice criteria map."
}
require(categories.any { it.action == Action.ALLOW }) {
"At least one category must use Action.ALLOW as the catch-all. " +
"Without it every message could be suppressed, which is unrecoverable for the user."
}
}
}
package me.ethanxu.jevnoisegate.core.decision
import me.ethanxu.jevnoisegate.core.model.FailureReason
import me.ethanxu.jevnoisegate.sdk.typesafe.APIConnectionException
import me.ethanxu.jevnoisegate.sdk.typesafe.APITimeoutException
import me.ethanxu.jevnoisegate.sdk.typesafe.AuthenticationException
import me.ethanxu.jevnoisegate.sdk.typesafe.ChoiceAnswer
import me.ethanxu.jevnoisegate.sdk.typesafe.InternalServerException
import me.ethanxu.jevnoisegate.sdk.typesafe.PermissionDeniedException
import me.ethanxu.jevnoisegate.sdk.typesafe.RateLimitException
import me.ethanxu.jevnoisegate.sdk.typesafe.TypeSafeClient
import me.ethanxu.jevnoisegate.sdk.typesafe.TypeSafeException
import kotlinx.coroutines.CancellationException
/**
* 基于 TypeSafe Jev 的判断后端。
*
* 只负责**一次请求的往返与错误归类**,不做任何策略判断 ——
* 动作映射一律交给 [DecisionMapper]。
*/
class TypeSafeBackend(
private val client: TypeSafeClient,
) : DecisionBackend {
override suspend fun decide(input: DecisionInput): BackendOutcome {
val question = try {
QuestionFactory.categoryQuestion(input.categories)
} catch (e: IllegalArgumentException) {
// 配置非法,请求根本没发出去。这不是"模型判断失败"而是"我们没配好",
// 诊断页上需要区分这两种情况。
return BackendOutcome.Failed(FailureReason.NOT_CONFIGURED, e.message)
}
return try {
val result = client.systemOne(StateBuilder.build(input)) { ask(question) }
val answer: ChoiceAnswer = result.answer(question)
BackendOutcome.Decided(
category = answer.choice,
confidence = answer.confidence.toFloat(),
probabilities = answer.probabilities.mapValues { it.value.toFloat() },
)
} catch (e: CancellationException) {
// 协程取消是控制流信号,必须原样传播,否则破坏结构化并发。
throw e
} catch (e: APITimeoutException) {
// 注意顺序:APITimeoutException 继承自 APIConnectionException,
// 必须排在它前面,否则永远匹配不到。
BackendOutcome.Failed(FailureReason.TIMEOUT, e.message)
} catch (e: RateLimitException) {
BackendOutcome.Failed(FailureReason.RATE_LIMITED, e.message)
} catch (e: AuthenticationException) {
BackendOutcome.Failed(FailureReason.AUTH_FAILED, e.message)
} catch (e: PermissionDeniedException) {
BackendOutcome.Failed(FailureReason.AUTH_FAILED, e.message)
} catch (e: InternalServerException) {
BackendOutcome.Failed(FailureReason.SERVER_ERROR, e.message)
} catch (e: APIConnectionException) {
BackendOutcome.Failed(FailureReason.NETWORK, e.message)
} catch (e: TypeSafeException) {
BackendOutcome.Failed(FailureReason.INVALID_RESPONSE, e.message)
}
}
}
package me.ethanxu.jevnoisegate.core.decision
import me.ethanxu.jevnoisegate.core.model.Action
import me.ethanxu.jevnoisegate.core.model.CategoryConfig
import me.ethanxu.jevnoisegate.sdk.typesafe.ChoiceQuestion
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class QuestionFactoryTest {
// -----------------------------------------------------------------------
// 配置即答案空间
// -----------------------------------------------------------------------
@Test
fun `category names become the choice criteria keys`() {
// 这是「配置即答案空间」的核心断言:用户改配置就等于改答案空间,
// 不需要动任何代码。
val question = QuestionFactory.categoryQuestion(Fixtures.categories())
val criteria = (question.question as ChoiceQuestion).criteria
assertEquals(listOf("验证码", "广告", "正常"), criteria.keys.toList())
assertEquals(QuestionFactory.CATEGORY_QUESTION_ID, question.name)
}
@Test
fun `adding a category only adds an option`() {
val extra = Fixtures.categories() + CategoryConfig(
name = "账单",
description = "信用卡与账单提醒",
action = Action.ALLOW,
minConfidence = 0f,
)
val criteria = (QuestionFactory.categoryQuestion(extra).question as ChoiceQuestion).criteria
assertTrue("账单" in criteria)
assertEquals(4, criteria.size)
}
// -----------------------------------------------------------------------
// 提示词渲染
// -----------------------------------------------------------------------
@Test
fun `keywords are rendered into the option rubric`() {
val rendered = QuestionFactory.renderCriteria(Fixtures.categories()[1])
assertEquals("推销商品与活动的营销信息(典型用词:优惠、促销)", rendered)
}
@Test
fun `description alone is used when there are no keywords`() {
assertEquals("用户预期内的信息", QuestionFactory.renderCriteria(Fixtures.categories()[2]))
}
@Test
fun `rendered criteria are actually sent to the model`() {
val criteria = (QuestionFactory.categoryQuestion(Fixtures.categories()).question as ChoiceQuestion)
.criteria
val adCriteria = criteria.getValue("广告")
// criteria 的值是 JsonElement,最终会被序列化进请求体
assertTrue(adCriteria.toString().contains("优惠"))
}
// -----------------------------------------------------------------------
// 校验:结构约束
// -----------------------------------------------------------------------
@Test
fun `empty category list is rejected`() {
assertFailsWith<IllegalArgumentException> { QuestionFactory.categoryQuestion(emptyList()) }
}
@Test
fun `blank category name is rejected`() {
val bad = listOf(
CategoryConfig(" ", "描述", emptyList(), Action.ALLOW, 0f),
Fixtures.categories()[2],
)
assertFailsWith<IllegalArgumentException> { QuestionFactory.categoryQuestion(bad) }
}
@Test
fun `duplicate category names are rejected`() {
// 重名会在 criteria 这个 map 里静默塌缩,导致答案空间与配置不一致 ——
// 这种错必须早点炸出来。
val duplicated = Fixtures.categories() + CategoryConfig(
name = "广告",
description = "另一个广告定义",
action = Action.ALLOW,
minConfidence = 0f,
)
assertFailsWith<IllegalArgumentException> { QuestionFactory.categoryQuestion(duplicated) }
}
// -----------------------------------------------------------------------
// 校验:安全护栏
// -----------------------------------------------------------------------
@Test
fun `config without any ALLOW category is rejected`() {
// 没有放行类别意味着所有消息都可能被吃掉,且用户无从恢复。
// 这是一条硬护栏,不允许用户把自己配进这个状态。
val allSuppressing = listOf(
CategoryConfig("广告", "营销信息", emptyList(), Action.QUARANTINE, 0.9f),
CategoryConfig("诈骗", "诈骗信息", emptyList(), Action.SILENT_SUPPRESS, 0.7f),
)
assertFailsWith<IllegalArgumentException> { QuestionFactory.categoryQuestion(allSuppressing) }
}
@Test
fun `default categories satisfy every constraint`() {
// 出厂配置必须自身合法,否则应用首次启动就会崩。
val question = QuestionFactory.categoryQuestion(DefaultCategories.all())
assertEquals(4, (question.question as ChoiceQuestion).criteria.size)
}
@Test
fun `default config keeps the otp category at zero threshold`() {
val otp = DefaultCategories.all().first { it.name == "验证码" }
assertEquals(Action.ALLOW, otp.action)
assertEquals(0f, otp.minConfidence)
}
}
package me.ethanxu.jevnoisegate.core.model
/**
* 对一条事件采取的动作,按侵入性从低到高排列。
*
* 注意:这里刻意**没有**"删除"。Android 上第三方 App 无法从短信收件箱删除消息,
* 也无法阻止通知的声音/震动(回调发生在投递之后)。因此动作层只提供"不打扰"能力,
* 真正的降噪手段是渠道级引导(见 :feature:notification)。
*/
enum class Action {
/** 放行,不做任何处理。默认动作,也是所有失败路径的兜底动作。 */
ALLOW,
/** 静默移除:cancelNotification,不留痕迹。 */
SILENT_SUPPRESS,
/**
* 隔离:静默移除并写入本地回收站,用户可在 App 内查看和捞回。
* 捞回会让该指纹升级为 [DecidedBy.USER] 决策。
*/
QUARANTINE,
}
/**
* 决策的来源,同时决定其权重。
*
* 权重顺序(高→低):[USER] > [OTP_GATE] > [MODEL] > [CACHE] > [FALLBACK]。
* [USER] 永不被模型覆盖。
*/
enum class DecidedBy {
/** 用户手动纠正(含从回收站捞回)。最高权重,永不被覆盖。 */
USER,
/** 本地 OTP 保险丝命中,直接放行且不曾上送 API。 */
OTP_GATE,
/** 用户配置的号码黑名单命中,直接拦截且不曾上送 API。 */
BLACKLIST,
/** 模型判断结果。 */
MODEL,
/** 指纹缓存复用,未产生网络请求。 */
CACHE,
/** 失败降级(超时/断网/5xx)。一律 fail-open 放行。 */
FALLBACK,
}
/**
* 决策降级放行的原因。
*
* 存在的理由是**诊断**:`DecidedBy.FALLBACK` 只说明"我们放行了",不说明"因为什么放行"。
* 而"最近 30% 的请求都超时"和"最近 30% 的请求都被限流"需要完全不同的处理,
* 诊断页必须能区分。
*/
enum class FailureReason {
/** 单次尝试超时。 */
TIMEOUT,
/** 连接层失败(DNS、TLS、连接中断)。 */
NETWORK,
/** 触发限流(HTTP 429)。 */
RATE_LIMITED,
/**
* 鉴权失败(HTTP 401 / 403)。
*
* 与 [INVALID_RESPONSE] 分开是因为处理方式完全不同:鉴权失败意味着 API key 没配、
* 配错了或被吊销 —— 那是**必须去修配置**,而不是重试或调参。
*/
AUTH_FAILED,
/** 服务端错误(HTTP 5xx,含 529 过载)。 */
SERVER_ERROR,
/** 响应无法解析,或问题的答案缺失/类型不符。 */
INVALID_RESPONSE,
/** 分类配置缺失或非法(如重名),请求根本没发出去。 */
NOT_CONFIGURED,
/** 用户关闭了模型判断。 */
DISABLED,
}
/**
* 渠道历史统计,作为 state 的一部分喂给模型。
*
* 让模型知道"这个渠道历史上几乎全是广告",比单看一条文本的准确率高得多 ——
* 这是纯文本分类做不到的上下文。
*/
data class ChannelContext(
val packageName: String,
val channelId: String?,
val totalSeen: Int = 0,
val suppressedCount: Int = 0,
val restoredCount: Int = 0,
) {
/** 被判定为噪音的比例;样本不足时为 0。 */
val noiseRatio: Float
get() = if (totalSeen <= 0) 0f else suppressedCount.toFloat() / totalSeen
}
/**
* 用户的分类配置 —— 声明式知识的唯一载体。
*
* [name] 会**直接成为 TypeSafe `Choice.criteria` 的 key**,即"配置即答案空间":
* 增加一个分类不需要改任何代码,只是多一个 Choice 选项。
*/
data class CategoryConfig(
val name: String,
/** 进入 `criteria` 的选项描述,是模型判断依据的主要来源。 */
val description: String,
/**
* 辅助提示词。由 `QuestionFactory` 渲染进该分类的 `criteria` 描述里 ——
* 关键词天然是**分类级**的("优惠/促销"属于广告,"转账/账户异常"属于诈骗),
* 因此放进对应选项的 rubric 比放在全局 state 更准确。
*/
val keywords: List<String> = emptyList(),
/** 判定为该分类时执行的动作。 */
val action: Action,
/**
* 低于此置信度则不执行 [action],退回 ALLOW。
*
* 这是代码里唯一的"策略",且完全由配置驱动。验证码类默认 0f,即永不拦截 ——
* 因为漏放一条广告无感,错拦一条验证码会让用户被锁在银行外面。
*/
val minConfidence: Float,
)
/**
* 事件经流水线后的最终结果。
*/
data class FinalDecision(
val event: MessageEvent,
val action: Action,
val decidedBy: DecidedBy,
/** 命中的分类名;未命中模型或降级时为 null。 */
val category: String? = null,
/** 模型给出的置信度;非模型决策时为 null。 */
val confidence: Float? = null,
/** 仅当 [decidedBy] 为 [DecidedBy.FALLBACK] 时非空,说明降级原因。 */
val failureReason: FailureReason? = null,
/** 端到端耗时,用于诊断页统计 P50/P95 —— 这是"能否跑赢震动"这一首要风险的直接证据。 */
val latencyMillis: Long = 0L,
)
[versions]
agp = "9.4.0"
kotlin = "2.4.20"
ksp = "2.3.12"
composeBom = "2026.09.00"
activityCompose = "1.13.0"
coreKtx = "1.19.0"
lifecycle = "2.11.0"
navigation3 = "1.1.7"
materialKolor = "5.0.1"
room = "2.8.5"
datastore = "1.2.1"
work = "2.11.2"
hilt = "2.60.1"
ktor = "3.6.0"
coroutines = "1.11.0"
serialization = "1.11.0"
junit = "4.13.2"
androidxTestJunit = "1.3.0"
[libraries]
# --- Compose ---
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# --- 导航 ---
# 注意:NavDisplay 在独立的 navigation3-ui artifact 里,不在 navigation3-runtime。
# KernelSU 没有显式声明它,是经 miuix-navigation3-ui 传递进来的;我们显式声明避免隐式依赖。
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
# --- 主题(Material 动态取色)---
# 它是 Compose Multiplatform 库,显式排除了 androidx.compose.material3 ——
# 要求由使用方提供 material3,我们通过上面的覆盖提供 1.5.0-alpha28。
material-kolor = { group = "com.materialkolor", name = "material-kolor", version.ref = "materialKolor" }
# --- Room ---
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# --- DataStore / Work ---
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version = "1.4.0" }
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version = "1.4.0" }
# --- Hilt ---
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# --- Ktor (TypeSafe SDK HTTP 层) ---
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
# --- Coroutines / Serialization ---
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# --- Test ---
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" }
[plugins]
# 注意:KGP 自带的插件(multiplatform / jvm / compose / serialization)由根
# build.gradle.kts 的 buildscript classpath 提供,因此**不要在版本目录里声明它们**,
# 在模块里直接用不带版本号的 id("...") 应用即可。声明版本会导致
# "already on the classpath with an unknown version" 解析失败。
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
sdk/typesafe/src/androidMain/kotlin/me/ethanxu/jevnoisegate/sdk/typesafe/HttpClientFactory.android.kt
package me.ethanxu.jevnoisegate.sdk.typesafe
import io.ktor.client.HttpClient
import io.ktor.client.engine.ProxyBuilder
import io.ktor.client.engine.http
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import okhttp3.Credentials
internal actual fun defaultHttpClient(timeoutMs: Long, proxy: ProxySpec?): HttpClient =
HttpClient(OkHttp) {
expectSuccess = false
install(HttpTimeout) {
requestTimeoutMillis = timeoutMs
connectTimeoutMillis = timeoutMs
socketTimeoutMillis = timeoutMs
}
if (proxy != null) {
engine {
// Ktor 3.x 用 ProxyBuilder 工厂而非链式 Builder:
// HTTP 走 Url,SOCKS 走 host/port。
//
// HTTP 代理对 HTTPS 目标会自动改用 CONNECT 隧道,因此
// "HTTP 代理"与"HTTPS 代理"是同一份配置(见 ProxySpec 注释)。
this.proxy = when (proxy.kind) {
ProxyKind.HTTP -> ProxyBuilder.http("http://${proxy.host}:${proxy.port}")
ProxyKind.SOCKS -> ProxyBuilder.socks(proxy.host, proxy.port)
}
if (proxy.hasCredentials) {
config {
// Ktor 的 ProxyConfig 不带凭据(在 JVM 上它就是 java.net.Proxy),
// 所以认证只能下沉到 OkHttp:代理返回 407 时补上 Proxy-Authorization 重试。
proxyAuthenticator { _, response ->
// 已经有该头说明重试过一轮仍被拒 —— 再重试就是死循环。
if (response.request.header("Proxy-Authorization") != null) {
null
} else {
response.request.newBuilder()
.header(
"Proxy-Authorization",
Credentials.basic(proxy.username, proxy.password),
)
.build()
}
}
}
}
}
}
}
package me.ethanxu.jevnoisegate.sdk.typesafe
/**
* TypeSafe 返回的类型化答案。
*
* 对应上游 `types.ts` 的 `NoulResponse` / `ChoiceResponse` / `ScoreResponse`。
* 之所以用 sealed 层级,是因为这三型的字段集合互不相同,且调用方需要穷尽分支。
*/
sealed interface Answer {
/** 与产生它的 question 的 `type` 一致。 */
val type: String
}
/**
* noul(是/否)答案:`noul` 为 0–1 的概率值。
*
* 注意:上游明确说明 noul 答案**不带 confidence**,因为它本身就是概率。
*/
data class NoulAnswer(
val noul: Double,
) : Answer {
override val type: String get() = TYPE
companion object {
const val TYPE = "noul"
}
}
/** choice 答案:被选中的选项、各选项的概率分布、以及由此推导的 confidence。 */
data class ChoiceAnswer(
val choice: String,
val confidence: Double,
val probabilities: Map<String, Double>,
) : Answer {
override val type: String get() = TYPE
companion object {
const val TYPE = "choice"
}
}
/**
* score 答案:概率加权得分(可落在整数档位之间)、档位回照表、以及概率分布。
*/
data class ScoreAnswer(
val score: Double,
val confidence: Double,
val legend: Map<String, String>,
val probabilities: Map<String, Double>,
) : Answer {
override val type: String get() = TYPE
companion object {
const val TYPE = "score"
}
}
package me.ethanxu.jevnoisegate.sdk.typesafe
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** 解析错误的上下文,把散落在各处的响应元信息收拢成一个值对象。 */
data class APIErrorResponse(
val status: Int,
val body: JsonElement?,
val rawBody: String?,
/** 取自 `x-typesafe-request-id`,缺失时为 null。 */
val requestId: String?,
/** 响应 header(键统一小写)。用于解析 `Retry-After`。 */
val headers: Map<String, String> = emptyMap(),
)
/** SDK 所有异常的基类。对应上游 `TypeSafeError`。 */
open class TypeSafeException(
message: String,
cause: Throwable? = null,
) : Exception(message, cause)
/**
* 服务端返回了非 2xx 响应。对应上游 `APIError`。
*
* 子类严格对齐上游的状态码分派:400 / 401 / 403 / 404 / 422 / 429 / 5xx。
*/
open class APIException(
val response: APIErrorResponse,
message: String,
) : TypeSafeException(message) {
val status: Int get() = response.status
val body: JsonElement? get() = response.body
val requestId: String? get() = response.requestId
companion object {
/** 上限与上游 `MAX_RAW_BODY_IN_MESSAGE` 一致。 */
private const val MAX_RAW_BODY_IN_MESSAGE = 200
/** 按状态码构造对应的异常子类。 */
fun fromResponse(response: APIErrorResponse): APIException {
val message = describe(response)
return when (response.status) {
400 -> BadRequestException(response, message)
401 -> AuthenticationException(response, message)
403 -> PermissionDeniedException(response, message)
404 -> NotFoundException(response, message)
422 -> UnprocessableEntityException(response, message)
429 -> RateLimitException(
response = response,
message = message,
retryAfterMs = parseRetryAfter(response.headers, System.currentTimeMillis()),
)
in 500..599 -> InternalServerException(response, message)
else -> APIException(response, message)
}
}
/** 从响应体里尽量抠出可读信息,顺序与上游 `extractMessage` 一致。 */
private fun describe(response: APIErrorResponse): String {
val detail = extractMessage(response.body)
if (detail != null) return "${response.status} $detail"
val raw = response.rawBody
if (raw.isNullOrEmpty()) return "${response.status} status code (no body)"
val shown = if (raw.length > MAX_RAW_BODY_IN_MESSAGE) {
raw.take(MAX_RAW_BODY_IN_MESSAGE) + "…"
} else {
raw
}
return "${response.status} $shown"
}
private fun extractMessage(body: JsonElement?): String? {
val primitive = (body as? JsonPrimitive)?.takeIf { it.isString }
if (primitive != null) {
val text = primitive.content
return text.ifEmpty { null }
}
val obj = body as? JsonObject ?: return null
(obj["error"] as? JsonPrimitive)?.takeIf { it.isString }?.let { return it.content }
(obj["error"] as? JsonObject)?.get("message")
?.let { it as? JsonPrimitive }?.takeIf { it.isString }?.let { return it.content }
(obj["message"] as? JsonPrimitive)?.takeIf { it.isString }?.let { return it.content }
when (val detail = obj["detail"]) {
is JsonPrimitive -> if (detail.isString) return detail.content
is JsonObject -> (detail["message"] as? JsonPrimitive)
?.takeIf { it.isString }?.let { return it.content }
is JsonArray -> return describeValidationErrors(detail)
else -> Unit
}
return null
}
/** 把 FastAPI 风格的校验错误格式化成 `path: message; path: message`。 */
private fun describeValidationErrors(errors: JsonArray): String? {
val parts = errors.mapNotNull { entry ->
val obj = entry.jsonObjectOrNull() ?: return@mapNotNull null
val msg = obj["msg"]?.jsonPrimitive?.takeIf { it.isString }?.content
?: return@mapNotNull null
val loc = (obj["loc"] as? JsonArray)
?.mapNotNull { it.jsonPrimitive.takeIf { p -> p.isString }?.content }
?.filter { it != "body" }
?.joinToString(".")
.orEmpty()
if (loc.isEmpty()) msg else "$loc: $msg"
}
return parts.takeIf { it.isNotEmpty() }?.joinToString("; ")
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
}
}
/** HTTP 400:请求不合法。 */
class BadRequestException(response: APIErrorResponse, message: String) :
APIException(response, message)
/** HTTP 401:认证失败。 */
class AuthenticationException(response: APIErrorResponse, message: String) :
APIException(response, message)
/** HTTP 403:无权访问。 */
class PermissionDeniedException(response: APIErrorResponse, message: String) :
APIException(response, message)
/** HTTP 404:资源不存在。 */
class NotFoundException(response: APIErrorResponse, message: String) :
APIException(response, message)
/** HTTP 422:请求体校验失败。 */
class UnprocessableEntityException(response: APIErrorResponse, message: String) :
APIException(response, message)
/** HTTP 429:超出限流。 */
class RateLimitException(
response: APIErrorResponse,
message: String,
/** 服务端给出的重试延迟;缺失或非法时为 null。 */
val retryAfterMs: Long?,
) : APIException(response, message)
/** HTTP 5xx:服务端处理失败。 */
class InternalServerException(response: APIErrorResponse, message: String) :
APIException(response, message)
/**
* 请求或响应体传输失败(DNS、TLS、连接中断等)。对应上游 `APIConnectionError`。
*/
open class APIConnectionException(
message: String,
cause: Throwable? = null,
) : TypeSafeException(message, cause)
/** 完整响应未在超时时间内到达。对应上游 `APITimeoutError`。 */
class APITimeoutException(
/** 配置的超时毫秒数。 */
val timeoutMs: Long,
cause: Throwable? = null,
) : APIConnectionException("Request timed out after ${timeoutMs}ms.", cause)
/*
* 与上游的一处**有意分歧**:上游有 `APIUserAbortError`,表示调用方通过 AbortSignal 取消。
*
* Kotlin 协程里取消是控制流信号而非错误 —— `CancellationException` 必须原样向上传播,
* 否则会破坏结构化并发(父作用域无法正确判定子任务已取消)。因此本 SDK 不把它包装成
* 业务异常,调用方按协程惯例捕获 `kotlinx.coroutines.CancellationException` 即可。
*/
package me.ethanxu.jevnoisegate.sdk.typesafe
import io.ktor.client.HttpClient
/**
* 构造默认的 [HttpClient]。
*
* 声明为 `expect` 是因为 HTTP 引擎是平台相关的:Android/JVM 用 OkHttp 引擎,
* 而 `commonMain` 里不能引用它。测试通过构造函数注入 Ktor `MockEngine`,
* 因此不会走到这里。
*
* 实现必须设置 `expectSuccess = false` —— 让 4xx/5xx 以正常响应的形式返回,
* 由 [TypeSafeClient] 自行分派异常类型,才能还原上游的状态码 → 异常映射。
*
* [proxy] 为 null 时直连。
*/
internal expect fun defaultHttpClient(timeoutMs: Long, proxy: ProxySpec?): HttpClient
package me.ethanxu.jevnoisegate.sdk.typesafe
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
/*
* 响应解析的安全取值工具。
*
* 上游是 TypeScript,反序列化后的对象字段访问没有运行期校验(字段缺失会得到 undefined 并静默传播)。
* Kotlin 这边选择在解析点就把结构问题暴露成 TypeSafeException —— 因为本项目的调用方是
* 自动化流水线,静默的 undefined 会变成"分类结果为空"这种更难排查的故障。
*/
internal fun JsonObject.requireDouble(key: String): Double {
val primitive = this[key] as? JsonPrimitive
?: throw TypeSafeException("Response field \"$key\" is missing or not a number.")
return primitive.doubleOrNull
?: throw TypeSafeException("Response field \"$key\" is not a number: ${primitive.content}")
}
internal fun JsonObject.requireString(key: String): String {
val primitive = this[key] as? JsonPrimitive
?: throw TypeSafeException("Response field \"$key\" is missing or not a string.")
if (!primitive.isString) {
throw TypeSafeException("Response field \"$key\" is not a string: ${primitive.content}")
}
return primitive.content
}
internal fun JsonObject.requireDoubleMap(key: String): Map<String, Double> {
val obj = this[key] as? JsonObject
?: throw TypeSafeException("Response field \"$key\" is missing or not an object.")
return obj.mapValues { (entryKey, value) ->
(value as? JsonPrimitive)?.doubleOrNull
?: throw TypeSafeException("Response field \"$key.$entryKey\" is not a number.")
}
}
internal fun JsonObject.requireStringMap(key: String): Map<String, String> {
val obj = this[key] as? JsonObject
?: throw TypeSafeException("Response field \"$key\" is missing or not an object.")
return obj.mapValues { (entryKey, value) ->
(value as? JsonPrimitive)?.takeIf { it.isString }?.content
?: throw TypeSafeException("Response field \"$key.$entryKey\" is not a string.")
}
}
/** 宽松取值:字段缺失或类型不符都返回 null。用于可选字段。 */
internal fun JsonObject.stringOrNull(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
internal fun JsonObject.intOrNull(key: String): Int? =
(this[key] as? JsonPrimitive)?.doubleOrNull?.toInt()
internal fun JsonObject.objectOrNull(key: String): JsonObject? = this[key] as? JsonObject
/** 把任意 JSON 值转成可读文本,用于把 state 塞进日志。 */
internal fun JsonElement.describeForLog(maxLength: Int = 200): String {
val text = toString()
return if (text.length > maxLength) text.take(maxLength) + "…" else text
}
package me.ethanxu.jevnoisegate.sdk.typesafe
/** 日志级别,由详细到简略。对应上游 `LOG_LEVELS`。 */
enum class LogLevel {
DEBUG, INFO, WARN, ERROR, OFF;
}
/** 默认日志级别。对应上游 `DEFAULT_LOG_LEVEL`。 */
val DEFAULT_LOG_LEVEL: LogLevel = LogLevel.WARN
/**
* 日志接收器。
*
* 上游直接依赖 `console`;Android 侧应适配到 `android.util.Log`,
* 因此这里只保留接口,由宿主注入实现。
*/
interface TypeSafeLogger {
fun debug(message: String)
fun info(message: String)
fun warn(message: String, throwable: Throwable? = null)
fun error(message: String, throwable: Throwable? = null)
}
/** 不做任何事的日志器。SDK 的默认值 —— 静默优于意外打到 logcat。 */
object NoOpLogger : TypeSafeLogger {
override fun debug(message: String) = Unit
override fun info(message: String) = Unit
override fun warn(message: String, throwable: Throwable?) = Unit
override fun error(message: String, throwable: Throwable?) = Unit
}
/** 把日志器按级别过滤,低于 [level] 的调用变成空操作。对应上游 `withLevel`。 */
internal fun TypeSafeLogger.atLevel(level: LogLevel): TypeSafeLogger {
if (level == LogLevel.OFF) return NoOpLogger
val rank = level.ordinal
return object : TypeSafeLogger {
override fun debug(message: String) {
if (LogLevel.DEBUG.ordinal >= rank) this@atLevel.debug(message)
}
override fun info(message: String) {
if (LogLevel.INFO.ordinal >= rank) this@atLevel.info(message)
}
override fun warn(message: String, throwable: Throwable?) {
if (LogLevel.WARN.ordinal >= rank) this@atLevel.warn(message, throwable)
}
override fun error(message: String, throwable: Throwable?) {
if (LogLevel.ERROR.ordinal >= rank) this@atLevel.error(message, throwable)
}
}
}
/** 保留 key 后缀以辨认身份的凭证类 header。 */
private val KEY_HEADERS: Set<String> = setOf("authorization", "proxy-authorization", "x-api-key")
/** 值整体打码的 header。 */
private val OPAQUE_HEADERS: Set<String> = setOf("cookie", "set-cookie")
/**
* 打码一个凭证值:保留 scheme,并在密钥长度大于 8 时保留末四位。
* 对应上游 `redactKey`。
*/
private fun redactKey(value: String): String {
val parts = value.split(Regex("\\s+"), limit = 2)
val scheme = if (parts.size == 2) parts[0] else null
val secret = if (parts.size == 2) parts[1] else parts[0]
val tail = if (secret.length > 8) secret.takeLast(4) else ""
return (scheme?.let { "$it " } ?: "") + "***" + tail
}
/** 复制一份 header,把已知凭证打码。对应上游 `redactHeaders`。 */
internal fun redactHeaders(headers: Map<String, String>): Map<String, String> =
headers.mapValues { (name, value) ->
when (name.lowercase()) {
in KEY_HEADERS -> redactKey(value)
in OPAQUE_HEADERS -> "***"
else -> value
}
}
package me.ethanxu.jevnoisegate.sdk.typesafe
/** 账号可用模型列表。对应上游 `client.models`。 */
class ModelsResource internal constructor(
private val fetch: suspend () -> List<ModelCard>,
) {
/** 列出账号可用的模型。对应上游 `Models.list()`。 */
suspend fun list(): List<ModelCard> = fetch()
}
package me.ethanxu.jevnoisegate.sdk.typesafe
/**
* 代理类型。
*
* 只有两种底层实现,这与用户在设置里看到的三个选项不是一回事:
*
* - [HTTP]:HTTP 代理。**HTTPS 目标也走它** —— 通过 `CONNECT` 建立隧道,
* 这是 HTTP 代理的标准行为,不存在独立的"HTTPS 代理"协议。
* - [SOCKS]:SOCKS5 代理。
*
* 界面上把 HTTP 与 HTTPS 分开列出只是为了对齐常见叫法,
* 两者映射到同一个实现,详见 [ProxySpec.fromSettings]。
*/
enum class ProxyKind {
HTTP,
SOCKS,
}
/** 一组可用的代理配置。为 null 表示直连。 */
data class ProxySpec(
val kind: ProxyKind,
val host: String,
val port: Int,
/** 代理认证用户名;为空表示代理不需要认证。 */
val username: String = "",
val password: String = "",
) {
val hasCredentials: Boolean get() = username.isNotBlank()
companion object {
/**
* 从设置值构造。任何一项不合法都返回 null(直连)。
*
* **宁可直连也不要半配置的代理**:主机填了端口没填、或端口越界时,
* 带着这种配置去连只会一路超时,报错还看不出原因。
* 直连至少能让用户在诊断页看到真实的上游错误。
*/
fun fromSettings(
type: String,
host: String,
port: Int,
username: String = "",
password: String = "",
): ProxySpec? {
if (type == PROXY_NONE || host.isBlank() || port !in 1..65535) return null
val kind = when (type) {
PROXY_HTTP, PROXY_HTTPS -> ProxyKind.HTTP
PROXY_SOCKS5 -> ProxyKind.SOCKS
else -> return null
}
return ProxySpec(kind, host.trim(), port, username.trim(), password)
}
}
}
// 与 :core:data 的 AppPreferences 常量保持一致。
// SDK 不依赖 core:data(它是可独立发布的产物),因此这里重复声明字面量,
// 由下方 fromSettings 的 when 兜底未知取值 —— 拼错只会退化为直连,不会崩。
private const val PROXY_NONE = "none"
private const val PROXY_HTTP = "http"
private const val PROXY_HTTPS = "https"
private const val PROXY_SOCKS5 = "socks5"
package me.ethanxu.jevnoisegate.sdk.typesafe
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
/**
* 一个类型化问题。
*
* 对应上游 `types.ts` 的 `NoulQuestion | ScoreQuestion | ChoiceQuestion`。
* 与上游的关键差异:[Question] 只挑了一个方向的类型参数 —— 泛型参数 `A` 表示**答案**类型,
* 由各个子类固定下来。这样 [SystemOneResult.answer] 可以在编译期保证取到正确类型,
* 而上游只能靠 TS 的 `ResultFor<T>` 条件类型在类型层面推导、运行期仍然是 `Record<string, Answer>`。
*/
sealed class Question<A : Answer> {
/** 与返回答案的 `type` 字段一致。 */
abstract val type: String
/** 问题本身,可以是文本、JSON 对象或数组。 */
abstract val instructions: EntryType
internal abstract fun toJson(): JsonObject
internal abstract fun parseAnswer(element: JsonObject): A
}
/** noul 问题的正反两面描述。对应上游 `NoulQuestion["criteria"]`。 */
data class NoulCriteria(
/** 回答为"是"(接近 1)的含义。 */
val trueDescription: EntryType = null,
/** 回答为"否"(接近 0)的含义。 */
val falseDescription: EntryType = null,
)
/**
* 是非题。答案是 [NoulAnswer]。
*
* 上游明确说明:noul 答案**不携带 confidence**,因为返回值本身就是概率。
*/
class NoulQuestion(
override val instructions: EntryType = null,
val criteria: NoulCriteria? = null,
) : Question<NoulAnswer>() {
override val type: String get() = NoulAnswer.TYPE
override fun toJson(): JsonObject = buildJsonObject {
put("type", JsonPrimitive(type))
put("instructions", instructions ?: JsonNull)
criteria?.let { c ->
put(
"criteria",
buildJsonObject {
put("true", c.trueDescription ?: JsonNull)
put("false", c.falseDescription ?: JsonNull)
},
)
}
}
override fun parseAnswer(element: JsonObject): NoulAnswer =
NoulAnswer(noul = element.requireDouble("noul"))
}
/**
* 从一组具名选项中择一。答案是 [ChoiceAnswer]。
*
* `criteria` 的 key 就是答案空间 —— 本项目的核心做法是让用户配置的分类直接成为这里的 key。
*/
class ChoiceQuestion(
override val instructions: EntryType,
val criteria: Map<String, EntryType>,
) : Question<ChoiceAnswer>() {
override val type: String get() = ChoiceAnswer.TYPE
override fun toJson(): JsonObject = buildJsonObject {
put("type", JsonPrimitive(type))
put("instructions", instructions ?: JsonNull)
put(
"criteria",
buildJsonObject {
criteria.forEach { (label, description) ->
put(label, description ?: JsonNull)
}
},
)
}
override fun parseAnswer(element: JsonObject): ChoiceAnswer = ChoiceAnswer(
choice = element.requireString("choice"),
confidence = element.requireDouble("confidence"),
probabilities = element.requireDoubleMap("probabilities"),
)
}
/**
* 按有序量规打分。答案是 [ScoreAnswer]。
*
* `criteria` 是有序档位描述,**至少两项**(由 [validateQuestions] 强制)。
*/
class ScoreQuestion(
override val instructions: EntryType,
val criteria: List<EntryType>,
) : Question<ScoreAnswer>() {
override val type: String get() = ScoreAnswer.TYPE
override fun toJson(): JsonObject = buildJsonObject {
put("type", JsonPrimitive(type))
put("instructions", instructions ?: JsonNull)
put("criteria", JsonArray(criteria.map { it ?: JsonNull }))
}
override fun parseAnswer(element: JsonObject): ScoreAnswer = ScoreAnswer(
score = element.requireDouble("score"),
confidence = element.requireDouble("confidence"),
legend = element.requireStringMap("legend"),
probabilities = element.requireDoubleMap("probabilities"),
)
}
/**
* 一个具名问题的句柄,同时绑定答案类型。
*
* 用法:
* ```
* val category = QuestionId("category", ChoiceQuestion("属于哪一类", mapOf("广告" to "…", "正常" to "…")))
* val result = client.systemOne(state) { ask(category) }
* val answer: ChoiceAnswer = result.answer(category) // 编译期即确定类型
* ```
*/
class QuestionId<A : Answer>(
val name: String,
val question: Question<A>,
)
// ---------------------------------------------------------------------------
// 构造器(对应上游 questions.ts 的 noul / choice / score)
// ---------------------------------------------------------------------------
/*
* 上游对 `criteria` 传错容器类型(choice 传数组、score 传 map)会抛运行期错误;
* Kotlin 里由参数类型在编译期保证,因此不需要那两条校验。
*
* 关于下面的**重载**:上游的 `EntryType = string | object | array | null`,
* 在 Kotlin 里落成 `JsonElement?`,而 `JsonElement` 不接受裸 `String`。
* 由于 instructions 与 criteria 描述在真实使用中几乎总是纯文本(本项目里 100% 是),
* 这里为最常见场景补一组 `String` 重载,同时保留 `JsonElement` 版本以表达结构化内容。
* 两组重载不会歧义:`String` 不是 `JsonElement?` 的子类型。
*/
/** 构造是非题,instructions 为纯文本。 */
fun noul(
instructions: String,
criteria: NoulCriteria? = null,
): NoulQuestion = NoulQuestion(JsonPrimitive(instructions), criteria)
/** 构造是非题。`instructions` 可以是 JSON 对象或数组。 */
fun noul(
instructions: EntryType = null,
criteria: NoulCriteria? = null,
): NoulQuestion = NoulQuestion(instructions, criteria)
/** 构造纯文本描述的 noul 正反面 criteria。 */
fun noulCriteria(
trueDescription: String? = null,
falseDescription: String? = null,
): NoulCriteria = NoulCriteria(
trueDescription = trueDescription?.let(::JsonPrimitive),
falseDescription = falseDescription?.let(::JsonPrimitive),
)
/** 构造选择题,instructions 与选项描述均为纯文本。描述传 `null` 表示不给额外说明。 */
fun choice(
instructions: String,
criteria: Map<String, String?>,
): ChoiceQuestion = ChoiceQuestion(
instructions = JsonPrimitive(instructions),
criteria = criteria.mapValues { (_, description) -> description?.let(::JsonPrimitive) },
)
/** 构造选择题。`instructions` 可以是 JSON 对象或数组,选项描述同理。 */
fun choice(
instructions: EntryType,
criteria: Map<String, EntryType>,
): ChoiceQuestion = ChoiceQuestion(instructions, criteria)
/** 构造打分题,instructions 与各档位描述均为纯文本。 */
fun score(
instructions: String,
criteria: List<String?>,
): ScoreQuestion = ScoreQuestion(
instructions = JsonPrimitive(instructions),
criteria = criteria.map { description -> description?.let(::JsonPrimitive) },
)
/** 构造打分题。`instructions` 可以是 JSON 对象或数组,档位描述同理。 */
fun score(
instructions: EntryType,
criteria: List<EntryType>,
): ScoreQuestion = ScoreQuestion(instructions, criteria)
/**
* 渐构建构器,供 [TypeSafeClient.systemOne] 的 lambda 形式使用。
*/
class SystemOneRequestBuilder internal constructor(private val state: EntryType) {
private val questions = LinkedHashMap<String, Question<*>>()
/** 覆盖本次调用的模型;不设置则使用客户端的 `defaultModel`。 */
var model: String? = null
/** 登记一个问题,答案稍后通过同一个 [QuestionId] 取回。 */
fun <A : Answer> ask(id: QuestionId<A>): SystemOneRequestBuilder = apply {
questions[id.name] = id.question
}
/** 登记一个问题,答案稍后按 [name] 取回。 */
fun <A : Answer> ask(name: String, question: Question<A>): SystemOneRequestBuilder = apply {
questions[name] = question
}
internal fun build(): SystemOneRequest =
SystemOneRequest(state = state, model = model, questions = questions.toMap())
}
/**
* 校验问题集合。对应上游 `validateQuestions`。
*
* 只保留 Kotlin 类型系统无法表达的两条约束:非空、以及 score 至少两项。
*
* @throws TypeSafeException 问题集合为空,或有 score 问题的档位少于两项。
*/
internal fun validateQuestions(questions: Map<String, Question<*>>) {
if (questions.isEmpty()) {
throw TypeSafeException("At least one question is required.")
}
questions.forEach { (name, question) ->
if (question is ScoreQuestion && question.criteria.size < 2) {
throw TypeSafeException(
"Score question \"$name\" has ${question.criteria.size} criteria; " +
"at least two scores are required.",
)
}
}
}
package me.ethanxu.jevnoisegate.sdk.typesafe
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToLong
/** 单次尝试的默认超时(毫秒)。对应上游 `DEFAULT_TIMEOUT_MS`。 */
const val DEFAULT_TIMEOUT_MS: Long = 10_000
/** 默认重试状态码:408、429,以及全部 5xx。对应上游默认值。 */
val DEFAULT_RETRYABLE_STATUSES: Set<Int> = buildSet {
add(408)
add(429)
addAll(500..599)
}
/**
* 重试策略。字段与默认值**逐项对齐**上游 `DEFAULT_RETRY_POLICY`。
*
* @throws TypeSafeException 任一字段取值非法。
*/
data class RetryPolicy(
/** 首次尝试之外的最大重试次数;0 表示不重试。 */
val maxRetries: Int = 2,
/** 首次退避延迟(毫秒),此后翻倍直至 [backoffMaxMs]。 */
val backoffInitialMs: Long = 500,
/** 退避延迟上限(毫秒)。 */
val backoffMaxMs: Long = 5_000,
/** 每次退避随机扣减的比例,0–1。 */
val backoffJitter: Double = 0.25,
/** 触发重试的 HTTP 状态码。 */
val httpStatuses: Set<Int> = DEFAULT_RETRYABLE_STATUSES,
/** 是否优先采用服务端 `Retry-After` / `retry-after-ms`。 */
val respectRetryAfter: Boolean = true,
/** 服务端重试延迟上限(毫秒);超出则退回退避算法。 */
val maxRetryAfterMs: Long = 60_000,
/** 是否重试连接失败。 */
val apiConnectionError: Boolean = true,
/** 是否重试超时。 */
val apiTimeoutError: Boolean = true,
) {
init {
if (maxRetries < 0) {
throw TypeSafeException("RetryPolicy.maxRetries must be non-negative, got $maxRetries.")
}
if (backoffInitialMs < 0) {
throw TypeSafeException(
"RetryPolicy.backoffInitialMs must be non-negative, got $backoffInitialMs.",
)
}
if (backoffMaxMs < 0) {
throw TypeSafeException(
"RetryPolicy.backoffMaxMs must be non-negative, got $backoffMaxMs.",
)
}
if (backoffJitter < 0 || backoffJitter > 1) {
throw TypeSafeException(
"RetryPolicy.backoffJitter must be between 0 and 1, got $backoffJitter.",
)
}
if (maxRetryAfterMs < 0) {
throw TypeSafeException(
"RetryPolicy.maxRetryAfterMs must be non-negative, got $maxRetryAfterMs.",
)
}
httpStatuses.forEach { status ->
if (status < 100 || status > 999) {
throw TypeSafeException(
"RetryPolicy.httpStatuses must contain HTTP status codes, got $status.",
)
}
}
}
/** 该状态码是否可重试。 */
fun isRetryableStatus(status: Int): Boolean = status in httpStatuses
}
/**
* 解析 `retry-after-ms` 或 `Retry-After` 为毫秒,优先 `retry-after-ms`。
*
* `Retry-After` 支持两种形式:秒数,或 HTTP 日期。两者都无法解析时返回 null。
*/
internal fun parseRetryAfter(
headers: Map<String, String>,
nowMs: Long,
): Long? {
headers["retry-after-ms"]?.let { raw ->
val ms = raw.trim().toDoubleOrNull()
if (ms != null && ms.isFinite() && ms >= 0) return ms.toLong()
}
val raw = headers["retry-after"]?.trim() ?: return null
raw.toDoubleOrNull()?.let { seconds ->
return if (seconds >= 0) (seconds * 1000).toLong() else null
}
return parseHttpDateToEpochMillis(raw)?.let { maxOf(0L, it - nowMs) }
}
// ---------------------------------------------------------------------------
// HTTP-date 解析
//
// 上游用 `Date.parse(raw)` 处理 `Retry-After: <HTTP-date>` 形式。Kotlin 的 commonMain
// 里没有 java.time,而为了这一条极少数路径引入 kotlinx-datetime 不划算,
// 因此按 RFC 1123 格式手写解析。日期→纪元秒用 Howard Hinnant 的 days_from_civil 算法,
// 是精确的整数运算,不涉及闰年特判。
// ---------------------------------------------------------------------------
private val RFC_1123_PATTERN = Regex(
"""(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*(\d{1,2})\s*([A-Za-z]{3})\s*(\d{4})\s*""" +
"""(\d{1,2}):(\d{2}):(\d{2})\s*GMT""",
RegexOption.IGNORE_CASE,
)
private val MONTH_INDEX: Map<String, Int> = listOf(
"jan", "feb", "mar", "apr", "may", "jun",
"jul", "aug", "sep", "oct", "nov", "dec",
).withIndex().associate { (index, name) -> name to index + 1 }
/** 解析 RFC 1123 HTTP 日期(如 `Sun, 06 Nov 1994 08:49:37 GMT`)为纪元毫秒;无法解析返回 null。 */
internal fun parseHttpDateToEpochMillis(raw: String): Long? {
val match = RFC_1123_PATTERN.matchEntire(raw) ?: return null
val day = match.groupValues[1].toIntOrNull() ?: return null
val month = MONTH_INDEX[match.groupValues[2].lowercase()] ?: return null
val year = match.groupValues[3].toIntOrNull() ?: return null
val hour = match.groupValues[4].toIntOrNull() ?: return null
val minute = match.groupValues[5].toIntOrNull() ?: return null
val second = match.groupValues[6].toIntOrNull() ?: return null
if (day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..60) return null
val days = daysFromCivil(year, month, day)
val secondsOfDay = hour * 3600L + minute * 60L + second
return (days * 86_400L + secondsOfDay) * 1000L
}
/** Howard Hinnant 的 days_from_civil:返回自 1970-01-01 起的天数(可为负)。 */
private fun daysFromCivil(year: Int, month: Int, day: Int): Long {
val y = if (month <= 2) year - 1 else year
val era = (if (y >= 0) y else y - 399) / 400
val yearOfEra = y - era * 400
val dayOfYear = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1
val dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
return era.toLong() * 146_097L + dayOfEra.toLong() - 719_468L
}
/**
* 计算第 [attempt] 次(从 0 起)重试前应等待的毫秒数。
*
* 与上游 `retryDelayMs` 一致:服务端给出且不超过上限时采用服务端值,
* 否则用带上限的指数退避并随机扣减抖动。
*/
internal fun retryDelayMs(
attempt: Int,
retryAfterMs: Long?,
policy: RetryPolicy,
random: () -> Double,
): Long {
if (policy.respectRetryAfter && retryAfterMs != null && retryAfterMs <= policy.maxRetryAfterMs) {
return retryAfterMs
}
val exponential = min(
policy.backoffInitialMs * 2.0.pow(attempt),
policy.backoffMaxMs.toDouble(),
)
return (exponential * (1 - random() * policy.backoffJitter)).roundToLong()
}

