diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt index 4c260280..73f83113 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderEditorScreen.kt @@ -140,7 +140,6 @@ import compose.icons.feathericons.Eye import compose.icons.feathericons.EyeOff import compose.icons.feathericons.FileText import compose.icons.feathericons.Folder -import compose.icons.feathericons.Menu import compose.icons.feathericons.Minus import compose.icons.feathericons.Play import compose.icons.feathericons.Plus @@ -298,6 +297,7 @@ fun ProviderEditorScreen( models.add(to.index, models.removeAt(from.index)) modelsOrderDirty = true } + val hapticFeedback = LocalHapticFeedback.current fun saveAndNavigateBack() { modelsOrderDirty = false @@ -718,33 +718,18 @@ fun ProviderEditorScreen( saveCurrent() }, showDivider = !isLast && !isDragging, - dragHandle = { - val haptic = LocalHapticFeedback.current - IconButton( - onClick = {}, - modifier = Modifier - .size(32.dp) - .longPressDraggableHandle( - onDragStarted = { - haptic.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) - }, - onDragStopped = { - haptic.performHapticFeedback(HapticFeedbackType.GestureEnd) - if (modelsOrderDirty) { - modelsOrderDirty = false - saveCurrent() - } - } - ) - ) { - Icon( - imageVector = FeatherIcons.Menu, - contentDescription = stringResource(R.string.provider_sort_drag_handle), - tint = MaterialTheme.semanticColors.subtleText, - modifier = Modifier.size(18.dp) - ) + dragModifier = Modifier.longPressDraggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd) + if (modelsOrderDirty) { + modelsOrderDirty = false + saveCurrent() + } } - } + ) ) } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderModelComponents.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderModelComponents.kt index 12061daf..9cdbb395 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderModelComponents.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProviderModelComponents.kt @@ -6,8 +6,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -41,6 +39,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -65,37 +65,69 @@ import compose.icons.feathericons.AlertCircle import compose.icons.feathericons.Check import compose.icons.feathericons.Copy import compose.icons.feathericons.Plus -import compose.icons.feathericons.X import kotlinx.coroutines.launch +private val ModelLogoSize = 40.dp +private val ModelTestButtonWidth = 56.dp +private val ModelContentStart = ModelLogoSize + Spacing.md +private val ModelTestAreaWidth = ModelTestButtonWidth + Spacing.sm + +/** 模型能力标签。Image / Tools 各自独立,token 上下限合并进同一个胶囊。 */ @Composable -@OptIn(ExperimentalLayoutApi::class) -private fun ModelMetadataTags(metadata: ModelMetadata?) { - val pillText = MaterialTheme.colorScheme.onSurfaceVariant - val pillBg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) +private fun ModelMetadataTags( + metadata: ModelMetadata?, + modifier: Modifier = Modifier +) { + metadata ?: return + val input = metadata.inputTokens?.takeIf { it > 0 } ?: metadata.contextTokens.takeIf { it > 0 } + val output = metadata.outputTokens?.takeIf { it > 0 } + val tokens = listOfNotNull( + input?.let { "↑${formatTokenLimit(it)}" }, + output?.let { "↓${formatTokenLimit(it)}" } + ) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + verticalAlignment = Alignment.CenterVertically ) { - metadata?.let { - if (it.supportsVision) { - McpPill(text = "Image", textColor = pillText, backgroundColor = pillBg) - } - if (it.supportsTools) { - McpPill(text = "Tools", textColor = pillText, backgroundColor = pillBg) - } - val input = it.inputTokens?.takeIf { tokens -> tokens > 0 } - ?: it.contextTokens.takeIf { tokens -> tokens > 0 } - if (input != null) { - McpPill(text = "↑ ${formatTokenLimit(input)}", textColor = pillText, backgroundColor = pillBg) - } - it.outputTokens?.takeIf { tokens -> tokens > 0 }?.let { output -> - McpPill(text = "↓ ${formatTokenLimit(output)}", textColor = pillText, backgroundColor = pillBg) - } + if (metadata.supportsVision) { + ModelTagPill(texts = listOf("Image")) + } + if (metadata.supportsTools) { + ModelTagPill(texts = listOf("Tools")) + } + if (tokens.isNotEmpty()) { + ModelTagPill(texts = tokens) } } } +/** 胶囊标签。多段文本用固定间距并排,不用空格字符撑间距。 */ +@Composable +private fun ModelTagPill(texts: List) { + Row( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + RoundedCornerShape(Radius.pill) + ) + .padding(horizontal = 7.dp, vertical = 3.dp), + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + texts.forEach { text -> + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** 模型行。logo 与「测试」相对名称+标签整块垂直居中,对齐提供商列表。 */ @Composable internal fun ProviderModelRow( model: String, @@ -106,9 +138,10 @@ internal fun ProviderModelRow( onRemove: () -> Unit, onEdit: () -> Unit = {}, showDivider: Boolean = false, - dragHandle: (@Composable () -> Unit)? = null + dragModifier: Modifier = Modifier ) { var showDetail by remember { mutableStateOf(false) } + val sortDescription = stringResource(R.string.provider_sort_long_press) SwipeToDeleteRow( onDelete = onRemove, @@ -120,48 +153,54 @@ internal fun ProviderModelRow( .fillMaxWidth() .padding(horizontal = Spacing.lg, vertical = 12.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(Radius.sm) - ) - ) { - ModelLogoIcon( - modelName = model, - size = 22.dp, - modifier = Modifier.align(Alignment.Center) - ) - } - Spacer(Modifier.width(Spacing.md)) - - Column(modifier = Modifier.weight(1f)) { + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { Text( model, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = ModelContentStart, end = ModelTestAreaWidth) ) if (metadata.hasVisibleTags()) { - Spacer(Modifier.height(6.dp)) - ModelMetadataTags(metadata) + Spacer(Modifier.height(Spacing.xs)) + ModelMetadataTags( + metadata = metadata, + modifier = Modifier.padding(start = ModelContentStart) + ) } } - - Spacer(Modifier.width(Spacing.sm)) - + Row( + modifier = Modifier + .matchParentSize() + .padding(end = ModelTestAreaWidth) + .then(dragModifier) + .semantics { + contentDescription = sortDescription + }, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(ModelLogoSize) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(Radius.sm) + ), + contentAlignment = Alignment.Center + ) { + ModelLogoIcon(modelName = model, size = 22.dp) + } + Spacer(Modifier.width(Spacing.md)) + } CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 0.dp ) { Box( modifier = Modifier - .width(56.dp) + .align(Alignment.CenterEnd) + .width(ModelTestButtonWidth) .height(36.dp), contentAlignment = Alignment.Center ) { @@ -172,7 +211,7 @@ internal fun ProviderModelRow( onClick = onTest, contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = 0.dp), modifier = Modifier - .width(56.dp) + .width(ModelTestButtonWidth) .height(32.dp) ) { Text( @@ -182,19 +221,13 @@ internal fun ProviderModelRow( } } } - - if (dragHandle != null) { - Spacer(Modifier.width(Spacing.xs)) - dragHandle() - } } } - result?.let { r -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(top = Spacing.xs, start = 52.dp) + .padding(top = Spacing.xs, start = ModelContentStart) .clip(RoundedCornerShape(4.dp)) .clickable { showDetail = true } .padding(horizontal = 4.dp, vertical = 2.dp) @@ -553,7 +586,6 @@ private fun formatTokenLimit(tokens: Int): String = private fun String.trimDecimal(): String = replace(Regex("(\\.\\d)\\d+"), "$1").removeSuffix(".0") -@OptIn(ExperimentalLayoutApi::class) @Composable internal fun FetchModelRow( model: String, diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProvidersAndLogSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProvidersAndLogSection.kt index bab1aeca..f7a5b6c1 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/ProvidersAndLogSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/ProvidersAndLogSection.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -29,11 +28,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.zIndex import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -43,7 +43,6 @@ import com.aicode.core.ui.SwipeToDeleteRow import com.aicode.feature.settings.domain.model.AIProviderConfig import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight -import compose.icons.feathericons.Menu import androidx.compose.ui.res.stringResource import com.aicode.R import sh.calvin.reorderable.ReorderableItem @@ -51,7 +50,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState /** * 提供商二级页:列表 + 空态提示。新增/编辑由顶栏「+」与点击触发 [ProviderEditorScreen],左滑删除。 - * 每行为一组圆角卡片,长按行尾拖拽手柄(≡)可调整提供商顺序(持久化到 sortOrder)。 + * 每行为一组圆角卡片,长按内容区域可调整提供商顺序(持久化到 sortOrder)。 */ @Composable internal fun ProvidersSection( @@ -68,6 +67,7 @@ internal fun ProvidersSection( val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> onReorder(from.index, to.index) } + val hapticFeedback = LocalHapticFeedback.current LazyColumn( state = lazyListState, modifier = Modifier @@ -112,29 +112,14 @@ internal fun ProvidersSection( provider = provider, onEdit = { onEdit(provider) }, onDelete = { onDelete(provider) }, - dragHandle = { - val haptic = LocalHapticFeedback.current - IconButton( - onClick = {}, - modifier = Modifier - .size(32.dp) - .longPressDraggableHandle( - onDragStarted = { - haptic.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) - }, - onDragStopped = { - haptic.performHapticFeedback(HapticFeedbackType.GestureEnd) - } - ) - ) { - Icon( - imageVector = FeatherIcons.Menu, - contentDescription = stringResource(R.string.provider_sort_drag_handle), - tint = MaterialTheme.semanticColors.subtleText, - modifier = Modifier.size(18.dp) - ) + dragModifier = Modifier.longPressDraggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd) } - } + ) ) } } @@ -161,16 +146,17 @@ internal fun EmptyHint(text: String) { /** * 提供商行:左侧品牌 logo(按提供商名称自动识别), - * 中部两行(名称 / 类型 + 模型数量 pills),右侧状态 pill + 箭头 + 拖拽手柄。 - * 整行点击进入编辑,左滑露出删除按钮。 + * 中部两行(名称 / 类型 + 模型数量 pills),右侧状态 pill + 箭头。 + * 整行点击进入编辑,左滑露出删除按钮;长按左侧信息区域可调整顺序。 */ @Composable fun ProviderItem( provider: AIProviderConfig, onEdit: () -> Unit, onDelete: () -> Unit, - dragHandle: (@Composable () -> Unit)? = null + dragModifier: Modifier = Modifier ) { + val sortDescription = stringResource(R.string.provider_sort_long_press) // 状态色与 MCP 行一致:启用用主题 tertiary(绿调),停用用 outline(灰)。 val statusColor = if (provider.isEnabled) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outline @@ -184,52 +170,62 @@ fun ProviderItem( .padding(horizontal = Spacing.lg, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - // 左侧品牌 logo 容器 - Box( + Row( modifier = Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp) - ) + .weight(1f) + .then(dragModifier) + .semantics { + contentDescription = sortDescription + }, + verticalAlignment = Alignment.CenterVertically ) { - ProviderLogoIcon( - provider = provider, - size = 22.dp, - modifier = Modifier.size(22.dp).align(Alignment.Center) - ) - } + // 左侧品牌 logo 容器 + Box( + modifier = Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + ) { + ProviderLogoIcon( + provider = provider, + size = 22.dp, + modifier = Modifier.size(22.dp).align(Alignment.Center) + ) + } - Spacer(modifier = Modifier.width(Spacing.md)) + Spacer(modifier = Modifier.width(Spacing.md)) - // 中间:名称 / 类型 + 模型数量 - Column(modifier = Modifier.weight(1f)) { - Text( - text = provider.name, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth() - ) + // 中间:名称 / 类型 + 模型数量 + Column(modifier = Modifier.weight(1f)) { + Text( + text = provider.name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(6.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - McpPill( - text = providerTypeLabel(provider.type), - textColor = MaterialTheme.colorScheme.onSurfaceVariant, - backgroundColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) - ) - McpPill( - text = stringResource(R.string.provider_models_count_tag, provider.models.size), - textColor = MaterialTheme.colorScheme.onSurfaceVariant, - backgroundColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.weight(1f, fill = false) - ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + McpPill( + text = providerTypeLabel(provider.type), + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) + McpPill( + text = stringResource(R.string.provider_models_count_tag, provider.models.size), + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.weight(1f, fill = false) + ) + } } } @@ -248,12 +244,6 @@ fun ProviderItem( tint = MaterialTheme.semanticColors.subtleText, modifier = Modifier.size(18.dp) ) - - // 行尾拖拽手柄(长按排序) - if (dragHandle != null) { - Spacer(Modifier.width(Spacing.xs)) - dragHandle() - } } } -} \ No newline at end of file +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 579b9d27..51066640 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -831,7 +831,7 @@ No providers yet, tap + to add one - Long press to reorder + Long press to reorder Log Level Logs below this level will be ignored. Use VERBOSE during development (log all); NONE to disable logging. Log files are located at Android/data/<package>/files/logs/. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 425332eb..1fd4a62b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -831,7 +831,7 @@ 还没有提供商,点右上角 + 添加 - 长按拖动排序 + 长按拖动排序 日志等级 记录低于该等级的日志将被忽略。开发期建议 VERBOSE(全部记录);NONE 关闭日志。 日志文件位于 Android/data/<包名>/files/logs/。 diff --git a/docs-site/docs/guide/providers.md b/docs-site/docs/guide/providers.md index 8c511f35..c7d0100d 100644 --- a/docs-site/docs/guide/providers.md +++ b/docs-site/docs/guide/providers.md @@ -22,7 +22,7 @@ AiCode 自己不提供模型,你需要接入至少一个模型服务才能开 - 点一行进入编辑页。 - **左滑一行**露出红色删除按钮。 -- **长按行尾的拖拽手柄上下拖动**可以调整顺序,重启后顺序保持。 +- **长按名称或类型信息区域上下拖动**可以调整顺序,重启后顺序保持。 ## 多 Key 模式 @@ -82,7 +82,7 @@ App 重启后会回到第一个 Key,冷却记录清空。 **测试**:每个模型行都有「测试」按钮,发一次真实请求验证连通性,结果显示在行下方。 -**排序**:长按模型行尾的拖拽手柄上下拖动,可以调整模型顺序,保存后立即生效(模型选择弹窗与默认模型列表都按这个顺序展示),重启后顺序保持。 +**排序**:长按模型名称区域上下拖动,可以调整模型顺序,保存后立即生效(模型选择弹窗与默认模型列表都按这个顺序展示),重启后顺序保持。 **删除**:模型行左滑删除。如果删掉的模型正在被使用(当前提供商的选中模型、新会话默认模型、识图 / 压缩 / 标题模型,或者某个历史会话绑定的模型),相关选择会自动回退到默认模型,不会再拿已删除的模型发请求。