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 1/2] =?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 d659640bb4ddc7edea7ead0c4626b3762cc765d3 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 2/2] =?UTF-8?q?refactor(prompt):=20=E6=8A=BD=E5=87=BA=20Pr?= =?UTF-8?q?omptFileResolver=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"