基于Kotlin与Jetpack Compose构建本地AI提示词管理工具

基于Kotlin与Jetpack Compose构建本地AI提示词管理工具 1. 项目缘起为什么我们需要一个本地AI提示词保险库作为一名每天和代码、AI助手打交道的开发者我发现自己陷入了一个奇怪的效率悖论。我们引入AI工具本意是提升效率但为了用好它我们却花费了大量时间在“管理”它上。最典型的问题就是那些精心调试、效果绝佳的提示词总是在用过一次后就消失在了聊天历史的长河中。你可能花了十分钟甚至半小时通过反复调整措辞、添加上下文、明确输出格式才得到一个能稳定生成符合你团队代码规范的API接口模板的提示词。用了一次很完美。一周后当需要再次生成一个类似的微服务接口时你面对着聊天窗口大脑一片空白。是“Generate a RESTful API for user management”还是“Create a Spring Boot controller with CRUD operations”你隐约记得上次的提示词里好像还指定了响应格式和异常处理但具体怎么写的忘了。于是你不得不开始一场痛苦的考古挖掘在几十甚至上百条杂乱无章的对话记录里来回翻找寄希望于模糊的关键词搜索能给你一丝线索。或者你干脆放弃从头再来重新花十分钟去“发明轮子”。这种重复劳动带来的挫败感让我开始思考我们对待AI提示词的方式是不是从根本上就错了聊天界面是为线性对话设计的它不是知识库更不是可检索、可复用的资产管理系统。当AI从偶尔的玩具变成我们工作流中不可或缺的生产力组件时我们就必须用更专业的工具来管理它。这就是我动手构建“AI Prompt Vault”AI提示词保险库的最初动机——不是为了做一个炫酷的App而是为了解决一个真实、具体、每天都在发生的效率痛点。2. 核心设计思路从问题出发定义产品形态在决定动手之前我花了些时间梳理这个“提示词管理工具”到底应该长什么样。市面上已经有一些在线的提示词分享平台或浏览器插件但它们大多不符合我的核心诉求。我的需求清单非常明确极致的检索速度当我需要一个“写Dockerfile”的提示词时我希望在输入“dock”的瞬间相关结果就应该出现。任何网络延迟或加载动画都是不可接受的。绝对的隐私与所有权我的提示词里可能包含项目代码片段、内部架构描述甚至未公开的业务逻辑。它们必须100%存储在我自己的设备上绝不能上传到任何第三方服务器。跨场景的便捷性灵感不只在办公桌前迸发。可能在通勤路上想到一个复杂的正则表达式问题也可能在会议室白板前需要快速调出一个系统设计提问框架。工具需要能随时随地访问。极简的组织方式我不需要复杂的项目管理功能但必须能对提示词进行快速分类和打标。一个扁平的、超过50条记录的列表很快就会失去可用性。基于这些原则“一个本地优先、移动端为主的应用”这个形态就变得清晰起来。为什么是Android App而非浏览器插件或桌面应用除了移动场景的考量还有一个深层原因它强迫我保持功能的纯粹。一个移动应用有着天然的界面约束这能有效防止我陷入“功能蔓延”的陷阱逼着我只做最核心、最必要的事情——存储、组织、检索、复制。这四件事必须做到极致。2.1 技术选型背后的逻辑确定了产品形态接下来就是技术栈的选择。我的目标是构建一个快速、稳定、维护成本低的原生应用。Kotlin是毫无悬念的首选。对于Android开发而言它不仅仅是“更好的Java”。其空安全特性让我在编写数据处理逻辑尤其是用户输入的提示词内容时信心大增从编译器层面避免了大量的潜在崩溃。扩展函数和更简洁的语法也让代码的可读性和编写效率大幅提升。例如处理字符串裁剪或日期格式化用Kotlin的标准库函数一行代码往往就能搞定。Jetpack Compose是我本次开发中最大的惊喜也是我决定采用现代Android开发栈的关键。传统的XML布局方式在构建动态列表、实现流畅的搜索过滤交互时需要频繁操作Adapter、ViewHolder状态管理也比较分散。而Compose的声明式UI范式完美契合了这个工具的需求。当用户在搜索框输入文字时我只需要更新存储搜索关键词的状态变量UI就会自动响应重组出过滤后的列表。整个交互逻辑变得异常直观和线性开发迭代速度飞快。构建一个带分类标签的提示词卡片在Compose里就是一系列可组合函数的嵌套比在XML里维护复杂的视图层级要清晰得多。Room SQLite作为本地数据库是“本地优先”架构的基石。提示词数据量不大但读写频繁尤其是搜索操作。Room提供的编译时SQL校验和方便的Dao接口抽象让我能快速构建数据层。我设计了一个简单的三表结构Prompt提示词主体、Tag标签、以及连接二者的PromptTagJoin表。这样一个提示词可以拥有多个标签如“#正则表达式”、“#代码生成”、“#项目A”实现了灵活的多对多分类。Room对Flow的支持使得数据库的任何更新都能自动通知UI层实现实时刷新。Coroutines Flow是处理异步操作和数据的生命线。所有数据库操作插入、查询、更新都必须在后台线程执行绝不能阻塞UI线程。使用协程我可以轻松地用viewModelScope.launch包裹数据库调用并用StateFlow来承载列表数据的状态加载中、成功、错误。当用户在搜索框输入时我会收集输入流进行防抖处理比如延迟300毫秒然后触发新的数据库查询并通过Flow将结果流式更新到UI。这套响应式架构确保了应用在任何操作下都保持流畅。注意关于数据持久化的思考有朋友问为什么不直接用简单的文件存储如JSON。对于纯粹的个人备份文件存储可行。但一旦涉及检索尤其是模糊搜索、多标签联合筛选和频繁增删改一个轻量级的关系型数据库在性能和开发便利性上具有压倒性优势。Room帮我处理了所有SQLite的样板代码让我能专注于业务逻辑。3. 关键功能实现与细节打磨有了清晰的设计和稳固的技术栈接下来就是具体的实现。这个过程并非简单地堆砌功能每一个细节都围绕着“提升核心体验”来打磨。3.1 高效可扩展的数据层设计数据模型的设计直接决定了应用的扩展能力和性能。核心的Prompt实体包含标题、内容、创建时间、最后使用时间等字段。但核心在于Tag系统。我最初设计是让Prompt实体直接包含一个tags: ListString字段但这在Room中查询效率很低尤其是需要“查找所有带有‘Python’和‘API’标签的提示词”时。最终的方案是规范化的三表结构Prompt:id(主键),title,content,createdTimeTag:id(主键),name(唯一)PromptTagCrossRef:promptId,tagId(复合主键)这样添加标签就是插入一条关联记录删除标签时如果该标签没有被其他提示词使用则同步清理Tag表查询带有特定标签的提示词就是一个简单的JOIN操作。为了简化上层调用我创建了一个PromptWithTags的数据类它包含一个Prompt对象和一个ListTagRoom的Relation注解可以自动帮我完成这次关联查询在Dao中返回的就是这个复合对象对UI层非常友好。// 数据类定义示例 Entity data class Prompt( PrimaryKey(autoGenerate true) val id: Long 0, val title: String, val content: String, val createdAt: Long System.currentTimeMillis() ) Entity data class Tag( PrimaryKey val name: String ) Entity(primaryKeys [promptId, tagName]) data class PromptTagCrossRef( val promptId: Long, val tagName: String ) // 查询时使用的复合对象 data class PromptWithTags( Embedded val prompt: Prompt, Relation( parentColumn id, entityColumn name, associateBy Junction(PromptTagCrossRef::class) ) val tags: ListTag )3.2 即时搜索与过滤的流畅体验搜索是这款工具的命脉。我的目标是实现“零延迟”感知。实现原理是结合数据库的LIKE查询和Flow的响应式特性。首先在Dao中定义搜索函数它接收一个搜索词参数并在title和content字段中进行模糊匹配%通配符。Query(SELECT * FROM prompt WHERE title LIKE % || :query || % OR content LIKE % || :query || %) fun searchPrompts(query: String): FlowListPromptWithTags在ViewModel中我暴露一个searchQuery的MutableStateFlow和一个searchResults的StateFlow。在init块中我会对searchQuery这个流进行变换操作init { viewModelScope.launch { searchQuery .debounce(300) // 防抖避免每输入一个字母就查询 .distinctUntilChanged() // 仅当查询词真正变化时触发 .flatMapLatest { query - // 取消前一个未完成的查询发起最新查询 if (query.isBlank()) { promptDao.getAllPrompts() // 如果搜索框为空显示全部 } else { promptDao.searchPrompts(query) } } .flowOn(Dispatchers.IO) // 在IO线程执行数据库操作 .catch { e - emit(emptyList()) } // 出错时返回空列表避免崩溃 .collect { results - _searchResults.value results } } }UI层Compose只需要收集searchResults这个状态并渲染列表即可。debounce和flatMapLatest的运用确保了搜索既响应迅速又不会因快速输入而导致不必要的性能浪费和结果闪烁。3.3 跨厂商的剪贴板兼容性处理“一键复制”听起来简单但在Android的碎片化生态里却是个小坑。核心代码ClipboardManager.setPrimaryClip()本身是标准的但不同厂商设备特别是小米、华为等有深度定制的系统对剪贴板操作的成功回调时机、以及系统通知的显示方式处理不一。为了保证用户在任何设备上复制后都有明确的成功反馈我做了两层处理核心操作在ViewModel中执行复制逻辑并捕获可能的安全异常。视觉反馈使用Snackbar作为统一的成功提示。在Compose中通过Scaffold的snackbarHostState来显示。关键技巧是在显示Snackbar之前加入一个极短的延迟如50毫秒确保剪贴板操作完全完成再提示用户这样能避免在一些慢速设备上提示出现但复制未生效的尴尬情况。fun copyToClipboard(context: Context, text: String, snackbarHostState: SnackbarHostState) { viewModelScope.launch { try { val clipboard ContextCompat.getSystemService(context, ClipboardManager::class.java) clipboard?.setPrimaryClip(ClipData.newPlainText(AI Prompt, text)) // 稍作延迟确保操作完成 delay(50L) snackbarHostState.showSnackbar( message 提示词已复制, actionLabel 确定 ) } catch (e: SecurityException) { // 处理某些严格权限设备的异常 snackbarHostState.showSnackbar( message 复制失败请检查权限, actionLabel 确定 ) } } }3.4 灵活轻量的分类与标签系统我放弃了传统的文件夹树状结构采用了更灵活的标签系统。用户可以为提示词添加多个标签如#Kotlin、#正则表达式、#项目Alpha。在UI上主屏幕提供了一个标签云视图显示所有使用过的标签及其频次点击任一标签即可过滤出所有带此标签的提示词。添加标签的交互也经过精心设计。在编辑提示词界面有一个标签输入框支持输入新标签或从已有标签中选择。我使用了Flow来实时匹配用户输入和已有标签库提供自动完成建议大大提升了添加效率。标签数据同样存储在Room中通过PromptTagCrossRef表与提示词关联。4. 开发中的挑战与实战心得这个项目虽然不大但在开发过程中依然遇到了几个值得分享的技术挑战和决策点。4.1 数据库迁移与架构演进第一个版本Prompt实体只有id、title和content三个字段。上线后我很快意识到需要“最后使用时间”来对提示词进行智能排序最近常用的排前面。这就涉及到了数据库迁移。在Room中你需要更新Entity注解类的字段并在Database注解中增加版本号同时提供Migration对象。对于简单的增加字段Room的autoMigrations特性在大多数情况下可以自动处理但为了保险起见特别是未来可能更复杂的变更我选择显式地编写迁移脚本。Database( entities [Prompt::class, Tag::class, PromptTagCrossRef::class], version 2, autoMigrations [ AutoMigration (from 1, to 2) ] ) abstract class AppDatabase : RoomDatabase() { abstract fun promptDao(): PromptDao }实操心得关于数据库版本管理即使有autoMigration在开发初期如果数据不重要我有时会直接采取“破坏性”更新——即清空数据库并重建。可以通过在Room.databaseBuilder()后调用.fallbackToDestructiveMigration()实现。但这绝对不能在已发布的生产版本中使用对于已上线的App每一次数据库变更都必须谨慎规划和测试迁移路径。4.2 列表性能与懒加载当用户的提示词库增长到几百条时一次性加载所有PromptWithTags对象到内存并渲染虽然对于这个量级的数据可能依然流畅但不是一个好习惯。我使用LazyColumn来渲染列表它是Compose中用于长列表的惰性布局只会渲染当前可视区域及附近少量的项目性能极佳。关键在于传递给LazyColumn的列表数据searchResults已经由数据库查询和Flow准备好了Compose的响应式系统会高效地处理更新。4.3 状态管理的清晰边界在Compose中状态管理是核心。我严格遵守了“单向数据流”的原则UI层只负责显示和发送事件。它从ViewModel中收集StateFlow如uiState并通过函数调用将用户操作如点击复制、输入搜索词通知给ViewModel。ViewModel作为状态持有者和逻辑处理器。它接收UI事件调用Repository层处理业务逻辑读写数据库然后更新其持有的状态StateFlow。Repository Dao纯粹的数据层负责与Room数据库交互。这种清晰的分离使得代码易于测试可以单独测试ViewModel的逻辑和维护。例如搜索功能的逻辑完全在ViewModel中通过操作Flow来完成UI对此一无所知只负责显示结果。5. 常见问题与排查实录在个人使用和分享给少数朋友测试的过程中我遇到并解决了一些典型问题。5.1 问题一搜索时输入过快导致列表闪烁或卡顿现象在搜索框快速连续输入时列表内容会快速跳动偶尔感觉不跟手。排查检查searchQuery的Flow处理链。最初没有加debounce和distinctUntilChanged导致每次按键都会触发一次数据库查询和UI重组。解决如前面所述加入.debounce(300)和.distinctUntilChanged()操作符。debounce确保在用户停止输入300毫秒后才发起查询distinctUntilChanged确保只有查询词真正变化时才触发避免了因连续输入相同字母虽然不常见或快速删除又输入导致的无效查询。5.2 问题二从后台返回应用后列表有时显示为空现象切换到其他应用再切回来偶尔发现提示词列表空了但重启App又正常。排查检查ViewModel中Flow的收集生命周期。最初在Composable中直接使用collectAsState()收集Flow当Composable进入后台时收集会停止。当应用从后台返回如果searchQuery的Flow有新的发射比如从空字符串变为空字符串但结果Flow的收集尚未重新开始或状态未正确恢复就可能显示为空。解决确保状态恢复的可靠性。将核心的列表数据状态searchResults存储在ViewModel的StateFlow中而不是在UI层直接收集数据库Flow。ViewModel的生命周期比UI长其状态在配置变更如旋转屏幕时也会被保留。同时在UI层使用collectAsStateWithLifecycle()需要lifecycle-runtime-compose依赖来收集StateFlow它能感知生命周期在后台自动暂停收集避免不必要的资源消耗和潜在的状态不一致。5.3 问题三标签删除后关联的提示词标签未同步清理现象删除了一个标签如“#废弃”但之前打过这个标签的提示词在数据库关联表中仍然留有记录虽然UI上不显示但可能影响后续查询。排查检查Tag的删除操作。最初只执行了tagDao.delete(tag)这只会删除Tag表自身的记录PromptTagCrossRef表中的关联记录成了“孤儿数据”。解决利用Room数据库的外键约束或通过事务进行级联操作。我在PromptTagCrossRef实体中定义了外键约束指向Tag表并设置了onDelete CASCADE。这样当删除一个Tag时数据库会自动删除所有与之关联的PromptTagCrossRef记录。另一种方式是在Repository层用一个事务包裹删除标签和清理关联表的操作。// 在CrossRef实体中定义外键 Entity( primaryKeys [promptId, tagName], foreignKeys [ ForeignKey( entity Tag::class, parentColumns [name], childColumns [tagName], onDelete ForeignKey.CASCADE // 级联删除 ), ForeignKey( entity Prompt::class, parentColumns [id], childColumns [promptId], onDelete ForeignKey.CASCADE ) ] ) data class PromptTagCrossRef( val promptId: Long, val tagName: String )5.4 问题四在不同Android版本上复制成功的Snackbar提示有时不显示现象在部分Android 10或更低版本的设备上点击复制后Snackbar没有弹出。排查发现是在协程中调用snackbarHostState.showSnackbar时所在的协程作用域可能因为某些原因如ViewModel被清空而提前取消或者UI状态尚未准备好。解决确保Snackbar的显示逻辑在正确的协程上下文和UI线程中执行。我将显示Snackbar的调用移到了LaunchedEffect或直接放在可以感知UI生命周期的协程作用域中如viewModelScope.launch并确保在更新UI状态后执行。对于低版本兼容性避免使用过于新的Snackbar API采用最基础的showSnackbar方法。6. 项目反思与未来可能的演进构建这个工具的过程是一个不断做减法的过程。每当我想到一个新功能——比如集成ChatGPT API直接发送提示词、云端同步、Markdown渲染、智能提示词分析——我都会问自己这会不会让打开App、找到并复制一个提示词的核心路径变得更长答案往往是“会”。所以这些想法都被放到了“Maybe Later”清单里。目前的核心价值恰恰在于它的单一和专注。它就是一个数字化的卡片盒安静、快速、完全属于你。通过这个项目我再次深刻体会到解决一个自己亲身经历、且足够具体的痛点是驱动个人项目成功的最佳动力。你不仅是开发者更是首席用户体验官和首席测试员你能第一时间感知到哪个交互别扭哪个功能冗余。关于未来如果用户基数增长我可能会考虑以下方向但每一步都会非常谨慎安全的端到端加密云同步让用户在手机和桌面网页端通过浏览器访问一个本地服务器同步数据但前提是加解密完全在客户端进行服务器无法查看任何明文。提示词模板与变量允许在提示词中定义如{{语言}}、{{框架}}这样的占位符复制前弹窗填写生成最终提示词。这能进一步提升复用效率。轻量的统计分析告诉用户你最常用的标签是什么哪些提示词被复制的次数最多帮助用户优化自己的提示词库。但无论如何“快速存取”这个核心体验永远不能被破坏。工具应该服务于人而不是让人去适应工具的复杂性。这个小小的提示词保险库至少让我个人的AI工作流摆脱了不断“重复发明提示词”的窘境真正把时间还给了思考和创造。如果你也受困于混乱的AI聊天历史不妨试试为自己构建一个类似的系统或许你会发现管理好那些智慧的“提问模板”本身就是一项高回报的投资。