From 65bde51159c1d4bad7f34f360f3d621476f60c83 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:11:19 +0000 Subject: [PATCH 01/10] =?UTF-8?q?fix(browser):=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E5=90=8E=20WebView=20=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=B1=82=E8=A2=AB=E5=86=BB=E7=BB=93=EF=BC=8C=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=AE=9A=E6=9C=9F=E5=94=A4=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面不可见时 WebView 暂停渲染层: JS 定时器降速到约 0.5 次/秒, 5-7 分钟后完全冻结, 届时连 reload 都会超时。关闭面板时注入心跳脚本并启动监督协程, 每 60s 检测心跳停摆 则短暂强制 VISIBLE 恢复渲染管线; 重新打开面板取消保活。 --- .../domain/tool/browser/BrowserManager.kt | 74 ++++++++++++++++++- .../browser/presentation/BrowserScreen.kt | 1 + 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt index 020fab31..ae72c161 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt @@ -36,6 +36,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json @@ -116,6 +118,11 @@ class BrowserManager @Inject constructor( private const val NIGHT_STYLE_ID = "__bicode_night_css__" private val NIGHT_BG_COLOR = 0xFF121212.toInt() + /** 心跳保活 JS:每秒写时间戳;页面不可见被冻结后时间戳会停摆,读取端据此判断并唤醒。 */ + private const val KEEPALIVE_JS = + "(function(){window.__bicodeKeepAlive={n:0,t:Date.now()};" + + "setInterval(function(){var k=window.__bicodeKeepAlive;k.n++;k.t=Date.now();},1000);})()" + /** 反色夜间样式:整页 invert + hue-rotate,图片/视频二次反色还原原始观感。 */ private const val NIGHT_CSS = "html{filter:invert(1) hue-rotate(180deg);background:#fff}" + @@ -166,6 +173,59 @@ class BrowserManager @Inject constructor( private val _state = MutableStateFlow(BrowserState()) val state: StateFlow = _state.asStateFlow() + /** 面板不可见时仍需保活的标签:计时器被 WebView 暂停后由监督协程定期唤醒。 */ + private val keepAliveTabIds = java.util.concurrent.ConcurrentHashMap.newKeySet() + + private var supervisorJob: kotlinx.coroutines.Job? = null + + /** 每 60s 检查一次保活标签的心跳,冻结则唤醒;避免长时间不可见后连 reload 都超时。 */ + private fun startKeepAliveSupervisor() { + if (supervisorJob?.isActive == true) return + supervisorJob = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launch { + while (isActive) { + delay(60_000) + if (containerView != null) continue + for (id in keepAliveTabIds) wakeIfFrozen(id) + } + } + } + + /** + * 页面不可见(面板关闭/非活动标签)时 WebView 会暂停渲染层:JS 定时器降速到约 0.5 次/秒, + * 5-7 分钟后完全冻结(实测),届时连 reload 都会超时。心跳读取 + 短暂强制可见可解除暂停。 + */ + private fun injectKeepAlive(tab: TabHolder) { + runCatching { tab.webView.evaluateJavascript(KEEPALIVE_JS, null) } + } + + /** + * 若标签处于冻结状态(心跳停摆且面板未打开)则唤醒:强制 VISIBLE + 触发布局, + * 让 WebView 恢复渲染管线后放回隐藏宿主。供后台监督协程与工具调用前调用。 + */ + fun wakeIfFrozen(tabId: String? = null) { + if (containerView != null) return + val tab = findTab(tabId ?: activeTabId) ?: return + if (!keepAliveTabIds.contains(tab.id)) return + val frozen = runCatching { + val before = tab.webView.evaluateJavascriptSync( + "(function(){var k=window.__bicodeKeepAlive;return k?k.t:0})()" + )?.trim()?.trim('"')?.toLongOrNull() + before == null || System.currentTimeMillis() - before > 10_000 + }.getOrDefault(true) + if (!frozen) return + runCatching { + tab.webView.visibility = View.VISIBLE + val density = appContext.resources.displayMetrics.density + tab.webView.measure( + View.MeasureSpec.makeMeasureSpec((HEADLESS_WIDTH_DP * density).toInt(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec((HEADLESS_HEIGHT_DP * density).toInt(), View.MeasureSpec.EXACTLY) + ) + tab.webView.layout(0, 0, (HEADLESS_WIDTH_DP * density).toInt(), (HEADLESS_HEIGHT_DP * density).toInt()) + tab.webView.requestLayout() + tab.webView.invalidate() + } + } + /** 选择器辅助函数,prepend 到所有需要选择器的 JS 中。支持 ref= / text= / text*= / role= / xpath= / CSS。 */ private val selectorHelper = """ function __resolveSelector(selector){ @@ -668,12 +728,19 @@ class BrowserManager @Inject constructor( return container } + /** 面板重新打开后取消对应标签的保活监督。 */ + fun attachVisible(tabId: String? = null) { + val id = tabId ?: activeTabId + if (id.isNotEmpty()) keepAliveTabIds.remove(id) + } + fun detachFromViewHierarchy() { containerView?.removeAllViews() containerView = null _state.update { it.copy(attached = false) } - // 前端面板关闭后,把当前激活 Tab 移回 hiddenHost 保持渲染管线激活 + // 前端面板关闭后,把当前激活 Tab 移回 hiddenHost 保持渲染管线激活, + // 并注册保活:WebView 不可见 5-7 分钟后渲染层被冻结,监督协程会定期唤醒。 hiddenHost?.let { host -> val density = appContext.resources.displayMetrics.density val width = (HEADLESS_WIDTH_DP * density).toInt().coerceAtLeast(1) @@ -683,11 +750,16 @@ class BrowserManager @Inject constructor( (active.webView.parent as? ViewGroup)?.removeView(active.webView) host.addView(active.webView, ViewGroup.LayoutParams(width, height)) } + keepAliveTabIds.add(active.id) + injectKeepAlive(active) } } + startKeepAliveSupervisor() } fun destroy() { + keepAliveTabIds.clear() + supervisorJob?.cancel() tabs.forEach { tab -> tab.webView.apply { stopLoading() diff --git a/app/src/main/java/com/aicode/feature/browser/presentation/BrowserScreen.kt b/app/src/main/java/com/aicode/feature/browser/presentation/BrowserScreen.kt index 22975cae..49bfb8be 100644 --- a/app/src/main/java/com/aicode/feature/browser/presentation/BrowserScreen.kt +++ b/app/src/main/java/com/aicode/feature/browser/presentation/BrowserScreen.kt @@ -156,6 +156,7 @@ fun BrowserScreen( ) { AndroidView( factory = { context -> + browserManager.attachVisible() browserManager.getOrCreateContainerView(context) }, modifier = Modifier.fillMaxSize() From fdb241177067a21bc9f9493419478fbc0de6a5a3 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 02/10] =?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 4eecd084..326bbdba 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, @@ -537,6 +542,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() @@ -1165,6 +1173,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 c59f7eb2..e410dba4 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() @@ -331,13 +336,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) } @@ -388,6 +405,11 @@ fun SettingsScreen( viewModel.refreshSubAgents() } + // 本地模式下进入技能页时连接远程 SSH 并扫描其工作区技能。 + LaunchedEffect(executionMode) { + if (executionMode == ExecutionMode.LOCAL_PROOT) viewModel.connectRemoteSkills() + } + // 编辑保存后回详情页:等列表刷新出新快照再换,避免详情页停在保存前的旧值(改名时按新名找)。 LaunchedEffect(skills, pendingSkillName) { val target = pendingSkillName ?: return@LaunchedEffect @@ -397,6 +419,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 @@ -512,8 +544,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() // 从详情页进来的改完回详情页,但得等新快照到位再展示 @@ -816,11 +855,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( @@ -1014,8 +1056,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 @@ -1111,7 +1154,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 9bea4cb0..60dd8a68 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -976,6 +976,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 e50ff03b..7a84ea88 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -976,6 +976,14 @@ 作用域 全局 当前项目 + 远程服务器 + 远程服务器 · %1$s + 远程服务器 + 未配置远程 SSH。去「设置 → 运行环境」配置后,可在此管理远程工作区的技能。 + 远程工作区暂无技能 + 正在连接远程服务器… + 连接失败:%1$s + 点按重试 建议用英文小写,如 pdf-report 简述何时该用它,AI 据此判断要不要加载 写清这类任务的标准流程与注意事项,支持 Markdown From b5fd912cdb290c252e79ca08f18a60c0f0183d65 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 03/10] =?UTF-8?q?fix:=20RemoteSkillFileAccess=20unsupporte?= =?UTF-8?q?d=20=E6=96=B9=E6=B3=95=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=20Un?= =?UTF-8?q?it=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() From 17fbded23e8f831bff0c1f645fd4934a7b5fe286 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:11:19 +0000 Subject: [PATCH 04/10] =?UTF-8?q?fix(settings):=20=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=8A=80=E8=83=BD=E5=88=86=E7=BB=84?= =?UTF-8?q?=E5=9C=A8=E8=BF=9C=E7=A8=8B=E6=A8=A1=E5=BC=8F=E4=B8=8B=E4=B9=9F?= =?UTF-8?q?=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实现仅在本地模式显示「远程服务器」分组, 远程模式下管理不了服务器技能。 远程技能管理走独立 SFTP 通道, 与执行模式无关, 两种模式都展示。 --- .../settings/presentation/component/SettingsScreen.kt | 11 +++++------ .../settings/presentation/component/SkillsSection.kt | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) 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 e410dba4..d8af1631 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 @@ -87,7 +87,6 @@ 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 @@ -405,9 +404,9 @@ fun SettingsScreen( viewModel.refreshSubAgents() } - // 本地模式下进入技能页时连接远程 SSH 并扫描其工作区技能。 + // 远程技能管理用独立 SFTP 通道,不依赖执行模式;本地/远程模式都展示「远程服务器」分组。 LaunchedEffect(executionMode) { - if (executionMode == ExecutionMode.LOCAL_PROOT) viewModel.connectRemoteSkills() + viewModel.connectRemoteSkills() } // 编辑保存后回详情页:等列表刷新出新快照再换,避免详情页停在保存前的旧值(改名时按新名找)。 @@ -545,7 +544,7 @@ fun SettingsScreen( initial = editingSkill, saveState = skillSaveState, defaultSource = skillSource, - remoteAvailable = executionMode == ExecutionMode.LOCAL_PROOT, + remoteAvailable = true, onSave = { form, source -> when (source) { SkillSource.GLOBAL -> viewModel.saveSkill(form, SkillScope.GLOBAL, editingSkill?.name) @@ -856,7 +855,7 @@ fun SettingsScreen( projectName = currentProjectName, entries = skills, remoteState = remoteSkillsState, - remoteVisible = executionMode == ExecutionMode.LOCAL_PROOT, + remoteVisible = true, onDelete = { skillToDelete = it }, onOpenDetail = { selectedSkill = it @@ -1058,7 +1057,7 @@ fun SettingsScreen( SkillAddSheet( source = skillSource, onSourceChange = { skillSource = it }, - remoteAvailable = executionMode == ExecutionMode.LOCAL_PROOT, + remoteAvailable = true, onManual = { showSkillAddSheet = false editingSkill = null 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 b37650f8..1d92c8f9 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 @@ -50,8 +50,8 @@ import compose.icons.feathericons.ChevronRight * 技能二级页:折叠分组列表——「当前项目 / 全局 / 远程服务器」三组各自可折叠, * 每行一个技能(图标 + 名称 + 描述),左滑删除,点击行进入详情。 * - * 「远程服务器」组仅在本地模式([remoteVisible])出现,展示「远程 SSH 模式」那台服务器 - * 工作区里的技能;远程模式下「当前项目」组本身就是远程技能,故不重复展示。 + * 「远程服务器」组展示「远程 SSH 模式」那台服务器工作区里的技能;经独立 SFTP 通道读写, + * 本地/远程模式下都可用(远程模式下「当前项目」组即远程技能,远程分组用于换连接/跨工作区管理)。 */ @Composable internal fun SkillsSection( From e6cc0bfb179c078c5f8413672d20ac3d4a1bfc77 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:05:11 +0000 Subject: [PATCH 05/10] =?UTF-8?q?feat(agent):=20AI=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E4=B8=AD=E8=87=AA=E5=8A=A8=E6=B2=89=E6=B7=80=E9=95=BF=E6=9C=9F?= =?UTF-8?q?=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提示词纪律 + 引擎兑底两层: - 新增 agent/memory-discipline.md, 随记忆清单注入系统提示: 偏好/纠正/项目约定/已验证踩坑当轮必须记, edit 优先不重复 save - 新增 MemoryCurator: 轮次完成后用压缩专用模型(回退聊天模型)从本轮对话静默抽取记忆直接落盘, JSON 解析失败整批丢弃 - AgentWorkflow 新增 curateMemory, AIAgentViewModel 在 Completed 后台异步调用: 子会话跳过, 同会话 10 分钟节流, 静默失败 - MemoryTool 描述同步强化 --- .../assets/prompts/agent/memory-curator.md | 18 +++ .../assets/prompts/agent/memory-discipline.md | 14 ++ .../agent/domain/memory/MemoryCurator.kt | 122 ++++++++++++++++++ .../domain/prompt/SystemPromptProvider.kt | 16 ++- .../agent/domain/tool/memory/MemoryTool.kt | 2 +- .../agent/domain/workflow/AgentWorkflow.kt | 8 ++ .../domain/workflow/StatefulAgentWorkflow.kt | 15 +++ .../agent/presentation/AIAgentViewModel.kt | 33 +++++ 8 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 app/src/main/assets/prompts/agent/memory-curator.md create mode 100644 app/src/main/assets/prompts/agent/memory-discipline.md create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt diff --git a/app/src/main/assets/prompts/agent/memory-curator.md b/app/src/main/assets/prompts/agent/memory-curator.md new file mode 100644 index 00000000..2346dd74 --- /dev/null +++ b/app/src/main/assets/prompts/agent/memory-curator.md @@ -0,0 +1,18 @@ +你是一个记忆整理器。给定一段对话记录,判断其中是否有值得长期记住的信息。 + +只提取以下三类事实,且必须是对话中明确出现的信号,不要推测、不要脑补: + +1. 用户偏好:用户明确表达的喜好或习惯(输出风格、沟通方式、常用工具等),跨项目通用。 +2. 项目约定:本项目的工作方式(构建命令、目录结构、提交规范、专属配置等)。 +3. 踩坑经验:讨论中确认的 bug 根因与修法、验证过的绕行方案。 + +输出严格的 JSON 数组,没有任何其他文本。每条格式: + +[{"name": "snake_case 短名", "description": "一句话摘要(何时该读它)", "scope": "global 或 project", "content": "Markdown 正文,具体、可执行"}, ...] + +规则: +- 没有值得记的就输出 []。 +- name 是文件名:英文小写加下划线,不含空格和路径分隔符,不超过 64 字符。 +- content 写具体事实,不要写空话;单条不超过 500 字。 +- 宁缺毋滥:普通任务执行过程、一次性问题不要记。 +- 最多 3 条。 diff --git a/app/src/main/assets/prompts/agent/memory-discipline.md b/app/src/main/assets/prompts/agent/memory-discipline.md new file mode 100644 index 00000000..2d314204 --- /dev/null +++ b/app/src/main/assets/prompts/agent/memory-discipline.md @@ -0,0 +1,14 @@ +# 记忆纪律(何时必须调用 memory 工具) + +长期记忆不是可选项。以下信号出现时,**当轮立即**用 memory 工具记录,不要等会话结束,不要等用户要求: + +- 用户表达个人偏好(输出风格、沟通方式、习惯做法)→ scope=global; +- 用户纠正过你的做法、指出你说错的事实 → 把纠正记下来(global 或按内容归 project),避免再犯; +- 项目约定(构建方式、目录结构、分支/提交规范、专属工具链)→ scope=project; +- 定位到 bug 根因并验证修复后,把「根因 + 修法」沉淀成踩坑记忆 → scope=project。未经根因确认的猜测不记。 + +记录方式: + +- 已有相关记忆(见下方清单)→ 用 memory(action=edit) 局部更新正文,不要新建重复文件; +- 确属新主题 → memory(action=save),description 写清「何时该读它」; +- 同一事实已经记录过 → 不再重复记录;记忆内容过时 → 用 edit 修正或 delete 清理。 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt new file mode 100644 index 00000000..f31ecf02 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt @@ -0,0 +1,122 @@ +package com.aicode.feature.agent.domain.memory + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.model.AgentMessage +import com.aicode.feature.agent.domain.provider.AIProvider +import com.aicode.feature.agent.domain.workflow.SystemPromptProvider +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "MemoryCurator" + +/** 单次抽取的上限条数(与提示词约定一致)。 */ +private const val MAX_CANDIDATES = 3 + +/** 一轮对话送给整理器的上下文上限(字符):只看最近发生的事,控制成本。 */ +private const val MAX_TRANSCRIPT_CHARS = 12_000 + +/** + * 引擎级记忆兜底:一轮对话结束后,用轻量模型静默抽取值得长期记住的事实, + * 直接写入 [MemoryRepository]。主模型忘了调用 memory 工具时由此兜底; + * 全程静默失败,绝不影响对话主流程。 + */ +@Singleton +class MemoryCurator @Inject constructor( + private val memoryRepository: MemoryRepository, + private val promptProvider: SystemPromptProvider +) { + /** 提示词文件名与 [SystemPromptProvider.resolvePrompt] 的路径约定一致。 */ + private fun prompt(): String = + promptProvider.resolvePrompt("agent/memory-curator.md") + + /** + * 抽取并落盘本轮对话的记忆。 + * @param provider 由调用方解析好的轻量 provider(压缩专用模型或当前聊天模型回退)。 + * @param transcript 本轮对话文本("用户: …/助手: …" 行)。 + * @return 本次实际写入的记忆条数(失败为 0)。 + */ + suspend fun curate( + provider: AIProvider, + sessionId: String, + projectRoot: String?, + transcript: String + ): Int = runCatching { + if (transcript.isBlank()) return@runCatching 0 + val systemPrompt = prompt().replace(LEADING_COMMENT, "").trim() + if (systemPrompt.isEmpty()) return@runCatching 0 + + val response = provider.complete( + systemPrompt = systemPrompt, + messages = listOf(AgentMessage.UserMessage(content = transcript.take(MAX_TRANSCRIPT_CHARS))), + tools = emptyList() + ) + val candidates = parseCandidates(response.content) + var saved = 0 + for (c in candidates) { + val ok = memoryRepository.saveMemory( + name = c.name, + description = c.description, + content = c.content, + scope = c.scope, + projectRoot = projectRoot + ) + if (ok) saved++ + } + if (saved > 0) FileLogger.i(TAG, "会话 $sessionId 自动沉淀 $saved 条记忆") + saved + }.onFailure { e -> + FileLogger.w(TAG, "自动记忆整理失败(静默忽略): ${e.message}") + }.getOrDefault(0) + + /** 解析整理器输出;格式不合法/越界条目一律丢弃,宁缺毋滥。 */ + private fun parseCandidates(content: String): List { + val start = content.indexOf('[') + val end = content.lastIndexOf(']') + if (start < 0 || end <= start) return emptyList() + val arr = runCatching { + Json.parseToJsonElement(content.substring(start, end + 1)).jsonArray + }.getOrNull() ?: return emptyList() + + return arr.asSequence() + .mapNotNull { el -> runCatching { el.jsonObject }.getOrNull() } + .mapNotNull { obj -> + runCatching { + val name = obj["name"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() + val description = obj["description"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() + val body = obj["content"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() + val isProject = obj["scope"]?.jsonPrimitive?.contentOrNull?.trim() + ?.equals("project", ignoreCase = true) == true + if (name.isEmpty() || body.isEmpty() || description.isEmpty()) return@runCatching null + Candidate( + name = name, + description = description.take(200), + content = body.take(2000), + scope = if (isProject) MemoryScope.PROJECT else MemoryScope.GLOBAL + ) + }.getOrNull() + } + .filter { isValidName(it.name) } + .take(MAX_CANDIDATES) + .toList() + } + + /** 与记忆文件名规则对齐:小写英文/数字/下划线/连字符,长度 1..64。 */ + private fun isValidName(name: String): Boolean = + name.length <= 64 && name.matches(Regex("[a-z0-9][a-z0-9_-]*")) + + private data class Candidate( + val name: String, + val description: String, + val content: String, + val scope: MemoryScope + ) + + private companion object { + val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt index 33e8b329..164e77ab 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt @@ -194,7 +194,8 @@ class SystemPromptProvider @Inject constructor( val memories = try { memoryRepository.listMemories(ctx.projectRoot) } catch (e: Exception) { return null } if (memories.isEmpty()) { cachedByKey[key] = "" - return null + // 空清单也要注入纪律:首次会话正是建立记忆的起点。 + return memoryDiscipline() } val globalMemories = memories.filter { it.scope == MemoryScope.GLOBAL } @@ -212,11 +213,19 @@ class SystemPromptProvider @Inject constructor( } }.trimEnd() - cachedByKey[key] = content + // 记忆纪律紧跟清单注入:清单告诉模型「有什么」,纪律告诉它「何时必须写」。 + val full = listOf(content, memoryDiscipline()).mapNotNull { it }.joinToString("\n\n") + if (full.isEmpty()) return null + + cachedByKey[key] = full trimIfNeeded() - return content + return full } + /** 记忆纪律正文(无记忆清单时单独注入)。 */ + private fun memoryDiscipline(): String? = + resolvePrompt(MEMORY_DISCIPLINE_FILE).replace(LEADING_COMMENT, "").trim().ifEmpty { null } + private fun trimIfNeeded() { if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() } @@ -428,6 +437,7 @@ class SystemPromptProvider @Inject constructor( const val AGENTS_FILE = "AGENTS.md" const val CLAUDE_FILE = "CLAUDE.md" const val SUBAGENT_BASE_FILE = "agent/subagent-base.md" + const val MEMORY_DISCIPLINE_FILE = "agent/memory-discipline.md" const val MAX_AGENTS_CHARS = 32_000 /** 会话级缓存 key 数量上限:超过后整体清空,仅防长期累积;正常会话数远小于此。 */ const val SOURCE_CACHE_LIMIT = 32 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt index 6aa98fee..c1f78526 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt @@ -39,7 +39,7 @@ class MemoryTool @Inject constructor( } } override val description = - "管理 AI 的长期记忆(读取/保存/局部编辑/删除/列表)。发现新的用户偏好、项目约定或架构决策时主动记录。" + "管理 AI 的长期记忆(读取/保存/局部编辑/删除/列表)。用户明确表达的偏好、纠正、项目约定与已验证的踩坑根因,必须当轮用本工具记录,不等用户要求。" /** edits 数组单个元素的结构,供 function-calling 的 items schema,语义与 editFile 一致。 */ private val editItemSchema: Map = mapOf( diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt index 0b560b1d..ec3c0610 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt @@ -121,4 +121,12 @@ interface AgentWorkflow { * @return 建议的提交说明,失败返回 null。 */ suspend fun generateCommitMessage(diff: String): String? + + /** + * 会话轮次结束后的记忆兑现(引擎级兑底):用轻量模型从本轮对话文本中抽取值得长期记住的事实, + * 直接写入记忆存储。静默失败,不抛异常。 + * @param transcript 本轮对话文本("用户: …/助手: …" 行),由调用方截取最近内容拼出。 + * @return 本次实际写入的记忆条数。 + */ + suspend fun curateMemory(sessionId: String, projectRoot: String?, transcript: String): Int } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt index f8153990..6baf65b2 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt @@ -8,6 +8,7 @@ import com.aicode.feature.agent.data.local.entity.LlmCallRecordEntity import com.aicode.feature.agent.data.remote.anthropic.AnthropicApi import com.aicode.feature.agent.data.remote.gemini.GeminiApi import com.aicode.feature.agent.data.remote.openai.OpenAIApi +import com.aicode.feature.agent.domain.memory.MemoryCurator import com.aicode.feature.agent.domain.model.AgentContext import com.aicode.feature.agent.domain.model.AgentImage import com.aicode.feature.agent.domain.model.AgentMessage @@ -104,6 +105,7 @@ class StatefulAgentWorkflow @Inject constructor( private val keyRotator: ProviderKeyRotator, private val agentNotificationCenter: AgentNotificationCenter, private val eventInjector: AgentEventInjector, + private val memoryCurator: MemoryCurator, private val fileAccess: FileAccessProvider ) : AgentWorkflow { @@ -993,6 +995,19 @@ class StatefulAgentWorkflow @Inject constructor( FileLogger.w(TAG, "生成提交信息失败", e) }.getOrNull() + /** + * 会话轮次结束后的记忆兑底:优先用压缩专用模型(轻量、便宜)抽记忆, + * 未配置则回退当前聊天模型。全静默,任何失败都不影响调用方。 + */ + override suspend fun curateMemory(sessionId: String, projectRoot: String?, transcript: String): Int = runCatching { + if (transcript.isBlank()) return@runCatching 0 + val provider = resolveCompactionFallbackProvider(sessionId) + ?: getEffectiveProvider(sessionId) + memoryCurator.curate(provider, sessionId, projectRoot, transcript) + }.onFailure { e -> + FileLogger.w(TAG, "记忆兑现跳过: ${e.message}") + }.getOrDefault(0) + private suspend fun runToolStream( tool: StreamingAgentTool, toolCall: ToolCall, diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index a993319d..d8f12321 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -156,6 +156,9 @@ class AIAgentViewModel @Inject constructor( private val sessionJobs = mutableMapOf() + /** 记忆兑现节流:每个会话上次自动整理的时间戳,10 分钟内不重复跑。 */ + private val lastCurateAt = java.util.concurrent.ConcurrentHashMap() + /** * agent 执行期间持有的 CPU 唤醒锁:熄屏后系统会挂起进程,使流式响应中断、工具调用卡死。 * 不计数(setReferenceCounted(false)),多会话共用一把锁,最后一个任务结束时统一释放。 @@ -950,6 +953,8 @@ class AIAgentViewModel @Inject constructor( const val TAG = "AIAgentViewModel" const val AGENT_COMPLETE_CHANNEL = "agent_complete" const val AGENT_COMPLETE_NOTIFICATION_ID = 100 + /** 记忆兑现节流:同一会话两次自动整理的最小间隔。 */ + const val MEMORY_CURATE_INTERVAL_MS = 10 * 60 * 1000L /** wakeLock 超时保险:构建、装依赖类工具动辄十几分钟,给足 60 分钟;任务正常结束会主动释放。 */ const val KEEPALIVE_TIMEOUT_MS = 60 * 60 * 1000L /** 从后台任务通知文本中提取 内容,供系统通知正文展示。 */ @@ -1582,6 +1587,34 @@ class AIAgentViewModel @Inject constructor( if (!inForeground && agentSoundSettings.isEnabled()) { showAgentCompletedNotification(modelRequest) } + + // 引擎级记忆兑底:主模型当轮没调 memory 工具时,后台用轻量模型抽取沉淀。 + // 静默失败、不进对话流;子会话跳过(避免与父会话重复沉淀);同一会话 10 分钟内不重复跑。 + if (!isSub) { + val now = System.currentTimeMillis() + val last = lastCurateAt[sessionId] ?: 0L + if (now - last >= MEMORY_CURATE_INTERVAL_MS) { + lastCurateAt[sessionId] = now + viewModelScope.launch { + // 本轮对话文本:发送前的持久化历史尾部 + 用户请求 + 流式回答快照。 + val tail = history.takeLast(6).joinToString("\n") { msg -> + when (msg) { + is com.aicode.feature.agent.domain.model.AgentMessage.UserMessage -> "用户: ${msg.content}" + is com.aicode.feature.agent.domain.model.AgentMessage.AssistantMessage -> "助手: ${msg.content}" + else -> "" + } + }.removeSuffix("\n") + val transcript = buildString { + if (tail.isNotBlank()) appendLine(tail) + append("用户: ").appendLine(request) + _streamingTexts.value[sessionId]?.take(4000)?.let { append("助手: ").appendLine(it) } + }.trim() + if (transcript.isNotBlank()) { + agentWorkflow.curateMemory(sessionId, projectRoot, transcript) + } + } + } + } } is AgentEvent.ModeChanged -> { // 模式切换事件:PlanApprovalManager 已在 workflow 层面挂起等待用户批准 From 33edd5518d9aa05fdbf799f69518b7e24778cd42 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:04:02 +0000 Subject: [PATCH 06/10] =?UTF-8?q?refactor(prompt):=20=E6=8A=BD=E5=87=BA=20?= =?UTF-8?q?PromptFileResolver=20=E4=BF=AE=20Hilt=20=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemoryCurator 注入 SystemPromptProvider 与后者依赖 MemoryRepository 形成环, KSP PROCESSING_ERROR。抽出独立的 PromptFileResolver(仅依赖 ContainerInstaller), SystemPromptProvider 与 MemoryCurator 共用, resolvePrompt 行为不变。 --- .../agent/domain/memory/MemoryCurator.kt | 9 ++-- .../agent/domain/prompt/PromptFileResolver.kt | 54 +++++++++++++++++++ .../domain/prompt/SystemPromptProvider.kt | 28 ++-------- 3 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFileResolver.kt diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt index f31ecf02..d3428998 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt @@ -3,7 +3,7 @@ package com.aicode.feature.agent.domain.memory import com.aicode.core.util.FileLogger import com.aicode.feature.agent.domain.model.AgentMessage import com.aicode.feature.agent.domain.provider.AIProvider -import com.aicode.feature.agent.domain.workflow.SystemPromptProvider +import com.aicode.feature.agent.domain.prompt.PromptFileResolver import kotlinx.serialization.json.Json import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray @@ -28,11 +28,10 @@ private const val MAX_TRANSCRIPT_CHARS = 12_000 @Singleton class MemoryCurator @Inject constructor( private val memoryRepository: MemoryRepository, - private val promptProvider: SystemPromptProvider + private val promptFileResolver: PromptFileResolver ) { - /** 提示词文件名与 [SystemPromptProvider.resolvePrompt] 的路径约定一致。 */ - private fun prompt(): String = - promptProvider.resolvePrompt("agent/memory-curator.md") + /** 提示词文件名,与 [PromptFileResolver.resolve] 的路径约定一致。 */ + private fun prompt(): String = promptFileResolver.resolve("agent/memory-curator.md") /** * 抽取并落盘本轮对话的记忆。 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFileResolver.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFileResolver.kt new file mode 100644 index 00000000..7035f95c --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFileResolver.kt @@ -0,0 +1,54 @@ +package com.aicode.feature.agent.domain.prompt + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.container.ContainerInstaller +import dagger.hilt.android.qualifiers.ApplicationContext +import android.content.Context +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 单个提示词片段的文件解析器,供 [SystemPromptProvider] 与无循环依赖需求的 + * 其它组件(如 MemoryCurator)共用: + * + * - 名字是顶层 `<NN>-*.md`:先按数字身份在 `prompts.custom/` 顶层找覆盖(尾部名称可自由改), + * - 其余名字(含 `agent/` 子目录):按精确同名在 `prompts.custom/<name>` 找覆盖; + * - 再落到 `prompts/<name>`(本地默认副本),最后 assets(内置兜底)。 + * + * 本地副本由 [ContainerInstaller.extractPrompts] 在启动时全量释放,App 升级后随之更新。 + */ +@Singleton +class PromptFileResolver @Inject constructor( + @param:ApplicationContext private val context: Context, + private val containerInstaller: ContainerInstaller +) { + private val customDir: File + get() = File(containerInstaller.aicodeDir, "prompts.custom") + + private val customFragmentsByNumber: Map<Int, File> by lazy { + PromptFragmentResolver.numberedFragments(customDir).toMap() + } + + fun resolve(name: String): String { + PromptFragmentResolver.parseNumber(name) + ?.let { number -> readFileOrNull(customFragmentsByNumber[number])?.let { return it } } + readFileOrNull(File(customDir, name))?.let { return it } + readFileOrNull(File(File(containerInstaller.aicodeDir, "prompts"), name))?.let { return it } + return context.assets.open("prompts/$name").bufferedReader().use { it.readText() } + } + + private fun readFileOrNull(file: File?): String? { + if (file == null || !file.isFile) return null + return try { + file.bufferedReader().use { it.readText() } + } catch (e: Exception) { + FileLogger.w(TAG, "读取提示词失败 ${file.name}: ${e.message}", e) + null + } + } + + private companion object { + const val TAG = "PromptFileResolver" + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt index 164e77ab..c538b794 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt @@ -32,6 +32,7 @@ class SystemPromptProvider @Inject constructor( @param:ApplicationContext private val context: Context, private val skillRepository: SkillRepository, private val memoryRepository: MemoryRepository, + private val promptFileResolver: PromptFileResolver, private val containerInstaller: ContainerInstaller, private val agentDefinitionRepository: AgentDefinitionRepository ) { @@ -406,31 +407,8 @@ class SystemPromptProvider @Inject constructor( private fun currentDate(): String = java.time.ZonedDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd")) - /** - * 按优先级解析单个提示词片段: - * - 名字是顶层 `<NN>-*.md`:先按数字身份在 `prompts.custom/` 顶层找覆盖(尾部名称可自由改), - * - 其余名字(含 `agent/` 子目录):按精确同名在 `prompts.custom/<name>` 找覆盖; - * 再落到 `prompts/<name>`(本地默认副本),最后 assets(内置兜底)。 - * - * 本地副本由 [ContainerInstaller.extractPrompts] 在启动时全量释放,App 升级后随之更新。 - */ - fun resolvePrompt(name: String): String { - PromptFragmentResolver.parseNumber(name) - ?.let { number -> readFileOrNull(customFragmentsByNumber[number])?.let { return it } } - readFileOrNull(File(customDir, name))?.let { return it } - readFileOrNull(File(File(containerInstaller.aicodeDir, "prompts"), name))?.let { return it } - return context.assets.open("prompts/$name").bufferedReader().use { it.readText() } - } - - private fun readFileOrNull(file: File?): String? { - if (file == null || !file.isFile) return null - return try { - file.bufferedReader().use { it.readText() } - } catch (e: Exception) { - FileLogger.w(TAG, "读取提示词失败 ${file.name}: ${e.message}", e) - null - } - } + /** 按优先级解析单个提示词片段,见 [PromptFileResolver.resolve]。保留本方法以兼容现有调用点。 */ + fun resolvePrompt(name: String): String = promptFileResolver.resolve(name) private companion object { const val TAG = "SystemPromptProvider" From 13b5b84a5165c6ecd9bc670e53d10a3ab6dc2593 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:11:21 +0000 Subject: [PATCH 07/10] =?UTF-8?q?fix(di):=20AgentModule=20=E8=A1=A5?= =?UTF-8?q?=E4=BC=A0=20MemoryCurator=EF=BC=9BSystemPromptProvider=20?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=20readFileOrNull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/aicode/di/AgentModule.kt | 3 +++ .../agent/domain/prompt/SystemPromptProvider.kt | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/src/main/java/com/aicode/di/AgentModule.kt b/app/src/main/java/com/aicode/di/AgentModule.kt index 48f79bc5..c6d956c4 100644 --- a/app/src/main/java/com/aicode/di/AgentModule.kt +++ b/app/src/main/java/com/aicode/di/AgentModule.kt @@ -17,6 +17,7 @@ import com.aicode.feature.agent.data.remote.anthropic.AnthropicApi import com.aicode.feature.agent.data.remote.gemini.GeminiApi import com.aicode.feature.agent.data.remote.openai.OpenAIApi import com.aicode.feature.agent.domain.container.CommandEngine +import com.aicode.feature.agent.domain.memory.MemoryCurator import com.aicode.feature.agent.domain.container.DelegatingCommandEngine import com.aicode.feature.agent.domain.container.LinuxContainerEngine import com.aicode.feature.agent.domain.container.RemoteSshConnection @@ -346,6 +347,7 @@ object AgentModule { keyRotator: ProviderKeyRotator, agentNotificationCenter: AgentNotificationCenter, eventInjector: AgentEventInjector, + memoryCurator: MemoryCurator, fileAccess: FileAccessProvider ): AgentWorkflow { return StatefulAgentWorkflow( @@ -372,6 +374,7 @@ object AgentModule { keyRotator, agentNotificationCenter, eventInjector, + memoryCurator, fileAccess ) } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt index c538b794..83a75f97 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt @@ -410,6 +410,17 @@ class SystemPromptProvider @Inject constructor( /** 按优先级解析单个提示词片段,见 [PromptFileResolver.resolve]。保留本方法以兼容现有调用点。 */ fun resolvePrompt(name: String): String = promptFileResolver.resolve(name) + /** 直接读本地文件内容;失败返回 null。供静态基线与自定义片段合并时使用。 */ + private fun readFileOrNull(file: File?): String? { + if (file == null || !file.isFile) return null + return try { + file.bufferedReader().use { it.readText() } + } catch (e: Exception) { + FileLogger.w(TAG, "读取提示词失败 ${file.name}: ${e.message}", e) + null + } + } + private companion object { const val TAG = "SystemPromptProvider" const val AGENTS_FILE = "AGENTS.md" From 465359e245788f777b75b201513b7e968073db39 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:54:52 +0000 Subject: [PATCH 08/10] =?UTF-8?q?fix(ssh):=20connect()=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=87=8D=E6=8A=9B=20IOException=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E6=A8=A1=E5=BC=8F=E8=BF=9E=E6=8E=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server closed connection during identification exchange 时 connect() 捕获后 throw e 重拋,在协程取消过程中变成 suppressed 异常逃逸到主线程崩溃。 改为只记日志 + 设状态 FAILED,调用方通过 isConnected()/connectionState 判断成败。 tryReconnectIfDisconnected 对应改为用 isConnected() 判断而非 runCatching.isSuccess。 --- .../domain/container/RemoteSshConnection.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/container/RemoteSshConnection.kt b/app/src/main/java/com/aicode/feature/agent/domain/container/RemoteSshConnection.kt index 4c1a2148..28e0e1f1 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/container/RemoteSshConnection.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/container/RemoteSshConnection.kt @@ -105,7 +105,9 @@ class RemoteSshConnection @Inject constructor( _connectionState.value = ConnectionState.CONNECTED } catch (e: Exception) { _connectionState.value = ConnectionState.FAILED - throw e + // 不重抛:SSH 连接失败是可恢复的网络问题,重抛会在协程取消时变成 suppressed + // 异常逃逸到主线程崩溃。调用方通过 connectionState / isConnected() 判断成败。 + FileLogger.w(TAG, "SSH 连接失败: ${e.message}", e) } } @@ -214,16 +216,16 @@ class RemoteSshConnection @Inject constructor( val cfg = config ?: return false if (isConnected()) return true _connectionState.value = ConnectionState.CONNECTING - return runCatching { connect(cfg) } - .onSuccess { - FileLogger.i(TAG, "SSH 重连成功(前台触发)") - runCatching { onReconnected?.invoke() } - } - .onFailure { - FileLogger.w(TAG, "SSH 重连失败(前台触发)", it) - _connectionState.value = ConnectionState.FAILED - } - .isSuccess + connect(cfg) + // connect 不再抛异常,用实际连接状态判断成败 + return if (isConnected()) { + FileLogger.i(TAG, "SSH 重连成功(前台触发)") + runCatching { onReconnected?.invoke() } + true + } else { + FileLogger.w(TAG, "SSH 重连失败(前台触发)") + false + } } /** From d9f3c0e6a5ba0b1eba53ae384003dfbc1b4d5f1a Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:06:35 +0000 Subject: [PATCH 09/10] =?UTF-8?q?fix(memory):=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=8A=80=E8=83=BD=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E9=9F=A7=E6=80=A7=20+=20=E8=87=AA=E5=8A=A8=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=20P1/P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listAllSkills 各源独立 catch: 远程扫描失败不拖没全局技能 - curator 不再覆盖已有同名记忆(避免覆盖主模型写的完整版本) - transcript 改 takeLast: 保留本轮对话而非丢掉它 - scope=PROJECT 但无工作区时降级 GLOBAL 而非静默丢弃 - curator 写入后失效记忆清单会话级缓存, 下一轮 prompt 能看到新记忆 - 节流时间戳改为 curateMemory 成功后记账, 失败不占窗口 - 文件名校验对齐 MemorySource.sanitizeName(中文名不再被丢弃) - 双层 runCatching 去外层, 日志带堆栈 - memory-discipline.md 改为不依赖位置的表述 --- .../assets/prompts/agent/memory-discipline.md | 3 +- .../agent/domain/memory/MemoryCurator.kt | 31 +++++++++++++------ .../domain/prompt/SystemPromptProvider.kt | 9 ++++++ .../agent/domain/skill/SkillRepository.kt | 12 +++++-- .../domain/workflow/StatefulAgentWorkflow.kt | 22 ++++++++----- .../agent/presentation/AIAgentViewModel.kt | 5 ++- 6 files changed, 60 insertions(+), 22 deletions(-) diff --git a/app/src/main/assets/prompts/agent/memory-discipline.md b/app/src/main/assets/prompts/agent/memory-discipline.md index 2d314204..75072f93 100644 --- a/app/src/main/assets/prompts/agent/memory-discipline.md +++ b/app/src/main/assets/prompts/agent/memory-discipline.md @@ -9,6 +9,7 @@ 记录方式: -- 已有相关记忆(见下方清单)→ 用 memory(action=edit) 局部更新正文,不要新建重复文件; +- 先用 memory(action=list) 确认是否已有同名/同主题记忆,再决定 save 还是 edit; +- 已有相关记忆 → 用 memory(action=edit) 局部更新正文,不要新建重复文件; - 确属新主题 → memory(action=save),description 写清「何时该读它」; - 同一事实已经记录过 → 不再重复记录;记忆内容过时 → 用 edit 修正或 delete 清理。 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt index d3428998..222dc6e8 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryCurator.kt @@ -1,6 +1,7 @@ package com.aicode.feature.agent.domain.memory import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.memory.MemorySource import com.aicode.feature.agent.domain.model.AgentMessage import com.aicode.feature.agent.domain.provider.AIProvider import com.aicode.feature.agent.domain.prompt.PromptFileResolver @@ -51,25 +52,39 @@ class MemoryCurator @Inject constructor( val response = provider.complete( systemPrompt = systemPrompt, - messages = listOf(AgentMessage.UserMessage(content = transcript.take(MAX_TRANSCRIPT_CHARS))), + messages = listOf(AgentMessage.UserMessage(content = transcript.takeLast(MAX_TRANSCRIPT_CHARS))), tools = emptyList() ) val candidates = parseCandidates(response.content) var saved = 0 for (c in candidates) { + // 已有同名记忆不覆盖:主模型当轮写的完整版本不该被 curator 的截断版覆盖。 + val existing = memoryRepository.loadContent(c.name, projectRoot) + if (existing != null) { + FileLogger.i(TAG, "记忆「${c.name}」已存在,跳过自动覆盖") + continue + } + // PROJECT 但无工作区时降级为 GLOBAL(静默场景下降级比丢弃合理)。 + val effectiveScope = if (c.scope == MemoryScope.PROJECT && projectRoot.isNullOrBlank()) { + MemoryScope.GLOBAL + } else { + c.scope + } val ok = memoryRepository.saveMemory( name = c.name, description = c.description, content = c.content, - scope = c.scope, + scope = effectiveScope, projectRoot = projectRoot ) if (ok) saved++ } - if (saved > 0) FileLogger.i(TAG, "会话 $sessionId 自动沉淀 $saved 条记忆") + if (saved > 0) { + FileLogger.i(TAG, "会话 $sessionId 自动沉淀 $saved 条记忆") + } saved }.onFailure { e -> - FileLogger.w(TAG, "自动记忆整理失败(静默忽略): ${e.message}") + FileLogger.w(TAG, "自动记忆整理失败(静默忽略): ${e.message}", e) }.getOrDefault(0) /** 解析整理器输出;格式不合法/越界条目一律丢弃,宁缺毋滥。 */ @@ -85,7 +100,7 @@ class MemoryCurator @Inject constructor( .mapNotNull { el -> runCatching { el.jsonObject }.getOrNull() } .mapNotNull { obj -> runCatching { - val name = obj["name"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() + val name = obj["name"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() val description = obj["description"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() val body = obj["content"]?.jsonPrimitive?.contentOrNull?.trim().orEmpty() val isProject = obj["scope"]?.jsonPrimitive?.contentOrNull?.trim() @@ -99,15 +114,11 @@ class MemoryCurator @Inject constructor( ) }.getOrNull() } - .filter { isValidName(it.name) } + .filter { MemorySource.sanitizeName(it.name).isNotEmpty() } .take(MAX_CANDIDATES) .toList() } - /** 与记忆文件名规则对齐:小写英文/数字/下划线/连字符,长度 1..64。 */ - private fun isValidName(name: String): Boolean = - name.length <= 64 && name.matches(Regex("[a-z0-9][a-z0-9_-]*")) - private data class Candidate( val name: String, val description: String, diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt index 83a75f97..918281e2 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt @@ -230,6 +230,10 @@ class SystemPromptProvider @Inject constructor( private fun trimIfNeeded() { if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() } + + fun invalidate(key: SourceCacheKey) { + cachedByKey.remove(key) + } } /** 会话级缓存 key:同一会话同一工作区共享一份快照,避免每轮重扫磁盘导致 system prompt 变化。 */ @@ -410,6 +414,11 @@ class SystemPromptProvider @Inject constructor( /** 按优先级解析单个提示词片段,见 [PromptFileResolver.resolve]。保留本方法以兼容现有调用点。 */ fun resolvePrompt(name: String): String = promptFileResolver.resolve(name) + /** 失效记忆清单的会话级缓存:curator 写入新记忆后调用,让下一轮 system prompt 看到新清单。 */ + fun invalidateMemoryCache(sessionId: String?, projectRoot: String?) { + memoryListSource.invalidate(SourceCacheKey(sessionId, projectRoot)) + } + /** 直接读本地文件内容;失败返回 null。供静态基线与自定义片段合并时使用。 */ private fun readFileOrNull(file: File?): String? { if (file == null || !file.isFile) return null 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..dbd588fd 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 @@ -23,8 +23,16 @@ class SkillRepository @Inject constructor( private val fileAccess: FileAccessProvider ) { /** 全部技能(含来源作用域),未过滤禁用;同名技能项目级优先(与 MCP 两级配置一致)。 */ - fun listAllSkills(): List<SkillEntry> = - mergeAll(localDirectorySkillSource.listSkills(), projectDirectorySkillSource.listSkills()) + fun listAllSkills(): List<SkillEntry> { + // 各源独立 catch:远程扫描失败不该把全局技能也拖没。 + val global = runCatching { localDirectorySkillSource.listSkills() } + .onFailure { FileLogger.w(TAG, "全局技能扫描失败", it) } + .getOrDefault(emptyList()) + val project = runCatching { projectDirectorySkillSource.listSkills() } + .onFailure { FileLogger.w(TAG, "项目技能扫描失败", it) } + .getOrDefault(emptyList()) + return mergeAll(global, project) + } /** 启用的技能列表(注入系统提示词用),禁用技能被过滤。 */ fun listSkills(): List<Skill> = diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt index 6baf65b2..6c16ba29 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt @@ -999,14 +999,20 @@ class StatefulAgentWorkflow @Inject constructor( * 会话轮次结束后的记忆兑底:优先用压缩专用模型(轻量、便宜)抽记忆, * 未配置则回退当前聊天模型。全静默,任何失败都不影响调用方。 */ - override suspend fun curateMemory(sessionId: String, projectRoot: String?, transcript: String): Int = runCatching { - if (transcript.isBlank()) return@runCatching 0 - val provider = resolveCompactionFallbackProvider(sessionId) - ?: getEffectiveProvider(sessionId) - memoryCurator.curate(provider, sessionId, projectRoot, transcript) - }.onFailure { e -> - FileLogger.w(TAG, "记忆兑现跳过: ${e.message}") - }.getOrDefault(0) + override suspend fun curateMemory(sessionId: String, projectRoot: String?, transcript: String): Int { + if (transcript.isBlank()) return 0 + return try { + val provider = resolveCompactionFallbackProvider(sessionId) + ?: getEffectiveProvider(sessionId) + val saved = memoryCurator.curate(provider, sessionId, projectRoot, transcript) + if (saved > 0) promptProvider.invalidateMemoryCache(sessionId, projectRoot) + saved + } catch (e: Exception) { + if (e is CancellationException) throw e + FileLogger.w(TAG, "记忆兑现失败: ${e.message}", e) + 0 + } + } private suspend fun runToolStream( tool: StreamingAgentTool, diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index d8f12321..06136b6b 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -1594,7 +1594,6 @@ class AIAgentViewModel @Inject constructor( val now = System.currentTimeMillis() val last = lastCurateAt[sessionId] ?: 0L if (now - last >= MEMORY_CURATE_INTERVAL_MS) { - lastCurateAt[sessionId] = now viewModelScope.launch { // 本轮对话文本:发送前的持久化历史尾部 + 用户请求 + 流式回答快照。 val tail = history.takeLast(6).joinToString("\n") { msg -> @@ -1610,6 +1609,10 @@ class AIAgentViewModel @Inject constructor( _streamingTexts.value[sessionId]?.take(4000)?.let { append("助手: ").appendLine(it) } }.trim() if (transcript.isNotBlank()) { + // 成功后才记账:失败/取消时不占用 10 分钟窗口,下一轮重试。 + agentWorkflow.curateMemory(sessionId, projectRoot, transcript) + lastCurateAt[sessionId] = System.currentTimeMillis() + } agentWorkflow.curateMemory(sessionId, projectRoot, transcript) } } From b664a03a75e94ebb6c1a08b2e17e39ed73aba73a Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:16:26 +0000 Subject: [PATCH 10/10] =?UTF-8?q?fix(browser):=20=E4=BF=9D=E6=B4=BB?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=EF=BC=9Asuspend/=E8=B6=85?= =?UTF-8?q?=E6=97=B6/=E6=A0=87=E7=AD=BE=E5=90=8C=E6=AD=A5/scope=20?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wakeIfFrozen 改 suspend 修编译错误(非 suspend 调 suspend 函数) - 心跳读取包 withTimeout(2s): 已冻结 WebView 的 JS callback 永不回调, 裸 await 死锁监督协程 - 唤醒后重注入心跳: 原注入失败时不重注入会永久误判冻结无限空转 - selectTab/closeTab 同步 keepAliveTabIds: 面板关闭期间切换标签后新标签不丢保活 - attachVisible 清空全部保活集合并停监督协程, 不再每 60s 无意义唤醒 - supervisor scope 改类字段 SupervisorJob, destroy() 置空 job --- .../domain/tool/browser/BrowserManager.kt | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt index ae72c161..46da8bf1 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/browser/BrowserManager.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -181,15 +182,20 @@ class BrowserManager @Inject constructor( /** 每 60s 检查一次保活标签的心跳,冻结则唤醒;避免长时间不可见后连 reload 都超时。 */ private fun startKeepAliveSupervisor() { if (supervisorJob?.isActive == true) return - supervisorJob = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launch { + supervisorJob = supervisorScope.launch { while (isActive) { delay(60_000) if (containerView != null) continue + if (keepAliveTabIds.isEmpty()) continue for (id in keepAliveTabIds) wakeIfFrozen(id) } } } + private val supervisorScope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main + ) + /** * 页面不可见(面板关闭/非活动标签)时 WebView 会暂停渲染层:JS 定时器降速到约 0.5 次/秒, * 5-7 分钟后完全冻结(实测),届时连 reload 都会超时。心跳读取 + 短暂强制可见可解除暂停。 @@ -201,17 +207,25 @@ class BrowserManager @Inject constructor( /** * 若标签处于冻结状态(心跳停摆且面板未打开)则唤醒:强制 VISIBLE + 触发布局, * 让 WebView 恢复渲染管线后放回隐藏宿主。供后台监督协程与工具调用前调用。 + * + * 心跳读取带 2s 超时:已冻结的 WebView 无法执行 JS(callback 永不回调), + * 裸 await 会永久挂起阻塞监督协程——超时即判定冻结,直接进唤醒流程。 */ - fun wakeIfFrozen(tabId: String? = null) { + suspend fun wakeIfFrozen(tabId: String? = null) { if (containerView != null) return val tab = findTab(tabId ?: activeTabId) ?: return if (!keepAliveTabIds.contains(tab.id)) return - val frozen = runCatching { - val before = tab.webView.evaluateJavascriptSync( - "(function(){var k=window.__bicodeKeepAlive;return k?k.t:0})()" - )?.trim()?.trim('"')?.toLongOrNull() + val frozen = try { + val before = withTimeout(2_000) { + tab.webView.evaluateJavascriptSync( + "(function(){var k=window.__bicodeKeepAlive;return k?k.t:0})()" + ) + }?.trim()?.trim('"')?.toLongOrNull() before == null || System.currentTimeMillis() - before > 10_000 - }.getOrDefault(true) + } catch (e: Exception) { + if (e is CancellationException) throw e + true + } if (!frozen) return runCatching { tab.webView.visibility = View.VISIBLE @@ -223,6 +237,9 @@ class BrowserManager @Inject constructor( tab.webView.layout(0, 0, (HEADLESS_WIDTH_DP * density).toInt(), (HEADLESS_HEIGHT_DP * density).toInt()) tab.webView.requestLayout() tab.webView.invalidate() + // 唤醒后重注入心跳:原注入可能失败(页面未加载完/JS 异常), + // 不重注入会永久误判冻结、无限空转唤醒。 + injectKeepAlive(tab) } } @@ -728,10 +745,11 @@ class BrowserManager @Inject constructor( return container } - /** 面板重新打开后取消对应标签的保活监督。 */ + /** 面板重新打开后取消全部保活监督:清空集合并停掉监督协程。 */ fun attachVisible(tabId: String? = null) { - val id = tabId ?: activeTabId - if (id.isNotEmpty()) keepAliveTabIds.remove(id) + keepAliveTabIds.clear() + supervisorJob?.cancel() + supervisorJob = null } fun detachFromViewHierarchy() { @@ -760,6 +778,7 @@ class BrowserManager @Inject constructor( fun destroy() { keepAliveTabIds.clear() supervisorJob?.cancel() + supervisorJob = null tabs.forEach { tab -> tab.webView.apply { stopLoading() @@ -812,6 +831,7 @@ class BrowserManager @Inject constructor( tab.pendingDialogResult?.cancel() tab.consoleLogs.clear() tabs.remove(tab) + keepAliveTabIds.remove(tabId) if (tabs.isEmpty()) { // 所有标签都被关闭,自动重置为一个新的空白标签页 @@ -828,6 +848,11 @@ class BrowserManager @Inject constructor( suspend fun selectTab(tabId: String): Boolean = withContext(Dispatchers.Main) { if (findTab(tabId) == null) return@withContext false + // 面板关闭期间的标签切换要同步保活集合:新激活标签纳入保活,旧标签移出。 + if (containerView == null && keepAliveTabIds.isNotEmpty()) { + keepAliveTabIds.remove(activeTabId) + keepAliveTabIds.add(tabId) + } activeTabId = tabId updateContainerView() publishState()