From f8fb3cb5e0564accb7ea1100519721db5a0df92e Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:55:13 +0000 Subject: [PATCH 01/18] =?UTF-8?q?feat(prompts):=20=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=95=B0=E6=8D=AE=E5=B1=82?= =?UTF-8?q?=EF=BC=88=E6=A8=A1=E5=9E=8B=20+=20=E5=AD=98=E5=8F=96=20+=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 1 片:为「用户自定义提示词」打地基,暂不动界面与注入。 - UserPrompt:id / name / position / content - UserPromptPosition:BEFORE_ALL(所有提示词之前)/ AFTER_SYSTEM(系统提示词之后)/ OFF(关闭) —— 用户提示词不掺进内置片段的编号排序(内置编号固定,掺进去会打乱基线),走两个独立槽位 - UserPromptScope:GLOBAL(跟 App 走)/ PROJECT(跟工作区走) - UserPromptStore:落盘 /prompts.custom/user/.md(项目级走 ProjectAicodeRoot 解析的 项目配置目录,本地/远程两种执行模式都已覆盖),文件格式与记忆文件同款 frontmatter + 正文 - id 内嵌时间戳 → 字典序即创建序(同类提示词按创建顺序注入) - 单测:frontmatter 往返、作用域隔离、无工作区时不写项目级、删除、创建顺序 --- .../feature/agent/domain/prompt/UserPrompt.kt | 53 +++++++ .../agent/domain/prompt/UserPromptStore.kt | 130 ++++++++++++++++++ .../domain/prompt/UserPromptStoreTest.kt | 86 ++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt create mode 100644 app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt new file mode 100644 index 00000000..7c0a1978 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt @@ -0,0 +1,53 @@ +package com.aicode.feature.agent.domain.prompt + +/** + * 用户自定义提示词。 + * + * 与内置片段(`assets/prompts/` 下按编号排序的 00/10/15…)不同,它由用户在 App 里创建, + * 不参与编号排序,只决定「插在哪」——见 [UserPromptPosition]。 + * + * @param id 稳定标识,落盘时作为文件名(不含扩展名) + * @param name 用户起的名字,用于列表显示 + * @param position 注入位置 + * @param content 提示词正文 + */ +data class UserPrompt( + val id: String, + val name: String, + val position: UserPromptPosition, + val content: String +) + +/** 作用域:全局(跟 App 走)与项目(跟工作区走)。 */ +enum class UserPromptScope { + GLOBAL, + PROJECT +} + +/** + * 注入位置。 + * + * 内置片段的顺序由编号固定,用户提示词不掺进那套编号,而是走两个独立槽位: + * - [BEFORE_ALL]:拼在所有提示词之前(基线之前) + * - [AFTER_SYSTEM]:拼在系统提示词之后(动态段之后) + * - [OFF]:暂时关闭,保留内容但不注入 + */ +enum class UserPromptPosition { + BEFORE_ALL, + AFTER_SYSTEM, + OFF; + + companion object { + fun fromStorage(raw: String?): UserPromptPosition = when (raw?.trim()?.lowercase()) { + "before_all" -> BEFORE_ALL + "after_system" -> AFTER_SYSTEM + else -> OFF + } + } + + fun toStorage(): String = when (this) { + BEFORE_ALL -> "before_all" + AFTER_SYSTEM -> "after_system" + OFF -> "off" + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt new file mode 100644 index 00000000..c937a9fe --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt @@ -0,0 +1,130 @@ +package com.aicode.feature.agent.domain.prompt + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.container.ContainerInstaller +import com.aicode.feature.workspace.domain.ProjectAicodeRoot +import org.yaml.snakeyaml.Yaml +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 用户自定义提示词的存取。 + * + * 落盘位置: + * - 全局:`/prompts.custom/user/.md` + * - 项目:`<项目配置目录>/prompts.custom/user/.md`(目录由 [ProjectAicodeRoot] 解析, + * 本地/远程两种执行模式都已覆盖,不用自己判断) + * + * 文件格式与记忆文件同款:YAML frontmatter(name / position)+ 正文。 + */ +@Singleton +class UserPromptStore @Inject constructor( + private val containerInstaller: ContainerInstaller, + private val projectAicodeRoot: ProjectAicodeRoot +) { + + /** 新建一条:id 由这里生成(时间戳 + 随机后缀,保证文件名不冲突)。 */ + fun newPrompt( + name: String, + position: UserPromptPosition, + content: String + ): UserPrompt = UserPrompt( + id = "p" + System.currentTimeMillis().toString(36) + "-" + UUID.randomUUID().toString().take(4), + name = name, + position = position, + content = content + ) + + /** 列出某作用域下的用户提示词,按创建顺序(文件名内嵌时间戳,字典序即创建序)。 */ + fun list(scope: UserPromptScope, projectRoot: String?): List { + val dir = userDir(scope, projectRoot) ?: return emptyList() + if (!dir.isDirectory) return emptyList() + return (dir.listFiles { file -> file.isFile && file.extension == "md" } ?: return emptyList()) + .sortedBy { it.name } + .mapNotNull { parse(it) } + } + + fun save(scope: UserPromptScope, projectRoot: String?, prompt: UserPrompt): Boolean { + val dir = userDir(scope, projectRoot) ?: return false + return try { + if (!dir.exists()) dir.mkdirs() + File(dir, "${sanitizeId(prompt.id)}.md").writeText(format(prompt)) + true + } catch (e: Exception) { + FileLogger.e(TAG, "保存用户提示词失败: ${prompt.id}", e) + false + } + } + + fun delete(scope: UserPromptScope, projectRoot: String?, id: String): Boolean { + val dir = userDir(scope, projectRoot) ?: return false + val file = File(dir, "${sanitizeId(id)}.md") + return file.isFile && file.delete() + } + + private fun userDir(scope: UserPromptScope, projectRoot: String?): File? = when (scope) { + UserPromptScope.GLOBAL -> File(File(containerInstaller.aicodeDir, CUSTOM_DIR), USER_DIR) + UserPromptScope.PROJECT -> + projectRoot?.takeIf { it.isNotBlank() } + ?.let { File(File(projectAicodeRoot.forPath(it), CUSTOM_DIR), USER_DIR) } + } + + private fun parse(file: File): UserPrompt? { + val text = try { + file.readText() + } catch (e: Exception) { + FileLogger.w(TAG, "读取用户提示词失败: ${file.name}", e) + return null + } + val (frontmatter, body) = splitFrontmatter(text) + return UserPrompt( + id = file.nameWithoutExtension, + name = frontmatter["name"]?.toString()?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension, + position = UserPromptPosition.fromStorage(frontmatter["position"]?.toString()), + content = body.trim() + ) + } + + private fun format(prompt: UserPrompt): String = + "---\nname: ${yamlScalar(prompt.name)}\nposition: ${prompt.position.toStorage()}\n---\n${prompt.content}" + + private fun splitFrontmatter(text: String): Pair, String> { + val normalized = text.replace("\r\n", "\n") + if (!normalized.startsWith("---\n")) return emptyMap() to normalized + val end = normalized.indexOf("\n---", startIndex = 3) + if (end < 0) return emptyMap() to normalized + val block = normalized.substring(4, end) + val rest = normalized.substring(end + 4).removePrefix("\n") + val map = try { + Yaml().load>(block) ?: emptyMap() + } catch (e: Exception) { + FileLogger.w(TAG, "解析用户提示词 frontmatter 失败", e) + emptyMap() + } + return map to rest + } + + /** 只保留文件名安全字符,防止 id 里出现路径分隔符等。 */ + private fun sanitizeId(id: String): String = + id.trim().replace(Regex("[^A-Za-z0-9._-]"), "-").take(64).ifBlank { "prompt" } + + private fun yamlScalar(value: String): String { + val needsQuote = value.contains(':') || value.contains('#') || + value.contains('"') || value.contains('\'') || + value.startsWith('-') || value.startsWith(' ') || value.endsWith(' ') || + value.contains('\n') || value.isBlank() + return if (needsQuote) { + "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + } else { + value + } + } + + private companion object { + const val TAG = "UserPromptStore" + const val CUSTOM_DIR = "prompts.custom" + const val USER_DIR = "user" + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt new file mode 100644 index 00000000..d5db8172 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt @@ -0,0 +1,86 @@ +package com.aicode.feature.agent.domain.prompt + +import com.aicode.feature.agent.domain.container.ContainerInstaller +import com.aicode.feature.workspace.domain.ProjectAicodeRoot +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** 用户提示词存取:frontmatter 往返、作用域隔离、删除。 */ +class UserPromptStoreTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var globalDir: java.io.File + private lateinit var projectDir: java.io.File + + private fun store(): UserPromptStore { + globalDir = tempFolder.newFolder("aicode") + projectDir = tempFolder.newFolder("project-aicode") + val container = mockk() + every { container.aicodeDir } returns globalDir + val projectRoot = mockk() + every { projectRoot.forPath(any()) } returns projectDir + return UserPromptStore(container, projectRoot) + } + + @Test + fun save_thenList_roundtripsFrontmatter() { + val store = store() + val prompt = store.newPrompt("总纲:先给结论", UserPromptPosition.BEFORE_ALL, "回答先给结论,再给理由。") + + assertTrue(store.save(UserPromptScope.GLOBAL, null, prompt)) + + val listed = store.list(UserPromptScope.GLOBAL, null) + assertEquals(1, listed.size) + assertEquals("总纲:先给结论", listed[0].name) + assertEquals(UserPromptPosition.BEFORE_ALL, listed[0].position) + assertEquals("回答先给结论,再给理由。", listed[0].content) + } + + @Test + fun scope_isolatesGlobalAndProject() { + val store = store() + store.save(UserPromptScope.GLOBAL, "/ws", store.newPrompt("g", UserPromptPosition.OFF, "global")) + store.save(UserPromptScope.PROJECT, "/ws", store.newPrompt("p", UserPromptPosition.OFF, "project")) + + assertEquals(listOf("g"), store.list(UserPromptScope.GLOBAL, "/ws").map { it.name }) + assertEquals(listOf("p"), store.list(UserPromptScope.PROJECT, "/ws").map { it.name }) + } + + @Test + fun projectScope_withoutWorkspace_writesNothing() { + val store = store() + assertFalse(store.save(UserPromptScope.PROJECT, null, store.newPrompt("p", UserPromptPosition.OFF, "x"))) + assertTrue(store.list(UserPromptScope.PROJECT, null).isEmpty()) + } + + @Test + fun delete_removesPrompt() { + val store = store() + val prompt = store.newPrompt("t", UserPromptPosition.AFTER_SYSTEM, "body") + store.save(UserPromptScope.GLOBAL, null, prompt) + + assertTrue(store.delete(UserPromptScope.GLOBAL, null, prompt.id)) + assertTrue(store.list(UserPromptScope.GLOBAL, null).isEmpty()) + assertFalse(store.delete(UserPromptScope.GLOBAL, null, prompt.id)) + } + + @Test + fun list_keepsCreationOrder() { + val store = store() + val first = store.newPrompt("a", UserPromptPosition.OFF, "1") + val second = store.newPrompt("b", UserPromptPosition.OFF, "2") + store.save(UserPromptScope.GLOBAL, null, first) + store.save(UserPromptScope.GLOBAL, null, second) + + // id 内嵌时间戳,字典序即创建序(同毫秒时退化为随机后缀,顺序不保证——只断言都在) + assertEquals(setOf("a", "b"), store.list(UserPromptScope.GLOBAL, null).map { it.name }.toSet()) + } +} From d47ccf4915d7dd9e7a8e6340dc12ee77e7c0cd23 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:14:50 +0000 Subject: [PATCH 02/18] =?UTF-8?q?feat(prompts):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=8E=A5=E5=85=A5=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=EF=BC=88=E4=B8=A4=E4=B8=AA=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E6=A7=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 2 片:把用户自定义提示词真正拼进系统提示词。 - SystemPromptProvider 注入 UserPromptStore - BEFORE_ALL:拼在静态基线(00/10/15…)之前 - AFTER_SYSTEM:拼在整个系统提示词末尾(动态段之后) - OFF 不注入;正文为空的条目跳过 - 全局 + 项目的条目一并取,按各自创建顺序拼接 - 只在主代理 build() 生效(子代理与 .no-builtin 模式不注入) 下一步(第 3 片)才是界面:提示词页 + 右上角「+」三入口 + 添加/编辑页。 --- .../domain/prompt/SystemPromptProvider.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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..fd0878e5 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 userPromptStore: UserPromptStore, private val containerInstaller: ContainerInstaller, private val agentDefinitionRepository: AgentDefinitionRepository ) { @@ -229,6 +230,29 @@ class SystemPromptProvider @Inject constructor( private val subAgentBaseSource = SubAgentBaseSource() private val subAgentListSource = SubAgentListSource() private val memoryListSource = MemoryListSource() + + /** + * 用户自定义提示词:不参与内置片段的编号排序,只按注入位置拼在两端。 + * + * - [UserPromptPosition.BEFORE_ALL]:拼在所有内置提示词之前 + * - [UserPromptPosition.AFTER_SYSTEM]:拼在整个系统提示词之后 + * - [UserPromptPosition.OFF]:不注入 + * + * 只在主代理的 build() 里用(子代理与「仅自定义片段」模式不注入)。 + */ + private inner class UserPromptSource { + fun buildAt(position: UserPromptPosition, ctx: AgentContext): String? { + val prompts = runCatching { + userPromptStore.list(UserPromptScope.GLOBAL, ctx.projectRoot) + + userPromptStore.list(UserPromptScope.PROJECT, ctx.projectRoot) + }.getOrDefault(emptyList()) + .filter { it.position == position && it.content.isNotBlank() } + if (prompts.isEmpty()) return null + return prompts.joinToString("\n\n") { it.content.trim() } + } + } + + private val userPromptSource = UserPromptSource() private val activeSkillsSource = ActiveSkillsSource() private val projectRuleSource = ProjectRuleSource() private val workspaceSource = WorkspaceSource() @@ -273,6 +297,11 @@ class SystemPromptProvider @Inject constructor( // 4. 组装最终提示词:把稳定不变的重头基线放最前面(享受 KV Cache),变化部分放末尾 return buildString { + // 用户提示词(最前):拼在基线之前 + userPromptSource.buildAt(UserPromptPosition.BEFORE_ALL, agentContext)?.let { + append(it) + append("\n\n") + } append(staticContent) if (SKILLS_VAR !in rawStatic) skillsContent?.let { append("\n\n"); append(it) } @@ -288,6 +317,12 @@ class SystemPromptProvider @Inject constructor( append("\n\n") append(timeContent) } + + // 用户提示词(最后):拼在整个系统提示词之后 + userPromptSource.buildAt(UserPromptPosition.AFTER_SYSTEM, agentContext)?.let { + append("\n\n") + append(it) + } } } From eec53921a4f8d85c30629e845338e579ddeef28b Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:21:43 +0000 Subject: [PATCH 03/18] =?UTF-8?q?feat(prompts):=20=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E7=89=87=E6=AE=B5=E7=9A=84=E8=AF=BB=E5=8F=96=E4=B8=8E=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=AE=A1=E7=90=86=EF=BC=88=E7=AC=AC=203a=20=E6=AD=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为界面提供「生效片段」数据源: - listFragments:内置(assets/prompts 顶层带编号的 md)+ 自定义覆盖/新增合并,按编号升序; 每项带编号、名称、内置文件名、覆盖文件、生效正文、是否被覆盖 - 内置清单不硬编码:直接列 assets/prompts,App 升级新增片段自动出现 - saveOverride / deleteOverride:写/删 prompts.custom/-<名称>.md(编号即身份, 同编号只保留一个覆盖),与手工放文件完全等价 - isBuiltinDisabled / setBuiltinDisabled:管理 .no-builtin 标记 下一步(3b):提示词页列表 + 顶部说明 + 全局/项目分组。 --- .../domain/prompt/PromptFragmentRepository.kt | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt new file mode 100644 index 00000000..578cacce --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt @@ -0,0 +1,144 @@ +package com.aicode.feature.agent.domain.prompt + +import android.content.Context +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.container.ContainerInstaller +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 内置静态片段(`assets/prompts/-<名称>.md`)的读取与「覆盖」管理。 + * + * 机制说明:内置编号即身份。用户在 App 里改某个片段,落盘为 `prompts.custom/-<名称>.md`, + * 与手工放文件完全等价(见 [PromptFragmentResolver])——所以 App 里改与直接改文件不会打架。 + * + * 内置片段清单不硬编码,直接列 `assets/prompts` 顶层带编号的 md,App 升级新增片段自动出现。 + */ +@Singleton +class PromptFragmentRepository @Inject constructor( + @param:ApplicationContext private val context: Context, + private val containerInstaller: ContainerInstaller +) { + + /** + * 一个生效的静态片段。 + * + * @param number 编号(顺序依据) + * @param title 名称(取自文件名去掉编号与扩展名) + * @param builtinFile 内置文件名;自定义新增的片段为 null + * @param overrideFile 覆盖文件;未覆盖时为 null + * @param content 生效正文(有覆盖读覆盖,否则读内置) + */ + data class Fragment( + val number: Int, + val title: String, + val builtinFile: String?, + val overrideFile: File?, + val content: String + ) { + val isOverridden: Boolean get() = overrideFile != null + } + + private val customDir: File get() = File(containerInstaller.aicodeDir, CUSTOM_DIR) + + /** 生效的静态片段清单,按编号升序。 */ + fun listFragments(): List { + val builtin = builtinFiles() + val builtinNumbers = builtin.keys.sorted() + val merged = PromptFragmentResolver.mergeStatic( + builtinNumbers, + PromptFragmentResolver.numberedFragments(customDir) + ) + return merged.mapNotNull { (number, override) -> + val builtinName = builtin[number] + val title = (override?.nameWithoutExtension ?: builtinName?.removeSuffix(".md")) + ?.substringAfter('-', missingDelimiterValue = "") + ?: return@mapNotNull null + val content = override?.let { readText(it) } + ?: builtinName?.let { readAsset(it) } + ?: return@mapNotNull null + Fragment( + number = number, + title = title, + builtinFile = builtinName, + overrideFile = override, + content = content + ) + } + } + + /** 只读内置片段(用户可改的那个固定项,默认 00)的生效正文;不存在返回 null。 */ + fun fragment(number: Int): Fragment? = listFragments().firstOrNull { it.number == number } + + /** 写覆盖:同名编号的旧覆盖文件先清掉(编号即身份,同编号只保留一个)。 */ + fun saveOverride(number: Int, title: String, content: String): Boolean { + return try { + if (!customDir.exists()) customDir.mkdirs() + deleteOverridesFor(number) + File(customDir, "%02d-%s.md".format(number, sanitizeTitle(title))).writeText(content) + true + } catch (e: Exception) { + FileLogger.e(TAG, "写提示词覆盖失败: $number", e) + false + } + } + + /** 删除覆盖,恢复内置默认内容。 */ + fun deleteOverride(number: Int): Boolean = deleteOverridesFor(number) + + fun isBuiltinDisabled(): Boolean = PromptFragmentResolver.isBuiltinDisabled(customDir) + + /** 切换「完全禁用内置提示词」(`.no-builtin` 标记文件)。 */ + fun setBuiltinDisabled(disabled: Boolean): Boolean { + return try { + val marker = File(customDir, PromptFragmentResolver.DISABLE_BUILTIN_FILE) + if (disabled) { + if (!customDir.exists()) customDir.mkdirs() + marker.writeText("") + } else if (marker.isFile) { + marker.delete() + } + true + } catch (e: Exception) { + FileLogger.e(TAG, "切换禁用内置提示词失败", e) + false + } + } + + /** `assets/prompts` 顶层带编号的 md:编号 → 文件名。 */ + private fun builtinFiles(): Map { + val names = runCatching { context.assets.list(ASSET_DIR)?.toList() }.getOrNull() ?: return emptyMap() + val byNumber = LinkedHashMap() + names.filter { it.endsWith(".md") }.sorted().forEach { name -> + val number = PromptFragmentResolver.parseNumber(name) ?: return@forEach + byNumber.putIfAbsent(number, name) + } + return byNumber + } + + private fun readAsset(name: String): String? = runCatching { + context.assets.open("$ASSET_DIR/$name").bufferedReader().use { it.readText() } + }.getOrNull() + + private fun readText(file: File): String? = runCatching { file.readText() }.getOrNull() + + private fun deleteOverridesFor(number: Int): Boolean { + val files = customDir.listFiles { file -> + file.isFile && PromptFragmentResolver.parseNumber(file.name) == number + } ?: return false + var deleted = false + files.forEach { deleted = it.delete() || deleted } + return deleted + } + + private fun sanitizeTitle(title: String): String = + title.trim().replace(Regex("[^A-Za-z0-9._\\u4e00-\\u9fa5-]"), "-").take(40).ifBlank { "fragment" } + + private companion object { + const val TAG = "PromptFragmentRepository" + const val ASSET_DIR = "prompts" + const val CUSTOM_DIR = "prompts.custom" + } +} From 130be7d687b3c953ffab97b5f601cfa66ab00c69 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:36:16 +0000 Subject: [PATCH 04/18] =?UTF-8?q?feat(prompts):=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E9=A1=B5=E7=8A=B6=E6=80=81=E4=B8=8E=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=203b=20=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PromptsViewModel:用户提示词的增删改、固定内置片段(00)的覆盖读写与恢复、 .no-builtin 开关;磁盘操作全放 IO 线程 - PromptsSection:顶部一句说明 + 「默认提示词(00)」+ 全局/项目两组; 行显示名称 + 注入位置标签,自建条目支持左滑删除,内置项只可点进编辑 - 中英文案各 11 条(含三种注入位置标签) 注意:本步只加状态与列表组件,尚未接进设置页(下一步 3c 建页面入口、底部弹层与编辑页)。 --- .../settings/presentation/PromptsViewModel.kt | 131 +++++++++++++ .../presentation/component/PromptsSection.kt | 180 ++++++++++++++++++ app/src/main/res/values-en/strings.xml | 12 ++ app/src/main/res/values/strings.xml | 12 ++ 4 files changed, 335 insertions(+) create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt new file mode 100644 index 00000000..6182e34d --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -0,0 +1,131 @@ +package com.aicode.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aicode.feature.agent.domain.prompt.PromptFragmentRepository +import com.aicode.feature.agent.domain.prompt.UserPrompt +import com.aicode.feature.agent.domain.prompt.UserPromptPosition +import com.aicode.feature.agent.domain.prompt.UserPromptScope +import com.aicode.feature.agent.domain.prompt.UserPromptStore +import com.aicode.feature.workspace.data.repository.WorkspaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** 自定义提示词页的可变状态。 */ +data class PromptsUiState( + /** 用户可改的固定内置片段(00 身份/总纲)的生效正文;读不到时为 null。 */ + val defaultFragmentContent: String? = null, + val defaultFragmentOverridden: Boolean = false, + val globalPrompts: List = emptyList(), + val projectPrompts: List = emptyList(), + val builtinDisabled: Boolean = false, + /** 没有选中工作区时项目组不可用。 */ + val hasWorkspace: Boolean = false, + val loading: Boolean = true +) + +/** + * 自定义提示词页状态:用户提示词的增删改 + 固定内置片段(00)的覆盖 + 内置开关。 + * + * 读写都是磁盘 IO,统一放 IO 线程。 + */ +@HiltViewModel +class PromptsViewModel @Inject constructor( + private val userPromptStore: UserPromptStore, + private val fragmentRepository: PromptFragmentRepository, + private val workspaceRepository: WorkspaceRepository +) : ViewModel() { + + private val _state = MutableStateFlow(PromptsUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + val loaded = withContext(Dispatchers.IO) { + val default = fragmentRepository.fragment(DEFAULT_FRAGMENT_NUMBER) + PromptsUiState( + defaultFragmentContent = default?.content, + defaultFragmentOverridden = default?.isOverridden == true, + globalPrompts = userPromptStore.list(UserPromptScope.GLOBAL, projectRoot), + projectPrompts = userPromptStore.list(UserPromptScope.PROJECT, projectRoot), + builtinDisabled = fragmentRepository.isBuiltinDisabled(), + hasWorkspace = projectRoot.isNotBlank(), + loading = false + ) + } + _state.value = loaded + } + } + + /** 保存固定内置片段(00)的覆盖;内容为空视为删除覆盖(恢复内置默认)。 */ + fun saveDefaultFragment(content: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (content.isBlank()) { + fragmentRepository.deleteOverride(DEFAULT_FRAGMENT_NUMBER) + } else { + fragmentRepository.saveOverride(DEFAULT_FRAGMENT_NUMBER, DEFAULT_FRAGMENT_TITLE, content) + } + } + refresh() + } + } + + /** 恢复固定内置片段的默认内容(删掉覆盖)。 */ + fun resetDefaultFragment() { + viewModelScope.launch { + withContext(Dispatchers.IO) { fragmentRepository.deleteOverride(DEFAULT_FRAGMENT_NUMBER) } + refresh() + } + } + + fun savePrompt( + existing: UserPrompt?, + name: String, + scope: UserPromptScope, + position: UserPromptPosition, + content: String + ) { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + withContext(Dispatchers.IO) { + val prompt = existing?.copy(name = name, position = position, content = content) + ?: userPromptStore.newPrompt(name, position, content) + userPromptStore.save(scope, projectRoot, prompt) + } + refresh() + } + } + + fun deletePrompt(prompt: UserPrompt, scope: UserPromptScope) { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + withContext(Dispatchers.IO) { userPromptStore.delete(scope, projectRoot, prompt.id) } + refresh() + } + } + + fun setBuiltinDisabled(disabled: Boolean) { + viewModelScope.launch { + withContext(Dispatchers.IO) { fragmentRepository.setBuiltinDisabled(disabled) } + refresh() + } + } + + companion object { + /** 用户可改的固定片段:00 身份/总纲。其余内置片段在「高级设置」里只读展示。 */ + const val DEFAULT_FRAGMENT_NUMBER = 0 + private const val DEFAULT_FRAGMENT_TITLE = "identity" + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt new file mode 100644 index 00000000..a1da370f --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -0,0 +1,180 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Spacing +import com.aicode.core.theme.semanticColors +import com.aicode.core.ui.SwipeToDeleteRow +import com.aicode.feature.agent.domain.prompt.UserPrompt +import com.aicode.feature.agent.domain.prompt.UserPromptPosition +import com.aicode.feature.agent.domain.prompt.UserPromptScope +import com.aicode.feature.settings.presentation.PromptsUiState +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.FileText + +/** + * 自定义提示词页:顶部一句说明 + 固定内置片段(00)+ 全局/项目两组用户提示词。 + * + * 用户提示词按「创建顺序」注入,分「最前 / 最后 / 关闭」三种位置,见 [UserPromptPosition]。 + */ +@Composable +internal fun PromptsSection( + state: PromptsUiState, + onOpenDefaultFragment: () -> Unit, + onOpenPrompt: (UserPrompt, UserPromptScope) -> Unit, + onDeletePrompt: (UserPrompt, UserPromptScope) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + Text( + text = stringResource(R.string.prompts_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = Spacing.sm, bottom = Spacing.xs) + ) + + SettingsGroupHeader(text = stringResource(R.string.prompts_group_default)) + SettingsGroup { + PromptRow( + title = stringResource(R.string.prompts_default_title), + subtitle = stringResource( + if (state.defaultFragmentOverridden) R.string.prompts_state_overridden + else R.string.prompts_state_builtin + ), + onClick = onOpenDefaultFragment + ) + } + + SettingsGroupHeader(text = stringResource(R.string.perm_global)) + SettingsGroup { + if (state.globalPrompts.isEmpty()) { + EmptyHint(stringResource(R.string.prompts_empty)) + } else { + state.globalPrompts.forEachIndexed { index, prompt -> + if (index > 0) SettingsDivider() + PromptRow( + title = prompt.name, + subtitle = positionLabel(prompt.position), + onClick = { onOpenPrompt(prompt, UserPromptScope.GLOBAL) }, + onDelete = { onDeletePrompt(prompt, UserPromptScope.GLOBAL) } + ) + } + } + } + + SettingsGroupHeader(text = stringResource(R.string.skills_scope_project)) + SettingsGroup { + if (state.projectPrompts.isEmpty()) { + EmptyHint( + stringResource( + if (state.hasWorkspace) R.string.prompts_empty + else R.string.prompts_no_workspace + ) + ) + } else { + state.projectPrompts.forEachIndexed { index, prompt -> + if (index > 0) SettingsDivider() + PromptRow( + title = prompt.name, + subtitle = positionLabel(prompt.position), + onClick = { onOpenPrompt(prompt, UserPromptScope.PROJECT) }, + onDelete = { onDeletePrompt(prompt, UserPromptScope.PROJECT) } + ) + } + } + } + } +} + +/** 用户提示词只能软删除(全局/项目都走同一条路)。 */ +@Composable +private fun PromptRow( + title: String, + subtitle: String, + onClick: () -> Unit, + onDelete: (() -> Unit)? = null +) { + val row: @Composable () -> Unit = { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.semanticColors.cardSurface) + .padding(start = Spacing.lg, end = Spacing.lg, top = 11.dp, bottom = 11.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier.width(Spacing.sm)) + androidx.compose.material3.Icon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = MaterialTheme.semanticColors.subtleText, + modifier = Modifier.width(18.dp) + ) + } + } + + if (onDelete == null) { + // 固定内置片段不可删:只做普通可点行 + androidx.compose.foundation.clickable(onClick = onClick) { row() } + } else { + SwipeToDeleteRow(onDelete = onDelete, onClick = onClick) { row() } + } +} + +@Composable +private fun EmptyHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 12.dp) + ) +} + +@Composable +private fun positionLabel(position: UserPromptPosition): String = when (position) { + UserPromptPosition.BEFORE_ALL -> stringResource(R.string.prompts_position_before_all) + UserPromptPosition.AFTER_SYSTEM -> stringResource(R.string.prompts_position_after_system) + UserPromptPosition.OFF -> stringResource(R.string.prompts_position_off) +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ec1b7549..42ce4feb 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -965,6 +965,18 @@ %1$d tools Details + + Prompts + Your own prompts live here. The fixed \"global default prompt\" is editable; prompts you add are injected in creation order, either before all built-in prompts or after the system prompt. + Default prompt + Global default prompt (00) + Built-in default + Modified (overrides built-in) + No prompts added yet + No workspace selected; project prompts are unavailable + First (before built-ins) + Last (end of system prompt) + Off (not injected) No skills installed Tap + in the top bar to create or import one. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b1015eb..635f8b9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -965,6 +965,18 @@ %1$d 个工具 详情 + + 提示词 + 这里放你自己的提示词。固定的「全局默认提示词」可改;自己添加的按创建顺序注入,可选插在所有提示词之前或系统提示词之后。 + 默认提示词 + 全局默认提示词(00) + 内置默认内容 + 已修改(覆盖内置) + 还没有添加提示词 + 未选择工作区,项目级提示词不可用 + 最前(内置之前) + 最后(系统提示词末尾) + 已关闭(不注入) 还没有安装技能 点右上角「+」新建或导入 From 947ba5a3178c95c1ccae3c0618ba0384a233dde9 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:40:15 +0000 Subject: [PATCH 05/18] =?UTF-8?q?fix(prompts):=20=E4=BF=AE=203b=20?= =?UTF-8?q?=E7=9A=84=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF=EF=BC=88CI=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PromptsSection 里的私有 EmptyHint 与同包 ProvidersAndLogSection 里的 internal EmptyHint 重名, 导致重载解析歧义与冲突 → 改名 PromptEmptyHint - clickable 是 Modifier 扩展不是独立组件,原来写成 androidx.compose.foundation.clickable(onClick){} → 改为 Box(Modifier.clickable(onClick)) - 图标改用 Modifier.size 并正规导入 Icon;去掉没用到的 FileText 导入 --- .../presentation/component/PromptsSection.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index a1da370f..9f22d115 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -1,7 +1,9 @@ package com.aicode.feature.settings.presentation.component 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 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,8 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,7 +34,6 @@ import com.aicode.feature.agent.domain.prompt.UserPromptScope import com.aicode.feature.settings.presentation.PromptsUiState import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight -import compose.icons.feathericons.FileText /** * 自定义提示词页:顶部一句说明 + 固定内置片段(00)+ 全局/项目两组用户提示词。 @@ -74,7 +77,7 @@ internal fun PromptsSection( SettingsGroupHeader(text = stringResource(R.string.perm_global)) SettingsGroup { if (state.globalPrompts.isEmpty()) { - EmptyHint(stringResource(R.string.prompts_empty)) + PromptEmptyHint(stringResource(R.string.prompts_empty)) } else { state.globalPrompts.forEachIndexed { index, prompt -> if (index > 0) SettingsDivider() @@ -91,7 +94,7 @@ internal fun PromptsSection( SettingsGroupHeader(text = stringResource(R.string.skills_scope_project)) SettingsGroup { if (state.projectPrompts.isEmpty()) { - EmptyHint( + PromptEmptyHint( stringResource( if (state.hasWorkspace) R.string.prompts_empty else R.string.prompts_no_workspace @@ -145,25 +148,26 @@ private fun PromptRow( ) } Spacer(modifier = Modifier.width(Spacing.sm)) - androidx.compose.material3.Icon( + Icon( imageVector = FeatherIcons.ChevronRight, contentDescription = null, tint = MaterialTheme.semanticColors.subtleText, - modifier = Modifier.width(18.dp) + modifier = Modifier.size(18.dp) ) } } if (onDelete == null) { - // 固定内置片段不可删:只做普通可点行 - androidx.compose.foundation.clickable(onClick = onClick) { row() } + // 固定内置片段不可删:只做普通可点行(clickable 是 Modifier 扩展,不能当组件用) + Box(modifier = Modifier.clickable(onClick = onClick)) { row() } } else { SwipeToDeleteRow(onDelete = onDelete, onClick = onClick) { row() } } } +/** 空态提示(名字带 Prompt 前缀:同包已有 EmptyHint)。 */ @Composable -private fun EmptyHint(text: String) { +private fun PromptEmptyHint(text: String) { Text( text = text, style = MaterialTheme.typography.bodyMedium, From 87e2af2435891dd35a6521302e9315794d82e2f4 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:46:30 +0000 Subject: [PATCH 06/18] =?UTF-8?q?feat(prompts):=20=E6=B7=BB=E5=8A=A0/?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=A1=B5=EF=BC=88=E7=AC=AC=203c=20=E6=AD=A5?= =?UTF-8?q?=E4=B8=8A=E5=8D=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照新建子代理页,按用户规格砍到只剩四样: - 名称(AppTextField) - 作用域(全局 / 项目;无工作区时项目不可选) - 提示词注入(最前 / 最后 / 已关闭,各带一句说明) - Agents 提示词正文(多行输入) 删掉:用途描述、模型分组、工具集分组。 保存动作放行内按钮(与技能/子代理编辑页一致,不抢外壳顶栏动作槽)。 中英文案各 17 条。 尚未接进设置页(下一步 3c 下半:页面入口 + 右上角「+」弹层三入口)。 --- .../component/PromptEditorSection.kt | 173 ++++++++++++++++++ app/src/main/res/values-en/strings.xml | 18 ++ app/src/main/res/values/strings.xml | 18 ++ 3 files changed, 209 insertions(+) create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt new file mode 100644 index 00000000..9f35324d --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt @@ -0,0 +1,173 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Spacing +import com.aicode.core.theme.semanticColors +import com.aicode.core.ui.AppSwitch +import com.aicode.core.ui.AppTextField +import com.aicode.feature.agent.domain.prompt.UserPromptPosition +import com.aicode.feature.agent.domain.prompt.UserPromptScope + +/** + * 添加 / 编辑用户提示词(照新建子代理页,但砍到只剩四样): + * 名称、作用域、注入位置、Agents 提示词正文。 + * + * 没有用途描述、模型、工具集——用户提示词不需要这些。 + */ +@Composable +internal fun PromptEditorSection( + isNew: Boolean, + initialName: String, + initialScope: UserPromptScope, + initialPosition: UserPromptPosition, + initialContent: String, + hasWorkspace: Boolean, + onSave: (name: String, scope: UserPromptScope, position: UserPromptPosition, content: String) -> Unit +) { + var name by remember { mutableStateOf(initialName) } + var scope by remember { mutableStateOf(initialScope) } + var position by remember { mutableStateOf(initialPosition) } + var content by remember { mutableStateOf(initialContent) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.prompts_field_basic)) + SettingsGroup { + Column(modifier = Modifier.padding(Spacing.lg)) { + AppTextField( + value = name, + onValueChange = { name = it }, + label = stringResource(R.string.prompts_field_name), + placeholder = stringResource(R.string.prompts_field_name_placeholder) + ) + } + } + + SettingsGroupHeader(text = stringResource(R.string.prompts_field_scope)) + SettingsGroup { + SettingsRow( + icon = null, + title = stringResource(R.string.prompts_scope_global), + subtitle = stringResource(R.string.prompts_scope_global_hint), + trailing = { + AppSwitch(checked = scope == UserPromptScope.GLOBAL, onCheckedChange = { scope = UserPromptScope.GLOBAL }) + } + ) + SettingsDivider() + SettingsRow( + icon = null, + title = stringResource(R.string.prompts_scope_project), + subtitle = stringResource( + if (hasWorkspace) R.string.prompts_scope_project_hint + else R.string.prompts_no_workspace + ), + trailing = { + AppSwitch( + checked = scope == UserPromptScope.PROJECT, + onCheckedChange = { if (hasWorkspace) scope = UserPromptScope.PROJECT } + ) + } + ) + } + + SettingsGroupHeader(text = stringResource(R.string.prompts_field_position)) + SettingsGroup { + PositionRow( + label = stringResource(R.string.prompts_position_before_all), + hint = stringResource(R.string.prompts_position_before_all_hint), + selected = position == UserPromptPosition.BEFORE_ALL, + onSelect = { position = UserPromptPosition.BEFORE_ALL } + ) + SettingsDivider() + PositionRow( + label = stringResource(R.string.prompts_position_after_system), + hint = stringResource(R.string.prompts_position_after_system_hint), + selected = position == UserPromptPosition.AFTER_SYSTEM, + onSelect = { position = UserPromptPosition.AFTER_SYSTEM } + ) + SettingsDivider() + PositionRow( + label = stringResource(R.string.prompts_position_off), + hint = stringResource(R.string.prompts_position_off_hint), + selected = position == UserPromptPosition.OFF, + onSelect = { position = UserPromptPosition.OFF } + ) + } + + SettingsGroupHeader(text = stringResource(R.string.prompts_field_content)) + SettingsGroup { + Column(modifier = Modifier.padding(Spacing.lg)) { + AppTextField( + value = content, + onValueChange = { content = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 160.dp), + singleLine = false, + label = stringResource(R.string.prompts_field_content), + placeholder = stringResource(R.string.prompts_field_content_placeholder) + ) + } + } + + Text( + text = stringResource(R.string.prompts_save_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.semanticColors.subtleText, + modifier = Modifier.padding(top = Spacing.sm) + ) + + // 保存动作放行内按钮,避免和外壳顶栏动作槽抢位置(与技能/子代理编辑页一致的做法) + SettingsGroup { + SettingsRow( + icon = null, + title = stringResource(if (isNew) R.string.prompts_action_add else R.string.prompts_action_save), + enabled = name.isNotBlank(), + onClick = { + onSave(name.trim(), scope, position, content) + } + ) + } + } +} + +@Composable +private fun PositionRow( + label: String, + hint: String, + selected: Boolean, + onSelect: () -> Unit +) { + SettingsRow( + icon = null, + title = label, + subtitle = hint, + trailing = { + AppSwitch(checked = selected, onCheckedChange = { onSelect() }) + } + ) +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 42ce4feb..cc671508 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -977,6 +977,24 @@ First (before built-ins) Last (end of system prompt) Off (not injected) + Basic + Name + e.g. Answer style + Scope + Global + Applies to every project + Project + Applies to the current workspace only + Injection + Placed before all built-in prompts, read first + Appended to the end of the system prompt, read last + Kept but not injected + Agents prompt + What the model should follow + Takes effect immediately (start a new session or continue the next turn). + Add + Save + No skills installed Tap + in the top bar to create or import one. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 635f8b9b..0dd00aa6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -977,6 +977,24 @@ 最前(内置之前) 最后(系统提示词末尾) 已关闭(不注入) + 基本信息 + 名称 + 例如:回答风格 + 作用域 + 全局 + 所有项目都生效 + 项目 + 只在当前工作区生效 + 提示词注入 + 拼在所有内置提示词之前,先被读到 + 拼在系统提示词末尾,最后被读到 + 内容保留,但不注入 + Agents 提示词 + 写你要模型遵守的内容 + 保存后立即生效(开新会话或下一轮对话即可看到)。 + 添加 + 保存 + 还没有安装技能 点右上角「+」新建或导入 From 01e06ffd2f67038c19666cefc129a21507845779 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:53:25 +0000 Subject: [PATCH 07/18] =?UTF-8?q?feat(prompts):=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E7=89=87=E6=AE=B5=E7=BC=96=E8=BE=91=E9=A1=B5=20+=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E7=9B=AE=E6=A0=87=E7=B1=BB=E5=9E=8B=EF=BC=88=E7=AC=AC?= =?UTF-8?q?=203c=20=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PromptEditTarget:区分三种入口(编辑固定内置片段 / 新建用户提示词 / 编辑已有) - FragmentEditorSection:固定内置片段(默认 00 身份/总纲)的编辑页——只能改正文、 可恢复内置默认;改动静默落盘为覆盖文件,App 自带文件永不被改 - 中英文案各 3 条 下一步(3c 收尾):设置页入口 + 右上角「+」弹层(添加提示词/高级设置/帮助)+ 上面两个编辑页的接线。 --- .../component/PromptEditorSection.kt | 64 +++++++++++++++++++ .../presentation/component/PromptsSection.kt | 13 ++++ app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 4 files changed, 83 insertions(+) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt index 9f35324d..442f6e7e 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt @@ -155,6 +155,70 @@ internal fun PromptEditorSection( } } +/** + * 固定内置片段(默认 00 身份/总纲)的编辑页:只能改正文,可恢复内置默认。 + * + * 修改落盘为 prompts.custom 下的覆盖文件,与手工放文件等价——内置文件本身永不被改。 + */ +@Composable +internal fun FragmentEditorSection( + initialContent: String, + overridden: Boolean, + onSave: (String) -> Unit, + onReset: () -> Unit +) { + var content by remember { mutableStateOf(initialContent) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.prompts_default_title)) + SettingsGroup { + Column(modifier = Modifier.padding(Spacing.lg)) { + AppTextField( + value = content, + onValueChange = { content = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 240.dp), + singleLine = false, + label = stringResource(R.string.prompts_field_content) + ) + } + } + + SettingsGroup { + SettingsRow( + icon = null, + title = stringResource(R.string.prompts_action_save), + enabled = content.isNotBlank(), + onClick = { onSave(content) } + ) + if (overridden) { + SettingsDivider() + SettingsRow( + icon = null, + title = stringResource(R.string.prompts_action_reset), + subtitle = stringResource(R.string.prompts_action_reset_hint), + onClick = onReset + ) + } + } + + Text( + text = stringResource(R.string.prompts_fragment_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.semanticColors.subtleText, + modifier = Modifier.padding(top = Spacing.sm) + ) + } +} + @Composable private fun PositionRow( label: String, diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index 9f22d115..7689dcdb 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -35,6 +35,19 @@ import com.aicode.feature.settings.presentation.PromptsUiState import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight +/** + * 编辑目标:区分三种入口。 + * + * - [isDefaultFragment] = true:编辑固定的内置片段(00),只有正文可改 + * - [prompt] = null:新建用户提示词 + * - 其余:编辑已有用户提示词 + */ +internal data class PromptEditTarget( + val prompt: UserPrompt? = null, + val scope: UserPromptScope = UserPromptScope.GLOBAL, + val isDefaultFragment: Boolean = false +) + /** * 自定义提示词页:顶部一句说明 + 固定内置片段(00)+ 全局/项目两组用户提示词。 * diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index cc671508..ff051cf0 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -994,6 +994,9 @@ Takes effect immediately (start a new session or continue the next turn). Add Save + Reset to built-in + Drop your edit and go back to the shipped content + Your edit is stored as an override file; the shipped file is never modified, so new built-in content after an app update can still be compared. No skills installed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0dd00aa6..53673d66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -994,6 +994,9 @@ 保存后立即生效(开新会话或下一轮对话即可看到)。 添加 保存 + 恢复内置默认 + 删掉你的修改,回到 App 自带内容 + 你的修改会存为覆盖文件,App 自带文件永不被改;升级 App 后新内置内容仍可整体对比。 还没有安装技能 From d1660442e5d3aa48e959e262931270cc4d6eed73 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:09:03 +0000 Subject: [PATCH 08/18] =?UTF-8?q?feat(prompts):=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E9=A1=B5=E6=8E=A5=E5=85=A5=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=20+=20=E3=80=8C+=E3=80=8D=E5=BC=B9=E5=B1=82=E4=B8=89=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=88=E7=AC=AC=203c=20=E6=AD=A5=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsSection 新增 Prompts / PromptEditor / PromptsAdvanced / PromptsHelp, 层级与返回目标(三者返回都回提示词页) - 菜单「AI 配置」组在「记忆」后新增「提示词」入口 - 动作槽:提示词页右上角「+」→ 底部弹层(添加提示词 / 高级设置 / 帮助) - 列表页接线:默认片段(00)→ 固定片段编辑页;用户提示词 → 编辑页;左滑删除 - 高级设置页:官方文档要点(内置摘要,不联网)+「完全禁用内置提示词」开关 - 帮助页:内置简短说明(五条) - 中英文案各 13 条(含文档要点与帮助正文) 到这里自定义提示词已可在 App 内完整使用。 --- .../presentation/component/PromptsSection.kt | 116 ++++++++++++++++++ .../presentation/component/SettingsScreen.kt | 109 ++++++++++++++++ app/src/main/res/values-en/strings.xml | 13 ++ app/src/main/res/values/strings.xml | 13 ++ 4 files changed, 251 insertions(+) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index 7689dcdb..d88882e5 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -27,6 +28,8 @@ import androidx.compose.ui.unit.dp import com.aicode.R import com.aicode.core.theme.Spacing import com.aicode.core.theme.semanticColors +import com.aicode.core.ui.AdaptiveModalBottomSheet +import com.aicode.core.ui.AppSwitch import com.aicode.core.ui.SwipeToDeleteRow import com.aicode.feature.agent.domain.prompt.UserPrompt import com.aicode.feature.agent.domain.prompt.UserPromptPosition @@ -34,6 +37,9 @@ import com.aicode.feature.agent.domain.prompt.UserPromptScope import com.aicode.feature.settings.presentation.PromptsUiState import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.Info +import compose.icons.feathericons.Plus +import compose.icons.feathericons.Sliders /** * 编辑目标:区分三种入口。 @@ -128,6 +134,116 @@ internal fun PromptsSection( } } +/** + * 右上角「+」弹层:三项入口(添加提示词 / 高级设置 / 帮助)。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PromptsAddSheet( + onDismiss: () -> Unit, + onAddPrompt: () -> Unit, + onAdvanced: () -> Unit, + onHelp: () -> Unit +) { + AdaptiveModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.prompts_add_sheet_title)) + SettingsGroup { + SettingsRow( + icon = FeatherIcons.Plus, + title = stringResource(R.string.prompts_add_prompt), + subtitle = stringResource(R.string.prompts_add_prompt_hint), + onClick = onAddPrompt + ) + SettingsDivider() + SettingsRow( + icon = FeatherIcons.Sliders, + title = stringResource(R.string.prompts_advanced), + subtitle = stringResource(R.string.prompts_advanced_hint), + onClick = onAdvanced + ) + SettingsDivider() + SettingsRow( + icon = FeatherIcons.Info, + title = stringResource(R.string.prompts_help), + subtitle = stringResource(R.string.prompts_help_hint), + onClick = onHelp + ) + } + } + } +} + +/** + * 高级设置:官方文档要点摘要 + 「完全禁用内置提示词」开关。 + * + * 摘要是内置文本(不联网),官方文档更新后需随 App 发版更新。 + */ +@Composable +internal fun PromptsAdvancedSection( + builtinDisabled: Boolean, + onToggleBuiltinDisabled: (Boolean) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.prompts_advanced_doc_title)) + SettingsGroup { + Text( + text = stringResource(R.string.prompts_advanced_doc_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Spacing.lg) + ) + } + + SettingsGroupHeader(text = stringResource(R.string.prompts_advanced_switch_title)) + SettingsGroup { + SettingsRow( + icon = null, + title = stringResource(R.string.prompts_disable_builtin), + subtitle = stringResource(R.string.prompts_disable_builtin_hint), + trailing = { + AppSwitch(checked = builtinDisabled, onCheckedChange = onToggleBuiltinDisabled) + } + ) + } + } +} + +/** 帮助:内置的简短说明(不联网)。 */ +@Composable +internal fun PromptsHelpSection() { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.prompts_help)) + SettingsGroup { + Text( + text = stringResource(R.string.prompts_help_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(Spacing.lg) + ) + } + } +} + /** 用户提示词只能软删除(全局/项目都走同一条路)。 */ @Composable private fun PromptRow( 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 6f0180b3..0f9b161a 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 @@ -71,6 +71,8 @@ import com.aicode.core.ui.pageExit import com.aicode.core.util.LogLevel import com.aicode.R import com.aicode.feature.agent.domain.mcp.McpServerEntry +import com.aicode.feature.agent.domain.prompt.UserPromptPosition +import com.aicode.feature.agent.domain.prompt.UserPromptScope import com.aicode.feature.agent.domain.mcp.McpServerConfig import com.aicode.feature.agent.domain.mcp.McpServerStatus import com.aicode.feature.agent.presentation.component.MarkdownContent @@ -144,6 +146,10 @@ internal enum class SettingsSection(@param:StringRes val titleRes: Int) { DefaultModels(R.string.settings_default_models), Mcp(R.string.settings_mcp), Skills(R.string.settings_skills), + Prompts(R.string.prompts_title), + PromptEditor(R.string.prompts_title), + PromptsAdvanced(R.string.prompts_advanced), + PromptsHelp(R.string.prompts_help), SkillDetail(R.string.settings_skills), SkillEditor(R.string.settings_skills), SubAgents(R.string.settings_subagents), @@ -176,6 +182,9 @@ private fun SettingsSection.depth(): Int = when (this) { SettingsSection.SkillEditor, SettingsSection.SubAgentEditor -> 3 SettingsSection.ProviderEditor, + SettingsSection.PromptEditor, + SettingsSection.PromptsAdvanced, + SettingsSection.PromptsHelp, SettingsSection.SkillDetail, SettingsSection.SubAgentDetail, SettingsSection.ContainerDownloads, @@ -323,6 +332,10 @@ fun SettingsScreen( } var selectedSkill by remember { mutableStateOf(null) } var skillToDelete by remember { mutableStateOf(null) } + + // 自定义提示词:右上角「+」弹层可见性 + 编辑目标(新建/编辑/固定片段) + var showPromptsAddSheet by remember { mutableStateOf(false) } + var promptEditTarget by remember { mutableStateOf(null) } // 技能编辑目标:null 表示新建一个;编辑现有技能时指向被编辑的条目。 var editingSkill by remember { mutableStateOf(null) } // 编辑页的返回目标:从详情页进就回详情页,从列表顶栏「+」进就回列表。 @@ -370,6 +383,9 @@ fun SettingsScreen( SettingsSection.ProviderEditor -> SettingsSection.Providers SettingsSection.Log -> logReturnSection.takeUnless { expanded && it == SettingsSection.Menu } SettingsSection.SkillDetail -> SettingsSection.Skills + SettingsSection.PromptEditor -> SettingsSection.Prompts + SettingsSection.PromptsAdvanced -> SettingsSection.Prompts + SettingsSection.PromptsHelp -> SettingsSection.Prompts SettingsSection.SkillEditor -> skillEditorReturn SettingsSection.SubAgentDetail -> SettingsSection.SubAgents SettingsSection.SubAgentEditor -> subAgentEditorReturn @@ -725,6 +741,14 @@ fun SettingsScreen( ) } } + SettingsSection.Prompts -> IconButton(onClick = { showPromptsAddSheet = true }) { + Icon( + FeatherIcons.Plus, + contentDescription = stringResource(R.string.prompts_add_prompt), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(22.dp) + ) + } SettingsSection.Storage -> { IconButton(onClick = { storageViewModel?.refresh() }) { Icon( @@ -822,6 +846,85 @@ fun SettingsScreen( section = SettingsSection.SkillDetail } ) + SettingsSection.Prompts -> { + val promptsViewModel: com.aicode.feature.settings.presentation.PromptsViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { promptsViewModel.refresh() } + PromptsSection( + state = promptsState, + onOpenDefaultFragment = { + promptEditTarget = PromptEditTarget(isDefaultFragment = true) + section = SettingsSection.PromptEditor + }, + onOpenPrompt = { prompt, scope -> + promptEditTarget = PromptEditTarget(prompt = prompt, scope = scope) + section = SettingsSection.PromptEditor + }, + onDeletePrompt = { prompt, scope -> promptsViewModel.deletePrompt(prompt, scope) } + ) + if (showPromptsAddSheet) { + PromptsAddSheet( + onDismiss = { showPromptsAddSheet = false }, + onAddPrompt = { + showPromptsAddSheet = false + promptEditTarget = PromptEditTarget() + section = SettingsSection.PromptEditor + }, + onAdvanced = { + showPromptsAddSheet = false + section = SettingsSection.PromptsAdvanced + }, + onHelp = { + showPromptsAddSheet = false + section = SettingsSection.PromptsHelp + } + ) + } + } + SettingsSection.PromptEditor -> { + val promptsViewModel: com.aicode.feature.settings.presentation.PromptsViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() + val target = promptEditTarget + if (target?.isDefaultFragment == true) { + FragmentEditorSection( + initialContent = promptsState.defaultFragmentContent.orEmpty(), + overridden = promptsState.defaultFragmentOverridden, + onSave = { + promptsViewModel.saveDefaultFragment(it) + section = SettingsSection.Prompts + }, + onReset = { + promptsViewModel.resetDefaultFragment() + section = SettingsSection.Prompts + } + ) + } else { + PromptEditorSection( + isNew = target?.prompt == null, + initialName = target?.prompt?.name.orEmpty(), + initialScope = target?.scope ?: UserPromptScope.GLOBAL, + initialPosition = target?.prompt?.position ?: UserPromptPosition.AFTER_SYSTEM, + initialContent = target?.prompt?.content.orEmpty(), + hasWorkspace = promptsState.hasWorkspace, + onSave = { name, scope, position, content -> + promptsViewModel.savePrompt(target?.prompt, name, scope, position, content) + section = SettingsSection.Prompts + } + ) + } + } + SettingsSection.PromptsAdvanced -> { + val promptsViewModel: com.aicode.feature.settings.presentation.PromptsViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() + PromptsAdvancedSection( + builtinDisabled = promptsState.builtinDisabled, + onToggleBuiltinDisabled = promptsViewModel::setBuiltinDisabled + ) + } + SettingsSection.PromptsHelp -> PromptsHelpSection() SettingsSection.SkillDetail -> selectedSkill?.let { entry -> SkillDetailSection( entry = entry, @@ -1348,6 +1451,12 @@ internal fun SettingsMenu( title = stringResource(SettingsSection.SubAgents.titleRes), onClick = { onOpen(SettingsSection.SubAgents) } ) + SettingsDivider() + SettingsRow( + icon = FeatherIcons.FileText, + title = stringResource(SettingsSection.Prompts.titleRes), + onClick = { onOpen(SettingsSection.Prompts) } + ) } // ── 运行环境 ── diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ff051cf0..e8e68828 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -997,6 +997,19 @@ Reset to built-in Drop your edit and go back to the shipped content Your edit is stored as an override file; the shipped file is never modified, so new built-in content after an app update can still be compared. + Prompts + Add prompt + Create a prompt of your own + Advanced + Built-in fragments and switches + Help + How it works and when it applies + Notes (key points from the docs) + The system prompt has two parts: the static baseline (built-in fragments, ordered 00/10/15/20/30/40/50/60/70) and the dynamic section (skills, subagents, memory, project rules, workspace, time).\n\nOverride: a file in prompts.custom/ with the same number replaces that built-in fragment — changing it in the app is equivalent to dropping the file there.\n\nAdd: use a free number in the same folder to insert your own fragment; fragments are ordered by number.\n\nDisable: the .no-builtin marker disables all built-in prompts; the system prompt then only contains your fragments, and dynamic content must be pulled in with {{AICODE_*}} variables.\n\nUser prompts: added on the Prompts page; they go before all prompts or after the system prompt, and do not take part in numbered ordering. + Built-in prompts + Disable all built-in prompts + Keep only your own fragments; you must supply every rule yourself + 1. "Global default prompt (00)" is the identity and general policy — editable, stored as an override, and resettable to the built-in default.\n\n2. Tap "+" to add your own prompt: name, scope (global/project), injection position, content.\n\n3. Injection: "First" goes before all built-in prompts (good for general policy); "Last" goes at the very end of the system prompt and is read last; "Off" keeps it but does not inject it.\n\n4. Changes apply immediately — the next turn (or a new session) picks them up.\n\n5. Longer prompts cost more context, so keep only the rules you actually need. No skills installed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53673d66..5ac6d40b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -997,6 +997,19 @@ 恢复内置默认 删掉你的修改,回到 App 自带内容 你的修改会存为覆盖文件,App 自带文件永不被改;升级 App 后新内置内容仍可整体对比。 + 提示词 + 添加提示词 + 新建一条你自己的提示词 + 高级设置 + 内置片段说明与开关 + 帮助 + 怎么用、什么时候生效 + 说明(官方文档要点) + 提示词分两段:静态基线(内置片段,按编号 00/10/15/20/30/40/50/60/70 顺序拼接)与动态段(技能、子代理、记忆、项目规则、工作区、时间)。\n\n覆盖:prompts.custom/ 下放同编号文件即覆盖对应内置片段,App 里的改动与手工放文件等价。\n\n新增:同目录下用没被占用的编号即可插入自己的片段,按编号排序。\n\n禁用:放 .no-builtin 标记可完全禁用内置提示词,此时系统提示词只由你的片段组成,动态内容需用 {{AICODE_*}} 变量按需取回。\n\n用户提示词:在「提示词」页添加,可选插在所有提示词之前或系统提示词之后,不参与编号排序。 + 内置提示词 + 完全禁用内置提示词 + 只保留你自己的片段;需要自己补全全部规则,谨慎开启 + 1. 「全局默认提示词(00)」是身份与总纲,可改;改动存为覆盖文件,随时能恢复内置默认。\n\n2. 右上角「+」添加自己的提示词:填名称、选作用域(全局/项目)、选注入位置、写内容。\n\n3. 注入位置:「最前」在所有内置提示词之前(适合总纲);「最后」在系统提示词末尾,最后被读到;「关闭」只保留不注入。\n\n4. 保存后立即生效:下一轮对话(或新会话)就带上。\n\n5. 提示词越长越占上下文,建议只写真正需要的规则。 还没有安装技能 From ad81efef62b8281cc676d7abfa99c07445ef25f8 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:22:43 +0000 Subject: [PATCH 09/18] =?UTF-8?q?feat(prompts):=20=E9=AB=98=E7=BA=A7?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E8=A1=A5=E5=86=85=E7=BD=AE=E7=89=87=E6=AE=B5?= =?UTF-8?q?=E6=B8=85=E5=8D=95=EF=BC=883d=20=E6=94=B6=E5=B0=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PromptsViewModel 暴露 fragments(内置片段 + 覆盖状态) - 高级设置页在「完全禁用内置提示词」开关下方列出内置片段: 编号 · 名称 + 「内置默认内容 / 已修改(覆盖内置)」——只读展示 - 按用户规格:界面里只有固定的 00 可改,其余片段放「高级」里供查阅 --- .../settings/presentation/PromptsViewModel.kt | 4 ++++ .../presentation/component/PromptsSection.kt | 18 ++++++++++++++++++ .../presentation/component/SettingsScreen.kt | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 25 insertions(+) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt index 6182e34d..f82ebccd 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aicode.feature.agent.domain.prompt.PromptFragmentRepository import com.aicode.feature.agent.domain.prompt.UserPrompt +import com.aicode.feature.agent.domain.prompt.UserPrompt import com.aicode.feature.agent.domain.prompt.UserPromptPosition import com.aicode.feature.agent.domain.prompt.UserPromptScope import com.aicode.feature.agent.domain.prompt.UserPromptStore @@ -25,6 +26,8 @@ data class PromptsUiState( val globalPrompts: List = emptyList(), val projectPrompts: List = emptyList(), val builtinDisabled: Boolean = false, + /** 内置静态片段清单(含覆盖状态),供「高级设置」只读展示。 */ + val fragments: List = emptyList(), /** 没有选中工作区时项目组不可用。 */ val hasWorkspace: Boolean = false, val loading: Boolean = true @@ -60,6 +63,7 @@ class PromptsViewModel @Inject constructor( globalPrompts = userPromptStore.list(UserPromptScope.GLOBAL, projectRoot), projectPrompts = userPromptStore.list(UserPromptScope.PROJECT, projectRoot), builtinDisabled = fragmentRepository.isBuiltinDisabled(), + fragments = fragmentRepository.listFragments(), hasWorkspace = projectRoot.isNotBlank(), loading = false ) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index d88882e5..b3daea08 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -31,6 +31,7 @@ import com.aicode.core.theme.semanticColors import com.aicode.core.ui.AdaptiveModalBottomSheet import com.aicode.core.ui.AppSwitch import com.aicode.core.ui.SwipeToDeleteRow +import com.aicode.feature.agent.domain.prompt.PromptFragmentRepository import com.aicode.feature.agent.domain.prompt.UserPrompt import com.aicode.feature.agent.domain.prompt.UserPromptPosition import com.aicode.feature.agent.domain.prompt.UserPromptScope @@ -187,6 +188,7 @@ internal fun PromptsAddSheet( @Composable internal fun PromptsAdvancedSection( builtinDisabled: Boolean, + fragments: List, onToggleBuiltinDisabled: (Boolean) -> Unit ) { Column( @@ -218,6 +220,22 @@ internal fun PromptsAdvancedSection( } ) } + + // 内置片段清单:只读展示。用户可改的只有固定的 00(在上一页),其余放这里供查阅。 + SettingsGroupHeader(text = stringResource(R.string.prompts_builtin_list_title)) + SettingsGroup { + fragments.forEachIndexed { index, fragment -> + if (index > 0) SettingsDivider() + SettingsRow( + icon = null, + title = "%02d · %s".format(fragment.number, fragment.title), + subtitle = stringResource( + if (fragment.isOverridden) R.string.prompts_state_overridden + else R.string.prompts_state_builtin + ) + ) + } + } } } 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 0f9b161a..9e6ac4e7 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 @@ -921,6 +921,7 @@ fun SettingsScreen( val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() PromptsAdvancedSection( builtinDisabled = promptsState.builtinDisabled, + fragments = promptsState.fragments, onToggleBuiltinDisabled = promptsViewModel::setBuiltinDisabled ) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index e8e68828..428eb784 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1007,6 +1007,7 @@ Notes (key points from the docs) The system prompt has two parts: the static baseline (built-in fragments, ordered 00/10/15/20/30/40/50/60/70) and the dynamic section (skills, subagents, memory, project rules, workspace, time).\n\nOverride: a file in prompts.custom/ with the same number replaces that built-in fragment — changing it in the app is equivalent to dropping the file there.\n\nAdd: use a free number in the same folder to insert your own fragment; fragments are ordered by number.\n\nDisable: the .no-builtin marker disables all built-in prompts; the system prompt then only contains your fragments, and dynamic content must be pulled in with {{AICODE_*}} variables.\n\nUser prompts: added on the Prompts page; they go before all prompts or after the system prompt, and do not take part in numbered ordering. Built-in prompts + Built-in fragments Disable all built-in prompts Keep only your own fragments; you must supply every rule yourself 1. "Global default prompt (00)" is the identity and general policy — editable, stored as an override, and resettable to the built-in default.\n\n2. Tap "+" to add your own prompt: name, scope (global/project), injection position, content.\n\n3. Injection: "First" goes before all built-in prompts (good for general policy); "Last" goes at the very end of the system prompt and is read last; "Off" keeps it but does not inject it.\n\n4. Changes apply immediately — the next turn (or a new session) picks them up.\n\n5. Longer prompts cost more context, so keep only the rules you actually need. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ac6d40b..f8669a63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1007,6 +1007,7 @@ 说明(官方文档要点) 提示词分两段:静态基线(内置片段,按编号 00/10/15/20/30/40/50/60/70 顺序拼接)与动态段(技能、子代理、记忆、项目规则、工作区、时间)。\n\n覆盖:prompts.custom/ 下放同编号文件即覆盖对应内置片段,App 里的改动与手工放文件等价。\n\n新增:同目录下用没被占用的编号即可插入自己的片段,按编号排序。\n\n禁用:放 .no-builtin 标记可完全禁用内置提示词,此时系统提示词只由你的片段组成,动态内容需用 {{AICODE_*}} 变量按需取回。\n\n用户提示词:在「提示词」页添加,可选插在所有提示词之前或系统提示词之后,不参与编号排序。 内置提示词 + 内置片段 完全禁用内置提示词 只保留你自己的片段;需要自己补全全部规则,谨慎开启 1. 「全局默认提示词(00)」是身份与总纲,可改;改动存为覆盖文件,随时能恢复内置默认。\n\n2. 右上角「+」添加自己的提示词:填名称、选作用域(全局/项目)、选注入位置、写内容。\n\n3. 注入位置:「最前」在所有内置提示词之前(适合总纲);「最后」在系统提示词末尾,最后被读到;「关闭」只保留不注入。\n\n4. 保存后立即生效:下一轮对话(或新会话)就带上。\n\n5. 提示词越长越占上下文,建议只写真正需要的规则。 From 61df20e85143a9aeb1658d0d4ce73fb646aa33aa Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:26:53 +0000 Subject: [PATCH 10/18] =?UTF-8?q?fix(prompts):=20=E5=8E=BB=E6=8E=89=20Prom?= =?UTF-8?q?ptsViewModel=20=E9=87=8C=E9=87=8D=E5=A4=8D=E7=9A=84=20UserPromp?= =?UTF-8?q?t=20import=EF=BC=88CI=20=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加 fragments 到状态时插入了第二个 import ...prompt.UserPrompt, Kotlin 报 Conflicting import: imported name 'UserPrompt' is ambiguous。 --- .../com/aicode/feature/settings/presentation/PromptsViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt index f82ebccd..e74f996d 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aicode.feature.agent.domain.prompt.PromptFragmentRepository import com.aicode.feature.agent.domain.prompt.UserPrompt -import com.aicode.feature.agent.domain.prompt.UserPrompt import com.aicode.feature.agent.domain.prompt.UserPromptPosition import com.aicode.feature.agent.domain.prompt.UserPromptScope import com.aicode.feature.agent.domain.prompt.UserPromptStore From 4d962e0ff2b62cd27f768686ae2f4acf3a834e2f Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:06:32 +0000 Subject: [PATCH 11/18] =?UTF-8?q?feat(prompts):=20=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E5=85=88=E8=AF=BB=E4=BD=BF=E7=94=A8=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=20+=20=E6=8D=A2=E6=8E=89=E4=B8=8E=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提示词页新增使用说明门槛:未读时先展示说明正文与「我已阅读,开始使用」, 点确认才放行;已读标记存 prompts.custom/.help-read(标记文件,与 .no-builtin 同路子, 备份/迁移配置时一起走) - 菜单图标:提示词从 FileText 换成 MessageSquare(原来与「记忆」撞图标) - 中英文案各 2 条 --- .../domain/prompt/PromptFragmentRepository.kt | 16 ++++++++ .../settings/presentation/PromptsViewModel.kt | 11 ++++++ .../presentation/component/PromptsSection.kt | 39 ++++++++++++++++++- .../presentation/component/SettingsScreen.kt | 3 ++ app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 6 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt index 578cacce..e7e6d65c 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt @@ -90,6 +90,21 @@ class PromptFragmentRepository @Inject constructor( fun isBuiltinDisabled(): Boolean = PromptFragmentResolver.isBuiltinDisabled(customDir) + /** + * 「已读使用说明」标记。 + * + * 用标记文件而不是 DataStore:与 `.no-builtin` 同一路子,位置就在 prompts.custom 下, + * 用户备份/迁移配置时跟着一起走。 + */ + fun isHelpRead(): Boolean = File(customDir, HELP_READ_MARKER).isFile + + fun markHelpRead() { + runCatching { + if (!customDir.exists()) customDir.mkdirs() + File(customDir, HELP_READ_MARKER).writeText("") + }.onFailure { FileLogger.w(TAG, "写已读标记失败", it) } + } + /** 切换「完全禁用内置提示词」(`.no-builtin` 标记文件)。 */ fun setBuiltinDisabled(disabled: Boolean): Boolean { return try { @@ -140,5 +155,6 @@ class PromptFragmentRepository @Inject constructor( const val TAG = "PromptFragmentRepository" const val ASSET_DIR = "prompts" const val CUSTOM_DIR = "prompts.custom" + const val HELP_READ_MARKER = ".help-read" } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt index e74f996d..7e6f8f11 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -25,6 +25,8 @@ data class PromptsUiState( val globalPrompts: List = emptyList(), val projectPrompts: List = emptyList(), val builtinDisabled: Boolean = false, + /** 是否已读过使用说明:未读时提示词页先展示帮助,读完才放行。 */ + val helpRead: Boolean = false, /** 内置静态片段清单(含覆盖状态),供「高级设置」只读展示。 */ val fragments: List = emptyList(), /** 没有选中工作区时项目组不可用。 */ @@ -62,6 +64,7 @@ class PromptsViewModel @Inject constructor( globalPrompts = userPromptStore.list(UserPromptScope.GLOBAL, projectRoot), projectPrompts = userPromptStore.list(UserPromptScope.PROJECT, projectRoot), builtinDisabled = fragmentRepository.isBuiltinDisabled(), + helpRead = fragmentRepository.isHelpRead(), fragments = fragmentRepository.listFragments(), hasWorkspace = projectRoot.isNotBlank(), loading = false @@ -126,6 +129,14 @@ class PromptsViewModel @Inject constructor( } } + /** 读完使用说明,放行进入提示词页。 */ + fun markHelpRead() { + viewModelScope.launch { + withContext(Dispatchers.IO) { fragmentRepository.markHelpRead() } + refresh() + } + } + companion object { /** 用户可改的固定片段:00 身份/总纲。其余内置片段在「高级设置」里只读展示。 */ const val DEFAULT_FRAGMENT_NUMBER = 0 diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index b3daea08..6b99d956 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -63,10 +63,17 @@ internal data class PromptEditTarget( @Composable internal fun PromptsSection( state: PromptsUiState, + onMarkHelpRead: () -> Unit, onOpenDefaultFragment: () -> Unit, onOpenPrompt: (UserPrompt, UserPromptScope) -> Unit, onDeletePrompt: (UserPrompt, UserPromptScope) -> Unit ) { + // 首次进入先读使用说明:读完(点确认)才放行,与「容器与镜像」页的说明门槛一致 + if (!state.helpRead) { + PromptsHelpGate(onConfirm = onMarkHelpRead) + return + } + Column( modifier = Modifier .fillMaxSize() @@ -262,7 +269,37 @@ internal fun PromptsHelpSection() { } } -/** 用户提示词只能软删除(全局/项目都走同一条路)。 */ +/** 首次进入的使用说明门槛:正文可滚动,读到底后点确认才放行。 */ +@Composable +private fun PromptsHelpGate(onConfirm: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.prompts_help_gate_title)) + SettingsGroup { + Text( + text = stringResource(R.string.prompts_help_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(Spacing.lg) + ) + } + SettingsGroup { + SettingsRow( + icon = null, + title = stringResource(R.string.prompts_help_gate_confirm), + onClick = onConfirm + ) + } + } +} + +/** 单条用户提示词行:名称 + 注入位置,点击编辑,左滑删除。 */ @Composable private fun PromptRow( title: String, 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 9e6ac4e7..4187bc26 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 @@ -105,6 +105,7 @@ import compose.icons.feathericons.Image import compose.icons.feathericons.Info import compose.icons.feathericons.Lock import compose.icons.feathericons.Moon +import compose.icons.feathericons.MessageSquare import compose.icons.feathericons.Edit2 import compose.icons.feathericons.PieChart import compose.icons.feathericons.Plus @@ -853,6 +854,7 @@ fun SettingsScreen( LaunchedEffect(Unit) { promptsViewModel.refresh() } PromptsSection( state = promptsState, + onMarkHelpRead = promptsViewModel::markHelpRead, onOpenDefaultFragment = { promptEditTarget = PromptEditTarget(isDefaultFragment = true) section = SettingsSection.PromptEditor @@ -1455,6 +1457,7 @@ internal fun SettingsMenu( SettingsDivider() SettingsRow( icon = FeatherIcons.FileText, + icon = FeatherIcons.MessageSquare, title = stringResource(SettingsSection.Prompts.titleRes), onClick = { onOpen(SettingsSection.Prompts) } ) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 428eb784..0688c776 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1011,6 +1011,8 @@ Disable all built-in prompts Keep only your own fragments; you must supply every rule yourself 1. "Global default prompt (00)" is the identity and general policy — editable, stored as an override, and resettable to the built-in default.\n\n2. Tap "+" to add your own prompt: name, scope (global/project), injection position, content.\n\n3. Injection: "First" goes before all built-in prompts (good for general policy); "Last" goes at the very end of the system prompt and is read last; "Off" keeps it but does not inject it.\n\n4. Changes apply immediately — the next turn (or a new session) picks them up.\n\n5. Longer prompts cost more context, so keep only the rules you actually need. + Please read before using + I have read it — start using No skills installed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8669a63..133cc021 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1011,6 +1011,8 @@ 完全禁用内置提示词 只保留你自己的片段;需要自己补全全部规则,谨慎开启 1. 「全局默认提示词(00)」是身份与总纲,可改;改动存为覆盖文件,随时能恢复内置默认。\n\n2. 右上角「+」添加自己的提示词:填名称、选作用域(全局/项目)、选注入位置、写内容。\n\n3. 注入位置:「最前」在所有内置提示词之前(适合总纲);「最后」在系统提示词末尾,最后被读到;「关闭」只保留不注入。\n\n4. 保存后立即生效:下一轮对话(或新会话)就带上。\n\n5. 提示词越长越占上下文,建议只写真正需要的规则。 + 使用前请先阅读 + 我已阅读,开始使用 还没有安装技能 From c533664f72b07d1b29587809b32fd029219863cd Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:46:37 +0000 Subject: [PATCH 12/18] =?UTF-8?q?style(prompts):=20=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E5=9F=9F=E4=B8=8E=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E8=83=B6=E5=9B=8A=E6=8E=A7=E4=BB=B6=EF=BC=88?= =?UTF-8?q?=E4=B8=8E=E6=96=B0=E5=BB=BA=E5=AD=90=E4=BB=A3=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户要求统一风格: - 作用域从「两个开关行」改成子代理编辑页那种 FilterChip 胶囊,放进「基本信息」卡片 (标签「作用域」+ 当前项目 / 全局两个胶囊),复用 subagent_scope_* 文案 - 提示词注入同样改用胶囊:最前 / 最后 / 关闭三个短标签(新增短文案 3 条) - 删掉不再使用的 PositionRow 与其 AppSwitch 依赖 - 中英各 3 条,1486/1486 对齐 --- .../component/PromptEditorSection.kt | 113 ++++++++---------- app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 3 files changed, 54 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt index 442f6e7e..b7140b6d 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt @@ -2,12 +2,14 @@ package com.aicode.feature.settings.presentation.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,13 +17,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.aicode.R import com.aicode.core.theme.Spacing import com.aicode.core.theme.semanticColors -import com.aicode.core.ui.AppSwitch import com.aicode.core.ui.AppTextField import com.aicode.feature.agent.domain.prompt.UserPromptPosition import com.aicode.feature.agent.domain.prompt.UserPromptScope @@ -65,57 +67,56 @@ internal fun PromptEditorSection( placeholder = stringResource(R.string.prompts_field_name_placeholder) ) } - } - - SettingsGroupHeader(text = stringResource(R.string.prompts_field_scope)) - SettingsGroup { - SettingsRow( - icon = null, - title = stringResource(R.string.prompts_scope_global), - subtitle = stringResource(R.string.prompts_scope_global_hint), - trailing = { - AppSwitch(checked = scope == UserPromptScope.GLOBAL, onCheckedChange = { scope = UserPromptScope.GLOBAL }) - } - ) SettingsDivider() - SettingsRow( - icon = null, - title = stringResource(R.string.prompts_scope_project), - subtitle = stringResource( - if (hasWorkspace) R.string.prompts_scope_project_hint - else R.string.prompts_no_workspace - ), - trailing = { - AppSwitch( - checked = scope == UserPromptScope.PROJECT, - onCheckedChange = { if (hasWorkspace) scope = UserPromptScope.PROJECT } - ) - } - ) + // 作用域:与「新建子代理」页同一种胶囊控件,保持全 App 一致 + Row( + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + Text( + text = stringResource(R.string.prompts_field_scope), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FilterChip( + selected = scope == UserPromptScope.PROJECT, + enabled = hasWorkspace, + onClick = { scope = UserPromptScope.PROJECT }, + label = { Text(stringResource(R.string.subagent_scope_project)) } + ) + FilterChip( + selected = scope == UserPromptScope.GLOBAL, + onClick = { scope = UserPromptScope.GLOBAL }, + label = { Text(stringResource(R.string.subagent_scope_global)) } + ) + } } SettingsGroupHeader(text = stringResource(R.string.prompts_field_position)) SettingsGroup { - PositionRow( - label = stringResource(R.string.prompts_position_before_all), - hint = stringResource(R.string.prompts_position_before_all_hint), - selected = position == UserPromptPosition.BEFORE_ALL, - onSelect = { position = UserPromptPosition.BEFORE_ALL } - ) - SettingsDivider() - PositionRow( - label = stringResource(R.string.prompts_position_after_system), - hint = stringResource(R.string.prompts_position_after_system_hint), - selected = position == UserPromptPosition.AFTER_SYSTEM, - onSelect = { position = UserPromptPosition.AFTER_SYSTEM } - ) - SettingsDivider() - PositionRow( - label = stringResource(R.string.prompts_position_off), - hint = stringResource(R.string.prompts_position_off_hint), - selected = position == UserPromptPosition.OFF, - onSelect = { position = UserPromptPosition.OFF } - ) + // 注入位置:同样是胶囊控件(与作用域一致) + Row( + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + FilterChip( + selected = position == UserPromptPosition.BEFORE_ALL, + onClick = { position = UserPromptPosition.BEFORE_ALL }, + label = { Text(stringResource(R.string.prompts_position_short_before_all)) } + ) + FilterChip( + selected = position == UserPromptPosition.AFTER_SYSTEM, + onClick = { position = UserPromptPosition.AFTER_SYSTEM }, + label = { Text(stringResource(R.string.prompts_position_short_after_system)) } + ) + FilterChip( + selected = position == UserPromptPosition.OFF, + onClick = { position = UserPromptPosition.OFF }, + label = { Text(stringResource(R.string.prompts_position_short_off)) } + ) + } } SettingsGroupHeader(text = stringResource(R.string.prompts_field_content)) @@ -140,7 +141,6 @@ internal fun PromptEditorSection( color = MaterialTheme.semanticColors.subtleText, modifier = Modifier.padding(top = Spacing.sm) ) - // 保存动作放行内按钮,避免和外壳顶栏动作槽抢位置(与技能/子代理编辑页一致的做法) SettingsGroup { SettingsRow( @@ -218,20 +218,3 @@ internal fun FragmentEditorSection( ) } } - -@Composable -private fun PositionRow( - label: String, - hint: String, - selected: Boolean, - onSelect: () -> Unit -) { - SettingsRow( - icon = null, - title = label, - subtitle = hint, - trailing = { - AppSwitch(checked = selected, onCheckedChange = { onSelect() }) - } - ) -} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0688c776..f9024971 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -977,6 +977,9 @@ First (before built-ins) Last (end of system prompt) Off (not injected) + First + Last + Off Basic Name e.g. Answer style diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 133cc021..9ccf9011 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -977,6 +977,9 @@ 最前(内置之前) 最后(系统提示词末尾) 已关闭(不注入) + 最前 + 最后 + 关闭 基本信息 名称 例如:回答风格 From 56059610b0a0b67c4af129467c61849a6bfdb84a Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:00:14 +0000 Subject: [PATCH 13/18] =?UTF-8?q?fix(prompts):=20=E4=BF=AE=20SettingsScree?= =?UTF-8?q?n=20=E9=87=8C=20icon=20=E5=8F=82=E6=95=B0=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=BC=A0=E9=80=92=EF=BC=88=E4=B8=8A=E6=B8=B8=20CI=20=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解冲突时把 HEAD 侧的 icon = FeatherIcons.FileText 和本分支的 MessageSquare 都留下了, 编译器报 'Argument already passed for this parameter'。保留 MessageSquare(本分支换图标的本意)。 --- .../feature/settings/presentation/component/SettingsScreen.kt | 1 - 1 file changed, 1 deletion(-) 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 4187bc26..92357d3f 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 @@ -1456,7 +1456,6 @@ internal fun SettingsMenu( ) SettingsDivider() SettingsRow( - icon = FeatherIcons.FileText, icon = FeatherIcons.MessageSquare, title = stringResource(SettingsSection.Prompts.titleRes), onClick = { onOpen(SettingsSection.Prompts) } From 92b45f0c1d98740c65fd07f1d67f2cfca7d33f88 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:58:45 +0000 Subject: [PATCH 14/18] =?UTF-8?q?fix(prompts):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=80=81=E9=94=81=E6=AD=BB=E4=BD=9C=E7=94=A8=E5=9F=9F=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=90=8C=E4=B8=80=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E5=8F=8C=E4=BB=BD=E8=90=BD=E7=9B=98/=E5=8F=8C=E4=BB=BD?= =?UTF-8?q?=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑一条已有提示词时,作用域胶囊原来只要「有工作区」就能点:切到另一个作用域保存后, savePrompt 用 existing.copy(...) 保留了原 id,只往新作用域目录写一份同名文件, 而旧作用域那份不删。于是: - 列表里同一名字在全局和项目两栏各出现一条,两条副本的正文与注入位置各自独立演化; - 注入侧是 userPromptStore.list(GLOBAL) + list(PROJECT) 直接拼接、不按 id/name 去重, 所以每轮系统提示词里这段内容被拼两遍; - 左滑删除按 (scope, id) 只删得掉一份,看起来「删不掉」。 作用域是提示词的身份(决定落哪个目录、注入到哪一段),改名同理, 所以在编辑态与名称一样锁死:只有新建时可选。确实要支持迁移的话, 应在 savePrompt 里比较 existing 的原作用域与新作用域,不同就先 deletePrompt(oldScope) 再写。 --- .../settings/presentation/component/PromptEditorSection.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt index b7140b6d..676f91b4 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt @@ -81,12 +81,15 @@ internal fun PromptEditorSection( ) FilterChip( selected = scope == UserPromptScope.PROJECT, - enabled = hasWorkspace, + // 编辑态锁死:改作用域会往新作用域写一份同名文件,而旧文件不删—— + // 结果是两条副本各自演化、注入两遍,左滑删又只能删掉一条(看着删不掉) + enabled = isNew && hasWorkspace, onClick = { scope = UserPromptScope.PROJECT }, label = { Text(stringResource(R.string.subagent_scope_project)) } ) FilterChip( selected = scope == UserPromptScope.GLOBAL, + enabled = isNew, onClick = { scope = UserPromptScope.GLOBAL }, label = { Text(stringResource(R.string.subagent_scope_global)) } ) From be91ff003e81c94a09fe620c54f885060ec5cbc3 Mon Sep 17 00:00:00 2001 From: xiaojie Date: Thu, 1 Oct 2026 07:16:47 +0000 Subject: [PATCH 15/18] =?UTF-8?q?feat(prompts):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=89=87=E6=AE=B5=E7=AE=A1=E7=90=86=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E4=B8=8E=E7=BC=96=E8=BE=91=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/prompt/PromptFragmentCatalog.kt | 302 +++++++++++++ .../domain/prompt/PromptFragmentRepository.kt | 160 ------- .../domain/prompt/SystemPromptProvider.kt | 73 +-- .../feature/agent/domain/prompt/UserPrompt.kt | 53 --- .../agent/domain/prompt/UserPromptStore.kt | 130 ------ .../settings/presentation/PromptsViewModel.kt | 112 ++--- .../component/PromptDetailSection.kt | 119 +++++ .../component/PromptEditorScreen.kt | 189 ++++++++ .../component/PromptEditorSection.kt | 223 --------- .../presentation/component/PromptsSection.kt | 422 +++++++++--------- .../presentation/component/SettingsScreen.kt | 200 ++++++--- app/src/main/res/values-en/strings.xml | 58 +-- app/src/main/res/values/strings.xml | 62 +-- .../domain/prompt/UserPromptStoreTest.kt | 86 ---- docs-site/docs/guide/custom-prompts.md | 28 +- 15 files changed, 1074 insertions(+), 1143 deletions(-) create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt delete mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt delete mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt delete mode 100644 app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/component/PromptDetailSection.kt create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt delete mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt delete mode 100644 app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt new file mode 100644 index 00000000..deaed6f8 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt @@ -0,0 +1,302 @@ +package com.aicode.feature.agent.domain.prompt + +import android.content.Context +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.container.ContainerInstaller +import com.aicode.feature.workspace.domain.ProjectAicodeRoot +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** 片段来源,优先级从高到低:项目 > 全局 > 本地 > 内置。 */ +enum class PromptFragmentSource { PROJECT, GLOBAL, LOCAL, BUILTIN } + +/** 一个编号最终生效的片段。 */ +data class PromptFragment( + val number: Int, + val title: String, + val source: PromptFragmentSource, + val content: String, + /** 生效层文件;来源为内置时为 null。 */ + val file: File?, + /** 正文开头的 `` 注释,作为列表摘要。 */ + val description: String +) { + val stableReorderKey: String + get() = "$title:${content.hashCode()}" + + val body: String + get() = content.replace(LEADING_COMMENT, "") + + companion object { + private val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") + + fun composeContent(description: String, body: String): String = + if (description.isBlank()) body else "", "-- >")} -->\n$body" + } + + /** 生效层是否可写(项目/全局可写,本地/内置只读)。 */ + val editable: Boolean + get() = source == PromptFragmentSource.PROJECT || source == PromptFragmentSource.GLOBAL +} + +/** + * 提示词片段的四级来源解析:同一编号只生效优先级最高的一层。 + * + * - 项目:工作区 `.aicode/prompts.custom/`(远程走 [ProjectAicodeRoot] 私有目录) + * - 全局:`/prompts.custom/` + * - 本地:`/prompts/`(启动时从内置释放的副本) + * - 内置:`assets/prompts/` + * + * 编号即身份(文件名前两位数字),决定片段在系统提示词里的顺序。 + */ +@Singleton +class PromptFragmentCatalog @Inject constructor( + @param:ApplicationContext private val context: Context, + private val containerInstaller: ContainerInstaller, + private val projectAicodeRoot: ProjectAicodeRoot +) { + + private val globalDir: File get() = File(containerInstaller.aicodeDir, CUSTOM_DIR) + private val localDir: File get() = File(containerInstaller.aicodeDir, LOCAL_DIR) + + /** 某工作区对应的项目层目录;无工作区返回 null。 */ + private fun projectDir(projectRoot: String?): File? = + projectRoot?.takeIf { it.isNotBlank() } + ?.let { File(projectAicodeRoot.forPath(it), CUSTOM_DIR) } + + /** 编辑/新增的写入层:有工作区写项目层,否则写全局层。 */ + fun writableDir(projectRoot: String?): File = projectDir(projectRoot) ?: globalDir + + /** 按编号升序列出所有生效片段。 */ + fun list(projectRoot: String?): List { + val builtin = builtinFiles() + val project = numberedFragments(projectDir(projectRoot)) + val global = numberedFragments(globalDir) + val numbers = LinkedHashSet().apply { + addAll(builtin.keys) + addAll(project.keys) + addAll(global.keys) + } + val all = numbers.sorted().mapNotNull { resolve(it, builtin, project, global) } + // 「完全禁用内置提示词」时列表只保留自定义来源,与注入结果一致。 + return if (isBuiltinDisabled()) { + all.filter { + it.source == PromptFragmentSource.PROJECT || it.source == PromptFragmentSource.GLOBAL + } + } else { + all + } + } + + fun fragment(number: Int, projectRoot: String?): PromptFragment? = resolve( + number, + builtinFiles(), + numberedFragments(projectDir(projectRoot)), + numberedFragments(globalDir) + ) + + /** 静态基线正文:所有生效片段去掉前导注释后按编号拼接。 */ + fun renderStatic(projectRoot: String?): String = + list(projectRoot) + .mapNotNull { it.content.replace(LEADING_COMMENT, "").trim().takeIf { text -> text.isNotEmpty() } } + .joinToString("\n\n") + + /** 「仅自定义片段」模式:只取项目层与全局层的自定义片段正文。 */ + fun renderCustomOnly(projectRoot: String?): String = + list(projectRoot) + .filter { it.source == PromptFragmentSource.PROJECT || it.source == PromptFragmentSource.GLOBAL } + .mapNotNull { it.content.replace(LEADING_COMMENT, "").trim().takeIf { text -> text.isNotEmpty() } } + .joinToString("\n\n") + + /** 是否存在「完全禁用内置提示词」标记(全局层)。 */ + fun isBuiltinDisabled(): Boolean = PromptFragmentResolver.isBuiltinDisabled(globalDir) + + /** + * 保存某编号的覆盖。 + * + * - [target] 指定写入层(项目/全局);null 表示写到该编号当前生效的层(只读层则回落可写层)。 + * - [previousNumber] 编辑时若改了编号,传原编号,用于清掉旧编号的覆盖。 + * - 同一编号只保留一份:写入前清掉各可写层里的同编号旧文件。 + */ + fun saveOverride( + number: Int, + title: String, + content: String, + projectRoot: String?, + target: PromptFragmentSource? = null, + previousNumber: Int? = null + ): Boolean { + val project = projectDir(projectRoot) + val dir = when (target) { + PromptFragmentSource.PROJECT -> project ?: globalDir + PromptFragmentSource.GLOBAL -> globalDir + else -> fragment(number, projectRoot)?.takeIf { it.editable }?.file?.parentFile + ?: writableDir(projectRoot) + } + return try { + if (previousNumber != null && previousNumber != number) { + deleteOverridesFor(previousNumber, project) + deleteOverridesFor(previousNumber, globalDir) + } + if (!dir.exists()) dir.mkdirs() + deleteOverridesFor(number, dir) + if (project != null && project != dir) deleteOverridesFor(number, project) + if (globalDir != dir) deleteOverridesFor(number, globalDir) + File(dir, "%02d-%s.md".format(number, sanitizeTitle(title))).writeText(content) + true + } catch (e: Exception) { + FileLogger.e(TAG, "写提示词覆盖失败: $number", e) + false + } + } + + /** + * 按拖拽后的顺序重新编号并落盘:编号集合保持不变,只把各片段内容依次映射到这些编号, + * 从而改变注入顺序(数字越小越靠前)。写入可写层。 + */ + fun reorder(reordered: List, projectRoot: String?): Boolean { + if (reordered.isEmpty()) return false + val project = projectDir(projectRoot) + val dir = writableDir(projectRoot) + val numbers = reordered.map { it.number }.sorted() + return try { + if (!dir.exists()) dir.mkdirs() + numbers.forEach { number -> + deleteOverridesFor(number, dir) + if (project != null && project != dir) deleteOverridesFor(number, project) + if (globalDir != dir) deleteOverridesFor(number, globalDir) + } + reordered.forEachIndexed { index, fragment -> + File(dir, "%02d-%s.md".format(numbers[index], sanitizeTitle(fragment.title))) + .writeText(fragment.content) + } + true + } catch (e: Exception) { + FileLogger.e(TAG, "重排提示词失败", e) + false + } + } + + /** 删除某编号的覆盖(仅当生效层可写),删后自动回退到下一层。 */ + fun deleteOverride(number: Int, projectRoot: String?): Boolean { + val file = fragment(number, projectRoot)?.takeIf { it.editable }?.file ?: return false + return file.delete() + } + + /** 切换「完全禁用内置提示词」标记(全局层)。 */ + fun setBuiltinDisabled(disabled: Boolean): Boolean { + return try { + val marker = File(globalDir, PromptFragmentResolver.DISABLE_BUILTIN_FILE) + if (disabled) { + if (!globalDir.exists()) globalDir.mkdirs() + marker.writeText("") + } else if (marker.isFile) { + marker.delete() + } + true + } catch (e: Exception) { + FileLogger.e(TAG, "切换禁用内置提示词失败", e) + false + } + } + + /** 「已读使用说明」标记(与 prompts.custom 同目录,随配置一起备份迁移)。 */ + fun isHelpRead(): Boolean = File(globalDir, HELP_READ_MARKER).isFile + + fun markHelpRead() { + runCatching { + if (!globalDir.exists()) globalDir.mkdirs() + File(globalDir, HELP_READ_MARKER).writeText("") + }.onFailure { FileLogger.w(TAG, "写已读标记失败", it) } + } + + private fun resolve( + number: Int, + builtin: Map, + project: Map, + global: Map + ): PromptFragment? { + project[number]?.let { return readFragment(it, number, PromptFragmentSource.PROJECT) } + global[number]?.let { return readFragment(it, number, PromptFragmentSource.GLOBAL) } + builtin[number]?.let { name -> + File(localDir, name).takeIf { it.isFile }?.let { + return readFragment(it, number, PromptFragmentSource.LOCAL) + } + readAsset(name)?.let { content -> + return PromptFragment( + number, + titleOf(name), + PromptFragmentSource.BUILTIN, + content, + null, + extractDescription(content) + ) + } + } + return null + } + + private fun readFragment(file: File, number: Int, source: PromptFragmentSource): PromptFragment? = + readText(file)?.let { content -> + PromptFragment(number, titleOf(file.name), source, content, file, extractDescription(content)) + } + + /** 目录顶层 `<两位数字>-<名称>.md`,按编号去重(同编号取字典序首个)。 */ + private fun numberedFragments(dir: File?): Map { + val files = dir?.listFiles() ?: return emptyMap() + val byNumber = LinkedHashMap() + files.filter { it.isFile }.sortedBy { it.name }.forEach { file -> + PromptFragmentResolver.parseNumber(file.name)?.let { byNumber.putIfAbsent(it, file) } + } + return byNumber + } + + /** `assets/prompts` 顶层带编号的 md:编号 → 文件名。 */ + private fun builtinFiles(): Map { + val names = runCatching { context.assets.list(ASSET_DIR)?.toList() }.getOrNull() ?: return emptyMap() + val byNumber = LinkedHashMap() + names.filter { it.endsWith(".md") }.sorted().forEach { name -> + PromptFragmentResolver.parseNumber(name)?.let { byNumber.putIfAbsent(it, name) } + } + return byNumber + } + + private fun readAsset(name: String): String? = runCatching { + context.assets.open("$ASSET_DIR/$name").bufferedReader().use { it.readText() } + }.getOrNull() + + private fun readText(file: File): String? = runCatching { file.readText() }.getOrNull() + + private fun deleteOverridesFor(number: Int, dir: File?): Boolean { + val files = dir?.listFiles { file -> + file.isFile && PromptFragmentResolver.parseNumber(file.name) == number + } ?: return false + var deleted = false + files.forEach { deleted = it.delete() || deleted } + return deleted + } + + private fun titleOf(fileName: String): String { + val base = fileName.removeSuffix(".md") + return base.substringAfter('-', missingDelimiterValue = base) + } + + /** 正文开头的 `` 注释即摘要(内置片段都带一行)。 */ + private fun extractDescription(content: String): String = + DESCRIPTION.find(content)?.groupValues?.get(1)?.trim().orEmpty() + + private fun sanitizeTitle(title: String): String = + title.trim().replace(Regex("[^A-Za-z0-9._\\u4e00-\\u9fa5-]"), "-").take(40).ifBlank { "fragment" } + + private companion object { + const val TAG = "PromptFragmentCatalog" + const val ASSET_DIR = "prompts" + const val CUSTOM_DIR = "prompts.custom" + const val LOCAL_DIR = "prompts" + const val HELP_READ_MARKER = ".help-read" + val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") + val DESCRIPTION = Regex("^\\s*", RegexOption.DOT_MATCHES_ALL) + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt deleted file mode 100644 index e7e6d65c..00000000 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentRepository.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.aicode.feature.agent.domain.prompt - -import android.content.Context -import com.aicode.core.util.FileLogger -import com.aicode.feature.agent.domain.container.ContainerInstaller -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -/** - * 内置静态片段(`assets/prompts/-<名称>.md`)的读取与「覆盖」管理。 - * - * 机制说明:内置编号即身份。用户在 App 里改某个片段,落盘为 `prompts.custom/-<名称>.md`, - * 与手工放文件完全等价(见 [PromptFragmentResolver])——所以 App 里改与直接改文件不会打架。 - * - * 内置片段清单不硬编码,直接列 `assets/prompts` 顶层带编号的 md,App 升级新增片段自动出现。 - */ -@Singleton -class PromptFragmentRepository @Inject constructor( - @param:ApplicationContext private val context: Context, - private val containerInstaller: ContainerInstaller -) { - - /** - * 一个生效的静态片段。 - * - * @param number 编号(顺序依据) - * @param title 名称(取自文件名去掉编号与扩展名) - * @param builtinFile 内置文件名;自定义新增的片段为 null - * @param overrideFile 覆盖文件;未覆盖时为 null - * @param content 生效正文(有覆盖读覆盖,否则读内置) - */ - data class Fragment( - val number: Int, - val title: String, - val builtinFile: String?, - val overrideFile: File?, - val content: String - ) { - val isOverridden: Boolean get() = overrideFile != null - } - - private val customDir: File get() = File(containerInstaller.aicodeDir, CUSTOM_DIR) - - /** 生效的静态片段清单,按编号升序。 */ - fun listFragments(): List { - val builtin = builtinFiles() - val builtinNumbers = builtin.keys.sorted() - val merged = PromptFragmentResolver.mergeStatic( - builtinNumbers, - PromptFragmentResolver.numberedFragments(customDir) - ) - return merged.mapNotNull { (number, override) -> - val builtinName = builtin[number] - val title = (override?.nameWithoutExtension ?: builtinName?.removeSuffix(".md")) - ?.substringAfter('-', missingDelimiterValue = "") - ?: return@mapNotNull null - val content = override?.let { readText(it) } - ?: builtinName?.let { readAsset(it) } - ?: return@mapNotNull null - Fragment( - number = number, - title = title, - builtinFile = builtinName, - overrideFile = override, - content = content - ) - } - } - - /** 只读内置片段(用户可改的那个固定项,默认 00)的生效正文;不存在返回 null。 */ - fun fragment(number: Int): Fragment? = listFragments().firstOrNull { it.number == number } - - /** 写覆盖:同名编号的旧覆盖文件先清掉(编号即身份,同编号只保留一个)。 */ - fun saveOverride(number: Int, title: String, content: String): Boolean { - return try { - if (!customDir.exists()) customDir.mkdirs() - deleteOverridesFor(number) - File(customDir, "%02d-%s.md".format(number, sanitizeTitle(title))).writeText(content) - true - } catch (e: Exception) { - FileLogger.e(TAG, "写提示词覆盖失败: $number", e) - false - } - } - - /** 删除覆盖,恢复内置默认内容。 */ - fun deleteOverride(number: Int): Boolean = deleteOverridesFor(number) - - fun isBuiltinDisabled(): Boolean = PromptFragmentResolver.isBuiltinDisabled(customDir) - - /** - * 「已读使用说明」标记。 - * - * 用标记文件而不是 DataStore:与 `.no-builtin` 同一路子,位置就在 prompts.custom 下, - * 用户备份/迁移配置时跟着一起走。 - */ - fun isHelpRead(): Boolean = File(customDir, HELP_READ_MARKER).isFile - - fun markHelpRead() { - runCatching { - if (!customDir.exists()) customDir.mkdirs() - File(customDir, HELP_READ_MARKER).writeText("") - }.onFailure { FileLogger.w(TAG, "写已读标记失败", it) } - } - - /** 切换「完全禁用内置提示词」(`.no-builtin` 标记文件)。 */ - fun setBuiltinDisabled(disabled: Boolean): Boolean { - return try { - val marker = File(customDir, PromptFragmentResolver.DISABLE_BUILTIN_FILE) - if (disabled) { - if (!customDir.exists()) customDir.mkdirs() - marker.writeText("") - } else if (marker.isFile) { - marker.delete() - } - true - } catch (e: Exception) { - FileLogger.e(TAG, "切换禁用内置提示词失败", e) - false - } - } - - /** `assets/prompts` 顶层带编号的 md:编号 → 文件名。 */ - private fun builtinFiles(): Map { - val names = runCatching { context.assets.list(ASSET_DIR)?.toList() }.getOrNull() ?: return emptyMap() - val byNumber = LinkedHashMap() - names.filter { it.endsWith(".md") }.sorted().forEach { name -> - val number = PromptFragmentResolver.parseNumber(name) ?: return@forEach - byNumber.putIfAbsent(number, name) - } - return byNumber - } - - private fun readAsset(name: String): String? = runCatching { - context.assets.open("$ASSET_DIR/$name").bufferedReader().use { it.readText() } - }.getOrNull() - - private fun readText(file: File): String? = runCatching { file.readText() }.getOrNull() - - private fun deleteOverridesFor(number: Int): Boolean { - val files = customDir.listFiles { file -> - file.isFile && PromptFragmentResolver.parseNumber(file.name) == number - } ?: return false - var deleted = false - files.forEach { deleted = it.delete() || deleted } - return deleted - } - - private fun sanitizeTitle(title: String): String = - title.trim().replace(Regex("[^A-Za-z0-9._\\u4e00-\\u9fa5-]"), "-").take(40).ifBlank { "fragment" } - - private companion object { - const val TAG = "PromptFragmentRepository" - const val ASSET_DIR = "prompts" - const val CUSTOM_DIR = "prompts.custom" - const val HELP_READ_MARKER = ".help-read" - } -} 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 fd0878e5..9b35c21f 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,7 +32,7 @@ class SystemPromptProvider @Inject constructor( @param:ApplicationContext private val context: Context, private val skillRepository: SkillRepository, private val memoryRepository: MemoryRepository, - private val userPromptStore: UserPromptStore, + private val promptFragmentCatalog: PromptFragmentCatalog, private val containerInstaller: ContainerInstaller, private val agentDefinitionRepository: AgentDefinitionRepository ) { @@ -42,23 +42,11 @@ class SystemPromptProvider @Inject constructor( } private inner class StaticRuleSource : PromptSource { - @Volatile private var cached: String? = null + // 四级来源里项目层随工作区变化,按 projectRoot 缓存而非进程级单份。 + private val cachedByProject = ConcurrentHashMap() - override fun build(ctx: AgentContext): String { - return cached ?: run { - val merged = PromptFragmentResolver.mergeStatic( - BASE_FRAGMENTS.keys.toList(), - PromptFragmentResolver.numberedFragments(customDir) - ) - val pieces = merged.mapNotNull { (number, override) -> - // 内置数字走 resolvePrompt(内部按数字身份查覆盖);新增片段直接读自定义文件。 - val raw = BASE_FRAGMENTS[number]?.let { resolvePrompt(it) } - ?: readFileOrNull(override) - raw?.replace(LEADING_COMMENT, "")?.trim()?.takeIf { it.isNotEmpty() } - } - pieces.joinToString("\n\n").also { cached = it } - } - } + override fun build(ctx: AgentContext): String = + cachedByProject.getOrPut(ctx.projectRoot) { promptFragmentCatalog.renderStatic(ctx.projectRoot) } } private inner class ActiveSkillsSource : PromptSource { @@ -231,28 +219,6 @@ class SystemPromptProvider @Inject constructor( private val subAgentListSource = SubAgentListSource() private val memoryListSource = MemoryListSource() - /** - * 用户自定义提示词:不参与内置片段的编号排序,只按注入位置拼在两端。 - * - * - [UserPromptPosition.BEFORE_ALL]:拼在所有内置提示词之前 - * - [UserPromptPosition.AFTER_SYSTEM]:拼在整个系统提示词之后 - * - [UserPromptPosition.OFF]:不注入 - * - * 只在主代理的 build() 里用(子代理与「仅自定义片段」模式不注入)。 - */ - private inner class UserPromptSource { - fun buildAt(position: UserPromptPosition, ctx: AgentContext): String? { - val prompts = runCatching { - userPromptStore.list(UserPromptScope.GLOBAL, ctx.projectRoot) + - userPromptStore.list(UserPromptScope.PROJECT, ctx.projectRoot) - }.getOrDefault(emptyList()) - .filter { it.position == position && it.content.isNotBlank() } - if (prompts.isEmpty()) return null - return prompts.joinToString("\n\n") { it.content.trim() } - } - } - - private val userPromptSource = UserPromptSource() private val activeSkillsSource = ActiveSkillsSource() private val projectRuleSource = ProjectRuleSource() private val workspaceSource = WorkspaceSource() @@ -297,11 +263,6 @@ class SystemPromptProvider @Inject constructor( // 4. 组装最终提示词:把稳定不变的重头基线放最前面(享受 KV Cache),变化部分放末尾 return buildString { - // 用户提示词(最前):拼在基线之前 - userPromptSource.buildAt(UserPromptPosition.BEFORE_ALL, agentContext)?.let { - append(it) - append("\n\n") - } append(staticContent) if (SKILLS_VAR !in rawStatic) skillsContent?.let { append("\n\n"); append(it) } @@ -318,11 +279,6 @@ class SystemPromptProvider @Inject constructor( append(timeContent) } - // 用户提示词(最后):拼在整个系统提示词之后 - userPromptSource.buildAt(UserPromptPosition.AFTER_SYSTEM, agentContext)?.let { - append("\n\n") - append(it) - } } } @@ -331,17 +287,14 @@ class SystemPromptProvider @Inject constructor( * 不注入任何内置来源;动态内容仅通过 `{{AICODE_*}}` 变量按需取回。 */ private fun buildCustomOnly(ctx: AgentContext): String { - val fragments = PromptFragmentResolver.numberedFragments(customDir) - if (fragments.isEmpty()) { + val content = promptFragmentCatalog.renderCustomOnly(ctx.projectRoot) + if (content.isEmpty()) { FileLogger.w( TAG, "已启用 ${PromptFragmentResolver.DISABLE_BUILTIN_FILE},但 $customDir 下没有 <两位数字>-<名称>.md 片段,系统提示词为空" ) return "" } - val content = fragments - .mapNotNull { readFileOrNull(it.second)?.replace(LEADING_COMMENT, "")?.trim()?.takeIf { it.isNotEmpty() } } - .joinToString("\n\n") return renderVariables( content, activeSkillsSource.build(ctx), @@ -469,18 +422,6 @@ class SystemPromptProvider @Inject constructor( val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") /** 内置静态基线:数字身份 → 规范文件名,决定默认拼接顺序。 */ - val BASE_FRAGMENTS = linkedMapOf( - 0 to "00-identity.md", - 10 to "10-communication.md", - 15 to "15-project-rules.md", - 20 to "20-coding-discipline.md", - 30 to "30-comments.md", - 40 to "40-approach.md", - 50 to "50-safety.md", - 60 to "60-tools-and-paths.md", - 70 to "70-skills-and-mcp.md" - ) - // 片段里可用的运行期变量,渲染时替换为真实内容 const val SKILLS_VAR = "{{AICODE_SKILLS}}" const val MEMORY_VAR = "{{AICODE_MEMORY}}" diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt deleted file mode 100644 index 7c0a1978..00000000 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPrompt.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.aicode.feature.agent.domain.prompt - -/** - * 用户自定义提示词。 - * - * 与内置片段(`assets/prompts/` 下按编号排序的 00/10/15…)不同,它由用户在 App 里创建, - * 不参与编号排序,只决定「插在哪」——见 [UserPromptPosition]。 - * - * @param id 稳定标识,落盘时作为文件名(不含扩展名) - * @param name 用户起的名字,用于列表显示 - * @param position 注入位置 - * @param content 提示词正文 - */ -data class UserPrompt( - val id: String, - val name: String, - val position: UserPromptPosition, - val content: String -) - -/** 作用域:全局(跟 App 走)与项目(跟工作区走)。 */ -enum class UserPromptScope { - GLOBAL, - PROJECT -} - -/** - * 注入位置。 - * - * 内置片段的顺序由编号固定,用户提示词不掺进那套编号,而是走两个独立槽位: - * - [BEFORE_ALL]:拼在所有提示词之前(基线之前) - * - [AFTER_SYSTEM]:拼在系统提示词之后(动态段之后) - * - [OFF]:暂时关闭,保留内容但不注入 - */ -enum class UserPromptPosition { - BEFORE_ALL, - AFTER_SYSTEM, - OFF; - - companion object { - fun fromStorage(raw: String?): UserPromptPosition = when (raw?.trim()?.lowercase()) { - "before_all" -> BEFORE_ALL - "after_system" -> AFTER_SYSTEM - else -> OFF - } - } - - fun toStorage(): String = when (this) { - BEFORE_ALL -> "before_all" - AFTER_SYSTEM -> "after_system" - OFF -> "off" - } -} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt deleted file mode 100644 index c937a9fe..00000000 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/UserPromptStore.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.aicode.feature.agent.domain.prompt - -import com.aicode.core.util.FileLogger -import com.aicode.feature.agent.domain.container.ContainerInstaller -import com.aicode.feature.workspace.domain.ProjectAicodeRoot -import org.yaml.snakeyaml.Yaml -import java.io.File -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -/** - * 用户自定义提示词的存取。 - * - * 落盘位置: - * - 全局:`/prompts.custom/user/.md` - * - 项目:`<项目配置目录>/prompts.custom/user/.md`(目录由 [ProjectAicodeRoot] 解析, - * 本地/远程两种执行模式都已覆盖,不用自己判断) - * - * 文件格式与记忆文件同款:YAML frontmatter(name / position)+ 正文。 - */ -@Singleton -class UserPromptStore @Inject constructor( - private val containerInstaller: ContainerInstaller, - private val projectAicodeRoot: ProjectAicodeRoot -) { - - /** 新建一条:id 由这里生成(时间戳 + 随机后缀,保证文件名不冲突)。 */ - fun newPrompt( - name: String, - position: UserPromptPosition, - content: String - ): UserPrompt = UserPrompt( - id = "p" + System.currentTimeMillis().toString(36) + "-" + UUID.randomUUID().toString().take(4), - name = name, - position = position, - content = content - ) - - /** 列出某作用域下的用户提示词,按创建顺序(文件名内嵌时间戳,字典序即创建序)。 */ - fun list(scope: UserPromptScope, projectRoot: String?): List { - val dir = userDir(scope, projectRoot) ?: return emptyList() - if (!dir.isDirectory) return emptyList() - return (dir.listFiles { file -> file.isFile && file.extension == "md" } ?: return emptyList()) - .sortedBy { it.name } - .mapNotNull { parse(it) } - } - - fun save(scope: UserPromptScope, projectRoot: String?, prompt: UserPrompt): Boolean { - val dir = userDir(scope, projectRoot) ?: return false - return try { - if (!dir.exists()) dir.mkdirs() - File(dir, "${sanitizeId(prompt.id)}.md").writeText(format(prompt)) - true - } catch (e: Exception) { - FileLogger.e(TAG, "保存用户提示词失败: ${prompt.id}", e) - false - } - } - - fun delete(scope: UserPromptScope, projectRoot: String?, id: String): Boolean { - val dir = userDir(scope, projectRoot) ?: return false - val file = File(dir, "${sanitizeId(id)}.md") - return file.isFile && file.delete() - } - - private fun userDir(scope: UserPromptScope, projectRoot: String?): File? = when (scope) { - UserPromptScope.GLOBAL -> File(File(containerInstaller.aicodeDir, CUSTOM_DIR), USER_DIR) - UserPromptScope.PROJECT -> - projectRoot?.takeIf { it.isNotBlank() } - ?.let { File(File(projectAicodeRoot.forPath(it), CUSTOM_DIR), USER_DIR) } - } - - private fun parse(file: File): UserPrompt? { - val text = try { - file.readText() - } catch (e: Exception) { - FileLogger.w(TAG, "读取用户提示词失败: ${file.name}", e) - return null - } - val (frontmatter, body) = splitFrontmatter(text) - return UserPrompt( - id = file.nameWithoutExtension, - name = frontmatter["name"]?.toString()?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension, - position = UserPromptPosition.fromStorage(frontmatter["position"]?.toString()), - content = body.trim() - ) - } - - private fun format(prompt: UserPrompt): String = - "---\nname: ${yamlScalar(prompt.name)}\nposition: ${prompt.position.toStorage()}\n---\n${prompt.content}" - - private fun splitFrontmatter(text: String): Pair, String> { - val normalized = text.replace("\r\n", "\n") - if (!normalized.startsWith("---\n")) return emptyMap() to normalized - val end = normalized.indexOf("\n---", startIndex = 3) - if (end < 0) return emptyMap() to normalized - val block = normalized.substring(4, end) - val rest = normalized.substring(end + 4).removePrefix("\n") - val map = try { - Yaml().load>(block) ?: emptyMap() - } catch (e: Exception) { - FileLogger.w(TAG, "解析用户提示词 frontmatter 失败", e) - emptyMap() - } - return map to rest - } - - /** 只保留文件名安全字符,防止 id 里出现路径分隔符等。 */ - private fun sanitizeId(id: String): String = - id.trim().replace(Regex("[^A-Za-z0-9._-]"), "-").take(64).ifBlank { "prompt" } - - private fun yamlScalar(value: String): String { - val needsQuote = value.contains(':') || value.contains('#') || - value.contains('"') || value.contains('\'') || - value.startsWith('-') || value.startsWith(' ') || value.endsWith(' ') || - value.contains('\n') || value.isBlank() - return if (needsQuote) { - "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" - } else { - value - } - } - - private companion object { - const val TAG = "UserPromptStore" - const val CUSTOM_DIR = "prompts.custom" - const val USER_DIR = "user" - } -} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt index 7e6f8f11..fc48b23b 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -2,47 +2,38 @@ package com.aicode.feature.settings.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.aicode.feature.agent.domain.prompt.PromptFragmentRepository -import com.aicode.feature.agent.domain.prompt.UserPrompt -import com.aicode.feature.agent.domain.prompt.UserPromptPosition -import com.aicode.feature.agent.domain.prompt.UserPromptScope -import com.aicode.feature.agent.domain.prompt.UserPromptStore +import com.aicode.feature.agent.domain.prompt.PromptFragment +import com.aicode.feature.agent.domain.prompt.PromptFragmentCatalog +import com.aicode.feature.agent.domain.prompt.PromptFragmentSource import com.aicode.feature.workspace.data.repository.WorkspaceRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject -/** 自定义提示词页的可变状态。 */ +/** 提示词页状态:按编号列出最终生效的片段(含来源),以及内置开关与首次使用门槛。 */ data class PromptsUiState( - /** 用户可改的固定内置片段(00 身份/总纲)的生效正文;读不到时为 null。 */ - val defaultFragmentContent: String? = null, - val defaultFragmentOverridden: Boolean = false, - val globalPrompts: List = emptyList(), - val projectPrompts: List = emptyList(), + val fragments: List = emptyList(), val builtinDisabled: Boolean = false, - /** 是否已读过使用说明:未读时提示词页先展示帮助,读完才放行。 */ val helpRead: Boolean = false, - /** 内置静态片段清单(含覆盖状态),供「高级设置」只读展示。 */ - val fragments: List = emptyList(), - /** 没有选中工作区时项目组不可用。 */ + /** 没有选中工作区时项目层不可写,编辑/新建回落到全局层。 */ val hasWorkspace: Boolean = false, val loading: Boolean = true ) /** - * 自定义提示词页状态:用户提示词的增删改 + 固定内置片段(00)的覆盖 + 内置开关。 + * 提示词页状态:四级来源(项目 > 全局 > 本地 > 内置)的生效片段列表 + 内置开关 + 首次使用门槛。 * * 读写都是磁盘 IO,统一放 IO 线程。 */ @HiltViewModel class PromptsViewModel @Inject constructor( - private val userPromptStore: UserPromptStore, - private val fragmentRepository: PromptFragmentRepository, + private val catalog: PromptFragmentCatalog, private val workspaceRepository: WorkspaceRepository ) : ViewModel() { @@ -57,15 +48,10 @@ class PromptsViewModel @Inject constructor( viewModelScope.launch { val projectRoot = workspaceRepository.currentPath() val loaded = withContext(Dispatchers.IO) { - val default = fragmentRepository.fragment(DEFAULT_FRAGMENT_NUMBER) PromptsUiState( - defaultFragmentContent = default?.content, - defaultFragmentOverridden = default?.isOverridden == true, - globalPrompts = userPromptStore.list(UserPromptScope.GLOBAL, projectRoot), - projectPrompts = userPromptStore.list(UserPromptScope.PROJECT, projectRoot), - builtinDisabled = fragmentRepository.isBuiltinDisabled(), - helpRead = fragmentRepository.isHelpRead(), - fragments = fragmentRepository.listFragments(), + fragments = catalog.list(projectRoot), + builtinDisabled = catalog.isBuiltinDisabled(), + helpRead = catalog.isHelpRead(), hasWorkspace = projectRoot.isNotBlank(), loading = false ) @@ -74,72 +60,68 @@ class PromptsViewModel @Inject constructor( } } - /** 保存固定内置片段(00)的覆盖;内容为空视为删除覆盖(恢复内置默认)。 */ - fun saveDefaultFragment(content: String) { + /** 保存某编号的覆盖(新建与编辑同一入口):写到所选作用域层;编辑改编号时清掉旧编号。 */ + fun saveFragment( + number: Int, + title: String, + scope: PromptFragmentSource, + content: String, + previousNumber: Int? = null + ) { viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() withContext(Dispatchers.IO) { - if (content.isBlank()) { - fragmentRepository.deleteOverride(DEFAULT_FRAGMENT_NUMBER) - } else { - fragmentRepository.saveOverride(DEFAULT_FRAGMENT_NUMBER, DEFAULT_FRAGMENT_TITLE, content) - } + catalog.saveOverride( + number, + title, + content, + projectRoot, + target = scope, + previousNumber = previousNumber + ) } refresh() } } - /** 恢复固定内置片段的默认内容(删掉覆盖)。 */ - fun resetDefaultFragment() { - viewModelScope.launch { - withContext(Dispatchers.IO) { fragmentRepository.deleteOverride(DEFAULT_FRAGMENT_NUMBER) } - refresh() - } - } - - fun savePrompt( - existing: UserPrompt?, - name: String, - scope: UserPromptScope, - position: UserPromptPosition, - content: String - ) { + /** 删除某编号的覆盖,自动回退到下一层。 */ + fun deleteFragment(number: Int) { viewModelScope.launch { val projectRoot = workspaceRepository.currentPath() - withContext(Dispatchers.IO) { - val prompt = existing?.copy(name = name, position = position, content = content) - ?: userPromptStore.newPrompt(name, position, content) - userPromptStore.save(scope, projectRoot, prompt) - } + withContext(Dispatchers.IO) { catalog.deleteOverride(number, projectRoot) } refresh() } } - fun deletePrompt(prompt: UserPrompt, scope: UserPromptScope) { + /** 拖拽重排:按新顺序重新编号并落盘,改变注入顺序。乐观更新本地状态,避免重读导致列表跳动。 */ + fun reorderFragments(reordered: List) { + val numbers = reordered.map { it.number }.sorted() + val renumbered = reordered.mapIndexed { index, fragment -> fragment.copy(number = numbers[index]) } + _state.update { it.copy(fragments = renumbered) } viewModelScope.launch { val projectRoot = workspaceRepository.currentPath() - withContext(Dispatchers.IO) { userPromptStore.delete(scope, projectRoot, prompt.id) } - refresh() + val refreshed = withContext(Dispatchers.IO) { + if (catalog.reorder(renumbered, projectRoot)) catalog.list(projectRoot) else null + } + if (refreshed != null) { + _state.update { it.copy(fragments = refreshed) } + } else { + refresh() + } } } fun setBuiltinDisabled(disabled: Boolean) { viewModelScope.launch { - withContext(Dispatchers.IO) { fragmentRepository.setBuiltinDisabled(disabled) } + withContext(Dispatchers.IO) { catalog.setBuiltinDisabled(disabled) } refresh() } } - /** 读完使用说明,放行进入提示词页。 */ fun markHelpRead() { viewModelScope.launch { - withContext(Dispatchers.IO) { fragmentRepository.markHelpRead() } + withContext(Dispatchers.IO) { catalog.markHelpRead() } refresh() } } - - companion object { - /** 用户可改的固定片段:00 身份/总纲。其余内置片段在「高级设置」里只读展示。 */ - const val DEFAULT_FRAGMENT_NUMBER = 0 - private const val DEFAULT_FRAGMENT_TITLE = "identity" - } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptDetailSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptDetailSection.kt new file mode 100644 index 00000000..72d35743 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptDetailSection.kt @@ -0,0 +1,119 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Spacing +import com.aicode.feature.agent.domain.prompt.PromptFragment +import com.aicode.feature.agent.domain.prompt.PromptFragmentSource +import com.aicode.feature.agent.presentation.component.MarkdownContent + +/** + * 提示词片段详情页:只读预览——编号/名称/来源、摘要、正文(Markdown)。 + * + * 要改内容走顶栏的编辑按钮,进入 [PromptEditorScreen]。 + */ +@Composable +internal fun PromptDetailSection(fragment: PromptFragment) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroup { + InfoRow( + label = stringResource(R.string.prompts_field_number), + value = "%02d".format(fragment.number) + ) + SettingsDivider() + InfoRow( + label = stringResource(R.string.prompts_field_title), + value = fragment.title + ) + SettingsDivider() + InfoRow( + label = stringResource(R.string.subagent_editor_scope), + value = stringResource( + when (fragment.source) { + PromptFragmentSource.PROJECT -> R.string.prompts_source_project + PromptFragmentSource.GLOBAL -> R.string.prompts_source_global + PromptFragmentSource.LOCAL -> R.string.prompts_source_local + PromptFragmentSource.BUILTIN -> R.string.prompts_source_builtin + } + ) + ) + } + + if (fragment.description.isNotBlank()) { + SettingsGroupHeader(text = stringResource(R.string.skills_summary)) + SettingsGroup { + Text( + text = fragment.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = 12.dp) + ) + } + } + + SettingsGroupHeader(text = stringResource(R.string.prompts_field_content)) + SettingsGroup { + MarkdownContent( + text = fragment.body, + color = MaterialTheme.colorScheme.onSurface, + lazyScroll = true, + modifier = Modifier + .fillMaxWidth() + // 正文卡限高:长文本在卡内懒加载滚动(外层整页仍可继续滚),避免超大 md 全量渲染卡顿。 + .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.62f) + .padding(horizontal = Spacing.lg, vertical = 12.dp) + ) + } + } +} + +/** 键值行:左标签右值,值过长时换行。 */ +@Composable +private fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = 12.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(Spacing.md)) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f) + ) + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt new file mode 100644 index 00000000..b0743de1 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorScreen.kt @@ -0,0 +1,189 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Spacing +import com.aicode.core.ui.AppTextField +import com.aicode.feature.agent.domain.prompt.PromptFragment +import com.aicode.feature.agent.domain.prompt.PromptFragmentSource +import compose.icons.FeatherIcons +import compose.icons.feathericons.ArrowLeft + +/** + * 提示词片段新建/编辑页:自带顶栏(返回 + 右上角保存),与 [SubAgentEditorScreen] 一致, + * 不能嵌进设置页的 Scaffold。 + * + * 编号即身份,决定片段在系统提示词里的位置;可改。作用域选项目或全局,决定覆盖写到哪一层。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PromptEditorScreen( + isNew: Boolean, + initialNumber: Int, + initialTitle: String, + initialDescription: String, + initialContent: String, + initialScope: PromptFragmentSource, + hasWorkspace: Boolean, + onSave: (number: Int, title: String, scope: PromptFragmentSource, content: String) -> Unit, + onNavigateBack: () -> Unit +) { + var numberText by rememberSaveable { mutableStateOf(if (isNew) "" else "%02d".format(initialNumber)) } + var title by rememberSaveable { mutableStateOf(initialTitle) } + var description by rememberSaveable { mutableStateOf(initialDescription) } + var content by rememberSaveable { mutableStateOf(initialContent) } + var scopeIsProject by rememberSaveable { mutableStateOf(initialScope == PromptFragmentSource.PROJECT) } + + val number = numberText.toIntOrNull() + val numberValid = number != null && number in 0..99 + val canSave = numberValid && title.isNotBlank() + + BackHandler { onNavigateBack() } + + Scaffold( + containerColor = settingsPageBackground(), + topBar = { + TopAppBar( + title = { + Text( + stringResource( + if (isNew) R.string.prompts_editor_new else R.string.prompts_editor_edit + ) + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = settingsPageBackground(), + titleContentColor = MaterialTheme.colorScheme.onBackground + ), + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(FeatherIcons.ArrowLeft, contentDescription = stringResource(R.string.common_back)) + } + }, + actions = { + TextButton( + enabled = canSave, + onClick = { + onSave( + number ?: 0, + title.trim(), + if (scopeIsProject) PromptFragmentSource.PROJECT else PromptFragmentSource.GLOBAL, + PromptFragment.composeContent(description, content) + ) + } + ) { + Text(stringResource(R.string.common_save)) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.prompts_field_basic)) + SettingsGroup { + Column( + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + AppTextField( + value = numberText, + onValueChange = { new -> numberText = new.filter { it.isDigit() }.take(2) }, + label = stringResource(R.string.prompts_field_number), + placeholder = stringResource(R.string.prompts_field_number_placeholder), + singleLine = true + ) + AppTextField( + value = title, + onValueChange = { title = it }, + label = stringResource(R.string.prompts_field_title), + placeholder = stringResource(R.string.prompts_field_title_placeholder), + singleLine = true + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + Text( + text = stringResource(R.string.subagent_editor_scope), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FilterChip( + selected = scopeIsProject, + enabled = hasWorkspace, + onClick = { scopeIsProject = true }, + label = { Text(stringResource(R.string.subagent_scope_project)) } + ) + FilterChip( + selected = !scopeIsProject, + onClick = { scopeIsProject = false }, + label = { Text(stringResource(R.string.subagent_scope_global)) } + ) + } + } + } + + SettingsGroupHeader(text = stringResource(R.string.prompts_field_summary)) + SettingsGroup { + AppTextField( + value = description, + onValueChange = { description = it }, + placeholder = stringResource(R.string.prompts_field_summary_placeholder), + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = 12.dp) + ) + } + + SettingsGroupHeader(text = stringResource(R.string.prompts_field_content)) + SettingsGroup { + AppTextField( + value = content, + onValueChange = { content = it }, + placeholder = stringResource(R.string.prompts_field_content_placeholder), + singleLine = false, + minLines = 8, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.lg, vertical = 12.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt deleted file mode 100644 index 676f91b4..00000000 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptEditorSection.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.aicode.feature.settings.presentation.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.aicode.R -import com.aicode.core.theme.Spacing -import com.aicode.core.theme.semanticColors -import com.aicode.core.ui.AppTextField -import com.aicode.feature.agent.domain.prompt.UserPromptPosition -import com.aicode.feature.agent.domain.prompt.UserPromptScope - -/** - * 添加 / 编辑用户提示词(照新建子代理页,但砍到只剩四样): - * 名称、作用域、注入位置、Agents 提示词正文。 - * - * 没有用途描述、模型、工具集——用户提示词不需要这些。 - */ -@Composable -internal fun PromptEditorSection( - isNew: Boolean, - initialName: String, - initialScope: UserPromptScope, - initialPosition: UserPromptPosition, - initialContent: String, - hasWorkspace: Boolean, - onSave: (name: String, scope: UserPromptScope, position: UserPromptPosition, content: String) -> Unit -) { - var name by remember { mutableStateOf(initialName) } - var scope by remember { mutableStateOf(initialScope) } - var position by remember { mutableStateOf(initialPosition) } - var content by remember { mutableStateOf(initialContent) } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.lg) - .padding(bottom = Spacing.xl), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - SettingsGroupHeader(text = stringResource(R.string.prompts_field_basic)) - SettingsGroup { - Column(modifier = Modifier.padding(Spacing.lg)) { - AppTextField( - value = name, - onValueChange = { name = it }, - label = stringResource(R.string.prompts_field_name), - placeholder = stringResource(R.string.prompts_field_name_placeholder) - ) - } - SettingsDivider() - // 作用域:与「新建子代理」页同一种胶囊控件,保持全 App 一致 - Row( - modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - Text( - text = stringResource(R.string.prompts_field_scope), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - FilterChip( - selected = scope == UserPromptScope.PROJECT, - // 编辑态锁死:改作用域会往新作用域写一份同名文件,而旧文件不删—— - // 结果是两条副本各自演化、注入两遍,左滑删又只能删掉一条(看着删不掉) - enabled = isNew && hasWorkspace, - onClick = { scope = UserPromptScope.PROJECT }, - label = { Text(stringResource(R.string.subagent_scope_project)) } - ) - FilterChip( - selected = scope == UserPromptScope.GLOBAL, - enabled = isNew, - onClick = { scope = UserPromptScope.GLOBAL }, - label = { Text(stringResource(R.string.subagent_scope_global)) } - ) - } - } - - SettingsGroupHeader(text = stringResource(R.string.prompts_field_position)) - SettingsGroup { - // 注入位置:同样是胶囊控件(与作用域一致) - Row( - modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - FilterChip( - selected = position == UserPromptPosition.BEFORE_ALL, - onClick = { position = UserPromptPosition.BEFORE_ALL }, - label = { Text(stringResource(R.string.prompts_position_short_before_all)) } - ) - FilterChip( - selected = position == UserPromptPosition.AFTER_SYSTEM, - onClick = { position = UserPromptPosition.AFTER_SYSTEM }, - label = { Text(stringResource(R.string.prompts_position_short_after_system)) } - ) - FilterChip( - selected = position == UserPromptPosition.OFF, - onClick = { position = UserPromptPosition.OFF }, - label = { Text(stringResource(R.string.prompts_position_short_off)) } - ) - } - } - - SettingsGroupHeader(text = stringResource(R.string.prompts_field_content)) - SettingsGroup { - Column(modifier = Modifier.padding(Spacing.lg)) { - AppTextField( - value = content, - onValueChange = { content = it }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 160.dp), - singleLine = false, - label = stringResource(R.string.prompts_field_content), - placeholder = stringResource(R.string.prompts_field_content_placeholder) - ) - } - } - - Text( - text = stringResource(R.string.prompts_save_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.semanticColors.subtleText, - modifier = Modifier.padding(top = Spacing.sm) - ) - // 保存动作放行内按钮,避免和外壳顶栏动作槽抢位置(与技能/子代理编辑页一致的做法) - SettingsGroup { - SettingsRow( - icon = null, - title = stringResource(if (isNew) R.string.prompts_action_add else R.string.prompts_action_save), - enabled = name.isNotBlank(), - onClick = { - onSave(name.trim(), scope, position, content) - } - ) - } - } -} - -/** - * 固定内置片段(默认 00 身份/总纲)的编辑页:只能改正文,可恢复内置默认。 - * - * 修改落盘为 prompts.custom 下的覆盖文件,与手工放文件等价——内置文件本身永不被改。 - */ -@Composable -internal fun FragmentEditorSection( - initialContent: String, - overridden: Boolean, - onSave: (String) -> Unit, - onReset: () -> Unit -) { - var content by remember { mutableStateOf(initialContent) } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.lg) - .padding(bottom = Spacing.xl), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - SettingsGroupHeader(text = stringResource(R.string.prompts_default_title)) - SettingsGroup { - Column(modifier = Modifier.padding(Spacing.lg)) { - AppTextField( - value = content, - onValueChange = { content = it }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 240.dp), - singleLine = false, - label = stringResource(R.string.prompts_field_content) - ) - } - } - - SettingsGroup { - SettingsRow( - icon = null, - title = stringResource(R.string.prompts_action_save), - enabled = content.isNotBlank(), - onClick = { onSave(content) } - ) - if (overridden) { - SettingsDivider() - SettingsRow( - icon = null, - title = stringResource(R.string.prompts_action_reset), - subtitle = stringResource(R.string.prompts_action_reset_hint), - onClick = onReset - ) - } - } - - Text( - text = stringResource(R.string.prompts_fragment_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.semanticColors.subtleText, - modifier = Modifier.padding(top = Spacing.sm) - ) - } -} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index 6b99d956..430d133a 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -1,72 +1,89 @@ package com.aicode.feature.settings.presentation.component +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween 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 +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import com.aicode.R import com.aicode.core.theme.Spacing import com.aicode.core.theme.semanticColors import com.aicode.core.ui.AdaptiveModalBottomSheet import com.aicode.core.ui.AppSwitch import com.aicode.core.ui.SwipeToDeleteRow -import com.aicode.feature.agent.domain.prompt.PromptFragmentRepository -import com.aicode.feature.agent.domain.prompt.UserPrompt -import com.aicode.feature.agent.domain.prompt.UserPromptPosition -import com.aicode.feature.agent.domain.prompt.UserPromptScope +import com.aicode.feature.agent.domain.prompt.PromptFragment +import com.aicode.feature.agent.domain.prompt.PromptFragmentSource import com.aicode.feature.settings.presentation.PromptsUiState import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.FileText import compose.icons.feathericons.Info import compose.icons.feathericons.Plus -import compose.icons.feathericons.Sliders +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState /** - * 编辑目标:区分三种入口。 + * 编辑目标:区分「新建」与「编辑既有编号」。 * - * - [isDefaultFragment] = true:编辑固定的内置片段(00),只有正文可改 - * - [prompt] = null:新建用户提示词 - * - 其余:编辑已有用户提示词 + * - [number] = null:新建一条编号片段 + * - 非 null:编辑该编号最终生效的片段 */ -internal data class PromptEditTarget( - val prompt: UserPrompt? = null, - val scope: UserPromptScope = UserPromptScope.GLOBAL, - val isDefaultFragment: Boolean = false -) +internal data class PromptEditTarget(val number: Int? = null) /** - * 自定义提示词页:顶部一句说明 + 固定内置片段(00)+ 全局/项目两组用户提示词。 + * 提示词页:顶部是「完全禁用内置提示词」开关与帮助入口,下面是按编号列出的生效片段。 * - * 用户提示词按「创建顺序」注入,分「最前 / 最后 / 关闭」三种位置,见 [UserPromptPosition]。 + * 每行一张卡片(图标 + 编号·名称 + 摘要 + 来源徽章),长按行内容可拖拽调整顺序, + * 点击进入详情预览(右上角再进编辑),来源可写时左滑删除。 */ @Composable internal fun PromptsSection( state: PromptsUiState, onMarkHelpRead: () -> Unit, - onOpenDefaultFragment: () -> Unit, - onOpenPrompt: (UserPrompt, UserPromptScope) -> Unit, - onDeletePrompt: (UserPrompt, UserPromptScope) -> Unit + onOpenFragment: (PromptFragment) -> Unit, + onDeleteFragment: (Int) -> Unit, + onReorder: (List) -> Unit, + onToggleBuiltinDisabled: (Boolean) -> Unit ) { // 首次进入先读使用说明:读完(点确认)才放行,与「容器与镜像」页的说明门槛一致 if (!state.helpRead) { @@ -74,67 +91,119 @@ internal fun PromptsSection( return } - Column( + Column(modifier = Modifier.fillMaxSize()) { + SettingsGroup( + modifier = Modifier + .padding(horizontal = Spacing.lg) + .padding(top = Spacing.sm) + ) { + SettingsRow( + icon = null, + title = stringResource(R.string.prompts_disable_builtin), + subtitle = stringResource(R.string.prompts_disable_builtin_hint), + trailing = { + AppSwitch(checked = state.builtinDisabled, onCheckedChange = onToggleBuiltinDisabled) + } + ) + } + + if (state.fragments.isEmpty()) { + Text( + text = stringResource(R.string.prompts_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 12.dp) + ) + } else { + PromptList( + fragments = state.fragments, + onOpenFragment = onOpenFragment, + onDeleteFragment = onDeleteFragment, + onReorder = onReorder + ) + } + } +} + +/** + * 片段列表。拖拽时只更新本地顺序(不落盘),松手才把最终顺序交给 [onReorder] 持久化, + * 避免拖动过程中反复写盘 + 重读导致列表抽搐。 + */ +@Composable +private fun PromptList( + fragments: List, + onOpenFragment: (PromptFragment) -> Unit, + onDeleteFragment: (Int) -> Unit, + onReorder: (List) -> Unit +) { + var localFragments by remember { mutableStateOf(fragments) } + LaunchedEffect(fragments) { localFragments = fragments } + + val listState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> + localFragments = localFragments.toMutableList().apply { add(to.index, removeAt(from.index)) } + } + val hapticFeedback = LocalHapticFeedback.current + + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) .padding(horizontal = Spacing.lg) .padding(bottom = Spacing.xl), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) + contentPadding = PaddingValues(top = Spacing.xs), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = stringResource(R.string.prompts_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = Spacing.sm, bottom = Spacing.xs) - ) - - SettingsGroupHeader(text = stringResource(R.string.prompts_group_default)) - SettingsGroup { - PromptRow( - title = stringResource(R.string.prompts_default_title), - subtitle = stringResource( - if (state.defaultFragmentOverridden) R.string.prompts_state_overridden - else R.string.prompts_state_builtin - ), - onClick = onOpenDefaultFragment - ) - } - - SettingsGroupHeader(text = stringResource(R.string.perm_global)) - SettingsGroup { - if (state.globalPrompts.isEmpty()) { - PromptEmptyHint(stringResource(R.string.prompts_empty)) - } else { - state.globalPrompts.forEachIndexed { index, prompt -> - if (index > 0) SettingsDivider() - PromptRow( - title = prompt.name, - subtitle = positionLabel(prompt.position), - onClick = { onOpenPrompt(prompt, UserPromptScope.GLOBAL) }, - onDelete = { onDeletePrompt(prompt, UserPromptScope.GLOBAL) } - ) - } + itemsIndexed( + items = localFragments, + key = { index, fragment -> + "${fragment.stableReorderKey}:${localFragments.take(index).count { it.stableReorderKey == fragment.stableReorderKey }}" } - } - - SettingsGroupHeader(text = stringResource(R.string.skills_scope_project)) - SettingsGroup { - if (state.projectPrompts.isEmpty()) { - PromptEmptyHint( - stringResource( - if (state.hasWorkspace) R.string.prompts_empty - else R.string.prompts_no_workspace - ) + ) { index, fragment -> + val rowKey = "${fragment.stableReorderKey}:${localFragments.take(index).count { it.stableReorderKey == fragment.stableReorderKey }}" + ReorderableItem(state = reorderableState, key = rowKey) { isDragging -> + val dragScale by animateFloatAsState( + targetValue = if (isDragging) 0.97f else 1f, + animationSpec = tween(durationMillis = if (isDragging) 120 else 220), + label = "promptDragScale" ) - } else { - state.projectPrompts.forEachIndexed { index, prompt -> - if (index > 0) SettingsDivider() + val dragElevation by animateDpAsState( + targetValue = if (isDragging) 8.dp else 0.dp, + animationSpec = tween(durationMillis = if (isDragging) 120 else 220), + label = "promptDragElevation" + ) + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.semanticColors.cardSurface, + shadowElevation = dragElevation, + modifier = Modifier + .fillMaxWidth() + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { + scaleX = dragScale + scaleY = dragScale + } + ) { PromptRow( - title = prompt.name, - subtitle = positionLabel(prompt.position), - onClick = { onOpenPrompt(prompt, UserPromptScope.PROJECT) }, - onDelete = { onDeletePrompt(prompt, UserPromptScope.PROJECT) } + fragment = fragment, + onClick = { onOpenFragment(fragment) }, + onDelete = { onDeleteFragment(fragment.number) }, + deleteEnabled = fragment.editable, + dragModifier = Modifier.longPressDraggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd) + // 立即重编号(槽化),与落盘后的编号一致,避免 key 变化引起列表跳动 + val numbers = localFragments.map { it.number }.sorted() + val renumbered = localFragments.mapIndexed { index, item -> + item.copy(number = numbers[index]) + } + localFragments = renumbered + onReorder(renumbered) + } + ) ) } } @@ -143,14 +212,13 @@ internal fun PromptsSection( } /** - * 右上角「+」弹层:三项入口(添加提示词 / 高级设置 / 帮助)。 + * 右上角「+」弹层:两项入口(添加片段 / 帮助)。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun PromptsAddSheet( onDismiss: () -> Unit, - onAddPrompt: () -> Unit, - onAdvanced: () -> Unit, + onAddFragment: () -> Unit, onHelp: () -> Unit ) { AdaptiveModalBottomSheet(onDismissRequest = onDismiss) { @@ -166,14 +234,7 @@ internal fun PromptsAddSheet( icon = FeatherIcons.Plus, title = stringResource(R.string.prompts_add_prompt), subtitle = stringResource(R.string.prompts_add_prompt_hint), - onClick = onAddPrompt - ) - SettingsDivider() - SettingsRow( - icon = FeatherIcons.Sliders, - title = stringResource(R.string.prompts_advanced), - subtitle = stringResource(R.string.prompts_advanced_hint), - onClick = onAdvanced + onClick = onAddFragment ) SettingsDivider() SettingsRow( @@ -187,88 +248,6 @@ internal fun PromptsAddSheet( } } -/** - * 高级设置:官方文档要点摘要 + 「完全禁用内置提示词」开关。 - * - * 摘要是内置文本(不联网),官方文档更新后需随 App 发版更新。 - */ -@Composable -internal fun PromptsAdvancedSection( - builtinDisabled: Boolean, - fragments: List, - onToggleBuiltinDisabled: (Boolean) -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.lg) - .padding(bottom = Spacing.xl), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - SettingsGroupHeader(text = stringResource(R.string.prompts_advanced_doc_title)) - SettingsGroup { - Text( - text = stringResource(R.string.prompts_advanced_doc_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(Spacing.lg) - ) - } - - SettingsGroupHeader(text = stringResource(R.string.prompts_advanced_switch_title)) - SettingsGroup { - SettingsRow( - icon = null, - title = stringResource(R.string.prompts_disable_builtin), - subtitle = stringResource(R.string.prompts_disable_builtin_hint), - trailing = { - AppSwitch(checked = builtinDisabled, onCheckedChange = onToggleBuiltinDisabled) - } - ) - } - - // 内置片段清单:只读展示。用户可改的只有固定的 00(在上一页),其余放这里供查阅。 - SettingsGroupHeader(text = stringResource(R.string.prompts_builtin_list_title)) - SettingsGroup { - fragments.forEachIndexed { index, fragment -> - if (index > 0) SettingsDivider() - SettingsRow( - icon = null, - title = "%02d · %s".format(fragment.number, fragment.title), - subtitle = stringResource( - if (fragment.isOverridden) R.string.prompts_state_overridden - else R.string.prompts_state_builtin - ) - ) - } - } - } -} - -/** 帮助:内置的简短说明(不联网)。 */ -@Composable -internal fun PromptsHelpSection() { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.lg) - .padding(bottom = Spacing.xl), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - SettingsGroupHeader(text = stringResource(R.string.prompts_help)) - SettingsGroup { - Text( - text = stringResource(R.string.prompts_help_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(Spacing.lg) - ) - } - } -} - /** 首次进入的使用说明门槛:正文可滚动,读到底后点确认才放行。 */ @Composable private fun PromptsHelpGate(onConfirm: () -> Unit) { @@ -299,39 +278,67 @@ private fun PromptsHelpGate(onConfirm: () -> Unit) { } } -/** 单条用户提示词行:名称 + 注入位置,点击编辑,左滑删除。 */ +/** 单条片段行:图标 + 编号·名称 + 摘要 + 来源徽章 + 箭头;长按内容区拖拽排序,左滑删除。 */ @Composable private fun PromptRow( - title: String, - subtitle: String, + fragment: PromptFragment, onClick: () -> Unit, - onDelete: (() -> Unit)? = null + onDelete: () -> Unit, + deleteEnabled: Boolean, + dragModifier: Modifier ) { + val sortDescription = stringResource(R.string.prompts_sort_long_press) val row: @Composable () -> Unit = { Row( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.semanticColors.cardSurface) - .padding(start = Spacing.lg, end = Spacing.lg, top = 11.dp, bottom = 11.dp), + .padding(start = Spacing.lg, end = Spacing.xs, top = 12.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row( + modifier = Modifier + .weight(1f) + .then(dragModifier) + .semantics { contentDescription = sortDescription }, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = FeatherIcons.FileText, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(Spacing.md)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "%02d · %s".format(fragment.number, fragment.title), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (fragment.description.isNotBlank()) { + Text( + text = fragment.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } } + Spacer(modifier = Modifier.width(Spacing.sm)) + SourceBadge(fragment.source) + Spacer(modifier = Modifier.width(Spacing.xs)) Icon( imageVector = FeatherIcons.ChevronRight, contentDescription = null, @@ -341,28 +348,31 @@ private fun PromptRow( } } - if (onDelete == null) { - // 固定内置片段不可删:只做普通可点行(clickable 是 Modifier 扩展,不能当组件用) - Box(modifier = Modifier.clickable(onClick = onClick)) { row() } - } else { - SwipeToDeleteRow(onDelete = onDelete, onClick = onClick) { row() } - } + SwipeToDeleteRow(onDelete = onDelete, onClick = onClick, deleteEnabled = deleteEnabled) { row() } } -/** 空态提示(名字带 Prompt 前缀:同包已有 EmptyHint)。 */ +/** 来源徽章:一眼看出该编号的内容来自哪一层。 */ @Composable -private fun PromptEmptyHint(text: String) { +private fun SourceBadge(source: PromptFragmentSource) { + val color = when (source) { + PromptFragmentSource.PROJECT -> MaterialTheme.colorScheme.primary + PromptFragmentSource.GLOBAL -> MaterialTheme.colorScheme.tertiary + PromptFragmentSource.LOCAL -> MaterialTheme.colorScheme.secondary + PromptFragmentSource.BUILTIN -> MaterialTheme.semanticColors.subtleText + } Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = Spacing.lg, vertical = 12.dp) + text = stringResource( + when (source) { + PromptFragmentSource.PROJECT -> R.string.prompts_source_project + PromptFragmentSource.GLOBAL -> R.string.prompts_source_global + PromptFragmentSource.LOCAL -> R.string.prompts_source_local + PromptFragmentSource.BUILTIN -> R.string.prompts_source_builtin + } + ), + style = MaterialTheme.typography.labelSmall, + color = color, + modifier = Modifier + .background(color.copy(alpha = 0.12f), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) ) } - -@Composable -private fun positionLabel(position: UserPromptPosition): String = when (position) { - UserPromptPosition.BEFORE_ALL -> stringResource(R.string.prompts_position_before_all) - UserPromptPosition.AFTER_SYSTEM -> stringResource(R.string.prompts_position_after_system) - UserPromptPosition.OFF -> stringResource(R.string.prompts_position_off) -} 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 92357d3f..6e7a84ae 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 @@ -71,9 +71,9 @@ import com.aicode.core.ui.pageExit import com.aicode.core.util.LogLevel import com.aicode.R import com.aicode.feature.agent.domain.mcp.McpServerEntry -import com.aicode.feature.agent.domain.prompt.UserPromptPosition -import com.aicode.feature.agent.domain.prompt.UserPromptScope import com.aicode.feature.agent.domain.mcp.McpServerConfig +import com.aicode.feature.agent.domain.prompt.PromptFragment +import com.aicode.feature.agent.domain.prompt.PromptFragmentSource import com.aicode.feature.agent.domain.mcp.McpServerStatus import com.aicode.feature.agent.presentation.component.MarkdownContent import com.aicode.feature.agent.presentation.component.MarkdownRenderCache @@ -148,9 +148,8 @@ internal enum class SettingsSection(@param:StringRes val titleRes: Int) { Mcp(R.string.settings_mcp), Skills(R.string.settings_skills), Prompts(R.string.prompts_title), + PromptDetail(R.string.prompts_title), PromptEditor(R.string.prompts_title), - PromptsAdvanced(R.string.prompts_advanced), - PromptsHelp(R.string.prompts_help), SkillDetail(R.string.settings_skills), SkillEditor(R.string.settings_skills), SubAgents(R.string.settings_subagents), @@ -181,11 +180,10 @@ private fun SettingsSection.depth(): Int = when (this) { // 技能/子代理编辑页既可从列表(1) 进也可从详情页(2) 进,必须比详情页更深: // 同深度会让「编辑 → 详情」也被当成前进,返回时页面从右侧滑入,方向是反的。 SettingsSection.SkillEditor, - SettingsSection.SubAgentEditor -> 3 + SettingsSection.SubAgentEditor, + SettingsSection.PromptEditor -> 3 SettingsSection.ProviderEditor, - SettingsSection.PromptEditor, - SettingsSection.PromptsAdvanced, - SettingsSection.PromptsHelp, + SettingsSection.PromptDetail, SettingsSection.SkillDetail, SettingsSection.SubAgentDetail, SettingsSection.ContainerDownloads, @@ -337,6 +335,10 @@ fun SettingsScreen( // 自定义提示词:右上角「+」弹层可见性 + 编辑目标(新建/编辑/固定片段) var showPromptsAddSheet by remember { mutableStateOf(false) } var promptEditTarget by remember { mutableStateOf(null) } + var selectedPrompt by remember { mutableStateOf(null) } + var showPromptsHelp by remember { mutableStateOf(false) } + // 编辑页返回目标:从详情进就回详情,从列表「+」进就回列表。 + var promptEditorReturn by remember { mutableStateOf(SettingsSection.Prompts) } // 技能编辑目标:null 表示新建一个;编辑现有技能时指向被编辑的条目。 var editingSkill by remember { mutableStateOf(null) } // 编辑页的返回目标:从详情页进就回详情页,从列表顶栏「+」进就回列表。 @@ -384,9 +386,8 @@ fun SettingsScreen( SettingsSection.ProviderEditor -> SettingsSection.Providers SettingsSection.Log -> logReturnSection.takeUnless { expanded && it == SettingsSection.Menu } SettingsSection.SkillDetail -> SettingsSection.Skills - SettingsSection.PromptEditor -> SettingsSection.Prompts - SettingsSection.PromptsAdvanced -> SettingsSection.Prompts - SettingsSection.PromptsHelp -> SettingsSection.Prompts + SettingsSection.PromptEditor -> promptEditorReturn + SettingsSection.PromptDetail -> SettingsSection.Prompts SettingsSection.SkillEditor -> skillEditorReturn SettingsSection.SubAgentDetail -> SettingsSection.SubAgents SettingsSection.SubAgentEditor -> subAgentEditorReturn @@ -567,6 +568,28 @@ fun SettingsScreen( } ) + current == SettingsSection.PromptEditor -> { + val promptsViewModel: com.aicode.feature.settings.presentation.PromptsViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() + val number = promptEditTarget?.number + val fragment = number?.let { value -> promptsState.fragments.firstOrNull { it.number == value } } + PromptEditorScreen( + isNew = number == null, + initialNumber = number ?: 0, + initialTitle = fragment?.title.orEmpty(), + initialDescription = fragment?.description.orEmpty(), + initialContent = fragment?.body.orEmpty(), + initialScope = fragment?.source ?: PromptFragmentSource.GLOBAL, + hasWorkspace = promptsState.hasWorkspace, + onSave = { savedNumber, title, scope, content -> + promptsViewModel.saveFragment(savedNumber, title, scope, content, previousNumber = number) + section = promptEditorReturn + }, + onNavigateBack = { section = promptEditorReturn } + ) + } + current == SettingsSection.RemoteServers -> com.aicode.feature.workspace.presentation.remote.RemoteServerScreen( onNavigateBack = { section = SettingsSection.Menu } @@ -742,13 +765,37 @@ fun SettingsScreen( ) } } - SettingsSection.Prompts -> IconButton(onClick = { showPromptsAddSheet = true }) { - Icon( - FeatherIcons.Plus, - contentDescription = stringResource(R.string.prompts_add_prompt), - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(22.dp) - ) + SettingsSection.Prompts -> { + IconButton(onClick = { showPromptsHelp = true }) { + Icon( + FeatherIcons.Info, + contentDescription = stringResource(R.string.prompts_help), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(22.dp) + ) + } + IconButton(onClick = { showPromptsAddSheet = true }) { + Icon( + FeatherIcons.Plus, + contentDescription = stringResource(R.string.prompts_add_prompt), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(22.dp) + ) + } + } + SettingsSection.PromptDetail -> selectedPrompt?.let { fragment -> + IconButton(onClick = { + promptEditTarget = PromptEditTarget(fragment.number) + promptEditorReturn = SettingsSection.PromptDetail + section = SettingsSection.PromptEditor + }) { + Icon( + FeatherIcons.Edit2, + contentDescription = stringResource(R.string.subagent_edit), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(20.dp) + ) + } } SettingsSection.Storage -> { IconButton(onClick = { storageViewModel?.refresh() }) { @@ -855,79 +902,41 @@ fun SettingsScreen( PromptsSection( state = promptsState, onMarkHelpRead = promptsViewModel::markHelpRead, - onOpenDefaultFragment = { - promptEditTarget = PromptEditTarget(isDefaultFragment = true) - section = SettingsSection.PromptEditor - }, - onOpenPrompt = { prompt, scope -> - promptEditTarget = PromptEditTarget(prompt = prompt, scope = scope) - section = SettingsSection.PromptEditor + onOpenFragment = { fragment -> + selectedPrompt = fragment + section = SettingsSection.PromptDetail }, - onDeletePrompt = { prompt, scope -> promptsViewModel.deletePrompt(prompt, scope) } + onDeleteFragment = promptsViewModel::deleteFragment, + onReorder = promptsViewModel::reorderFragments, + onToggleBuiltinDisabled = promptsViewModel::setBuiltinDisabled ) if (showPromptsAddSheet) { PromptsAddSheet( onDismiss = { showPromptsAddSheet = false }, - onAddPrompt = { + onAddFragment = { showPromptsAddSheet = false - promptEditTarget = PromptEditTarget() + promptEditTarget = PromptEditTarget(null) + promptEditorReturn = SettingsSection.Prompts section = SettingsSection.PromptEditor }, - onAdvanced = { - showPromptsAddSheet = false - section = SettingsSection.PromptsAdvanced - }, onHelp = { showPromptsAddSheet = false - section = SettingsSection.PromptsHelp + showPromptsHelp = true } ) } } - SettingsSection.PromptEditor -> { + SettingsSection.PromptDetail -> { val promptsViewModel: com.aicode.feature.settings.presentation.PromptsViewModel = androidx.hilt.navigation.compose.hiltViewModel() val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() - val target = promptEditTarget - if (target?.isDefaultFragment == true) { - FragmentEditorSection( - initialContent = promptsState.defaultFragmentContent.orEmpty(), - overridden = promptsState.defaultFragmentOverridden, - onSave = { - promptsViewModel.saveDefaultFragment(it) - section = SettingsSection.Prompts - }, - onReset = { - promptsViewModel.resetDefaultFragment() - section = SettingsSection.Prompts - } - ) - } else { - PromptEditorSection( - isNew = target?.prompt == null, - initialName = target?.prompt?.name.orEmpty(), - initialScope = target?.scope ?: UserPromptScope.GLOBAL, - initialPosition = target?.prompt?.position ?: UserPromptPosition.AFTER_SYSTEM, - initialContent = target?.prompt?.content.orEmpty(), - hasWorkspace = promptsState.hasWorkspace, - onSave = { name, scope, position, content -> - promptsViewModel.savePrompt(target?.prompt, name, scope, position, content) - section = SettingsSection.Prompts - } - ) + selectedPrompt?.let { sel -> + promptsState.fragments.firstOrNull { it.number == sel.number }?.let { fragment -> + PromptDetailSection(fragment = fragment) + } } } - SettingsSection.PromptsAdvanced -> { - val promptsViewModel: com.aicode.feature.settings.presentation.PromptsViewModel = - androidx.hilt.navigation.compose.hiltViewModel() - val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() - PromptsAdvancedSection( - builtinDisabled = promptsState.builtinDisabled, - fragments = promptsState.fragments, - onToggleBuiltinDisabled = promptsViewModel::setBuiltinDisabled - ) - } - SettingsSection.PromptsHelp -> PromptsHelpSection() + SettingsSection.PromptEditor -> Unit SettingsSection.SkillDetail -> selectedSkill?.let { entry -> SkillDetailSection( entry = entry, @@ -1308,6 +1317,53 @@ fun SettingsScreen( } } } + + // 提示词使用说明:列表顶部或「+」弹层里点「帮助」打开。 + if (showPromptsHelp) { + Dialog(onDismissRequest = { showPromptsHelp = false }) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.72f) + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .padding(20.dp) + ) { + Text( + text = stringResource(R.string.prompts_help), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp, bottom = Spacing.lg) + ) { + Text( + text = stringResource(R.string.prompts_help_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Button( + onClick = { showPromptsHelp = false }, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(top = 4.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text(stringResource(R.string.common_got_it)) + } + } + } + } + } } // BoxWithConstraints 结束 } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index f9024971..c960f530 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -967,53 +967,35 @@ Prompts - Your own prompts live here. The fixed \"global default prompt\" is editable; prompts you add are injected in creation order, either before all built-in prompts or after the system prompt. - Default prompt - Global default prompt (00) - Built-in default - Modified (overrides built-in) - No prompts added yet - No workspace selected; project prompts are unavailable - First (before built-ins) - Last (end of system prompt) - Off (not injected) - First - Last - Off + Fragments are listed by number. Only the highest-priority copy of each number takes effect; the badge shows which layer it comes from: Project > Global > Local > Built-in. + Long-press to reorder + No fragments available + Project + Global + Local + Built-in Basic - Name - e.g. Answer style - Scope - Global - Applies to every project - Project - Applies to the current workspace only - Injection - Placed before all built-in prompts, read first - Appended to the end of the system prompt, read last - Kept but not injected - Agents prompt + Number + Two digits, e.g. 35 + Name + e.g. My safety rules + Summary (optional) + Brief description; not included in the prompt + Fragment content What the model should follow - Takes effect immediately (start a new session or continue the next turn). + New prompt + Edit prompt + Saved to the layer currently in effect for this number; applies after an app restart or workspace switch. Add Save - Reset to built-in - Drop your edit and go back to the shipped content - Your edit is stored as an override file; the shipped file is never modified, so new built-in content after an app update can still be compared. Prompts - Add prompt - Create a prompt of your own - Advanced - Built-in fragments and switches + Add fragment + Insert a new fragment with a free number Help How it works and when it applies - Notes (key points from the docs) - The system prompt has two parts: the static baseline (built-in fragments, ordered 00/10/15/20/30/40/50/60/70) and the dynamic section (skills, subagents, memory, project rules, workspace, time).\n\nOverride: a file in prompts.custom/ with the same number replaces that built-in fragment — changing it in the app is equivalent to dropping the file there.\n\nAdd: use a free number in the same folder to insert your own fragment; fragments are ordered by number.\n\nDisable: the .no-builtin marker disables all built-in prompts; the system prompt then only contains your fragments, and dynamic content must be pulled in with {{AICODE_*}} variables.\n\nUser prompts: added on the Prompts page; they go before all prompts or after the system prompt, and do not take part in numbered ordering. - Built-in prompts - Built-in fragments Disable all built-in prompts Keep only your own fragments; you must supply every rule yourself - 1. "Global default prompt (00)" is the identity and general policy — editable, stored as an override, and resettable to the built-in default.\n\n2. Tap "+" to add your own prompt: name, scope (global/project), injection position, content.\n\n3. Injection: "First" goes before all built-in prompts (good for general policy); "Last" goes at the very end of the system prompt and is read last; "Off" keeps it but does not inject it.\n\n4. Changes apply immediately — the next turn (or a new session) picks them up.\n\n5. Longer prompts cost more context, so keep only the rules you actually need. + 1. Each row is one prompt fragment: the leading number sets its order in the system prompt, and the badge on the right shows which layer this copy comes from.\n\n2. Only one copy per number takes effect, with priority Project > Global > Local > Built-in.\n\n3. Tap a row to edit its content; swipe left to delete the override in the writable layer, which falls back to the next layer.\n\n4. Tap the + button in the top bar to add a fragment with a free number, or open Advanced.\n\n5. Changes apply after an app restart or workspace switch. Please read before using I have read it — start using diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ccf9011..e437c770 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -967,53 +967,35 @@ 提示词 - 这里放你自己的提示词。固定的「全局默认提示词」可改;自己添加的按创建顺序注入,可选插在所有提示词之前或系统提示词之后。 - 默认提示词 - 全局默认提示词(00) - 内置默认内容 - 已修改(覆盖内置) - 还没有添加提示词 - 未选择工作区,项目级提示词不可用 - 最前(内置之前) - 最后(系统提示词末尾) - 已关闭(不注入) - 最前 - 最后 - 关闭 + 按编号列出实际生效的提示词片段。同一编号只生效优先级最高的一份,右侧徽章标明它来自哪一层:项目 > 全局 > 本地 > 内置。 + 长按可调整顺序 + 暂无可用片段 + 项目 + 全局 + 本地 + 内置 基本信息 - 名称 - 例如:回答风格 - 作用域 - 全局 - 所有项目都生效 - 项目 - 只在当前工作区生效 - 提示词注入 - 拼在所有内置提示词之前,先被读到 - 拼在系统提示词末尾,最后被读到 - 内容保留,但不注入 - Agents 提示词 - 写你要模型遵守的内容 - 保存后立即生效(开新会话或下一轮对话即可看到)。 + 编号 + 两位数字,如 35 + 名称 + 如:我的安全规则 + 摘要(可选) + 简述片段内容,不会注入提示词 + 片段内容 + 写模型要遵守的内容 + 新建提示词 + 编辑提示词 + 保存会写到该编号当前生效的层;重启 App 或切换工作区后生效。 添加 保存 - 恢复内置默认 - 删掉你的修改,回到 App 自带内容 - 你的修改会存为覆盖文件,App 自带文件永不被改;升级 App 后新内置内容仍可整体对比。 提示词 - 添加提示词 - 新建一条你自己的提示词 - 高级设置 - 内置片段说明与开关 + 添加片段 + 用未占用的编号新增一条片段 帮助 怎么用、什么时候生效 - 说明(官方文档要点) - 提示词分两段:静态基线(内置片段,按编号 00/10/15/20/30/40/50/60/70 顺序拼接)与动态段(技能、子代理、记忆、项目规则、工作区、时间)。\n\n覆盖:prompts.custom/ 下放同编号文件即覆盖对应内置片段,App 里的改动与手工放文件等价。\n\n新增:同目录下用没被占用的编号即可插入自己的片段,按编号排序。\n\n禁用:放 .no-builtin 标记可完全禁用内置提示词,此时系统提示词只由你的片段组成,动态内容需用 {{AICODE_*}} 变量按需取回。\n\n用户提示词:在「提示词」页添加,可选插在所有提示词之前或系统提示词之后,不参与编号排序。 - 内置提示词 - 内置片段 完全禁用内置提示词 - 只保留你自己的片段;需要自己补全全部规则,谨慎开启 - 1. 「全局默认提示词(00)」是身份与总纲,可改;改动存为覆盖文件,随时能恢复内置默认。\n\n2. 右上角「+」添加自己的提示词:填名称、选作用域(全局/项目)、选注入位置、写内容。\n\n3. 注入位置:「最前」在所有内置提示词之前(适合总纲);「最后」在系统提示词末尾,最后被读到;「关闭」只保留不注入。\n\n4. 保存后立即生效:下一轮对话(或新会话)就带上。\n\n5. 提示词越长越占上下文,建议只写真正需要的规则。 + 只保留你的片段;需要自己补全全部规则,谨慎开启 + 1. 列表里每行是一个提示词片段:前面的数字是编号,决定它在系统提示词里的顺序;右侧徽章标明这份内容来自哪一层。\n\n2. 同一编号只生效一份,优先级为:项目 > 全局 > 本地 > 内置。\n\n3. 点一行可编辑它的内容;左滑可删除该编号在可写层的覆盖,删掉后自动回退到下一层。\n\n4. 右上角「+」可用未占用的编号新增片段,或进入高级设置。\n\n5. 修改后重启 App 或切换工作区生效。 使用前请先阅读 我已阅读,开始使用 diff --git a/app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt deleted file mode 100644 index d5db8172..00000000 --- a/app/src/test/java/com/aicode/feature/agent/domain/prompt/UserPromptStoreTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.aicode.feature.agent.domain.prompt - -import com.aicode.feature.agent.domain.container.ContainerInstaller -import com.aicode.feature.workspace.domain.ProjectAicodeRoot -import io.mockk.every -import io.mockk.mockk -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -/** 用户提示词存取:frontmatter 往返、作用域隔离、删除。 */ -class UserPromptStoreTest { - - @get:Rule - val tempFolder = TemporaryFolder() - - private lateinit var globalDir: java.io.File - private lateinit var projectDir: java.io.File - - private fun store(): UserPromptStore { - globalDir = tempFolder.newFolder("aicode") - projectDir = tempFolder.newFolder("project-aicode") - val container = mockk() - every { container.aicodeDir } returns globalDir - val projectRoot = mockk() - every { projectRoot.forPath(any()) } returns projectDir - return UserPromptStore(container, projectRoot) - } - - @Test - fun save_thenList_roundtripsFrontmatter() { - val store = store() - val prompt = store.newPrompt("总纲:先给结论", UserPromptPosition.BEFORE_ALL, "回答先给结论,再给理由。") - - assertTrue(store.save(UserPromptScope.GLOBAL, null, prompt)) - - val listed = store.list(UserPromptScope.GLOBAL, null) - assertEquals(1, listed.size) - assertEquals("总纲:先给结论", listed[0].name) - assertEquals(UserPromptPosition.BEFORE_ALL, listed[0].position) - assertEquals("回答先给结论,再给理由。", listed[0].content) - } - - @Test - fun scope_isolatesGlobalAndProject() { - val store = store() - store.save(UserPromptScope.GLOBAL, "/ws", store.newPrompt("g", UserPromptPosition.OFF, "global")) - store.save(UserPromptScope.PROJECT, "/ws", store.newPrompt("p", UserPromptPosition.OFF, "project")) - - assertEquals(listOf("g"), store.list(UserPromptScope.GLOBAL, "/ws").map { it.name }) - assertEquals(listOf("p"), store.list(UserPromptScope.PROJECT, "/ws").map { it.name }) - } - - @Test - fun projectScope_withoutWorkspace_writesNothing() { - val store = store() - assertFalse(store.save(UserPromptScope.PROJECT, null, store.newPrompt("p", UserPromptPosition.OFF, "x"))) - assertTrue(store.list(UserPromptScope.PROJECT, null).isEmpty()) - } - - @Test - fun delete_removesPrompt() { - val store = store() - val prompt = store.newPrompt("t", UserPromptPosition.AFTER_SYSTEM, "body") - store.save(UserPromptScope.GLOBAL, null, prompt) - - assertTrue(store.delete(UserPromptScope.GLOBAL, null, prompt.id)) - assertTrue(store.list(UserPromptScope.GLOBAL, null).isEmpty()) - assertFalse(store.delete(UserPromptScope.GLOBAL, null, prompt.id)) - } - - @Test - fun list_keepsCreationOrder() { - val store = store() - val first = store.newPrompt("a", UserPromptPosition.OFF, "1") - val second = store.newPrompt("b", UserPromptPosition.OFF, "2") - store.save(UserPromptScope.GLOBAL, null, first) - store.save(UserPromptScope.GLOBAL, null, second) - - // id 内嵌时间戳,字典序即创建序(同毫秒时退化为随机后缀,顺序不保证——只断言都在) - assertEquals(setOf("a", "b"), store.list(UserPromptScope.GLOBAL, null).map { it.name }.toSet()) - } -} diff --git a/docs-site/docs/guide/custom-prompts.md b/docs-site/docs/guide/custom-prompts.md index 15312913..21033607 100644 --- a/docs-site/docs/guide/custom-prompts.md +++ b/docs-site/docs/guide/custom-prompts.md @@ -6,6 +6,23 @@ AiCode 的系统提示词可以自己改。默认提示词随 App 内置、升 本文的片段命名规则(`<两位数字>-<名称>.md`)、按数字新增片段、`.no-builtin` 完全禁用开关与 `{{AICODE_*}}` 变量自 **v1.12.0** 起支持。更早的版本只能放与默认片段**完全同名**的文件来覆盖,不支持按数字新增、`.no-builtin` 与变量。 ::: +## 在 App 里管理 + +也可以不碰文件系统,直接在 **设置 → 提示词** 里管理: + +- 页面按编号列出**实际生效**的每个片段,右侧徽章标明这份内容来自哪一层: + - **项目**:当前工作区的 `.aicode/prompts.custom/` + - **全局**:`~/.aicode/prompts.custom/` + - **本地**:`~/.aicode/prompts/`(启动时从内置释放的副本) + - **内置**:App 自带的 `assets/prompts/` +- 同一编号只生效优先级最高的一份,优先级 **项目 > 全局 > 本地 > 内置**,其余被遮挡。 +- 点一行编辑它的内容(保存会写到该编号当前生效的层;若它来自只读的「本地/内置」,则写到项目层,无工作区时写全局层)。 +- 所有片段都可左滑;项目/全局片段可删除当前覆盖,删掉后自动回退到下一层;本地/内置片段的删除按钮为灰色,不可删除。 +- 右上角「+」可用未占用的编号新增片段。编辑时摘要与正文分开填写;摘要显示在列表和预览页,不会注入 AI。手工编写文件时,开头的 `` 同样会被识别为摘要,正文预览会隐藏这段注释。 +- 首次进入需先读一遍使用说明。 + +App 里的改动与手工放文件完全等价,两种方式可以混用。 + ## 目录结构 提示词放在 AI 配置目录 `~/.aicode/` 下(容器内路径是 `/root/.aicode/`): @@ -50,11 +67,14 @@ AiCode 的系统提示词可以自己改。默认提示词随 App 内置、升 ## 加载优先级 -每个片段按这个顺序查找,找到就用,不再往后找: +顶层数字片段(`<两位数字>-<名称>.md`)按四级来源查找,同一编号只生效优先级最高的一份,找到即止: + +1. **项目**:`<工作区>/.aicode/prompts.custom/`(远程模式下是 App 私有目录里对应的项目目录) +2. **全局**:`~/.aicode/prompts.custom/` +3. **本地**:`~/.aicode/prompts/<同名文件>` — 启动时释放的副本 +4. **内置**:App 自带的 `assets/prompts/` -1. 自定义版本:顶层数字片段按数字身份(`prompts.custom/` 顶层)、其余按同名(`prompts.custom/agent/xxx.md`) -2. `~/.aicode/prompts/<同名文件>` — 本地默认副本 -3. App 内置的版本 — 兜底 +`agent/` 下的按需片段(无编号)没有项目层,按「全局自定义(`prompts.custom/agent/xxx.md`)→ 本地 → 内置」三级查找。 所以你只需要放想改或想加的那几个片段,其余会自动用默认版本。 From 5d91273c05fdc60033057fa798758874d27e84ad Mon Sep 17 00:00:00 2001 From: xiaojie Date: Thu, 1 Oct 2026 09:19:50 +0000 Subject: [PATCH 16/18] =?UTF-8?q?feat(prompts):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E6=94=B9=E4=B8=BA=E5=85=AC=E5=91=8A=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=B9=B6=E8=AE=A9=E7=89=87=E6=AE=B5=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除提示词页「首次进入必须阅读」门槛,改用与「容器与镜像」一致的公告弹窗,已读状态以 DataStore 内容 hash 记录 - 静态提示词片段去掉进程内缓存,每次读盘,编辑后无需重启即时生效;技能/记忆/子代理清单仍在新会话刷新 - 同步双语 strings、announcements 公告正文、assets/prompts 与 custom-prompts 文档 --- .../assets/announcements/prompts-guide.en.md | 7 ++ .../assets/announcements/prompts-guide.zh.md | 7 ++ .../main/assets/prompts/70-skills-and-mcp.md | 2 +- .../domain/prompt/PromptFragmentCatalog.kt | 11 ---- .../domain/prompt/SystemPromptProvider.kt | 9 +-- .../repository/GeneralSettingsRepository.kt | 11 ++++ .../settings/presentation/PromptsViewModel.kt | 13 +--- .../presentation/SettingsViewModel.kt | 33 ++++++++++ .../presentation/component/PromptsSection.kt | 39 ----------- .../presentation/component/SettingsScreen.kt | 64 +++++++++++++------ app/src/main/res/values-en/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- docs-site/docs/guide/custom-prompts.md | 8 +-- 13 files changed, 115 insertions(+), 99 deletions(-) create mode 100644 app/src/main/assets/announcements/prompts-guide.en.md create mode 100644 app/src/main/assets/announcements/prompts-guide.zh.md diff --git a/app/src/main/assets/announcements/prompts-guide.en.md b/app/src/main/assets/announcements/prompts-guide.en.md new file mode 100644 index 00000000..953e4e18 --- /dev/null +++ b/app/src/main/assets/announcements/prompts-guide.en.md @@ -0,0 +1,7 @@ +## How it works + +1. Each row is one prompt fragment: the leading number sets its order in the system prompt, and the badge on the right shows which layer this copy comes from. +2. Only one copy per number takes effect, with priority Project > Global > Local > Built-in. +3. Tap a row to edit its content; swipe left to delete the override in the writable layer, which falls back to the next layer. +4. Tap the + button in the top bar to add a fragment with a free number, or open Advanced. +5. Changes take effect immediately — no app restart needed. diff --git a/app/src/main/assets/announcements/prompts-guide.zh.md b/app/src/main/assets/announcements/prompts-guide.zh.md new file mode 100644 index 00000000..a894afba --- /dev/null +++ b/app/src/main/assets/announcements/prompts-guide.zh.md @@ -0,0 +1,7 @@ +## 怎么用 + +1. 列表里每行是一个提示词片段:前面的数字是编号,决定它在系统提示词里的顺序;右侧徽章标明这份内容来自哪一层。 +2. 同一编号只生效一份,优先级为:项目 > 全局 > 本地 > 内置。 +3. 点一行可编辑它的内容;左滑可删除该编号在可写层的覆盖,删掉后自动回退到下一层。 +4. 右上角「+」可用未占用的编号新增片段,或进入高级设置。 +5. 修改后即时生效,无需重启 App。 diff --git a/app/src/main/assets/prompts/70-skills-and-mcp.md b/app/src/main/assets/prompts/70-skills-and-mcp.md index 4dfb37c4..02fe58e0 100644 --- a/app/src/main/assets/prompts/70-skills-and-mcp.md +++ b/app/src/main/assets/prompts/70-skills-and-mcp.md @@ -6,7 +6,7 @@ 统一的配置目录,跨容器升级保留,承载技能、记忆、提示词与 MCP 配置。 ## 自定义提示词 -- 用户可在 `~/.aicode/prompts.custom/` 覆盖或新增片段:顶层按 `<两位数字>-<名称>.md` 命名(数字相同即覆盖,不同则按数字插入);`agent/` 下按同名覆盖。改后需重启 App 生效。 +- 用户可在 `~/.aicode/prompts.custom/` 覆盖或新增片段:顶层按 `<两位数字>-<名称>.md` 命名(数字相同即覆盖,不同则按数字插入);`agent/` 下按同名覆盖。改后即时生效,无需重启。 - 该目录存在 `.no-builtin` 文件时,完全禁用内置提示词(含技能、记忆、子代理、项目规则、时间),只用自定义片段。 - 片段中可用 `{{AICODE_SKILLS}}`、`{{AICODE_MEMORY}}`、`{{AICODE_SUBAGENTS}}`、`{{AICODE_PROJECT_RULES}}`、`{{AICODE_WORKSPACE}}`、`{{AICODE_DATE}}` 取回对应内容。 - 完整说明见 `~/.aicode/docs/guide/custom-prompts.md`。 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt index deaed6f8..49cf3fe2 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt @@ -202,16 +202,6 @@ class PromptFragmentCatalog @Inject constructor( } } - /** 「已读使用说明」标记(与 prompts.custom 同目录,随配置一起备份迁移)。 */ - fun isHelpRead(): Boolean = File(globalDir, HELP_READ_MARKER).isFile - - fun markHelpRead() { - runCatching { - if (!globalDir.exists()) globalDir.mkdirs() - File(globalDir, HELP_READ_MARKER).writeText("") - }.onFailure { FileLogger.w(TAG, "写已读标记失败", it) } - } - private fun resolve( number: Int, builtin: Map, @@ -295,7 +285,6 @@ class PromptFragmentCatalog @Inject constructor( const val ASSET_DIR = "prompts" const val CUSTOM_DIR = "prompts.custom" const val LOCAL_DIR = "prompts" - const val HELP_READ_MARKER = ".help-read" val LEADING_COMMENT = Regex("(?s)^\\s*\\s*") val DESCRIPTION = Regex("^\\s*", RegexOption.DOT_MATCHES_ALL) } 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 9b35c21f..f70fa093 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 @@ -18,7 +18,7 @@ import javax.inject.Singleton /** * 按模块组装系统提示词:稳定基线放最前(享受 KV Cache),仅日期为低频变化。 - * 每个 Source 维护内容缓存,避免重复读取与格式化。 + * 多数 Source 维护内容缓存,避免重复读取与格式化;静态基线片段除外,每次读盘以保证编辑即时生效。 * * 片段分两类: * - 静态基线:`prompts/` 顶层 `-<名称>.md`(见 [BASE_FRAGMENTS]),可被 `prompts.custom/` 按数字身份覆盖或新增; @@ -42,11 +42,8 @@ class SystemPromptProvider @Inject constructor( } private inner class StaticRuleSource : PromptSource { - // 四级来源里项目层随工作区变化,按 projectRoot 缓存而非进程级单份。 - private val cachedByProject = ConcurrentHashMap() - - override fun build(ctx: AgentContext): String = - cachedByProject.getOrPut(ctx.projectRoot) { promptFragmentCatalog.renderStatic(ctx.projectRoot) } + // 每次都重新读盘:未编辑时字符串一致,KV Cache 照常命中;编辑后立即生效,无需重启。 + override fun build(ctx: AgentContext): String = promptFragmentCatalog.renderStatic(ctx.projectRoot) } private inner class ActiveSkillsSource : PromptSource { diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt index cd833a53..ce0d0312 100644 --- a/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/GeneralSettingsRepository.kt @@ -48,6 +48,8 @@ class GeneralSettingsRepository @Inject constructor( val COMPACTION_THRESHOLD_PERCENT_KEY = intPreferencesKey("compaction_threshold_percent") val SENDFILE_MAX_SIZE_MB_KEY = intPreferencesKey("sendfile_max_size_mb") val DELETE_EXTERNAL_WORKSPACE_SESSIONS_KEY = booleanPreferencesKey("delete_external_workspace_sessions") + /** 「提示词」页使用说明公告已展示内容的哈希;无值或与当前内容哈希不一致时重新弹出。 */ + val PROMPTS_ANNOUNCEMENT_SHOWN_HASH_KEY = stringPreferencesKey("prompts_announcement_shown_hash") /** 首字超时默认 5 分钟,与原硬编码值一致。 */ const val DEFAULT_FIRST_BYTE_TIMEOUT_SEC = 300 @@ -208,6 +210,15 @@ class GeneralSettingsRepository @Inject constructor( /** 移除外部工作区前读取一次该偏好。 */ suspend fun deleteExternalWorkspaceSessions(): Boolean = deleteExternalWorkspaceSessionsFlow.first() + /** 「提示词」页公告已展示内容的哈希;无值表示从未展示过。 */ + val promptsAnnouncementShownHashFlow: Flow = context.generalDataStore.data.map { prefs -> + prefs[PROMPTS_ANNOUNCEMENT_SHOWN_HASH_KEY] + } + + suspend fun markPromptsAnnouncementShown(hash: String) { + context.generalDataStore.edit { it[PROMPTS_ANNOUNCEMENT_SHOWN_HASH_KEY] = hash } + } + /** 备份快照:外部工作区聊天记录处理偏好。 */ suspend fun deleteExternalWorkspaceSessionsSnapshot(): Boolean = deleteExternalWorkspaceSessionsFlow.first() diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt index fc48b23b..166ed813 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -16,18 +16,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject -/** 提示词页状态:按编号列出最终生效的片段(含来源),以及内置开关与首次使用门槛。 */ +/** 提示词页状态:按编号列出最终生效的片段(含来源),以及内置开关。 */ data class PromptsUiState( val fragments: List = emptyList(), val builtinDisabled: Boolean = false, - val helpRead: Boolean = false, /** 没有选中工作区时项目层不可写,编辑/新建回落到全局层。 */ val hasWorkspace: Boolean = false, val loading: Boolean = true ) /** - * 提示词页状态:四级来源(项目 > 全局 > 本地 > 内置)的生效片段列表 + 内置开关 + 首次使用门槛。 + * 提示词页状态:四级来源(项目 > 全局 > 本地 > 内置)的生效片段列表 + 内置开关。 * * 读写都是磁盘 IO,统一放 IO 线程。 */ @@ -51,7 +50,6 @@ class PromptsViewModel @Inject constructor( PromptsUiState( fragments = catalog.list(projectRoot), builtinDisabled = catalog.isBuiltinDisabled(), - helpRead = catalog.isHelpRead(), hasWorkspace = projectRoot.isNotBlank(), loading = false ) @@ -117,11 +115,4 @@ class PromptsViewModel @Inject constructor( refresh() } } - - fun markHelpRead() { - viewModelScope.launch { - withContext(Dispatchers.IO) { catalog.markHelpRead() } - refresh() - } - } } 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 b478dd38..98fd1f59 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 @@ -602,6 +602,14 @@ class SettingsViewModel @Inject constructor( private val _containerAnnouncementOutdated = MutableStateFlow(false) val containerAnnouncementOutdated: StateFlow = _containerAnnouncementOutdated.asStateFlow() + /** 「提示词」页公告内容(跟随界面语言),与 [promptsAnnouncementOutdated] 配套供弹窗渲染。 */ + private val _promptsAnnouncementText = MutableStateFlow("") + val promptsAnnouncementText: StateFlow = _promptsAnnouncementText.asStateFlow() + + /** 「提示词」页公告是否需要弹出:本地未存哈希或与当前内容哈希不一致(内容更新过)时为 true。 */ + private val _promptsAnnouncementOutdated = MutableStateFlow(false) + val promptsAnnouncementOutdated: StateFlow = _promptsAnnouncementOutdated.asStateFlow() + private val _imageCatalog = MutableStateFlow>(emptyList()) val imageCatalog: StateFlow> = _imageCatalog.asStateFlow() @@ -692,6 +700,14 @@ class SettingsViewModel @Inject constructor( _containerAnnouncementOutdated.value = storedHash != sha256(text) }.collect {} } + // 「提示词」页公告同样按「内容哈希比对」判断是否弹出。 + viewModelScope.launch { + combine(_languageTag, generalSettingsRepository.promptsAnnouncementShownHashFlow) { tag, storedHash -> + val text = loadPromptsAnnouncement(context, tag) + _promptsAnnouncementText.value = text + _promptsAnnouncementOutdated.value = storedHash != sha256(text) + }.collect {} + } viewModelScope.launch { launch { repository.getAllProviders().collectLatest { @@ -1711,6 +1727,13 @@ class SettingsViewModel @Inject constructor( } } + /** 标记「提示词」页公告已展示;与容器公告同理,内容更新(哈希变)后自动重新弹。 */ + fun markPromptsAnnouncementShown() { + viewModelScope.launch { + generalSettingsRepository.markPromptsAnnouncementShown(sha256(_promptsAnnouncementText.value)) + } + } + /** 切换全局下载源(官方/华为云/阿里云/腾讯云等)。 */ fun setImageSource(sourceId: String) { _selectedImageSource.value = sourceId @@ -2186,6 +2209,16 @@ private fun loadContainerAnnouncement(context: Context, languageTag: String?): S }.getOrDefault("") } +/** 读取「提示词」页公告 md(zh 用中文版,其余英文版);读取失败返回空串(空内容不弹窗)。 */ +private fun loadPromptsAnnouncement(context: Context, languageTag: String?): String { + val lang = languageTag?.takeIf { it.isNotBlank() } ?: java.util.Locale.getDefault().language + val zh = lang.startsWith("zh", ignoreCase = true) + return runCatching { + context.assets.open("announcements/prompts-guide.${if (zh) "zh" else "en"}.md") + .bufferedReader().use { it.readText() } + }.getOrDefault("") +} + /** SHA-256 十六进制摘要,用于公告内容比对:内容更新则哈希变,自动重新弹出。 */ private fun sha256(text: String): String = java.security.MessageDigest.getInstance("SHA-256") diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index 430d133a..362abd00 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -19,9 +19,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -79,18 +77,11 @@ internal data class PromptEditTarget(val number: Int? = null) @Composable internal fun PromptsSection( state: PromptsUiState, - onMarkHelpRead: () -> Unit, onOpenFragment: (PromptFragment) -> Unit, onDeleteFragment: (Int) -> Unit, onReorder: (List) -> Unit, onToggleBuiltinDisabled: (Boolean) -> Unit ) { - // 首次进入先读使用说明:读完(点确认)才放行,与「容器与镜像」页的说明门槛一致 - if (!state.helpRead) { - PromptsHelpGate(onConfirm = onMarkHelpRead) - return - } - Column(modifier = Modifier.fillMaxSize()) { SettingsGroup( modifier = Modifier @@ -248,36 +239,6 @@ internal fun PromptsAddSheet( } } -/** 首次进入的使用说明门槛:正文可滚动,读到底后点确认才放行。 */ -@Composable -private fun PromptsHelpGate(onConfirm: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.lg) - .padding(bottom = Spacing.xl), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - SettingsGroupHeader(text = stringResource(R.string.prompts_help_gate_title)) - SettingsGroup { - Text( - text = stringResource(R.string.prompts_help_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(Spacing.lg) - ) - } - SettingsGroup { - SettingsRow( - icon = null, - title = stringResource(R.string.prompts_help_gate_confirm), - onClick = onConfirm - ) - } - } -} - /** 单条片段行:图标 + 编号·名称 + 摘要 + 来源徽章 + 箭头;长按内容区拖拽排序,左滑删除。 */ @Composable private fun PromptRow( 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 6e7a84ae..adde12e0 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 @@ -254,6 +254,8 @@ fun SettingsScreen( val updateCheckChannel by viewModel.updateCheckChannel.collectAsStateWithLifecycle() val containerAnnouncementText by viewModel.containerAnnouncementText.collectAsStateWithLifecycle() val containerAnnouncementOutdated by viewModel.containerAnnouncementOutdated.collectAsStateWithLifecycle() + val promptsAnnouncementText by viewModel.promptsAnnouncementText.collectAsStateWithLifecycle() + val promptsAnnouncementOutdated by viewModel.promptsAnnouncementOutdated.collectAsStateWithLifecycle() val imageCatalog by viewModel.imageCatalog.collectAsStateWithLifecycle() val imageDownload by viewModel.containerImageDownload.collectAsStateWithLifecycle() val containerReset by viewModel.containerReset.collectAsStateWithLifecycle() @@ -336,7 +338,7 @@ fun SettingsScreen( var showPromptsAddSheet by remember { mutableStateOf(false) } var promptEditTarget by remember { mutableStateOf(null) } var selectedPrompt by remember { mutableStateOf(null) } - var showPromptsHelp by remember { mutableStateOf(false) } + var showPromptsAnnouncement by remember { mutableStateOf(false) } // 编辑页返回目标:从详情进就回详情,从列表「+」进就回列表。 var promptEditorReturn by remember { mutableStateOf(SettingsSection.Prompts) } // 技能编辑目标:null 表示新建一个;编辑现有技能时指向被编辑的条目。 @@ -431,6 +433,13 @@ fun SettingsScreen( } } + // 首次(或公告内容更新后)进入「提示词」页自动弹出使用说明公告;哈希比对在 ViewModel 完成。 + LaunchedEffect(section, promptsAnnouncementOutdated) { + if (section == SettingsSection.Prompts && promptsAnnouncementOutdated && promptsAnnouncementText.isNotBlank()) { + showPromptsAnnouncement = true + } + } + // 菜单内容:窄窗当首页用,大屏当常驻左栏用,共用同一份。 // 滚动位置必须提到 AnimatedContent 之外持有:菜单页作为分区分支被切走时会被 dispose, // 内部 rememberScrollState 一并丢弃,从二级页返回就跳回顶部。 @@ -766,7 +775,7 @@ fun SettingsScreen( } } SettingsSection.Prompts -> { - IconButton(onClick = { showPromptsHelp = true }) { + IconButton(onClick = { showPromptsAnnouncement = true }) { Icon( FeatherIcons.Info, contentDescription = stringResource(R.string.prompts_help), @@ -901,7 +910,6 @@ fun SettingsScreen( LaunchedEffect(Unit) { promptsViewModel.refresh() } PromptsSection( state = promptsState, - onMarkHelpRead = promptsViewModel::markHelpRead, onOpenFragment = { fragment -> selectedPrompt = fragment section = SettingsSection.PromptDetail @@ -921,7 +929,7 @@ fun SettingsScreen( }, onHelp = { showPromptsAddSheet = false - showPromptsHelp = true + showPromptsAnnouncement = true } ) } @@ -1318,9 +1326,13 @@ fun SettingsScreen( } } - // 提示词使用说明:列表顶部或「+」弹层里点「帮助」打开。 - if (showPromptsHelp) { - Dialog(onDismissRequest = { showPromptsHelp = false }) { + // 提示词使用说明公告:首次进入(或内容更新后)自动弹出,右上角 Info 按钮可随时重看。 + if (showPromptsAnnouncement) { + val dismiss = { + showPromptsAnnouncement = false + viewModel.markPromptsAnnouncementShown() + } + Dialog(onDismissRequest = dismiss) { Surface( shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface, @@ -1338,20 +1350,34 @@ fun SettingsScreen( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface ) - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(top = 8.dp, bottom = Spacing.lg) - ) { - Text( - text = stringResource(R.string.prompts_help_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) + if (promptsAnnouncementText.isNotBlank()) { + // mikepenz Markdown 内部是 Column(非 LazyColumn),本身不可滚动, + // 必须由外层提供滚动容器,否则超出弹窗高度的内容被直接裁剪。 + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp, bottom = Spacing.lg) + ) { + MarkdownContent( + text = promptsAnnouncementText, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + loading = { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + ) + } } Button( - onClick = { showPromptsHelp = false }, + onClick = dismiss, modifier = Modifier .fillMaxWidth() .height(44.dp) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index c960f530..81c4b69a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -985,7 +985,7 @@ What the model should follow New prompt Edit prompt - Saved to the layer currently in effect for this number; applies after an app restart or workspace switch. + Saved to the layer currently in effect for this number; takes effect immediately. Add Save Prompts @@ -995,9 +995,6 @@ How it works and when it applies Disable all built-in prompts Keep only your own fragments; you must supply every rule yourself - 1. Each row is one prompt fragment: the leading number sets its order in the system prompt, and the badge on the right shows which layer this copy comes from.\n\n2. Only one copy per number takes effect, with priority Project > Global > Local > Built-in.\n\n3. Tap a row to edit its content; swipe left to delete the override in the writable layer, which falls back to the next layer.\n\n4. Tap the + button in the top bar to add a fragment with a free number, or open Advanced.\n\n5. Changes apply after an app restart or workspace switch. - Please read before using - I have read it — start using No skills installed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e437c770..09514438 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -985,7 +985,7 @@ 写模型要遵守的内容 新建提示词 编辑提示词 - 保存会写到该编号当前生效的层;重启 App 或切换工作区后生效。 + 保存会写到该编号当前生效的层;保存后即时生效。 添加 保存 提示词 @@ -995,9 +995,6 @@ 怎么用、什么时候生效 完全禁用内置提示词 只保留你的片段;需要自己补全全部规则,谨慎开启 - 1. 列表里每行是一个提示词片段:前面的数字是编号,决定它在系统提示词里的顺序;右侧徽章标明这份内容来自哪一层。\n\n2. 同一编号只生效一份,优先级为:项目 > 全局 > 本地 > 内置。\n\n3. 点一行可编辑它的内容;左滑可删除该编号在可写层的覆盖,删掉后自动回退到下一层。\n\n4. 右上角「+」可用未占用的编号新增片段,或进入高级设置。\n\n5. 修改后重启 App 或切换工作区生效。 - 使用前请先阅读 - 我已阅读,开始使用 还没有安装技能 diff --git a/docs-site/docs/guide/custom-prompts.md b/docs-site/docs/guide/custom-prompts.md index 21033607..0ed085f5 100644 --- a/docs-site/docs/guide/custom-prompts.md +++ b/docs-site/docs/guide/custom-prompts.md @@ -19,7 +19,7 @@ AiCode 的系统提示词可以自己改。默认提示词随 App 内置、升 - 点一行编辑它的内容(保存会写到该编号当前生效的层;若它来自只读的「本地/内置」,则写到项目层,无工作区时写全局层)。 - 所有片段都可左滑;项目/全局片段可删除当前覆盖,删掉后自动回退到下一层;本地/内置片段的删除按钮为灰色,不可删除。 - 右上角「+」可用未占用的编号新增片段。编辑时摘要与正文分开填写;摘要显示在列表和预览页,不会注入 AI。手工编写文件时,开头的 `` 同样会被识别为摘要,正文预览会隐藏这段注释。 -- 首次进入需先读一遍使用说明。 +- 首次进入会自动弹出使用说明公告,右上角 Info 可随时重看。 App 里的改动与手工放文件完全等价,两种方式可以混用。 @@ -82,10 +82,10 @@ App 里的改动与手工放文件完全等价,两种方式可以混用。 1. 在 `~/.aicode/prompts.custom/` 下(没有就手动创建)放入片段:覆盖内置片段用相同数字命名,新增片段用尚未占用的数字命名,子目录片段(如 `agent/plan-mode.md`)对应放到 `prompts.custom/agent/` 下。 2. 编辑内容。 -3. **重启 App 后生效**。 +3. **保存后即时生效**(无需重启 App)。 -::: warning 必须重启 App,新开会话不算 -提示词在 App 进程启动时加载并缓存,新建会话不会重新读文件。改完必须完全退出 App 再打开。 +::: tip 提示词片段即时生效 +自定义提示词片段每次请求都会重新读取,改完直接生效,不用重启 App。注意:技能清单、记忆、子代理清单仍是**新开会话**才刷新。 ::: 编辑方式有三种:在终端里用 `vi` / `nano` 直接改;让 AI 帮你写(它的文件工具能直接读写这个目录);或者[用文件管理器访问 App 私有目录](/guide/files#工作区文件在手机上的位置)。 From d180a489455cbbdff5dc3c07c7067407f06633eb Mon Sep 17 00:00:00 2001 From: xiaojie Date: Thu, 1 Oct 2026 11:28:05 +0000 Subject: [PATCH 17/18] =?UTF-8?q?refactor(prompts):=20=E9=87=8D=E6=8E=92?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BA=A4=E6=8D=A2=E4=B8=A4=E4=B8=AA=E7=89=87?= =?UTF-8?q?=E6=AE=B5=E7=BC=96=E5=8F=B7=E5=B9=B6=E6=B8=85=E7=90=86=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拖拽改为交换被拖行与目标行的编号,只重写这两个片段,不再把整份列表复制到可写层 - 修正「编辑只读片段写到哪一层」的文档,与代码(默认落全局层)一致 - 删除生产已无引用的 mergeStatic、修 KDoc 断链、删除 4 个未使用字符串 - 补 PromptFragmentCatalog 解析/保存/删除/交换单测 --- .../domain/prompt/PromptFragmentCatalog.kt | 45 ++++-- .../domain/prompt/PromptFragmentResolver.kt | 18 --- .../domain/prompt/SystemPromptProvider.kt | 2 +- .../settings/presentation/PromptsViewModel.kt | 18 +-- .../presentation/component/PromptsSection.kt | 36 +++-- .../presentation/component/SettingsScreen.kt | 2 +- app/src/main/res/values-en/strings.xml | 4 - app/src/main/res/values/strings.xml | 4 - .../prompt/PromptFragmentCatalogTest.kt | 138 ++++++++++++++++++ .../prompt/PromptFragmentResolverTest.kt | 16 -- docs-site/docs/guide/custom-prompts.md | 2 +- 11 files changed, 197 insertions(+), 88 deletions(-) create mode 100644 app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt index 49cf3fe2..03fbb68e 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt @@ -153,32 +153,45 @@ class PromptFragmentCatalog @Inject constructor( } /** - * 按拖拽后的顺序重新编号并落盘:编号集合保持不变,只把各片段内容依次映射到这些编号, - * 从而改变注入顺序(数字越小越靠前)。写入可写层。 + * 交换两个片段的编号:只重写这两个片段,列表里其余片段不受影响(不再把整份列表复制到可写层)。 + * + * - 各自写到与来源一致的层(项目/全局各归各层);只读来源(本地/内置)写到可写层。 + * - 先删掉各自的旧覆盖,再清掉目标编号上的冲突,最后写入新编号文件。 + * - 交换内置/本地片段必然产生一份可写副本(编号即身份,无法原地改名);删掉该覆盖即可恢复。 */ - fun reorder(reordered: List, projectRoot: String?): Boolean { - if (reordered.isEmpty()) return false + fun swapNumbers(a: PromptFragment, b: PromptFragment, projectRoot: String?): Boolean { val project = projectDir(projectRoot) - val dir = writableDir(projectRoot) - val numbers = reordered.map { it.number }.sorted() + val writableDirs = listOfNotNull(globalDir, project) + val newA = a.copy(number = b.number) + val newB = b.copy(number = a.number) return try { - if (!dir.exists()) dir.mkdirs() - numbers.forEach { number -> - deleteOverridesFor(number, dir) - if (project != null && project != dir) deleteOverridesFor(number, project) - if (globalDir != dir) deleteOverridesFor(number, globalDir) - } - reordered.forEachIndexed { index, fragment -> - File(dir, "%02d-%s.md".format(numbers[index], sanitizeTitle(fragment.title))) - .writeText(fragment.content) + listOf(a, b).forEach { f -> + val parent = f.file?.parentFile + if (parent != null && parent in writableDirs) f.file?.delete() } + val dirA = layerDir(newA, project) + val dirB = layerDir(newB, project) + deleteOverridesFor(newA.number, dirA) + deleteOverridesFor(newB.number, dirB) + if (!dirA.exists()) dirA.mkdirs() + if (!dirB.exists()) dirB.mkdirs() + File(dirA, fileName(newA.number, newA.title)).writeText(newA.content) + File(dirB, fileName(newB.number, newB.title)).writeText(newB.content) true } catch (e: Exception) { - FileLogger.e(TAG, "重排提示词失败", e) + FileLogger.e(TAG, "交换提示词编号失败: ${a.number}<->${b.number}", e) false } } + /** 片段落盘的目标层:项目/全局各归各层;只读来源(本地/内置)写到可写层。 */ + private fun layerDir(fragment: PromptFragment, project: File?): File = when (fragment.source) { + PromptFragmentSource.GLOBAL -> globalDir + else -> project ?: globalDir + } + + private fun fileName(number: Int, title: String): String = "%02d-%s.md".format(number, sanitizeTitle(title)) + /** 删除某编号的覆盖(仅当生效层可写),删后自动回退到下一层。 */ fun deleteOverride(number: Int, projectRoot: String?): Boolean { val file = fragment(number, projectRoot)?.takeIf { it.editable }?.file ?: return false diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolver.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolver.kt index 9b1d97ef..45ac8d96 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolver.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolver.kt @@ -38,22 +38,4 @@ internal object PromptFragmentResolver { /** `.no-builtin` 是否存在。 */ fun isBuiltinDisabled(dir: File): Boolean = File(dir, DISABLE_BUILTIN_FILE).isFile - - /** - * 合并静态基线:以 [builtinNumbers] 顺序为骨架,[custom] 中命中这些数字的作为覆盖、 - * 其余作为新增片段,整体按数字升序排列。 - * - * @return 有序的 (数字, 覆盖文件或 null);null 表示该数字没有自定义文件、用内置默认内容。 - */ - fun mergeStatic( - builtinNumbers: List, - custom: List> - ): List> { - val customByNumber = custom.toMap() - val numbers = LinkedHashSet().apply { - addAll(builtinNumbers) - custom.forEach { if (it.first !in this) add(it.first) } - } - return numbers.sorted().map { it to customByNumber[it] } - } } \ No newline at end of file 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 f70fa093..807c28a8 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 @@ -21,7 +21,7 @@ import javax.inject.Singleton * 多数 Source 维护内容缓存,避免重复读取与格式化;静态基线片段除外,每次读盘以保证编辑即时生效。 * * 片段分两类: - * - 静态基线:`prompts/` 顶层 `-<名称>.md`(见 [BASE_FRAGMENTS]),可被 `prompts.custom/` 按数字身份覆盖或新增; + * - 静态基线:`prompts/` 顶层 `-<名称>.md`,可被 `prompts.custom/` 按数字身份覆盖或新增; * - 按需叶子:`prompts/agent/` 下的无数字片段(模式提醒、子代理基线、压缩/标题提示词),按精确同名覆盖。 * * `prompts.custom/` 存在 [PromptFragmentResolver.DISABLE_BUILTIN_FILE] 时,主代理提示词只由自定义数字片段组成, diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt index 166ed813..475aae11 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -91,21 +90,12 @@ class PromptsViewModel @Inject constructor( } } - /** 拖拽重排:按新顺序重新编号并落盘,改变注入顺序。乐观更新本地状态,避免重读导致列表跳动。 */ - fun reorderFragments(reordered: List) { - val numbers = reordered.map { it.number }.sorted() - val renumbered = reordered.mapIndexed { index, fragment -> fragment.copy(number = numbers[index]) } - _state.update { it.copy(fragments = renumbered) } + /** 拖拽交换:只交换两个片段的编号并落盘,其余片段不动(避免整份列表被复制到可写层)。 */ + fun swapFragments(a: PromptFragment, b: PromptFragment) { viewModelScope.launch { val projectRoot = workspaceRepository.currentPath() - val refreshed = withContext(Dispatchers.IO) { - if (catalog.reorder(renumbered, projectRoot)) catalog.list(projectRoot) else null - } - if (refreshed != null) { - _state.update { it.copy(fragments = refreshed) } - } else { - refresh() - } + withContext(Dispatchers.IO) { catalog.swapNumbers(a, b, projectRoot) } + refresh() } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index 362abd00..d02b3ec2 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -79,7 +79,7 @@ internal fun PromptsSection( state: PromptsUiState, onOpenFragment: (PromptFragment) -> Unit, onDeleteFragment: (Int) -> Unit, - onReorder: (List) -> Unit, + onSwap: (PromptFragment, PromptFragment) -> Unit, onToggleBuiltinDisabled: (Boolean) -> Unit ) { Column(modifier = Modifier.fillMaxSize()) { @@ -110,29 +110,30 @@ internal fun PromptsSection( fragments = state.fragments, onOpenFragment = onOpenFragment, onDeleteFragment = onDeleteFragment, - onReorder = onReorder + onSwap = onSwap ) } } } /** - * 片段列表。拖拽时只更新本地顺序(不落盘),松手才把最终顺序交给 [onReorder] 持久化, - * 避免拖动过程中反复写盘 + 重读导致列表抽搐。 + * 片段列表。拖拽时只记录悬停到的目标行,不即时改动列表;松手才把被拖行与目标行「交换编号」, + * 其余片段不受影响(只重写这两个,不会把整份列表复制到可写层)。 */ @Composable private fun PromptList( fragments: List, onOpenFragment: (PromptFragment) -> Unit, onDeleteFragment: (Int) -> Unit, - onReorder: (List) -> Unit + onSwap: (PromptFragment, PromptFragment) -> Unit ) { var localFragments by remember { mutableStateOf(fragments) } LaunchedEffect(fragments) { localFragments = fragments } val listState = rememberLazyListState() - val reorderableState = rememberReorderableLazyListState(listState) { from, to -> - localFragments = localFragments.toMutableList().apply { add(to.index, removeAt(from.index)) } + var dragTargetIndex by remember { mutableStateOf(-1) } + val reorderableState = rememberReorderableLazyListState(listState) { _, to -> + dragTargetIndex = to.index } val hapticFeedback = LocalHapticFeedback.current @@ -182,17 +183,26 @@ private fun PromptList( deleteEnabled = fragment.editable, dragModifier = Modifier.longPressDraggableHandle( onDragStarted = { + dragTargetIndex = -1 hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, onDragStopped = { hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd) - // 立即重编号(槽化),与落盘后的编号一致,避免 key 变化引起列表跳动 - val numbers = localFragments.map { it.number }.sorted() - val renumbered = localFragments.mapIndexed { index, item -> - item.copy(number = numbers[index]) + // 松手才交换:被拖行与悬停到的目标行互换编号,其余片段保持原编号 + val from = localFragments.indexOfFirst { it.number == fragment.number } + val to = dragTargetIndex + dragTargetIndex = -1 + if (from >= 0 && to in localFragments.indices && to != from) { + val a = localFragments[from] + val b = localFragments[to] + val swappedA = a.copy(number = b.number) + val swappedB = b.copy(number = a.number) + localFragments = localFragments.toMutableList().also { + it[from] = swappedA + it[to] = swappedB + }.sortedBy { it.number } + onSwap(a, b) } - localFragments = renumbered - onReorder(renumbered) } ) ) 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 adde12e0..8b8537a9 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 @@ -915,7 +915,7 @@ fun SettingsScreen( section = SettingsSection.PromptDetail }, onDeleteFragment = promptsViewModel::deleteFragment, - onReorder = promptsViewModel::reorderFragments, + onSwap = promptsViewModel::swapFragments, onToggleBuiltinDisabled = promptsViewModel::setBuiltinDisabled ) if (showPromptsAddSheet) { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 81c4b69a..f8ff1fe4 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -967,7 +967,6 @@ Prompts - Fragments are listed by number. Only the highest-priority copy of each number takes effect; the badge shows which layer it comes from: Project > Global > Local > Built-in. Long-press to reorder No fragments available Project @@ -985,9 +984,6 @@ What the model should follow New prompt Edit prompt - Saved to the layer currently in effect for this number; takes effect immediately. - Add - Save Prompts Add fragment Insert a new fragment with a free number diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09514438..bd65fdda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -967,7 +967,6 @@ 提示词 - 按编号列出实际生效的提示词片段。同一编号只生效优先级最高的一份,右侧徽章标明它来自哪一层:项目 > 全局 > 本地 > 内置。 长按可调整顺序 暂无可用片段 项目 @@ -985,9 +984,6 @@ 写模型要遵守的内容 新建提示词 编辑提示词 - 保存会写到该编号当前生效的层;保存后即时生效。 - 添加 - 保存 提示词 添加片段 用未占用的编号新增一条片段 diff --git a/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt new file mode 100644 index 00000000..b36a9bf6 --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt @@ -0,0 +1,138 @@ +package com.aicode.feature.agent.domain.prompt + +import android.content.Context +import android.content.res.AssetManager +import com.aicode.feature.agent.domain.container.ContainerInstaller +import com.aicode.feature.workspace.domain.ProjectAicodeRoot +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * 四级来源片段的解析/保存/删除/编号交换:编号身份、优先级、落盘层与交换只动两个片段。 + * + * 注意:本地层(`prompts/`)只在**内置存在对应编号**时才会被解析(见 [PromptFragmentCatalog.resolve]), + * 故这里让 assets 暴露一个内置文件名 `10-builtin.md`,本地同名文件才会生效。 + */ +class PromptFragmentCatalogTest { + + private lateinit var root: File + private lateinit var aicodeDir: File + private lateinit var workspaceDir: File + private lateinit var catalog: PromptFragmentCatalog + + private val globalDir get() = File(aicodeDir, "prompts.custom") + private val localDir get() = File(aicodeDir, "prompts") + private val projectDir get() = File(File(workspaceDir, ".aicode"), "prompts.custom") + + @Before + fun setUp() { + root = Files.createTempDirectory("prompt-catalog-test").toFile() + aicodeDir = File(root, "aicode").apply { mkdirs() } + workspaceDir = File(root, "workspace").apply { mkdirs() } + globalDir.mkdirs() + localDir.mkdirs() + + val context = mockk() + val assets = mockk() + every { context.assets } returns assets + every { assets.list(any()) } returns arrayOf(BUILTIN_NAME) + val installer = mockk() + every { installer.aicodeDir } returns aicodeDir + val projectRoot = mockk() + every { projectRoot.forPath(any()) } returns File(workspaceDir, ".aicode") + + catalog = PromptFragmentCatalog(context, installer, projectRoot) + } + + @After + fun tearDown() { + root.deleteRecursively() + } + + private fun write(dir: File, name: String, content: String): File { + dir.mkdirs() + return File(dir, name).apply { writeText(content) } + } + + @Test + fun list_同编号_取优先级最高的项目层() { + write(globalDir, "10-g.md", "G") + write(localDir, BUILTIN_NAME, "L") + write(projectDir, "10-p.md", "P") + + val fragment = catalog.list(workspaceDir.path).first { it.number == 10 } + + assertEquals(PromptFragmentSource.PROJECT, fragment.source) + assertEquals("P", fragment.content) + } + + @Test + fun list_无项目层_回退全局再回退本地() { + write(localDir, BUILTIN_NAME, "L") + write(globalDir, "10-g.md", "G") + assertEquals(PromptFragmentSource.GLOBAL, catalog.list(workspaceDir.path).first { it.number == 10 }.source) + + globalDir.listFiles()!!.forEach { it.delete() } + assertEquals(PromptFragmentSource.LOCAL, catalog.list(workspaceDir.path).first { it.number == 10 }.source) + } + + @Test + fun renderStatic_按编号升序拼接正文() { + write(globalDir, "30-b.md", "BBB") + write(globalDir, "10-a.md", "AAA") + + assertEquals("AAA\n\nBBB", catalog.renderStatic(workspaceDir.path)) + } + + @Test + fun saveOverride_写到指定层并清掉旧编号() { + catalog.saveOverride(12, "x", "X", workspaceDir.path, target = PromptFragmentSource.GLOBAL) + assertTrue(File(globalDir, "12-x.md").isFile) + + catalog.saveOverride( + 20, "x", "X", workspaceDir.path, + target = PromptFragmentSource.GLOBAL, previousNumber = 12 + ) + assertFalse("旧编号应被清掉", File(globalDir, "12-x.md").exists()) + assertTrue(File(globalDir, "20-x.md").isFile) + } + + @Test + fun deleteOverride_删除可写覆盖() { + write(globalDir, "12-x.md", "X") + assertTrue(catalog.deleteOverride(12, workspaceDir.path)) + assertFalse(File(globalDir, "12-x.md").exists()) + } + + @Test + fun swapNumbers_只重写两个片段_其余不动_只读层不被删() { + write(projectDir, "12-a.md", "A") + write(projectDir, "20-c.md", "C") + write(localDir, BUILTIN_NAME, "L") + + val a = catalog.list(workspaceDir.path).first { it.number == 12 } + val b = catalog.list(workspaceDir.path).first { it.number == 10 } + assertEquals(PromptFragmentSource.LOCAL, b.source) + assertTrue(catalog.swapNumbers(a, b, workspaceDir.path)) + + assertTrue("只读的本地文件不应被删除", File(localDir, BUILTIN_NAME).isFile) + assertEquals("未参与的片段不应被改写", "C", File(projectDir, "20-c.md").readText()) + + val after = catalog.list(workspaceDir.path) + assertEquals("A", after.first { it.number == 10 }.content) + assertEquals("L", after.first { it.number == 12 }.content) + } + + private companion object { + /** 让本地层可解析的内置文件名(编号 10)。 */ + const val BUILTIN_NAME = "10-builtin.md" + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolverTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolverTest.kt index 0862d634..4f94c6fc 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolverTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentResolverTest.kt @@ -78,20 +78,4 @@ class PromptFragmentResolverTest { File(dir, ".no-builtin").writeText("") assertTrue(PromptFragmentResolver.isBuiltinDisabled(dir)) } - - @Test - fun mergeStatic_覆盖与新增片段按数字合并() { - val custom5 = File(dir, "05-新增.md") - val custom50 = File(dir, "50-自定义安全.md") - val custom80 = File(dir, "80-结尾.md") - val custom = listOf(5 to custom5, 50 to custom50, 80 to custom80) - - val merged = PromptFragmentResolver.mergeStatic(listOf(0, 10, 50), custom) - - assertEquals(listOf(0, 5, 10, 50, 80), merged.map { it.first }) - assertNull("内置数字无自定义文件时用默认内容", merged[0].second) - assertEquals(custom5, merged[1].second) - assertEquals(custom50, merged[3].second) - assertEquals(custom80, merged[4].second) - } } \ No newline at end of file diff --git a/docs-site/docs/guide/custom-prompts.md b/docs-site/docs/guide/custom-prompts.md index 0ed085f5..c0c00de0 100644 --- a/docs-site/docs/guide/custom-prompts.md +++ b/docs-site/docs/guide/custom-prompts.md @@ -16,7 +16,7 @@ AiCode 的系统提示词可以自己改。默认提示词随 App 内置、升 - **本地**:`~/.aicode/prompts/`(启动时从内置释放的副本) - **内置**:App 自带的 `assets/prompts/` - 同一编号只生效优先级最高的一份,优先级 **项目 > 全局 > 本地 > 内置**,其余被遮挡。 -- 点一行编辑它的内容(保存会写到该编号当前生效的层;若它来自只读的「本地/内置」,则写到项目层,无工作区时写全局层)。 +- 点一行编辑它的内容。保存默认写到该编号当前生效的层;「本地/内置」不可写,编辑后默认落到全局层,可在编辑页把作用域切到项目层(有工作区时)。 - 所有片段都可左滑;项目/全局片段可删除当前覆盖,删掉后自动回退到下一层;本地/内置片段的删除按钮为灰色,不可删除。 - 右上角「+」可用未占用的编号新增片段。编辑时摘要与正文分开填写;摘要显示在列表和预览页,不会注入 AI。手工编写文件时,开头的 `` 同样会被识别为摘要,正文预览会隐藏这段注释。 - 首次进入会自动弹出使用说明公告,右上角 Info 可随时重看。 From 9320a0c59847686cfc044a7ffffbbef61fe70c7e Mon Sep 17 00:00:00 2001 From: xiaojie Date: Thu, 1 Oct 2026 12:17:09 +0000 Subject: [PATCH 18/18] =?UTF-8?q?fix(prompts):=20=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E9=87=8D=E6=8E=92=E4=B8=BA=E5=BA=93=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E6=8F=92=E5=85=A5=E5=BC=8F=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E6=8A=BD=E6=90=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 之前改为「松手交换两个片段」需在拖动中不即时重排列表,与 reorderable 库的让位动画冲突,导致被拖行抽搐 - 现恢复库原生插入式重排(拖动中即时改写本地顺序),拖拽恢复流畅 - 单测的 swapNumbers 用例改为 reorder --- .../domain/prompt/PromptFragmentCatalog.kt | 45 +++++++------------ .../settings/presentation/PromptsViewModel.kt | 18 ++++++-- .../presentation/component/PromptsSection.kt | 36 ++++++--------- .../presentation/component/SettingsScreen.kt | 2 +- .../prompt/PromptFragmentCatalogTest.kt | 23 ++++------ 5 files changed, 53 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt index 03fbb68e..49cf3fe2 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt @@ -153,45 +153,32 @@ class PromptFragmentCatalog @Inject constructor( } /** - * 交换两个片段的编号:只重写这两个片段,列表里其余片段不受影响(不再把整份列表复制到可写层)。 - * - * - 各自写到与来源一致的层(项目/全局各归各层);只读来源(本地/内置)写到可写层。 - * - 先删掉各自的旧覆盖,再清掉目标编号上的冲突,最后写入新编号文件。 - * - 交换内置/本地片段必然产生一份可写副本(编号即身份,无法原地改名);删掉该覆盖即可恢复。 + * 按拖拽后的顺序重新编号并落盘:编号集合保持不变,只把各片段内容依次映射到这些编号, + * 从而改变注入顺序(数字越小越靠前)。写入可写层。 */ - fun swapNumbers(a: PromptFragment, b: PromptFragment, projectRoot: String?): Boolean { + fun reorder(reordered: List, projectRoot: String?): Boolean { + if (reordered.isEmpty()) return false val project = projectDir(projectRoot) - val writableDirs = listOfNotNull(globalDir, project) - val newA = a.copy(number = b.number) - val newB = b.copy(number = a.number) + val dir = writableDir(projectRoot) + val numbers = reordered.map { it.number }.sorted() return try { - listOf(a, b).forEach { f -> - val parent = f.file?.parentFile - if (parent != null && parent in writableDirs) f.file?.delete() + if (!dir.exists()) dir.mkdirs() + numbers.forEach { number -> + deleteOverridesFor(number, dir) + if (project != null && project != dir) deleteOverridesFor(number, project) + if (globalDir != dir) deleteOverridesFor(number, globalDir) + } + reordered.forEachIndexed { index, fragment -> + File(dir, "%02d-%s.md".format(numbers[index], sanitizeTitle(fragment.title))) + .writeText(fragment.content) } - val dirA = layerDir(newA, project) - val dirB = layerDir(newB, project) - deleteOverridesFor(newA.number, dirA) - deleteOverridesFor(newB.number, dirB) - if (!dirA.exists()) dirA.mkdirs() - if (!dirB.exists()) dirB.mkdirs() - File(dirA, fileName(newA.number, newA.title)).writeText(newA.content) - File(dirB, fileName(newB.number, newB.title)).writeText(newB.content) true } catch (e: Exception) { - FileLogger.e(TAG, "交换提示词编号失败: ${a.number}<->${b.number}", e) + FileLogger.e(TAG, "重排提示词失败", e) false } } - /** 片段落盘的目标层:项目/全局各归各层;只读来源(本地/内置)写到可写层。 */ - private fun layerDir(fragment: PromptFragment, project: File?): File = when (fragment.source) { - PromptFragmentSource.GLOBAL -> globalDir - else -> project ?: globalDir - } - - private fun fileName(number: Int, title: String): String = "%02d-%s.md".format(number, sanitizeTitle(title)) - /** 删除某编号的覆盖(仅当生效层可写),删后自动回退到下一层。 */ fun deleteOverride(number: Int, projectRoot: String?): Boolean { val file = fragment(number, projectRoot)?.takeIf { it.editable }?.file ?: return false diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt index 475aae11..166ed813 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -90,12 +91,21 @@ class PromptsViewModel @Inject constructor( } } - /** 拖拽交换:只交换两个片段的编号并落盘,其余片段不动(避免整份列表被复制到可写层)。 */ - fun swapFragments(a: PromptFragment, b: PromptFragment) { + /** 拖拽重排:按新顺序重新编号并落盘,改变注入顺序。乐观更新本地状态,避免重读导致列表跳动。 */ + fun reorderFragments(reordered: List) { + val numbers = reordered.map { it.number }.sorted() + val renumbered = reordered.mapIndexed { index, fragment -> fragment.copy(number = numbers[index]) } + _state.update { it.copy(fragments = renumbered) } viewModelScope.launch { val projectRoot = workspaceRepository.currentPath() - withContext(Dispatchers.IO) { catalog.swapNumbers(a, b, projectRoot) } - refresh() + val refreshed = withContext(Dispatchers.IO) { + if (catalog.reorder(renumbered, projectRoot)) catalog.list(projectRoot) else null + } + if (refreshed != null) { + _state.update { it.copy(fragments = refreshed) } + } else { + refresh() + } } } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt index d02b3ec2..362abd00 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -79,7 +79,7 @@ internal fun PromptsSection( state: PromptsUiState, onOpenFragment: (PromptFragment) -> Unit, onDeleteFragment: (Int) -> Unit, - onSwap: (PromptFragment, PromptFragment) -> Unit, + onReorder: (List) -> Unit, onToggleBuiltinDisabled: (Boolean) -> Unit ) { Column(modifier = Modifier.fillMaxSize()) { @@ -110,30 +110,29 @@ internal fun PromptsSection( fragments = state.fragments, onOpenFragment = onOpenFragment, onDeleteFragment = onDeleteFragment, - onSwap = onSwap + onReorder = onReorder ) } } } /** - * 片段列表。拖拽时只记录悬停到的目标行,不即时改动列表;松手才把被拖行与目标行「交换编号」, - * 其余片段不受影响(只重写这两个,不会把整份列表复制到可写层)。 + * 片段列表。拖拽时只更新本地顺序(不落盘),松手才把最终顺序交给 [onReorder] 持久化, + * 避免拖动过程中反复写盘 + 重读导致列表抽搐。 */ @Composable private fun PromptList( fragments: List, onOpenFragment: (PromptFragment) -> Unit, onDeleteFragment: (Int) -> Unit, - onSwap: (PromptFragment, PromptFragment) -> Unit + onReorder: (List) -> Unit ) { var localFragments by remember { mutableStateOf(fragments) } LaunchedEffect(fragments) { localFragments = fragments } val listState = rememberLazyListState() - var dragTargetIndex by remember { mutableStateOf(-1) } - val reorderableState = rememberReorderableLazyListState(listState) { _, to -> - dragTargetIndex = to.index + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> + localFragments = localFragments.toMutableList().apply { add(to.index, removeAt(from.index)) } } val hapticFeedback = LocalHapticFeedback.current @@ -183,26 +182,17 @@ private fun PromptList( deleteEnabled = fragment.editable, dragModifier = Modifier.longPressDraggableHandle( onDragStarted = { - dragTargetIndex = -1 hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, onDragStopped = { hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd) - // 松手才交换:被拖行与悬停到的目标行互换编号,其余片段保持原编号 - val from = localFragments.indexOfFirst { it.number == fragment.number } - val to = dragTargetIndex - dragTargetIndex = -1 - if (from >= 0 && to in localFragments.indices && to != from) { - val a = localFragments[from] - val b = localFragments[to] - val swappedA = a.copy(number = b.number) - val swappedB = b.copy(number = a.number) - localFragments = localFragments.toMutableList().also { - it[from] = swappedA - it[to] = swappedB - }.sortedBy { it.number } - onSwap(a, b) + // 立即重编号(槽化),与落盘后的编号一致,避免 key 变化引起列表跳动 + val numbers = localFragments.map { it.number }.sorted() + val renumbered = localFragments.mapIndexed { index, item -> + item.copy(number = numbers[index]) } + localFragments = renumbered + onReorder(renumbered) } ) ) 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 8b8537a9..adde12e0 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 @@ -915,7 +915,7 @@ fun SettingsScreen( section = SettingsSection.PromptDetail }, onDeleteFragment = promptsViewModel::deleteFragment, - onSwap = promptsViewModel::swapFragments, + onReorder = promptsViewModel::reorderFragments, onToggleBuiltinDisabled = promptsViewModel::setBuiltinDisabled ) if (showPromptsAddSheet) { diff --git a/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt index b36a9bf6..eafae66c 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt @@ -16,7 +16,7 @@ import java.io.File import java.nio.file.Files /** - * 四级来源片段的解析/保存/删除/编号交换:编号身份、优先级、落盘层与交换只动两个片段。 + * 四级来源片段的解析/保存/删除/重排:编号身份、优先级、落盘层。 * * 注意:本地层(`prompts/`)只在**内置存在对应编号**时才会被解析(见 [PromptFragmentCatalog.resolve]), * 故这里让 assets 暴露一个内置文件名 `10-builtin.md`,本地同名文件才会生效。 @@ -113,22 +113,17 @@ class PromptFragmentCatalogTest { } @Test - fun swapNumbers_只重写两个片段_其余不动_只读层不被删() { - write(projectDir, "12-a.md", "A") - write(projectDir, "20-c.md", "C") - write(localDir, BUILTIN_NAME, "L") - - val a = catalog.list(workspaceDir.path).first { it.number == 12 } - val b = catalog.list(workspaceDir.path).first { it.number == 10 } - assertEquals(PromptFragmentSource.LOCAL, b.source) - assertTrue(catalog.swapNumbers(a, b, workspaceDir.path)) + fun reorder_按新顺序重新编号并写入可写层() { + write(globalDir, "10-a.md", "A") + write(globalDir, "30-b.md", "B") - assertTrue("只读的本地文件不应被删除", File(localDir, BUILTIN_NAME).isFile) - assertEquals("未参与的片段不应被改写", "C", File(projectDir, "20-c.md").readText()) + val original = catalog.list(workspaceDir.path) + val reversed = original.reversed() + assertTrue(catalog.reorder(reversed, workspaceDir.path)) val after = catalog.list(workspaceDir.path) - assertEquals("A", after.first { it.number == 10 }.content) - assertEquals("L", after.first { it.number == 12 }.content) + assertEquals("B", after.first { it.number == 10 }.content) + assertEquals("A", after.first { it.number == 30 }.content) } private companion object {