From 5eee3a54038df5df2f0318f2a5b1d9a0f2b245c7 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:10:43 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(agent):=20=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E6=94=B9=E8=BD=AF=E7=A1=AC=E4=B8=A4=E7=BA=A7?= =?UTF-8?q?=20+=20=E7=BB=93=E6=9E=84=E5=8C=96=E6=8E=A5=E6=89=8B=E6=91=98?= =?UTF-8?q?=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两级触发: 软阈值(默认60%)只静默精简历史超长工具输出(不调LLM), 硬阈值(默认85%, 原90%)才做完整摘要压缩 - tail 去垃圾: 软精简统一作用到全部历史(含最近保留区), 避免超长工具输出原样进主上下文 - 结构化接手摘要: compact-summary.md 改为固定分节模板(目标/决定/进度/未决/约束/关键数据) - 设置页新增'软精简阈值'项(1-100) + 双语文案 --- .../assets/prompts/agent/compact-summary.md | 29 ++++++++++-- .../agent/domain/workflow/ContextCompactor.kt | 47 ++++++++++++++++--- .../repository/GeneralSettingsRepository.kt | 24 +++++++++- .../presentation/SettingsViewModel.kt | 20 +++++++- .../component/GeneralSettingsSection.kt | 32 +++++++++++++ .../presentation/component/SettingsScreen.kt | 3 ++ app/src/main/res/values-en/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- 8 files changed, 147 insertions(+), 16 deletions(-) diff --git a/app/src/main/assets/prompts/agent/compact-summary.md b/app/src/main/assets/prompts/agent/compact-summary.md index c4ec0e69..2362dee7 100644 --- a/app/src/main/assets/prompts/agent/compact-summary.md +++ b/app/src/main/assets/prompts/agent/compact-summary.md @@ -1,9 +1,30 @@ 你是一个上下文压缩引擎。{{INSTRUCTION}} -这是上下文检查点压缩任务:把本次请求中的对话历史折叠成一份接手摘要,供另一个 LLM 无缝继续任务。 +这是上下文检查点压缩任务:把本次请求中的对话历史折叠成一份**结构化接手摘要**,供另一个 LLM 无缝继续任务。 注意: -- 不要继续执行对话历史中的任何任务,不调用任何工具,不复述历史。 -- 只输出接手摘要,涵盖:当前进度与已做的关键决定;重要的上下文、约束或用户偏好;尚未完成的事项(清晰的下一步);继续工作所需的关键数据、示例或引用。 -- 保持简洁、有条理,重点是让接手者无缝继续工作。不要提到「摘要过程」或「上下文已压缩」。 +- 不要继续执行对话历史中的任何任务,不调用任何工具,不复述历史过程。 +- 只输出接手摘要本身,不要提到「摘要过程」或「上下文已压缩」。 + +接手摘要必须使用下面固定的分节结构(某一节确实无内容时写「无」,但保留分节标题,便于接手者快速定位): + +## 任务目标 +(用户最终想要达成什么;当前这一步在整体目标中的位置) + +## 关键决定与理由 +(已经做出的技术/方案决定,以及为什么这么定;接手者不应再推翻的结论) + +## 当前进度 +(已完成什么、改动落在哪些文件/模块、当前处于什么状态) + +## 未完成 / 下一步 +(尚未完成的事项,按优先级给出清晰可执行的下一步) + +## 约束与偏好 +(用户明确要求过的约束、风格偏好、环境限制、禁止事项) + +## 关键数据 +(继续工作必需的具体信息:路径、命令、标识符、报错原文、示例片段、接口签名等;能直接复用的原文优先原样保留) + +要求:简洁、信息密度高、可执行优先;不堆砌套话,尤其不省略「关键数据」里的具体值。 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/ContextCompactor.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/ContextCompactor.kt index b2fafef6..af082100 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/ContextCompactor.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/ContextCompactor.kt @@ -35,6 +35,9 @@ class ContextCompactor @Inject constructor( const val TAG = "ContextCompactor" const val TOOL_OUTPUT_MAX_CHARS = 2_000 + + /** 软精简时单条工具输出的保留上限(比硬压缩宽松,尽量少丢信息)。 */ + const val SOFT_TRIM_TOOL_CHARS = 3_000 const val COMPACT_PROMPT_FILE = "agent/compact-summary.md" val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") } @@ -70,15 +73,29 @@ class ContextCompactor @Inject constructor( val windowMetadata = modelMetadataService.resolve(windowModel.providerId, inferProviderType(windowModel), windowModel.model) val summaryMetadata = modelMetadataService.resolve(aiProvider.providerId, inferProviderType(aiProvider), aiProvider.model) val contextLimit = windowMetadata.contextTokens.takeIf { it > 0 } ?: ModelContextPolicy.DEFAULT_CONTEXT_TOKENS - // 触发阈值百分比由「偏好设置 → 模型」配置(默认 90,见 GeneralSettingsRepository)。 - val triggerThreshold = (contextLimit * generalSettingsRepository.compactionThresholdPercent() / 100.0).toInt() + // 硬阈值:完整摘要压缩;软阈值:只精简历史工具输出(不调 LLM)。均可在偏好设置配置。 + val hardPercent = generalSettingsRepository.compactionThresholdPercent() + val softPercent = generalSettingsRepository.softCompactionThresholdPercent() + val triggerThreshold = (contextLimit * hardPercent / 100.0).toInt() + val softThreshold = (contextLimit * softPercent / 100.0).toInt() // 真实 usage 优先(含 system prompt + tools,与上下文窗口同口径);取不到(0)回退本地估算 val currentTokens = lastInputTokens.takeIf { it > 0 } ?: estimatedTokens - val reachedThreshold = currentTokens >= triggerThreshold - val reachedHardLimit = currentTokens >= contextLimit - if (messages.size <= 2 || (!force && !reachedThreshold && !reachedHardLimit)) { - return messages.toList() + val reachedHard = currentTokens >= triggerThreshold || currentTokens >= contextLimit + val reachedSoft = currentTokens >= softThreshold + if (messages.size <= 2) return messages.toList() + // 软阈值:先静默精简历史里的超长工具输出(不调摘要模型、不发事件、不落库), + // 让上下文尽量在 40% 退化分界以下停留更久,只在真正逼近硬上限时才做完整摘要。 + if (!force && !reachedHard && reachedSoft) { + val trimmed = softTrim(messages) + if (trimmed !== messages) { + FileLogger.i( + TAG, + "会话 ${sessionId ?: "-"} 上下文约 $currentTokens tokens 达软阈值 $softThreshold,精简历史工具输出(未调用摘要模型)" + ) + } + return trimmed } + if (!force && !reachedHard) return messages.toList() val tokensSource = if (lastInputTokens > 0) "真实 usage" else "本地估算" // 窗口来源一并打出来:命中目录(含命中的 provider 与自定义覆盖)还是走了 128k 兜底, @@ -240,6 +257,24 @@ class ContextCompactor @Inject constructor( return newMessages } + /** + * 软精简:不调 LLM、不落库,只把历史里超长的工具输出截断,降低主上下文冗余。 + * 输入主要来自工具结果(文件内容、命令输出等),这里只做保守截断,不改角色结构、不动 DB。 + * 未产生变化时返回原列表引用,便于调用方判断。 + */ + private fun softTrim(messages: List): List { + var changed = false + val result = messages.map { msg -> + if (msg is AgentMessage.ToolResultMessage && msg.result.length > SOFT_TRIM_TOOL_CHARS) { + changed = true + msg.copy(result = msg.result.take(SOFT_TRIM_TOOL_CHARS) + "\n[工具输出已精简以节省上下文]") + } else { + msg + } + } + return if (changed) result else messages + } + /** * 调整拆分索引,确保 tail 不是以 ToolResultMessage 开头。 * diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt index cd833a53..61cdbb55 100644 --- a/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt @@ -46,6 +46,7 @@ class GeneralSettingsRepository @Inject constructor( val MAX_NETWORK_RETRIES_KEY = intPreferencesKey("max_network_retries") val ENTER_TO_SEND_KEY = booleanPreferencesKey("enter_to_send") val COMPACTION_THRESHOLD_PERCENT_KEY = intPreferencesKey("compaction_threshold_percent") + val SOFT_COMPACTION_THRESHOLD_PERCENT_KEY = intPreferencesKey("soft_compaction_threshold_percent") val SENDFILE_MAX_SIZE_MB_KEY = intPreferencesKey("sendfile_max_size_mb") val DELETE_EXTERNAL_WORKSPACE_SESSIONS_KEY = booleanPreferencesKey("delete_external_workspace_sessions") @@ -55,8 +56,11 @@ class GeneralSettingsRepository @Inject constructor( /** 网络重试次数默认 6,与原硬编码值一致。 */ const val DEFAULT_MAX_NETWORK_RETRIES = 6 - /** 自动压缩阈值默认 90%,与原硬编码值一致。 */ - const val DEFAULT_COMPACTION_THRESHOLD_PERCENT = 90 + /** 完整摘要压缩(硬)阈值默认 85%。 */ + const val DEFAULT_COMPACTION_THRESHOLD_PERCENT = 85 + + /** 软精简阈值默认 60%:达到即先精简历史里的超长工具输出,不调用摘要模型。 */ + const val DEFAULT_SOFT_COMPACTION_THRESHOLD_PERCENT = 60 /** sendFile 单个文件大小上限默认 100MB,与原硬编码值一致。 */ const val DEFAULT_SENDFILE_MAX_SIZE_MB = 100 @@ -171,6 +175,22 @@ class GeneralSettingsRepository @Inject constructor( /** 压缩前读取一次触发阈值百分比。 */ suspend fun compactionThresholdPercent(): Int = compactionThresholdPercentFlow.first() + /** 软精简阈值(上下文窗口的百分比):达到即先精简历史工具输出,不调 LLM;默认 60,限定 1..100。 */ + val softCompactionThresholdPercentFlow: Flow = context.generalDataStore.data.map { + (it[SOFT_COMPACTION_THRESHOLD_PERCENT_KEY] ?: DEFAULT_SOFT_COMPACTION_THRESHOLD_PERCENT).coerceIn(1, 100) + } + + suspend fun setSoftCompactionThresholdPercent(percent: Int) { + context.generalDataStore.edit { it[SOFT_COMPACTION_THRESHOLD_PERCENT_KEY] = percent.coerceIn(1, 100) } + } + + /** 压缩前读取一次软精简阈值百分比。 */ + suspend fun softCompactionThresholdPercent(): Int = softCompactionThresholdPercentFlow.first() + + suspend fun softCompactionThresholdPercentSnapshot(): Int = softCompactionThresholdPercentFlow.first() + + suspend fun restoreSoftCompactionThresholdPercent(percent: Int) = setSoftCompactionThresholdPercent(percent) + /** 备份快照:回车发送开关与压缩阈值。 */ suspend fun enterToSendSnapshot(): Boolean = enterToSendFlow.first() diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt index 4eecd084..f5aff662 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt @@ -492,9 +492,12 @@ class SettingsViewModel @Inject constructor( private val _enterToSend = MutableStateFlow(false) val enterToSend: StateFlow = _enterToSend.asStateFlow() - private val _compactionThresholdPercent = MutableStateFlow(90) + private val _compactionThresholdPercent = MutableStateFlow(85) val compactionThresholdPercent: StateFlow = _compactionThresholdPercent.asStateFlow() + private val _softCompactionThresholdPercent = MutableStateFlow(60) + val softCompactionThresholdPercent: StateFlow = _softCompactionThresholdPercent.asStateFlow() + private val _sendFileMaxSizeMb = MutableStateFlow(100) val sendFileMaxSizeMb: StateFlow = _sendFileMaxSizeMb.asStateFlow() @@ -814,6 +817,12 @@ class SettingsViewModel @Inject constructor( } } + launch { + generalSettingsRepository.softCompactionThresholdPercentFlow.collectLatest { + _softCompactionThresholdPercent.value = it + } + } + launch { generalSettingsRepository.sendFileMaxSizeMbFlow.collectLatest { _sendFileMaxSizeMb.value = it @@ -1509,13 +1518,20 @@ class SettingsViewModel @Inject constructor( } } - /** 自动压缩触发阈值(上下文窗口百分比,1..100)。 */ + /** 自动压缩触发阈值(硬,上下文窗口百分比,1..100)。 */ fun setCompactionThresholdPercent(percent: Int) { viewModelScope.launch { generalSettingsRepository.setCompactionThresholdPercent(percent) } } + /** 软精简触发阈值(上下文窗口百分比,1..100):达到即先精简历史工具输出,不调摘要模型。 */ + fun setSoftCompactionThresholdPercent(percent: Int) { + viewModelScope.launch { + generalSettingsRepository.setSoftCompactionThresholdPercent(percent) + } + } + /** sendFile 单个文件大小上限(MB,不小于 1)。 */ fun setSendFileMaxSizeMb(mb: Int) { viewModelScope.launch { diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/GeneralSettingsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/GeneralSettingsSection.kt index 370e54c4..68d70757 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/GeneralSettingsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/GeneralSettingsSection.kt @@ -58,6 +58,8 @@ internal fun GeneralSettingsSection( onToggleEnterToSend: (Boolean) -> Unit, compactionThresholdPercent: Int, onSetCompactionThresholdPercent: (Int) -> Unit, + softCompactionThresholdPercent: Int, + onSetSoftCompactionThresholdPercent: (Int) -> Unit, sendFileMaxSizeMb: Int, onSetSendFileMaxSizeMb: (Int) -> Unit, deleteExternalWorkspaceSessions: Boolean, @@ -68,6 +70,7 @@ internal fun GeneralSettingsSection( var editingStreamIdleTimeout by remember { mutableStateOf(false) } var editingMaxNetworkRetries by remember { mutableStateOf(false) } var editingCompactionThreshold by remember { mutableStateOf(false) } + var editingSoftCompactionThreshold by remember { mutableStateOf(false) } var editingSendFileMaxSize by remember { mutableStateOf(false) } Column( @@ -105,6 +108,20 @@ internal fun GeneralSettingsSection( ) } ) + SettingsDivider() + SettingsRow( + icon = null, + title = stringResource(R.string.settings_soft_compaction_threshold), + subtitle = stringResource(R.string.settings_soft_compaction_threshold_desc), + onClick = { editingSoftCompactionThreshold = true }, + trailing = { + Text( + text = stringResource(R.string.settings_percent_value, softCompactionThresholdPercent), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.semanticColors.subtleText + ) + } + ) } SettingsGroupHeader(text = stringResource(R.string.settings_general_session)) @@ -278,6 +295,21 @@ internal fun GeneralSettingsSection( ) } + if (editingSoftCompactionThreshold) { + NumberInputDialog( + title = stringResource(R.string.settings_soft_compaction_threshold), + initialValue = softCompactionThresholdPercent, + hint = stringResource(R.string.settings_compaction_threshold_input_hint), + minValue = 1, + maxValue = 100, + onConfirm = { + onSetSoftCompactionThresholdPercent(it) + editingSoftCompactionThreshold = false + }, + onDismiss = { editingSoftCompactionThreshold = false } + ) + } + if (editingSendFileMaxSize) { NumberInputDialog( title = stringResource(R.string.settings_sendfile_max_size), diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index c59f7eb2..2a45c941 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -219,6 +219,7 @@ fun SettingsScreen( val maxNetworkRetries by viewModel.maxNetworkRetries.collectAsStateWithLifecycle() val enterToSend by viewModel.enterToSend.collectAsStateWithLifecycle() val compactionThresholdPercent by viewModel.compactionThresholdPercent.collectAsStateWithLifecycle() + val softCompactionThresholdPercent by viewModel.softCompactionThresholdPercent.collectAsStateWithLifecycle() val sendFileMaxSizeMb by viewModel.sendFileMaxSizeMb.collectAsStateWithLifecycle() val deleteExternalWorkspaceSessions by viewModel.deleteExternalWorkspaceSessions.collectAsStateWithLifecycle() val themeMode by viewModel.themeMode.collectAsStateWithLifecycle() @@ -764,6 +765,8 @@ fun SettingsScreen( onToggleEnterToSend = { viewModel.setEnterToSend(it) }, compactionThresholdPercent = compactionThresholdPercent, onSetCompactionThresholdPercent = { viewModel.setCompactionThresholdPercent(it) }, + softCompactionThresholdPercent = softCompactionThresholdPercent, + onSetSoftCompactionThresholdPercent = { viewModel.setSoftCompactionThresholdPercent(it) }, sendFileMaxSizeMb = sendFileMaxSizeMb, onSetSendFileMaxSizeMb = { viewModel.setSendFileMaxSizeMb(it) }, deleteExternalWorkspaceSessions = deleteExternalWorkspaceSessions, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 9bea4cb0..0d5cf0fb 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -36,7 +36,9 @@ Enter key sends message When off, Enter inserts a newline and you send with the button Auto-compaction threshold - Compact context once it reaches this share of the window + Summarize early history once context reaches this share of the window + Soft trim threshold + Trim long tool outputs from history first once context reaches this share (without summarizing) %1$d%% Integer percentage between 1 and 100 Tools diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e50ff03b..8bcfdf96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,7 +36,9 @@ 回车键发送消息 关闭后回车换行,需点发送按钮发送 自动压缩阈值 - 上下文占用达到窗口的该比例时自动压缩 + 上下文占用达到窗口的该比例时,生成摘要压缩早期历史 + 软精简阈值 + 上下文占用达到该比例时,先精简历史里的超长工具输出,暂不调用摘要模型 %1$d%% 填 1-100 的整数百分比 工具 From 1b734b09601ce8309bc65c75cf3e41b19879daa5 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:29:35 +0000 Subject: [PATCH 2/5] =?UTF-8?q?ci:=20CI=20=E4=B8=8A=E4=BC=A0=20universal?= =?UTF-8?q?=20debug=20APK=20=E4=B8=BA=20artifact=EF=BC=8C=E4=BE=BF?= =?UTF-8?q?=E4=BA=8E=E4=B8=8B=E8=BD=BD=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bb155a1..48560396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,11 @@ jobs: # 跑全 flavor 6 变体是重复编译,无额外覆盖。 - name: Run unit tests run: ./gradlew :app:testUniversalDebugUnitTest + + # 上传 universal debug APK 作为 artifact,便于直接下载安装验证(无需签名 secrets)。 + - name: Upload debug APK artifact + uses: actions/upload-artifact@v4 + with: + name: aicode-debug-${{ github.sha }} + path: app/build/outputs/apk/universal/debug/app-universal-debug.apk + if-no-files-found: warn From 88619acfc19ccff76a9d4cd383a14941490a3216 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:57:01 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(agent):=20=E9=99=84=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=B8=B2=E8=A1=8C=E5=8C=96=E4=BF=AE=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E4=B8=A2=E9=99=84=E4=BB=B6=20+=20=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E5=9B=BE=E5=8C=BA=E8=87=AA=E5=8A=A8=E6=BB=9A=E5=88=B0?= =?UTF-8?q?=E6=9C=80=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handlePickedAttachments 用 Mutex 串行化: 两次上传并发时 pendingAttachments 读-改-写互相覆盖, 先选附件预览丢失 - PendingAttachmentPreviewList 保持横向滑动, 新附件加入自动滚到最右保证可见 --- .../presentation/component/AIChatPanel.kt | 31 ++++++++++++------- .../component/ChatInputAttachments.kt | 8 ++++- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt index 603e939c..f4811943 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/AIChatPanel.kt @@ -91,6 +91,8 @@ import java.io.File import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** @@ -491,6 +493,8 @@ fun AIChatPanel( } var pendingAttachments by remember { mutableStateOf>(emptyList()) } var uploadingCount by remember { mutableStateOf(0) } + // 串行化附件上传:两次上传并发时 pendingAttachments 的读-改-写会互相覆盖,导致先选的附件预览丢失。 + val attachmentUploadMutex = remember { Mutex() } var messageForMenu by remember { mutableStateOf(null) } var editingMessage by remember { mutableStateOf(null) } val listState = rememberLazyListState() @@ -709,20 +713,23 @@ fun AIChatPanel( scope.launch { var successCount = 0 val failures = mutableListOf() - uploadingCount = selected.size - try { - selected.forEach { uri -> - runCatching { - copyUriToWorkspace(context, uri, viewModel.fileAccess, includeImageData = images) - }.onSuccess { uploaded -> - pendingAttachments = pendingAttachments + uploaded.toPendingAttachment() - successCount += 1 - }.onFailure { error -> - failures += (error.message ?: uploadFallbackError(context)) + // 串行化:并发上传会让 pendingAttachments 的读-改-写互相覆盖,先选的附件预览丢失。 + attachmentUploadMutex.withLock { + uploadingCount = selected.size + try { + selected.forEach { uri -> + runCatching { + copyUriToWorkspace(context, uri, viewModel.fileAccess, includeImageData = images) + }.onSuccess { uploaded -> + pendingAttachments = pendingAttachments + uploaded.toPendingAttachment() + successCount += 1 + }.onFailure { error -> + failures += (error.message ?: uploadFallbackError(context)) + } } + } finally { + uploadingCount = 0 } - } finally { - uploadingCount = 0 } // 结果提示:全失败展示首个错误;有文件被上限截断或上传失败时用 partial 文案;全成功用 success 文案。 val skipped = uris.size - selected.size diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputAttachments.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputAttachments.kt index dd857db2..7a65e385 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputAttachments.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/ChatInputAttachments.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -148,10 +149,15 @@ internal fun PendingAttachmentPreviewList( ) { if (attachments.isEmpty()) return + val scrollState = rememberScrollState() + // 新附件加入时自动滚到最右,保证刚上传的附件可见(横向滑动,不改竖向)。 + LaunchedEffect(attachments.size) { + scrollState.animateScrollTo(scrollState.maxValue) + } Row( modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) + .horizontalScroll(scrollState) .padding(horizontal = Spacing.xs, vertical = Spacing.xs), horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { From 2800a788b60465273a92028a76d7e686fba0436f Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:28:40 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat(settings):=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=AE=A1=E7=90=86=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RemoteSkillFileAccess: 按远程 SSH 配置自建独立 SFTP 通道, 实现技能扫描/读写子集 - 新增 RemoteSkillsManager: 连接状态 + 远程技能 CRUD - SkillRepository 抽出 provider 参数化的 listSkillsFrom/saveTo/deleteSkillFrom/importMarkdownTo/importZipTo, 现有方法委托 - 技能页新增「远程服务器」分组(仅本地模式), 详情/编辑/添加支持远程来源 - v1 用远程 SSH 模式的 remoteWorkspacePath 作工作区根, 远程技能暂不含启用/禁用 --- .../agent/domain/skill/RemoteSkillsManager.kt | 149 +++++++++++ .../agent/domain/skill/SkillRepository.kt | 67 +++-- .../presentation/SettingsViewModel.kt | 76 +++++- .../settings/presentation/SkillSource.kt | 13 + .../presentation/component/SettingsScreen.kt | 65 ++++- .../presentation/component/SkillAddSheet.kt | 24 +- .../component/SkillDetailSection.kt | 38 +-- .../component/SkillEditorScreen.kt | 38 ++- .../presentation/component/SkillsSection.kt | 124 ++++++++- .../workspace/domain/RemoteSkillFileAccess.kt | 246 ++++++++++++++++++ app/src/main/res/values-en/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + 12 files changed, 784 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/skill/RemoteSkillsManager.kt create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/SkillSource.kt create mode 100644 app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt diff --git a/app/src/main/java/com/aicode/feature/agent/domain/skill/RemoteSkillsManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/skill/RemoteSkillsManager.kt new file mode 100644 index 00000000..ccaf60fd --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/skill/RemoteSkillsManager.kt @@ -0,0 +1,149 @@ +package com.aicode.feature.agent.domain.skill + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.container.SshHostKeyVerifier +import com.aicode.feature.settings.data.repository.ExecutionModeRepository +import com.aicode.feature.workspace.domain.RemoteSkillConnection +import com.aicode.feature.workspace.domain.RemoteSkillFileAccess +import com.aicode.feature.workspace.domain.WorkspacePathMapper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import java.io.InputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** 远程技能加载状态。 */ +sealed interface RemoteSkillsState { + /** 未配置远程 SSH(或未连接)。 */ + data object NotConfigured : RemoteSkillsState + + /** 正在连接 / 扫描。 */ + data object Loading : RemoteSkillsState + + /** 已连上 [host],[skills] 为远程工作区里的技能。 */ + data class Loaded(val host: String, val skills: List) : RemoteSkillsState + + /** 连接或读取失败。 */ + data class Failed(val message: String) : RemoteSkillsState +} + +/** + * 本地模式下管理「远程 SSH 模式」那台服务器技能的入口:按当前远程连接配置建立独立的 SFTP 访问器, + * 对远程工作区的 `.aicode/skills/` 做扫描与完整 CRUD。连接与访问器由本类持有,UI 只读 [state]。 + */ +@Singleton +class RemoteSkillsManager @Inject constructor( + private val skillRepository: SkillRepository, + private val executionModeRepository: ExecutionModeRepository, + private val hostKeyVerifier: SshHostKeyVerifier +) { + private val _state = MutableStateFlow(RemoteSkillsState.NotConfigured) + val state: StateFlow = _state.asStateFlow() + + private var access: RemoteSkillFileAccess? = null + private var currentHost: String? = null + + /** 远程工作区里的技能根(容器路径,经 provider 映射到远程工作区)。 */ + private val skillsRoot: String = "${WorkspacePathMapper.CONTAINER_ROOT}/.aicode/skills" + + /** 读取当前远程 SSH 配置并(重新)连接、扫描远程技能。 */ + suspend fun connect() { + _state.value = RemoteSkillsState.Loading + val settings = executionModeRepository.remoteConnectionFlow.first() + if (settings == null || settings.host.isBlank()) { + closeAccess() + _state.value = RemoteSkillsState.NotConfigured + return + } + try { + if (access == null || currentHost != settings.host) { + closeAccess() + access = RemoteSkillFileAccess( + RemoteSkillConnection( + host = settings.host, + port = settings.port, + username = settings.username, + password = settings.password, + workspaceRoot = settings.remoteWorkspacePath + ), + hostKeyVerifier + ) + currentHost = settings.host + } + val skills = skillRepository.listSkillsFrom(requireAccess(), skillsRoot) + _state.value = RemoteSkillsState.Loaded(settings.host, skills) + } catch (e: Exception) { + FileLogger.w(TAG, "加载远程技能失败", e) + _state.value = RemoteSkillsState.Failed(e.message ?: "连接失败") + } + } + + /** 重新扫描已连接的远程技能;未连接时退回 [connect]。 */ + suspend fun refresh() { + val a = access + val host = currentHost + if (a == null || host == null) return connect() + try { + val skills = skillRepository.listSkillsFrom(a, skillsRoot) + _state.value = RemoteSkillsState.Loaded(host, skills) + } catch (e: Exception) { + FileLogger.w(TAG, "刷新远程技能失败", e) + _state.value = RemoteSkillsState.Failed(e.message ?: "连接失败") + } + } + + suspend fun save(form: SkillForm, originalName: String?): SkillSaveError? { + val a = requireAccess() + val existing = skillRepository.listSkillsFrom(a, skillsRoot) + val error = skillRepository.saveTo(a, skillsRoot, form, originalName, existing) + if (error == null) refresh() + return error + } + + suspend fun delete(name: String): Boolean { + val a = requireAccess() + val existing = skillRepository.listSkillsFrom(a, skillsRoot) + val ok = skillRepository.deleteSkillFrom(a, name, existing) + if (ok) refresh() + return ok + } + + suspend fun importMarkdown(text: String, fallbackName: String): SkillImportReport { + val a = requireAccess() + val existing = existingNames(a) + val report = skillRepository.importMarkdownTo(a, skillsRoot, existing, text, fallbackName) + refresh() + return report + } + + suspend fun importZip(input: InputStream, fallbackName: String): SkillImportReport { + val a = requireAccess() + val existing = existingNames(a) + val report = skillRepository.importZipTo(a, skillsRoot, existing, input, fallbackName) + refresh() + return report + } + + fun disconnect() { + closeAccess() + _state.value = RemoteSkillsState.NotConfigured + } + + private suspend fun existingNames(a: RemoteSkillFileAccess): Set = + skillRepository.listSkillsFrom(a, skillsRoot).map { it.name.lowercase() }.toSet() + + private fun requireAccess(): RemoteSkillFileAccess = + access ?: throw IllegalStateException("远程技能未连接") + + private fun closeAccess() { + runCatching { access?.close() } + access = null + currentHost = null + } + + private companion object { + const val TAG = "RemoteSkillsManager" + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/skill/SkillRepository.kt b/app/src/main/java/com/aicode/feature/agent/domain/skill/SkillRepository.kt index f9453574..c9df82ed 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/skill/SkillRepository.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/skill/SkillRepository.kt @@ -30,6 +30,10 @@ class SkillRepository @Inject constructor( fun listSkills(): List = filterDisabled(listAllSkills(), skillConfigRepository.disabledNames()).map { it.skill } + /** 用外部 [provider] + 技能根 [root] 扫描技能(本地/远程同一套逻辑)。 */ + fun listSkillsFrom(provider: FileAccessProvider, root: String): List = + SkillDirectoryScanner.scan(provider, root) + /** 读取指定 skill 的完整指令正文;不存在 / 解析失败 / 已被禁用时返回 null。 */ fun loadInstructions(name: String): String? { if (name.lowercase() in skillConfigRepository.disabledNames()) return null @@ -52,21 +56,32 @@ class SkillRepository @Inject constructor( * 技能正文常按 `~/.aicode/skills/<目录>/run.py` 引用同目录脚本,跟着改名会把这些引用打断。 */ fun save(form: SkillForm, scope: SkillScope, originalName: String? = null): SkillSaveError? { + val existing = listAllSkills().filter { it.scope == scope }.map { it.skill } + return saveTo(providerFor(scope), skillsRoot(scope), form, originalName, existing) + } + + /** + * 用外部 [provider] + 技能根 [root] 保存技能(本地与远程同一套逻辑)。 + * [existing] 为该来源下已有技能,用于同名冲突判定与定位编辑前的原目录。 + */ + fun saveTo( + provider: FileAccessProvider, + root: String, + form: SkillForm, + originalName: String? = null, + existing: List = emptyList() + ): SkillSaveError? { val name = form.name.trim() if (!isValidName(name)) return SkillSaveError.INVALID_NAME if (form.instructions.isBlank()) return SkillSaveError.EMPTY_INSTRUCTIONS - val entries = listAllSkills() val keepingName = originalName != null && originalName.equals(name, ignoreCase = true) - if (!keepingName) { - val taken = entries.any { it.scope == scope && it.skill.name.equals(name, ignoreCase = true) } - if (taken) return SkillSaveError.NAME_CONFLICT + if (!keepingName && existing.any { it.name.equals(name, ignoreCase = true) }) { + return SkillSaveError.NAME_CONFLICT } val existingDir = originalName?.let { old -> - entries.firstOrNull { - it.scope == scope && it.skill.name.equals(old, ignoreCase = true) - }?.skill?.dirPath + existing.firstOrNull { it.name.equals(old, ignoreCase = true) }?.dirPath } val text = SkillParser.serialize( @@ -76,9 +91,8 @@ class SkillRepository @Inject constructor( instructions = form.instructions ) - val provider = providerFor(scope) return try { - val dir = existingDir?.takeIf { provider.isDirectory(it) } ?: "${skillsRoot(scope)}/$name" + val dir = existingDir?.takeIf { provider.isDirectory(it) } ?: "${root.trimEnd('/')}/$name" provider.mkdirs(dir) val target = instructionFile(provider, dir) ?: "${dir.trimEnd('/')}/$INSTRUCTION_FILE" provider.writeFile(target, text, overwrite = true) @@ -102,11 +116,29 @@ class SkillRepository @Inject constructor( * 名称非法 / 同名冲突 / 正文为空时整体失败,不落盘。 */ fun importMarkdown(text: String, fallbackName: String, scope: SkillScope): SkillImportReport = - SkillImporter.importMarkdown(providerFor(scope), skillsRoot(scope), existingNamesIn(scope), text, fallbackName) + importMarkdownTo(providerFor(scope), skillsRoot(scope), existingNamesIn(scope), text, fallbackName) + + /** 用外部 [provider] + 技能根 [root] 导入 Markdown 技能。 */ + fun importMarkdownTo( + provider: FileAccessProvider, + root: String, + existingNames: Set, + text: String, + fallbackName: String + ): SkillImportReport = SkillImporter.importMarkdown(provider, root, existingNames, text, fallbackName) /** 从 zip 输入流导入技能(可含多个技能目录)到指定作用域。 */ fun importZip(input: InputStream, fallbackName: String, scope: SkillScope): SkillImportReport = - SkillImporter.importArchive(providerFor(scope), skillsRoot(scope), existingNamesIn(scope), input, fallbackName) + importZipTo(providerFor(scope), skillsRoot(scope), existingNamesIn(scope), input, fallbackName) + + /** 用外部 [provider] + 技能根 [root] 导入 zip 技能。 */ + fun importZipTo( + provider: FileAccessProvider, + root: String, + existingNames: Set, + input: InputStream, + fallbackName: String + ): SkillImportReport = SkillImporter.importArchive(provider, root, existingNames, input, fallbackName) /** 指定作用域下已有技能名(小写),供导入查重。 */ private fun existingNamesIn(scope: SkillScope): Set = @@ -114,11 +146,14 @@ class SkillRepository @Inject constructor( /** 删除指定作用域的技能(删除其目录,不可恢复)。返回是否成功。 */ fun deleteSkill(name: String, scope: SkillScope): Boolean { - val entry = listAllSkills().firstOrNull { - it.skill.name.equals(name, ignoreCase = true) && it.scope == scope - } ?: return false - val dirPath = entry.skill.dirPath ?: return false - return safeDeleteSkillDir(providerFor(scope), dirPath) + val existing = listAllSkills().filter { it.scope == scope }.map { it.skill } + return deleteSkillFrom(providerFor(scope), name, existing) + } + + /** 用外部 [provider] 删除指定技能(删除其目录,不可恢复)。 */ + fun deleteSkillFrom(provider: FileAccessProvider, name: String, existing: List): Boolean { + val dirPath = existing.firstOrNull { it.name.equals(name, ignoreCase = true) }?.dirPath ?: return false + return safeDeleteSkillDir(provider, dirPath) } /** 全局技能固定在本地私有目录,项目级技能跟随工作区(可能是远程)。 */ diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt index f5aff662..eca967b7 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt @@ -29,6 +29,8 @@ import com.aicode.feature.agent.domain.mcp.McpToolDescriptor import com.aicode.feature.agent.domain.model.AgentMode import com.aicode.feature.agent.domain.permission.PermissionRule import com.aicode.feature.agent.domain.permission.PermissionRulesRepository +import com.aicode.feature.agent.domain.skill.RemoteSkillsManager +import com.aicode.feature.agent.domain.skill.RemoteSkillsState import com.aicode.feature.agent.domain.skill.SkillConfigRepository import com.aicode.feature.agent.domain.skill.SkillForm import com.aicode.feature.agent.domain.skill.SkillImportError @@ -226,7 +228,9 @@ data class SkillUiEntry( val disabled: Boolean, val instructions: String, /** 手写技能里的 `required_tools`,编辑页不展示,保存时原样写回。 */ - val requiredTools: List = emptyList() + val requiredTools: List = emptyList(), + /** 是否来自远程服务器工作区(本地模式下的「远程服务器」分组)。 */ + val remote: Boolean = false ) /** 子代理列表页的 UI 状态:定义内容 + 来源作用域 + 启停状态。 */ @@ -313,6 +317,7 @@ class SettingsViewModel @Inject constructor( private val permissionRulesRepository: PermissionRulesRepository, private val toolSafetySettingsRepository: ToolSafetySettingsRepository, private val skillRepository: SkillRepository, + private val remoteSkillsManager: RemoteSkillsManager, private val agentDefinitionRepository: AgentDefinitionRepository, private val agentDefinitionConfigRepository: AgentDefinitionConfigRepository, private val toolRegistry: ToolRegistry, @@ -540,6 +545,9 @@ class SettingsViewModel @Inject constructor( private val _skillImportState = MutableStateFlow(SkillImportState.Idle) val skillImportState: StateFlow = _skillImportState.asStateFlow() + /** 远程服务器技能状态(本地模式下管理「远程 SSH 模式」那台服务器)。 */ + val remoteSkills: StateFlow = remoteSkillsManager.state + private val _subAgents = MutableStateFlow>(emptyList()) val subAgents: StateFlow> = _subAgents.asStateFlow() @@ -1174,6 +1182,72 @@ class SettingsViewModel @Inject constructor( _skillSaveState.value = SkillSaveState.Idle } + // ================= 远程服务器技能(本地模式下管理「远程 SSH 模式」那台服务器) ================= + + /** 连接远程 SSH 并扫描其工作区技能(进入技能页 / 手动刷新时调用)。 */ + fun connectRemoteSkills() { + viewModelScope.launch { remoteSkillsManager.connect() } + } + + fun refreshRemoteSkills() { + viewModelScope.launch { remoteSkillsManager.refresh() } + } + + /** 保存远程技能;结果写入 [skillSaveState],编辑页据此退回或报错。 */ + fun saveRemoteSkill(form: SkillForm, originalName: String? = null) { + viewModelScope.launch { + val error = withContext(Dispatchers.IO) { remoteSkillsManager.save(form, originalName) } + _skillSaveState.value = if (error == null) SkillSaveState.Saved else SkillSaveState.Failed(error) + } + } + + fun deleteRemoteSkill(name: String) { + viewModelScope.launch { withContext(Dispatchers.IO) { remoteSkillsManager.delete(name) } } + } + + /** 从 Markdown 文件导入技能到远程工作区。 */ + fun importRemoteSkillFromMarkdown(uri: Uri) { + if (_skillImportState.value is SkillImportState.Running) return + _skillImportState.value = SkillImportState.Running + viewModelScope.launch { + val report = withContext(Dispatchers.IO) { + val name = queryDisplayName(uri) + if (!name.hasExtension(MARKDOWN_EXTENSIONS)) { + SkillImportReport(emptyList(), fatal = SkillImportError.UNSUPPORTED_FILE) + } else { + runCatching { + val text = context.contentResolver.openInputStream(uri) + ?.bufferedReader()?.use { it.readText() } + ?: throw java.io.IOException("openInputStream returned null") + remoteSkillsManager.importMarkdown(text, name.substringBeforeLast('.')) + }.getOrElse { SkillImportReport(emptyList(), fatal = SkillImportError.IO_FAILED) } + } + } + finishSkillImport(report) + } + } + + /** 从 zip 压缩包导入技能到远程工作区。 */ + fun importRemoteSkillFromZip(uri: Uri) { + if (_skillImportState.value is SkillImportState.Running) return + _skillImportState.value = SkillImportState.Running + viewModelScope.launch { + val report = withContext(Dispatchers.IO) { + val name = queryDisplayName(uri) + if (!name.hasExtension(ZIP_EXTENSIONS)) { + SkillImportReport(emptyList(), fatal = SkillImportError.UNSUPPORTED_FILE) + } else { + runCatching { + context.contentResolver.openInputStream(uri)?.use { input -> + remoteSkillsManager.importZip(input, name.substringBeforeLast('.')) + } ?: throw java.io.IOException("openInputStream returned null") + }.getOrElse { SkillImportReport(emptyList(), fatal = SkillImportError.INVALID_ARCHIVE) } + } + } + finishSkillImport(report) + } + } + /** * 从所选 Markdown 文件导入技能:读取文本并写入指定作用域。 * 扩展名不在白名单内直接报「类型不支持」,不落盘。 diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/SkillSource.kt b/app/src/main/java/com/aicode/feature/settings/presentation/SkillSource.kt new file mode 100644 index 00000000..a47997f1 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/SkillSource.kt @@ -0,0 +1,13 @@ +package com.aicode.feature.settings.presentation + +/** 技能的保存 / 导入目标来源。 */ +enum class SkillSource { + /** 全局技能(App 私有目录,跨项目共享)。 */ + GLOBAL, + + /** 当前项目工作区技能。 */ + PROJECT, + + /** 远程 SSH 模式那台服务器的工作区技能(仅本地模式下可管理)。 */ + REMOTE +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index 2a45c941..693ef085 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -85,7 +85,10 @@ import com.aicode.feature.settings.presentation.ShizukuViewModel import com.aicode.feature.settings.presentation.SkillImportState import com.aicode.feature.settings.presentation.SkillUiEntry import com.aicode.feature.agent.domain.skill.SkillImportError +import com.aicode.feature.agent.domain.skill.RemoteSkillsState import com.aicode.feature.agent.domain.skill.SkillScope +import com.aicode.feature.settings.data.repository.ExecutionMode +import com.aicode.feature.settings.presentation.SkillSource import com.aicode.feature.settings.presentation.SubAgentUiEntry import compose.icons.FeatherIcons import compose.icons.feathericons.ArrowLeft @@ -203,6 +206,8 @@ fun SettingsScreen( val skills by viewModel.skills.collectAsStateWithLifecycle() val skillSaveState by viewModel.skillSaveState.collectAsStateWithLifecycle() val skillImportState by viewModel.skillImportState.collectAsStateWithLifecycle() + val remoteSkillsState by viewModel.remoteSkills.collectAsStateWithLifecycle() + val executionMode by viewModel.executionMode.collectAsStateWithLifecycle() val subAgents by viewModel.subAgents.collectAsStateWithLifecycle() val subAgentSaveState by viewModel.subAgentSaveState.collectAsStateWithLifecycle() val globalRules by viewModel.globalRules.collectAsStateWithLifecycle() @@ -332,13 +337,25 @@ fun SettingsScreen( var pendingSkillName by remember { mutableStateOf(null) } // 「添加技能」底部弹层:选择作用域后走手动新建 / 文件导入 / 压缩包导入。 var showSkillAddSheet by remember { mutableStateOf(false) } - var skillImportScope by remember { mutableStateOf(SkillScope.GLOBAL) } + var skillSource by remember { mutableStateOf(SkillSource.GLOBAL) } // 技能文件 / 压缩包选择器:结果交给 ViewModel 读取并落盘到所选作用域。 val skillFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) viewModel.importSkillFromMarkdown(uri, skillImportScope) + if (uri != null) { + when (skillSource) { + SkillSource.GLOBAL -> viewModel.importSkillFromMarkdown(uri, SkillScope.GLOBAL) + SkillSource.PROJECT -> viewModel.importSkillFromMarkdown(uri, SkillScope.PROJECT) + SkillSource.REMOTE -> viewModel.importRemoteSkillFromMarkdown(uri) + } + } } val skillZipLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) viewModel.importSkillsFromZip(uri, skillImportScope) + if (uri != null) { + when (skillSource) { + SkillSource.GLOBAL -> viewModel.importSkillsFromZip(uri, SkillScope.GLOBAL) + SkillSource.PROJECT -> viewModel.importSkillsFromZip(uri, SkillScope.PROJECT) + SkillSource.REMOTE -> viewModel.importRemoteSkillFromZip(uri) + } + } } var selectedSubAgent by remember { mutableStateOf(null) } var subAgentToDelete by remember { mutableStateOf(null) } @@ -389,6 +406,11 @@ fun SettingsScreen( viewModel.refreshSubAgents() } + // 本地模式下进入技能页时连接远程 SSH 并扫描其工作区技能。 + LaunchedEffect(executionMode) { + if (executionMode == ExecutionMode.LOCAL_PROOT) viewModel.connectRemoteSkills() + } + // 编辑保存后回详情页:等列表刷新出新快照再换,避免详情页停在保存前的旧值(改名时按新名找)。 LaunchedEffect(skills, pendingSkillName) { val target = pendingSkillName ?: return@LaunchedEffect @@ -398,6 +420,16 @@ fun SettingsScreen( } } + // 远程技能保存后同理:从远程状态里取新快照。 + LaunchedEffect(remoteSkillsState, pendingSkillName) { + val target = pendingSkillName ?: return@LaunchedEffect + val loaded = remoteSkillsState as? RemoteSkillsState.Loaded ?: return@LaunchedEffect + loaded.skills.firstOrNull { it.name.equals(target, ignoreCase = true) }?.let { fresh -> + selectedSkill = fresh.toUiEntry() + pendingSkillName = null + } + } + // 编辑保存后回详情页:等列表刷新出新快照再换,避免详情页停在保存前的旧值(改名时按新名找)。 LaunchedEffect(subAgents, pendingSubAgentName) { val target = pendingSubAgentName ?: return@LaunchedEffect @@ -513,8 +545,15 @@ fun SettingsScreen( current == SettingsSection.SkillEditor -> SkillEditorScreen( initial = editingSkill, saveState = skillSaveState, - defaultScope = skillImportScope, - onSave = { form, scope -> viewModel.saveSkill(form, scope, editingSkill?.name) }, + defaultSource = skillSource, + remoteAvailable = executionMode == ExecutionMode.LOCAL_PROOT, + onSave = { form, source -> + when (source) { + SkillSource.GLOBAL -> viewModel.saveSkill(form, SkillScope.GLOBAL, editingSkill?.name) + SkillSource.PROJECT -> viewModel.saveSkill(form, SkillScope.PROJECT, editingSkill?.name) + SkillSource.REMOTE -> viewModel.saveRemoteSkill(form, editingSkill?.name) + } + }, onSaved = { savedName -> viewModel.clearSkillSaveState() // 从详情页进来的改完回详情页,但得等新快照到位再展示 @@ -819,11 +858,14 @@ fun SettingsScreen( SettingsSection.Skills -> SkillsSection( projectName = currentProjectName, entries = skills, + remoteState = remoteSkillsState, + remoteVisible = executionMode == ExecutionMode.LOCAL_PROOT, onDelete = { skillToDelete = it }, onOpenDetail = { selectedSkill = it section = SettingsSection.SkillDetail - } + }, + onConnectRemote = { viewModel.connectRemoteSkills() } ) SettingsSection.SkillDetail -> selectedSkill?.let { entry -> SkillDetailSection( @@ -1017,8 +1059,9 @@ fun SettingsScreen( if (showSkillAddSheet) { SkillAddSheet( - scope = skillImportScope, - onScopeChange = { skillImportScope = it }, + source = skillSource, + onSourceChange = { skillSource = it }, + remoteAvailable = executionMode == ExecutionMode.LOCAL_PROOT, onManual = { showSkillAddSheet = false editingSkill = null @@ -1114,7 +1157,11 @@ fun SettingsScreen( text = { Text(stringResource(R.string.skills_delete_confirm_message, target.name)) }, confirmButton = { TextButton(onClick = { - viewModel.deleteSkill(target.name, target.scope) + if (target.remote) { + viewModel.deleteRemoteSkill(target.name) + } else { + viewModel.deleteSkill(target.name, target.scope) + } skillToDelete = null }) { Text(stringResource(R.string.common_delete)) } }, diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillAddSheet.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillAddSheet.kt index 9bc126d2..b2ab62ed 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillAddSheet.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillAddSheet.kt @@ -19,21 +19,22 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.aicode.R import com.aicode.core.theme.Spacing -import com.aicode.feature.agent.domain.skill.SkillScope +import com.aicode.feature.settings.presentation.SkillSource import compose.icons.FeatherIcons import compose.icons.feathericons.Archive import compose.icons.feathericons.Edit3 import compose.icons.feathericons.FileText /** - * 「添加技能」底部弹层:顶部选择作用域(全局 / 当前项目),下方三种添加方式—— + * 「添加技能」底部弹层:顶部选择来源(全局 / 当前项目 / 远程服务器),下方三种添加方式—— * 手动新建(进编辑表单)、从文件导入(.md)、从压缩包导入(.zip,可含多个技能)。 */ @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun SkillAddSheet( - scope: SkillScope, - onScopeChange: (SkillScope) -> Unit, + source: SkillSource, + onSourceChange: (SkillSource) -> Unit, + remoteAvailable: Boolean, onManual: () -> Unit, onPickFile: () -> Unit, onPickZip: () -> Unit, @@ -69,15 +70,22 @@ internal fun SkillAddSheet( color = MaterialTheme.colorScheme.onSurfaceVariant ) FilterChip( - selected = scope == SkillScope.GLOBAL, - onClick = { onScopeChange(SkillScope.GLOBAL) }, + selected = source == SkillSource.GLOBAL, + onClick = { onSourceChange(SkillSource.GLOBAL) }, label = { Text(stringResource(R.string.skills_scope_global)) } ) FilterChip( - selected = scope == SkillScope.PROJECT, - onClick = { onScopeChange(SkillScope.PROJECT) }, + selected = source == SkillSource.PROJECT, + onClick = { onSourceChange(SkillSource.PROJECT) }, label = { Text(stringResource(R.string.skills_scope_project)) } ) + if (remoteAvailable) { + FilterChip( + selected = source == SkillSource.REMOTE, + onClick = { onSourceChange(SkillSource.REMOTE) }, + label = { Text(stringResource(R.string.skills_scope_remote)) } + ) + } } SettingsGroup { diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillDetailSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillDetailSection.kt index 2ed5fd9a..8970f04f 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillDetailSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillDetailSection.kt @@ -42,24 +42,26 @@ internal fun SkillDetailSection( .padding(bottom = Spacing.xl), verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { - // 卡片 1:是否启用(开关行) - SettingsGroup { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.lg, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.skills_enable), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - AppSwitch( - checked = !entry.disabled, - onCheckedChange = onToggle - ) + // 卡片 1:是否启用(开关行)。远程技能 v1 无启用/禁用概念,不展示。 + if (!entry.remote) { + SettingsGroup { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.skills_enable), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + AppSwitch( + checked = !entry.disabled, + onCheckedChange = onToggle + ) + } } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt index f5e97c58..cddf4275 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillEditorScreen.kt @@ -35,6 +35,7 @@ import com.aicode.core.ui.AppTextField import com.aicode.feature.agent.domain.skill.SkillForm import com.aicode.feature.agent.domain.skill.SkillSaveError import com.aicode.feature.agent.domain.skill.SkillScope +import com.aicode.feature.settings.presentation.SkillSource import com.aicode.feature.settings.presentation.SkillSaveState import com.aicode.feature.settings.presentation.SkillUiEntry import compose.icons.FeatherIcons @@ -54,16 +55,24 @@ import compose.icons.feathericons.ArrowLeft internal fun SkillEditorScreen( initial: SkillUiEntry?, saveState: SkillSaveState, - onSave: (SkillForm, SkillScope) -> Unit, + onSave: (SkillForm, SkillSource) -> Unit, onSaved: (String) -> Unit, onNavigateBack: () -> Unit, - defaultScope: SkillScope = SkillScope.GLOBAL + defaultSource: SkillSource = SkillSource.GLOBAL, + remoteAvailable: Boolean = false ) { var name by rememberSaveable { mutableStateOf(initial?.name ?: "") } var description by rememberSaveable { mutableStateOf(initial?.description ?: "") } var instructions by rememberSaveable { mutableStateOf(initial?.instructions ?: "") } - var scopeIsGlobal by rememberSaveable { - mutableStateOf(initial?.scope?.let { it == SkillScope.GLOBAL } ?: (defaultScope == SkillScope.GLOBAL)) + var source by rememberSaveable { + mutableStateOf( + when { + initial == null -> defaultSource + initial.remote -> SkillSource.REMOTE + initial.scope == SkillScope.GLOBAL -> SkillSource.GLOBAL + else -> SkillSource.PROJECT + } + ) } LaunchedEffect(saveState) { @@ -72,7 +81,6 @@ internal fun SkillEditorScreen( BackHandler { onNavigateBack() } - val scope = if (scopeIsGlobal) SkillScope.GLOBAL else SkillScope.PROJECT val canSave = name.isNotBlank() && instructions.isNotBlank() Scaffold( @@ -106,7 +114,7 @@ internal fun SkillEditorScreen( instructions = instructions.trim(), requiredTools = initial?.requiredTools ?: emptyList() ), - scope + source ) } ) { @@ -165,19 +173,27 @@ internal fun SkillEditorScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - // 作用域决定技能目录落在哪儿,改已有技能的作用域等于搬目录,这里只允许新建时选。 + // 来源决定技能目录落在哪儿,改已有技能的来源等于搬目录,这里只允许新建时选。 FilterChip( - selected = !scopeIsGlobal, + selected = source == SkillSource.PROJECT, enabled = initial == null, - onClick = { scopeIsGlobal = false }, + onClick = { source = SkillSource.PROJECT }, label = { Text(stringResource(R.string.skills_scope_project)) } ) FilterChip( - selected = scopeIsGlobal, + selected = source == SkillSource.GLOBAL, enabled = initial == null, - onClick = { scopeIsGlobal = true }, + onClick = { source = SkillSource.GLOBAL }, label = { Text(stringResource(R.string.skills_scope_global)) } ) + if (remoteAvailable || source == SkillSource.REMOTE) { + FilterChip( + selected = source == SkillSource.REMOTE, + enabled = initial == null, + onClick = { source = SkillSource.REMOTE }, + label = { Text(stringResource(R.string.skills_scope_remote)) } + ) + } } } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillsSection.kt index bc626cbd..b37650f8 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SkillsSection.kt @@ -2,6 +2,7 @@ package com.aicode.feature.settings.presentation.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -36,6 +38,8 @@ import com.aicode.core.theme.Radius import com.aicode.core.ui.SwipeToDeleteRow import com.aicode.core.theme.Spacing import com.aicode.core.theme.semanticColors +import com.aicode.feature.agent.domain.skill.RemoteSkillsState +import com.aicode.feature.agent.domain.skill.Skill import com.aicode.feature.agent.domain.skill.SkillScope import com.aicode.feature.settings.presentation.SkillUiEntry import compose.icons.FeatherIcons @@ -43,20 +47,27 @@ import compose.icons.feathericons.Book import compose.icons.feathericons.ChevronRight /** - * 技能二级页:与「工具授权」一致的折叠分组列表——「当前项目 / 全局」两组各自可折叠, + * 技能二级页:折叠分组列表——「当前项目 / 全局 / 远程服务器」三组各自可折叠, * 每行一个技能(图标 + 名称 + 描述),左滑删除,点击行进入详情。 + * + * 「远程服务器」组仅在本地模式([remoteVisible])出现,展示「远程 SSH 模式」那台服务器 + * 工作区里的技能;远程模式下「当前项目」组本身就是远程技能,故不重复展示。 */ @Composable internal fun SkillsSection( projectName: String?, entries: List, + remoteState: RemoteSkillsState, + remoteVisible: Boolean, onDelete: (SkillUiEntry) -> Unit, - onOpenDetail: (SkillUiEntry) -> Unit + onOpenDetail: (SkillUiEntry) -> Unit, + onConnectRemote: () -> Unit ) { - val projectSkills = entries.filter { it.scope == SkillScope.PROJECT } + val projectSkills = entries.filter { it.scope == SkillScope.PROJECT && !it.remote } val globalSkills = entries.filter { it.scope == SkillScope.GLOBAL } + val remoteSkills = (remoteState as? RemoteSkillsState.Loaded)?.skills.orEmpty() - if (entries.isEmpty()) { + if (entries.isEmpty() && (!remoteVisible || remoteSkills.isEmpty())) { Box( modifier = Modifier .fillMaxSize() @@ -98,6 +109,7 @@ internal fun SkillsSection( var projectExpanded by rememberSaveable { mutableStateOf(true) } var globalExpanded by rememberSaveable { mutableStateOf(true) } + var remoteExpanded by rememberSaveable { mutableStateOf(true) } Column( modifier = Modifier @@ -154,9 +166,58 @@ internal fun SkillsSection( } } } + + if (remoteVisible) { + val remoteHost = (remoteState as? RemoteSkillsState.Loaded)?.host + CollapsibleGroupHeader( + text = if (remoteHost != null) { + stringResource(R.string.skills_remote_group, remoteHost) + } else { + stringResource(R.string.skills_remote_group_plain) + }, + expanded = remoteExpanded, + onToggle = { remoteExpanded = !remoteExpanded } + ) + AnimatedVisibility(visible = remoteExpanded) { + SettingsGroup { + when (val s = remoteState) { + RemoteSkillsState.NotConfigured -> + SkillEmptyHint(stringResource(R.string.skills_remote_not_configured)) + RemoteSkillsState.Loading -> RemoteLoadingHint() + is RemoteSkillsState.Failed -> + SkillRetryHint(message = s.message, onRetry = onConnectRemote) + is RemoteSkillsState.Loaded -> + if (s.skills.isEmpty()) { + SkillEmptyHint(stringResource(R.string.skills_remote_empty)) + } else { + s.skills.forEachIndexed { index, skill -> + if (index > 0) SettingsDivider() + val entry = skill.toUiEntry() + SkillRow( + entry = entry, + onDelete = { onDelete(entry) }, + onClick = { onOpenDetail(entry) } + ) + } + } + } + } + } + } } } +/** 远程技能([Skill])转成列表/详情复用的 [SkillUiEntry],标记 [SkillUiEntry.remote]。 */ +internal fun Skill.toUiEntry(): SkillUiEntry = SkillUiEntry( + name = name, + description = description, + scope = SkillScope.PROJECT, + disabled = false, + instructions = instructions, + requiredTools = requiredTools, + remote = true +) + /** 单个技能行:图标 + 名称/描述 + 右箭头;左滑删除,点击行进入详情。 */ @Composable private fun SkillRow( @@ -206,11 +267,14 @@ private fun SkillRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) - McpPill( - text = stringResource(if (entry.disabled) R.string.common_disabled else R.string.common_enabled), - textColor = if (entry.disabled) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.tertiary, - backgroundColor = (if (entry.disabled) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.tertiary).copy(alpha = 0.12f) - ) + // 远程技能无「启用/禁用」概念(v1),不显示状态徽章。 + if (!entry.remote) { + McpPill( + text = stringResource(if (entry.disabled) R.string.common_disabled else R.string.common_enabled), + textColor = if (entry.disabled) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.tertiary, + backgroundColor = (if (entry.disabled) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.tertiary).copy(alpha = 0.12f) + ) + } } Text( text = entry.description.ifBlank { stringResource(R.string.mcp_no_description) }, @@ -243,3 +307,45 @@ private fun SkillEmptyHint(text: String) { modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 12.dp) ) } + +/** 远程分组加载中:一行进度指示。 */ +@Composable +private fun RemoteLoadingHint() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.skills_remote_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** 远程分组失败:错误文案 + 点击重试。 */ +@Composable +private fun SkillRetryHint(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onRetry() } + .padding(horizontal = Spacing.lg, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.skills_remote_failed, message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Text( + text = stringResource(R.string.skills_remote_retry), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } +} diff --git a/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt new file mode 100644 index 00000000..8f94a548 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt @@ -0,0 +1,246 @@ +package com.aicode.feature.workspace.domain + +import com.aicode.feature.agent.domain.container.SshHostKeyVerifier +import com.aicode.feature.agent.domain.container.friendlySshError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.sftp.FileMode +import net.schmizz.sshj.sftp.OpenMode +import net.schmizz.sshj.sftp.RemoteFile +import net.schmizz.sshj.sftp.SFTPClient +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.charset.Charset +import java.nio.file.FileAlreadyExistsException +import java.nio.file.NoSuchFileException +import java.util.EnumSet + +private const val IO_CHUNK = 32 * 1024 +private const val CONNECT_TIMEOUT_MS = 15_000 + +/** 连接远程技能服务器所需的信息,取自「远程 SSH 模式」的连接配置。 */ +data class RemoteSkillConnection( + val host: String, + val port: Int, + val username: String, + val password: String, + /** 远程工作区根路径;容器路径 `~/workspace` 映射到这里。 */ + val workspaceRoot: String +) + +/** + * 本地模式下管理远程技能用的 [FileAccessProvider]:按 [RemoteSkillConnection] 自建一条独立的 + * SSH/SFTP 通道,不经执行模式的共享连接([com.aicode.feature.agent.domain.container.RemoteSshConnection]), + * 避免与命令/文件通道及模式切换相互干扰。 + * + * 只实现技能扫描与读写需要的子集(`listFiles`/`listFilesRecursive`/`readFile`/`writeFile`/`writeBytes`/ + * `exists`/`isDirectory`/`mkdirs`/`deleteRecursively`),其余接口方法抛 [UnsupportedOperationException]—— + * 技能流程不会调用它们。路径入参为容器路径(`~/workspace/...`),经 [remotePathFor] 映射到远程真实路径。 + */ +class RemoteSkillFileAccess( + private val connection: RemoteSkillConnection, + private val hostKeyVerifier: SshHostKeyVerifier +) : FileAccessProvider { + + private val mutex = Mutex() + private var sshClient: SSHClient? = null + private var sftpClient: SFTPClient? = null + + /** 远程真实 home(连接后查 $HOME 缓存),供 `~` 展开。 */ + @Volatile + private var remoteHome: String? = null + + private fun toRemotePath(path: String): String = + remotePathFor(path, connection.workspaceRoot, remoteHome) + + override fun listFiles(path: String): List { + val remote = toRemotePath(path) + return withSftp { sftp -> + sftp.ls(remote).mapNotNull { info -> + val name = info.name + if (name == "." || name == "..") return@mapNotNull null + FileEntry( + name = name, + isDirectory = info.isDirectory, + size = info.attributes.size, + lastModified = info.attributes.mtime * 1000L, + localFile = null, + permissions = formatPermissions(info.attributes.permissions) + ) + } + } + } + + override fun listFilesRecursive(path: String, maxDepth: Int): List { + val remote = toRemotePath(path) + return withSftp { sftp -> + val result = mutableListOf() + fun walk(dir: String, rel: String, depth: Int) { + val entries = runCatching { sftp.ls(dir) }.getOrNull() ?: return + for (info in entries) { + val name = info.name + if (name == "." || name == "..") continue + val childRel = if (rel.isEmpty()) name else "$rel/$name" + if (info.isDirectory) { + if (depth < maxDepth) walk(info.path, childRel, depth + 1) + } else { + result += childRel + } + } + } + walk(remote, "", 1) + result + } + } + + override fun readFile(path: String): String = String(readAll(toRemotePath(path)), Charsets.UTF_8) + + override fun writeFile(path: String, content: String, overwrite: Boolean, encoding: Charset) = + writeBytes(path, content.toByteArray(encoding), overwrite) + + override fun writeBytes(path: String, bytes: ByteArray, overwrite: Boolean) { + val remote = toRemotePath(path) + withSftp { sftp -> + if (sftp.statExistence(remote) != null && !overwrite) throw FileAlreadyExistsException(File(remote)) + ensureParent(sftp, remote) + sftp.open(remote, EnumSet.of(OpenMode.WRITE, OpenMode.CREAT, OpenMode.TRUNC)).use { rf -> + writeAll(rf, bytes) + } + } + } + + override fun exists(path: String): Boolean = withSftp { it.statExistence(toRemotePath(path)) != null } + + override fun isDirectory(path: String): Boolean = + withSftp { it.statExistence(toRemotePath(path))?.type == FileMode.Type.DIRECTORY } + + override fun mkdirs(path: String) = withSftp { it.mkdirs(toRemotePath(path)) } + + override fun deleteRecursively(path: String) = withSftp { deleteRecursive(it, toRemotePath(path)) } + + override fun readLines(path: String): Sequence = unsupported() + override fun isFile(path: String): Boolean = unsupported() + override fun fileSize(path: String): Long = unsupported() + override fun lastModified(path: String): Long = unsupported() + override fun permissions(path: String): String = unsupported() + override fun readBytes(path: String): ByteArray = unsupported() + override fun writeStream(path: String, input: InputStream, overwrite: Boolean): Long = unsupported() + override fun copyToLocal(path: String): File = unsupported() + override fun delete(path: String) = unsupported() + override fun rename(path: String, newPath: String) = unsupported() + override fun copy(path: String, newPath: String, overwrite: Boolean) = unsupported() + override fun move(path: String, newPath: String, overwrite: Boolean) = unsupported() + override fun parentPath(path: String): String? = unsupported() + override fun toDisplayPath(path: String): String = unsupported() + + /** 关闭底层 SSH/SFTP 通道;之后再次调用任一方法会按需重连。 */ + fun close() = runBlocking { + withContext(Dispatchers.IO) { mutex.withLock { closeInternal() } } + } + + private fun withSftp(block: (SFTPClient) -> T): T = runBlocking { + withContext(Dispatchers.IO) { mutex.withLock { block(ensureSftpLocked()) } } + } + + /** 取当前 SFTP 通道;失效则关闭重建。调用方须已持有 [mutex]。 */ + private fun ensureSftpLocked(): SFTPClient { + val existing = sftpClient + if (existing != null && sshClient?.isConnected == true && sshClient?.isAuthenticated == true) { + return existing + } + closeInternal() + val conn = connection + val client = try { + SSHClient().apply { + setConnectTimeout(CONNECT_TIMEOUT_MS) + addHostKeyVerifier(hostKeyVerifier) + connect(conn.host, conn.port) + authPassword(conn.username, conn.password) + runCatching { + connection.keepAlive?.let { + it.setKeepAliveInterval(30) + it.start() + } + } + } + } catch (e: Exception) { + throw IOException(friendlySshError(e), e) + } + sshClient = client + runCatching { + val session = client.startSession() + val cmd = session.exec("echo \$HOME") + remoteHome = java.io.BufferedReader(java.io.InputStreamReader(cmd.inputStream)) + .readText().trim().ifEmpty { null } + session.close() + } + val sftp = client.newSFTPClient() + sftpClient = sftp + return sftp + } + + private fun closeInternal() { + runCatching { sftpClient?.close() } + runCatching { sshClient?.disconnect() } + sftpClient = null + sshClient = null + } + + /** 读取远程文件全部字节;不存在抛 [NoSuchFileException]。 */ + private fun readAll(remote: String): ByteArray = withSftp { sftp -> + val attrs = sftp.statExistence(remote) ?: throw NoSuchFileException(File(remote)) + if (attrs.type == FileMode.Type.DIRECTORY) throw IOException("是目录,无法按文件读取: $remote") + sftp.open(remote).use { rf -> readFully(rf) } + } + + private fun readFully(rf: RemoteFile): ByteArray { + val out = ByteArrayOutputStream() + val buf = ByteArray(IO_CHUNK) + var offset = 0L + while (true) { + val n = rf.read(offset, buf, 0, buf.size) + if (n <= 0) break + out.write(buf, 0, n) + offset += n + } + return out.toByteArray() + } + + private fun writeAll(rf: RemoteFile, bytes: ByteArray) { + var offset = 0 + while (offset < bytes.size) { + val n = minOf(IO_CHUNK, bytes.size - offset) + rf.write(offset.toLong(), bytes, offset, n) + offset += n + } + } + + /** 递归删除(SFTP 无递归删除原语):后序遍历,子项删完后 rmdir。 */ + private fun deleteRecursive(sftp: SFTPClient, remote: String) { + val attrs = sftp.statExistence(remote) ?: return + if (attrs.type == FileMode.Type.DIRECTORY) { + for (info in sftp.ls(remote)) { + val name = info.name + if (name == "." || name == "..") continue + deleteRecursive(sftp, info.path) + } + sftp.rmdir(remote) + } else { + sftp.rm(remote) + } + } + + private fun ensureParent(sftp: SFTPClient, remote: String) { + val parent = remote.substringBeforeLast('/', "") + if (parent.isNotEmpty()) sftp.mkdirs(parent) + } + + private fun unsupported(): T = + throw UnsupportedOperationException("RemoteSkillFileAccess 不支持该操作") +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0d5cf0fb..0c472c8c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -978,6 +978,14 @@ Scope Global This project + Remote server + Remote server · %1$s + Remote server + Remote SSH isn\'t configured. Set it up under Settings → Runtime to manage skills on the remote workspace here. + No skills on the remote workspace + Connecting to remote server… + Connection failed: %1$s + Tap to retry Lowercase is recommended, e.g. pdf-report When to use it; the AI decides whether to load it by this text Describe the standard workflow and caveats for this kind of task; Markdown supported diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8bcfdf96..1d5ee13c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -978,6 +978,14 @@ 作用域 全局 当前项目 + 远程服务器 + 远程服务器 · %1$s + 远程服务器 + 未配置远程 SSH。去「设置 → 运行环境」配置后,可在此管理远程工作区的技能。 + 远程工作区暂无技能 + 正在连接远程服务器… + 连接失败:%1$s + 点按重试 建议用英文小写,如 pdf-report 简述何时该用它,AI 据此判断要不要加载 写清这类任务的标准流程与注意事项,支持 Markdown From f00b054ffb3d4d77bb36437664a734dc4eb3f61d Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:33:10 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20RemoteSkillFileAccess=20unsupported?= =?UTF-8?q?=20=E6=96=B9=E6=B3=95=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=20Uni?= =?UTF-8?q?t=20=E8=BF=94=E5=9B=9E=E7=B1=BB=E5=9E=8B=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/workspace/domain/RemoteSkillFileAccess.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt index 8f94a548..f3566dfa 100644 --- a/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt +++ b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSkillFileAccess.kt @@ -132,10 +132,10 @@ class RemoteSkillFileAccess( override fun readBytes(path: String): ByteArray = unsupported() override fun writeStream(path: String, input: InputStream, overwrite: Boolean): Long = unsupported() override fun copyToLocal(path: String): File = unsupported() - override fun delete(path: String) = unsupported() - override fun rename(path: String, newPath: String) = unsupported() - override fun copy(path: String, newPath: String, overwrite: Boolean) = unsupported() - override fun move(path: String, newPath: String, overwrite: Boolean) = unsupported() + override fun delete(path: String): Unit = unsupported() + override fun rename(path: String, newPath: String): Unit = unsupported() + override fun copy(path: String, newPath: String, overwrite: Boolean): Unit = unsupported() + override fun move(path: String, newPath: String, overwrite: Boolean): Unit = unsupported() override fun parentPath(path: String): String? = unsupported() override fun toDisplayPath(path: String): String = unsupported()