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 new file mode 100644 index 00000000..49cf3fe2 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalog.kt @@ -0,0 +1,291 @@ +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 + } + } + + 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" + 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/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 33e8b329..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 @@ -18,10 +18,10 @@ import javax.inject.Singleton /** * 按模块组装系统提示词:稳定基线放最前(享受 KV Cache),仅日期为低频变化。 - * 每个 Source 维护内容缓存,避免重复读取与格式化。 + * 多数 Source 维护内容缓存,避免重复读取与格式化;静态基线片段除外,每次读盘以保证编辑即时生效。 * * 片段分两类: - * - 静态基线:`prompts/` 顶层 `-<名称>.md`(见 [BASE_FRAGMENTS]),可被 `prompts.custom/` 按数字身份覆盖或新增; + * - 静态基线:`prompts/` 顶层 `-<名称>.md`,可被 `prompts.custom/` 按数字身份覆盖或新增; * - 按需叶子:`prompts/agent/` 下的无数字片段(模式提醒、子代理基线、压缩/标题提示词),按精确同名覆盖。 * * `prompts.custom/` 存在 [PromptFragmentResolver.DISABLE_BUILTIN_FILE] 时,主代理提示词只由自定义数字片段组成, @@ -32,6 +32,7 @@ class SystemPromptProvider @Inject constructor( @param:ApplicationContext private val context: Context, private val skillRepository: SkillRepository, private val memoryRepository: MemoryRepository, + private val promptFragmentCatalog: PromptFragmentCatalog, private val containerInstaller: ContainerInstaller, private val agentDefinitionRepository: AgentDefinitionRepository ) { @@ -41,23 +42,8 @@ class SystemPromptProvider @Inject constructor( } private inner class StaticRuleSource : PromptSource { - @Volatile private var cached: String? = null - - 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 } - } - } + // 每次都重新读盘:未编辑时字符串一致,KV Cache 照常命中;编辑后立即生效,无需重启。 + override fun build(ctx: AgentContext): String = promptFragmentCatalog.renderStatic(ctx.projectRoot) } private inner class ActiveSkillsSource : PromptSource { @@ -229,6 +215,7 @@ class SystemPromptProvider @Inject constructor( private val subAgentBaseSource = SubAgentBaseSource() private val subAgentListSource = SubAgentListSource() private val memoryListSource = MemoryListSource() + private val activeSkillsSource = ActiveSkillsSource() private val projectRuleSource = ProjectRuleSource() private val workspaceSource = WorkspaceSource() @@ -288,6 +275,7 @@ class SystemPromptProvider @Inject constructor( append("\n\n") append(timeContent) } + } } @@ -296,17 +284,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), @@ -434,18 +419,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/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 new file mode 100644 index 00000000..166ed813 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/PromptsViewModel.kt @@ -0,0 +1,118 @@ +package com.aicode.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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( + val fragments: List = emptyList(), + val builtinDisabled: Boolean = false, + /** 没有选中工作区时项目层不可写,编辑/新建回落到全局层。 */ + val hasWorkspace: Boolean = false, + val loading: Boolean = true +) + +/** + * 提示词页状态:四级来源(项目 > 全局 > 本地 > 内置)的生效片段列表 + 内置开关。 + * + * 读写都是磁盘 IO,统一放 IO 线程。 + */ +@HiltViewModel +class PromptsViewModel @Inject constructor( + private val catalog: PromptFragmentCatalog, + 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) { + PromptsUiState( + fragments = catalog.list(projectRoot), + builtinDisabled = catalog.isBuiltinDisabled(), + hasWorkspace = projectRoot.isNotBlank(), + loading = false + ) + } + _state.value = loaded + } + } + + /** 保存某编号的覆盖(新建与编辑同一入口):写到所选作用域层;编辑改编号时清掉旧编号。 */ + fun saveFragment( + number: Int, + title: String, + scope: PromptFragmentSource, + content: String, + previousNumber: Int? = null + ) { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + withContext(Dispatchers.IO) { + catalog.saveOverride( + number, + title, + content, + projectRoot, + target = scope, + previousNumber = previousNumber + ) + } + refresh() + } + } + + /** 删除某编号的覆盖,自动回退到下一层。 */ + fun deleteFragment(number: Int) { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + withContext(Dispatchers.IO) { catalog.deleteOverride(number, projectRoot) } + refresh() + } + } + + /** 拖拽重排:按新顺序重新编号并落盘,改变注入顺序。乐观更新本地状态,避免重读导致列表跳动。 */ + 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() + 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) { catalog.setBuiltinDisabled(disabled) } + 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/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/PromptsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt new file mode 100644 index 00000000..362abd00 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/PromptsSection.kt @@ -0,0 +1,339 @@ +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.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.shape.RoundedCornerShape +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.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 sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +/** + * 编辑目标:区分「新建」与「编辑既有编号」。 + * + * - [number] = null:新建一条编号片段 + * - 非 null:编辑该编号最终生效的片段 + */ +internal data class PromptEditTarget(val number: Int? = null) + +/** + * 提示词页:顶部是「完全禁用内置提示词」开关与帮助入口,下面是按编号列出的生效片段。 + * + * 每行一张卡片(图标 + 编号·名称 + 摘要 + 来源徽章),长按行内容可拖拽调整顺序, + * 点击进入详情预览(右上角再进编辑),来源可写时左滑删除。 + */ +@Composable +internal fun PromptsSection( + state: PromptsUiState, + onOpenFragment: (PromptFragment) -> Unit, + onDeleteFragment: (Int) -> Unit, + onReorder: (List) -> Unit, + onToggleBuiltinDisabled: (Boolean) -> Unit +) { + 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() + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + contentPadding = PaddingValues(top = Spacing.xs), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + items = localFragments, + key = { index, fragment -> + "${fragment.stableReorderKey}:${localFragments.take(index).count { it.stableReorderKey == fragment.stableReorderKey }}" + } + ) { 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" + ) + 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( + 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) + } + ) + ) + } + } + } + } +} + +/** + * 右上角「+」弹层:两项入口(添加片段 / 帮助)。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PromptsAddSheet( + onDismiss: () -> Unit, + onAddFragment: () -> 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 = onAddFragment + ) + SettingsDivider() + SettingsRow( + icon = FeatherIcons.Info, + title = stringResource(R.string.prompts_help), + subtitle = stringResource(R.string.prompts_help_hint), + onClick = onHelp + ) + } + } + } +} + +/** 单条片段行:图标 + 编号·名称 + 摘要 + 来源徽章 + 箭头;长按内容区拖拽排序,左滑删除。 */ +@Composable +private fun PromptRow( + fragment: PromptFragment, + onClick: () -> Unit, + onDelete: () -> Unit, + deleteEnabled: Boolean, + dragModifier: Modifier +) { + val sortDescription = stringResource(R.string.prompts_sort_long_press) + val row: @Composable () -> Unit = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = Spacing.lg, end = Spacing.xs, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + 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, + tint = MaterialTheme.semanticColors.subtleText, + modifier = Modifier.size(18.dp) + ) + } + } + + SwipeToDeleteRow(onDelete = onDelete, onClick = onClick, deleteEnabled = deleteEnabled) { row() } +} + +/** 来源徽章:一眼看出该编号的内容来自哪一层。 */ +@Composable +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 = 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) + ) +} 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..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 @@ -72,6 +72,8 @@ import com.aicode.core.util.LogLevel import com.aicode.R import com.aicode.feature.agent.domain.mcp.McpServerEntry 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 @@ -103,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 @@ -144,6 +147,9 @@ 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), + PromptDetail(R.string.prompts_title), + PromptEditor(R.string.prompts_title), SkillDetail(R.string.settings_skills), SkillEditor(R.string.settings_skills), SubAgents(R.string.settings_subagents), @@ -174,8 +180,10 @@ private fun SettingsSection.depth(): Int = when (this) { // 技能/子代理编辑页既可从列表(1) 进也可从详情页(2) 进,必须比详情页更深: // 同深度会让「编辑 → 详情」也被当成前进,返回时页面从右侧滑入,方向是反的。 SettingsSection.SkillEditor, - SettingsSection.SubAgentEditor -> 3 + SettingsSection.SubAgentEditor, + SettingsSection.PromptEditor -> 3 SettingsSection.ProviderEditor, + SettingsSection.PromptDetail, SettingsSection.SkillDetail, SettingsSection.SubAgentDetail, SettingsSection.ContainerDownloads, @@ -246,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() @@ -323,6 +333,14 @@ 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) } + var selectedPrompt by remember { mutableStateOf(null) } + var showPromptsAnnouncement by remember { mutableStateOf(false) } + // 编辑页返回目标:从详情进就回详情,从列表「+」进就回列表。 + var promptEditorReturn by remember { mutableStateOf(SettingsSection.Prompts) } // 技能编辑目标:null 表示新建一个;编辑现有技能时指向被编辑的条目。 var editingSkill by remember { mutableStateOf(null) } // 编辑页的返回目标:从详情页进就回详情页,从列表顶栏「+」进就回列表。 @@ -370,6 +388,8 @@ fun SettingsScreen( SettingsSection.ProviderEditor -> SettingsSection.Providers SettingsSection.Log -> logReturnSection.takeUnless { expanded && it == SettingsSection.Menu } SettingsSection.SkillDetail -> SettingsSection.Skills + SettingsSection.PromptEditor -> promptEditorReturn + SettingsSection.PromptDetail -> SettingsSection.Prompts SettingsSection.SkillEditor -> skillEditorReturn SettingsSection.SubAgentDetail -> SettingsSection.SubAgents SettingsSection.SubAgentEditor -> subAgentEditorReturn @@ -413,6 +433,13 @@ fun SettingsScreen( } } + // 首次(或公告内容更新后)进入「提示词」页自动弹出使用说明公告;哈希比对在 ViewModel 完成。 + LaunchedEffect(section, promptsAnnouncementOutdated) { + if (section == SettingsSection.Prompts && promptsAnnouncementOutdated && promptsAnnouncementText.isNotBlank()) { + showPromptsAnnouncement = true + } + } + // 菜单内容:窄窗当首页用,大屏当常驻左栏用,共用同一份。 // 滚动位置必须提到 AnimatedContent 之外持有:菜单页作为分区分支被切走时会被 dispose, // 内部 rememberScrollState 一并丢弃,从二级页返回就跳回顶部。 @@ -550,6 +577,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 } @@ -725,6 +774,38 @@ fun SettingsScreen( ) } } + SettingsSection.Prompts -> { + IconButton(onClick = { showPromptsAnnouncement = 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() }) { Icon( @@ -822,6 +903,48 @@ 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, + onOpenFragment = { fragment -> + selectedPrompt = fragment + section = SettingsSection.PromptDetail + }, + onDeleteFragment = promptsViewModel::deleteFragment, + onReorder = promptsViewModel::reorderFragments, + onToggleBuiltinDisabled = promptsViewModel::setBuiltinDisabled + ) + if (showPromptsAddSheet) { + PromptsAddSheet( + onDismiss = { showPromptsAddSheet = false }, + onAddFragment = { + showPromptsAddSheet = false + promptEditTarget = PromptEditTarget(null) + promptEditorReturn = SettingsSection.Prompts + section = SettingsSection.PromptEditor + }, + onHelp = { + showPromptsAddSheet = false + showPromptsAnnouncement = true + } + ) + } + } + SettingsSection.PromptDetail -> { + val promptsViewModel: com.aicode.feature.settings.presentation.PromptsViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val promptsState by promptsViewModel.state.collectAsStateWithLifecycle() + selectedPrompt?.let { sel -> + promptsState.fragments.firstOrNull { it.number == sel.number }?.let { fragment -> + PromptDetailSection(fragment = fragment) + } + } + } + SettingsSection.PromptEditor -> Unit SettingsSection.SkillDetail -> selectedSkill?.let { entry -> SkillDetailSection( entry = entry, @@ -1202,6 +1325,71 @@ fun SettingsScreen( } } } + + // 提示词使用说明公告:首次进入(或内容更新后)自动弹出,右上角 Info 按钮可随时重看。 + if (showPromptsAnnouncement) { + val dismiss = { + showPromptsAnnouncement = false + viewModel.markPromptsAnnouncementShown() + } + Dialog(onDismissRequest = dismiss) { + 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 + ) + 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 = dismiss, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(top = 4.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text(stringResource(R.string.common_got_it)) + } + } + } + } + } } // BoxWithConstraints 结束 } @@ -1348,6 +1536,12 @@ internal fun SettingsMenu( title = stringResource(SettingsSection.SubAgents.titleRes), onClick = { onOpen(SettingsSection.SubAgents) } ) + SettingsDivider() + SettingsRow( + 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 ec1b7549..f8ff1fe4 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -965,6 +965,33 @@ %1$d tools Details + + Prompts + Long-press to reorder + No fragments available + Project + Global + Local + Built-in + Basic + 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 + New prompt + Edit prompt + Prompts + Add fragment + Insert a new fragment with a free number + Help + How it works and when it applies + Disable all built-in prompts + Keep only your own fragments; you must supply every rule yourself + 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..bd65fdda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -965,6 +965,33 @@ %1$d 个工具 详情 + + 提示词 + 长按可调整顺序 + 暂无可用片段 + 项目 + 全局 + 本地 + 内置 + 基本信息 + 编号 + 两位数字,如 35 + 名称 + 如:我的安全规则 + 摘要(可选) + 简述片段内容,不会注入提示词 + 片段内容 + 写模型要遵守的内容 + 新建提示词 + 编辑提示词 + 提示词 + 添加片段 + 用未占用的编号新增一条片段 + 帮助 + 怎么用、什么时候生效 + 完全禁用内置提示词 + 只保留你的片段;需要自己补全全部规则,谨慎开启 + 还没有安装技能 点右上角「+」新建或导入 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..eafae66c --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/prompt/PromptFragmentCatalogTest.kt @@ -0,0 +1,133 @@ +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 reorder_按新顺序重新编号并写入可写层() { + write(globalDir, "10-a.md", "A") + write(globalDir, "30-b.md", "B") + + val original = catalog.list(workspaceDir.path) + val reversed = original.reversed() + assertTrue(catalog.reorder(reversed, workspaceDir.path)) + + val after = catalog.list(workspaceDir.path) + assertEquals("B", after.first { it.number == 10 }.content) + assertEquals("A", after.first { it.number == 30 }.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 15312913..c0c00de0 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。手工编写文件时,开头的 `` 同样会被识别为摘要,正文预览会隐藏这段注释。 +- 首次进入会自动弹出使用说明公告,右上角 Info 可随时重看。 + +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`)→ 本地 → 内置」三级查找。 所以你只需要放想改或想加的那几个片段,其余会自动用默认版本。 @@ -62,10 +82,10 @@ AiCode 的系统提示词可以自己改。默认提示词随 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#工作区文件在手机上的位置)。