Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -614,11 +614,7 @@ fun AIChatPanel(
val currentMode by viewModel.currentSessionMode.collectAsStateWithLifecycle()
val slashCommands by viewModel.slashCommands.collectAsStateWithLifecycle()

var inputText by remember { mutableStateOf("") }
val inputDraft by viewModel.inputDraft.collectAsStateWithLifecycle()
LaunchedEffect(inputDraft) {
if (inputText != inputDraft) inputText = inputDraft
}
val inputText by viewModel.inputDraft.collectAsStateWithLifecycle()
// 输入 "/" 打开命令菜单时重扫技能,反映磁盘上技能的增删改。
LaunchedEffect(inputText) {
if (inputText == "/") viewModel.refreshSlashCommands()
Expand Down Expand Up @@ -958,7 +954,11 @@ fun AIChatPanel(
val prefix = currentText.trimStart().take(20)
prefix.isEmpty() || lastMsg.content.trimStart().startsWith(prefix)
}
val reasoningSettled = if (currentReasoning.isNullOrBlank()) {
// 落库消息没有 reasoning 时也算已就位:思考过程不落库的场景下,拿前缀去比会永远不吻合,
// 保留缓冲就永不退休 → 尾巴气泡与已落库气泡同屏,看起来就是同一条回复显示两遍。
// (textSettled 那条仍要比前缀——正文一定落库;reasoning 不一定。)
// 同 origin/fix/chat-duplicate-reply(929d8684)的判定。
val reasoningSettled = if (currentReasoning.isNullOrBlank() || lastMsg.reasoning.isNullOrBlank()) {
true
} else {
val prefix = currentReasoning.trimStart().take(20)
Expand Down Expand Up @@ -1205,7 +1205,6 @@ fun AIChatPanel(
inputImages = images,
inputAttachments = attachments.toAgentAttachments()
)
inputText = ""
viewModel.clearInputDraft()
pendingAttachments = emptyList()
followBottom = true
Expand Down Expand Up @@ -1576,7 +1575,9 @@ fun AIChatPanel(
val questionForPanel = rememberLastNonNull(pendingQuestion)
AnimatedVisibility(
visible = pendingQuestion != null,
modifier = Modifier.graphicsLayer { alpha = floatingPanelAlpha },
modifier = Modifier
.weight(1f, fill = false)
.graphicsLayer { alpha = floatingPanelAlpha },
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Expand Down Expand Up @@ -1610,7 +1611,7 @@ fun AIChatPanel(

ChatInputBar(
value = inputText,
onValueChange = { inputText = it; viewModel.updateInputDraft(it) },
onValueChange = viewModel::updateInputDraft,
onSend = sendMessage,
enterToSend = settingsViewModel?.enterToSend?.collectAsStateWithLifecycle()?.value ?: false,
onStop = { viewModel.stopAgent() },
Expand Down Expand Up @@ -1741,7 +1742,7 @@ fun AIChatPanel(
promptSnippet = targetMsg?.content ?: "",
onOptionSelected = { option ->
viewModel.executeRewindOption(targetId, option) { text, attachments ->
inputText = text
viewModel.updateInputDraft(text)
pendingAttachments = attachments.map { it.toPendingAttachment() }
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
Expand All @@ -43,7 +41,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
Expand All @@ -56,6 +53,7 @@ import com.aicode.feature.agent.domain.tool.question.SingleAnswer
import com.aicode.feature.agent.domain.tool.question.UserQuestionAnswer
import androidx.compose.ui.res.stringResource
import com.aicode.R
import com.aicode.core.ui.AppTextField
import com.aicode.core.ui.ExpandableChevronIcon
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronDown
Expand Down Expand Up @@ -309,25 +307,16 @@ private fun QuestionCard(
// 「其他」被选中时展开文本输入框
if (isOther && isSelected) {
Spacer(Modifier.height(Spacing.xs))
TextField(
AppTextField(
value = customText,
onValueChange = onCustomTextChanged,
placeholder = { Text(stringResource(R.string.ask_input_hint), style = MaterialTheme.typography.bodySmall) },
placeholder = stringResource(R.string.ask_input_hint),
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp),
textStyle = MaterialTheme.typography.bodySmall,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary
),
shape = RoundedCornerShape(Radius.sm)
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
Expand Down Expand Up @@ -234,9 +235,10 @@ internal fun ChatInputBar(
// 渐变终点固定在蒙版可视高度内:若跟随整个 Box(含 imeInset 被键盘拉长的部分),
// 键盘弹起时可见区域只占渐变前段,alpha 被摊薄到几乎透明——看起来像没有蒙版。
val maskGradientEndY = with(LocalDensity.current) { INPUT_BAR_MASK_HEIGHT.toPx() }
Box(
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
val dashboardBodyMaxHeight = (maxHeight - imeInset - Spacing.md).coerceAtLeast(0.dp) * 0.4f
// 半透明渐变蒙版:盖住输入框区域 + 导航栏(手势小白条)区域,滚动内容滑入时被遮罩
// (能看见但看不清);高度含 IME inset 随键盘上移,渐变在 INPUT_BAR_MASK_HEIGHT 内完成,
// 之下为纯色。注意 IME padding 不能加在外层 Box 上——align(BottomCenter) 的子项
Expand Down Expand Up @@ -279,6 +281,8 @@ internal fun ChatInputBar(
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = Spacing.sm, vertical = Spacing.xs)
) {
filteredCommands.forEach { command ->
Expand Down Expand Up @@ -351,6 +355,7 @@ internal fun ChatInputBar(
onRefresh = onRefreshDashboard,
onRefreshByButton = onRefreshDashboardByButton,
onExpandedChange = onDashboardExpandedChange,
maxExpandedBodyHeight = dashboardBodyMaxHeight,
forceCollapse = forceCollapseDashboard
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
Expand All @@ -42,6 +45,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aicode.core.theme.Radius
Expand All @@ -65,6 +69,7 @@ fun ProviderDashboardBar(
provider: AIProviderConfig,
state: ProviderDashboardState,
onRefresh: () -> Unit,
maxExpandedBodyHeight: Dp,
modifier: Modifier = Modifier,
forceCollapse: Boolean = false,
onRefreshByButton: () -> Unit = {},
Expand Down Expand Up @@ -242,6 +247,8 @@ fun ProviderDashboardBar(
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxExpandedBodyHeight)
.verticalScroll(rememberScrollState())
.padding(top = Spacing.sm),
verticalArrangement = Arrangement.spacedBy(Spacing.sm)
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,6 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
Expand Down Expand Up @@ -149,11 +146,11 @@ internal fun ToolMessageBody(
val edit = if (!running && !message.isError &&
(message.toolName == "editFile" || message.toolName == "writeFile")
) {
remember(message.id, message.content) { parseEditDiff(message.content) }
remember(message.id, message.content) { runCatching { parseEditDiff(message.content) }.getOrNull() }
} else null

val resultText = if (!running) {
remember(message.id, message.content) { formatToolResult(message.content) }
remember(message.id, message.content) { runCatching { formatToolResult(message.content) }.getOrElse { message.content } }
} else null
val argHint = remember(message.toolArgs) { toolArgHint(message.toolArgs) }
val argsFull = remember(message.toolArgs) { formatToolArgs(message.toolArgs) }
Expand Down Expand Up @@ -827,32 +824,32 @@ internal data class EditDiff(
/**
* 从持久化的 TOOL 内容中解析 editFile / writeFile 的结构化差异
*/
internal fun parseEditDiff(content: String): EditDiff? {
internal fun parseEditDiff(content: String): EditDiff? = runCatching {
val dataObj = extractToolDataObject(content)
if (dataObj != null) {
return parseEditDiffObject(dataObj)
return@runCatching parseEditDiffObject(dataObj)
}

val start = content.indexOf('{')
val end = content.lastIndexOf('}')
if (start < 0 || end <= start) return null
return runCatching {
parseEditDiffObject(Json.parseToJsonElement(content.substring(start, end + 1)).jsonObject)
}.getOrNull()
}
if (start < 0 || end <= start) return@runCatching null
val parsed = Json.parseToJsonElement(content.substring(start, end + 1)) as? JsonObject
?: return@runCatching null
parseEditDiffObject(parsed)
}.getOrNull()

private fun parseEditDiffObject(obj: JsonObject): EditDiff? {
val path = obj["path"]?.jsonPrimitive?.contentOrNull ?: ""
val added = obj["added_lines"]?.jsonPrimitive?.intOrNull ?: 0
val removed = obj["removed_lines"]?.jsonPrimitive?.intOrNull ?: 0

val hunks = obj["hunks"]?.jsonArray?.mapNotNull { el ->
val ho = el.jsonObject
val d = ho["diff"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
EditHunk(startLine = ho["start_line"]?.jsonPrimitive?.intOrNull ?: 1, diff = d)
val path = (obj["path"] as? JsonPrimitive)?.contentOrNull ?: ""
val added = (obj["added_lines"] as? JsonPrimitive)?.intOrNull ?: 0
val removed = (obj["removed_lines"] as? JsonPrimitive)?.intOrNull ?: 0

val hunks = (obj["hunks"] as? JsonArray)?.mapNotNull { el ->
val ho = el as? JsonObject ?: return@mapNotNull null
val d = (ho["diff"] as? JsonPrimitive)?.contentOrNull ?: return@mapNotNull null
EditHunk(startLine = (ho["start_line"] as? JsonPrimitive)?.intOrNull ?: 1, diff = d)
} ?: run {
val d = obj["diff"]?.jsonPrimitive?.contentOrNull ?: return null
listOf(EditHunk(startLine = obj["start_line"]?.jsonPrimitive?.intOrNull ?: 1, diff = d))
val d = (obj["diff"] as? JsonPrimitive)?.contentOrNull ?: return null
listOf(EditHunk(startLine = (obj["start_line"] as? JsonPrimitive)?.intOrNull ?: 1, diff = d))
}
if (hunks.isEmpty()) return null
return EditDiff(path = path, added = added, removed = removed, hunks = hunks)
Expand All @@ -861,43 +858,46 @@ private fun parseEditDiffObject(obj: JsonObject): EditDiff? {
/**
* 把落库的原始工具结果清洗成可读文本
*/
internal fun formatToolResult(raw: String): String {
internal fun formatToolResult(raw: String): String = runCatching {
val s = raw.withoutToolStatusPrefix()
parseToolTransport(s)?.let { obj ->
return when (obj["status"]?.jsonPrimitive?.contentOrNull) {
"error" -> obj["message"]?.jsonPrimitive?.contentOrNull ?: s
val transport = parseToolTransport(s)?.let { obj ->
when ((obj["status"] as? JsonPrimitive)?.contentOrNull) {
"error" -> (obj["message"] as? JsonPrimitive)?.contentOrNull ?: s
"success", "partial" -> formatToolData(obj["data"]) ?: s
else -> s
}
}
if (transport != null) return@runCatching transport

when {
s.startsWith("Error(") -> {
val msgIdx = s.indexOf("message=")
if (msgIdx >= 0) {
if (msgIdx < 0) {
s
} else {
var body = s.substring(msgIdx + "message=".length)
val codeIdx = body.lastIndexOf(", code=")
body = if (codeIdx >= 0) body.substring(0, codeIdx) else body.removeSuffix(")")
return body.trim()
body.trim()
}
}
s.startsWith("Success(data=") -> {
val inner = s.removePrefix("Success(data=").removeSuffix(")")
return formatJsonData(inner) ?: inner.trim()
formatJsonData(inner) ?: inner.trim()
}
s.startsWith("Partial(data=") -> {
var inner = s.removePrefix("Partial(data=")
val msgIdx = inner.lastIndexOf(", message=")
inner = if (msgIdx >= 0) inner.substring(0, msgIdx) else inner.removeSuffix(")")
return formatJsonData(inner) ?: inner.trim()
formatJsonData(inner) ?: inner.trim()
}
else -> s
}
return s
}
}.getOrElse { raw }

private fun parseToolTransport(raw: String): JsonObject? {
return runCatching {
val obj = Json.parseToJsonElement(raw.trim()).jsonObject
val obj = Json.parseToJsonElement(raw.trim()) as? JsonObject ?: return@runCatching null
if (obj["status"] != null) obj else null
}.getOrNull()
}
Expand Down Expand Up @@ -943,7 +943,7 @@ internal fun formatJsonData(jsonStr: String): String? = runCatching {
internal fun formatToolArgs(argsJson: String?): String? {
if (argsJson.isNullOrBlank()) return null
return runCatching {
val obj = Json.parseToJsonElement(argsJson).jsonObject
val obj = Json.parseToJsonElement(argsJson) as? JsonObject ?: return@runCatching null
if (obj.isEmpty()) return null
obj.entries.joinToString("\n") { (k, v) ->
val vv = (v as? JsonPrimitive)?.contentOrNull ?: v.toString()
Expand All @@ -956,7 +956,7 @@ internal fun formatToolArgs(argsJson: String?): String? {
internal fun toolArgHint(argsJson: String?): String? {
if (argsJson.isNullOrBlank()) return null
return runCatching {
val obj = Json.parseToJsonElement(argsJson).jsonObject
val obj = Json.parseToJsonElement(argsJson) as? JsonObject ?: return@runCatching null
val preferred = listOf("command", "cmd", "path", "file_path", "file", "query", "pattern", "url", "name")
val v = preferred.firstNotNullOfOrNull { obj[it] } ?: obj.values.firstOrNull()
val str = (v as? JsonPrimitive)?.contentOrNull ?: v?.toString()
Expand All @@ -968,7 +968,7 @@ internal fun toolArgHint(argsJson: String?): String? {
private fun extractFilePathArg(argsJson: String?): String? {
if (argsJson.isNullOrBlank()) return null
return runCatching {
val obj = Json.parseToJsonElement(argsJson).jsonObject
val obj = Json.parseToJsonElement(argsJson) as? JsonObject ?: return@runCatching null
listOf("path", "file_path", "file").firstNotNullOfOrNull { key ->
(obj[key] as? JsonPrimitive)?.contentOrNull
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import com.aicode.feature.settings.domain.model.ProxyType
import com.aicode.feature.settings.domain.model.sanitized
import com.aicode.feature.settings.domain.repository.AIProviderRepository
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
Expand Down Expand Up @@ -56,18 +59,18 @@ class AIProviderRepositoryImpl @Inject constructor(
override fun getAllProviders(): Flow<List<AIProviderConfig>> {
return aiProviderDao.getAllProviders().map { entities ->
entities.map { it.toDomainModel() }
}
}.flowOn(Dispatchers.IO)
}

override suspend fun getProviderById(id: String): AIProviderConfig? {
return aiProviderDao.getProviderById(id)?.toDomainModel()
override suspend fun getProviderById(id: String): AIProviderConfig? = withContext(Dispatchers.IO) {
aiProviderDao.getProviderById(id)?.toDomainModel()
}

/**
* 保存提供商。排序值以数据库当前值为准(重排可能异步持久化,UI 传入的
* sortOrder 可能陈旧,直接使用会撤销刚完成的排序);新提供商取最大排序 +1。
*/
override suspend fun saveProvider(provider: AIProviderConfig) {
override suspend fun saveProvider(provider: AIProviderConfig): Unit = withContext(Dispatchers.IO) {
agentDatabase.withTransaction {
val current = aiProviderDao.getProviderById(provider.id)
val sortOrder = current?.sortOrder ?: (aiProviderDao.getMaxSortOrder() + 1)
Expand Down
Loading
Loading