1. 项目缘起从个人痛点到一个离线优先的AI提示词工作空间如果你和我一样在过去一年里深度使用各种大语言模型那你一定也经历过这个阶段电脑桌面上散落着几十个.txt或.md文件名字从chatgpt_prompt_1.txt到final_final_v2_prompt.md应有尽有浏览器书签栏里塞满了各种在线的提示词库链接Notion或Obsidian里虽然建了数据库但每次想调用、修改或者测试一个提示词都要经历“打开软件 - 找到页面 - 复制 - 粘贴到聊天窗口 - 微调 - 再保存回去”的繁琐循环。更别提当你需要在没有网络的环境下或者想保护一些涉及商业逻辑、个人创意的核心提示词时那种无处安放的不安全感。PromptVault 就诞生于这种一片狼藉之中。它最初只是我为了解决自己“提示词管理混乱”这个具体问题而写的一个本地小工具。我的核心诉求很简单第一所有数据必须离线存储完全由我掌控第二要有一个清晰、高效的组织和检索方式第三能方便地测试和迭代提示词并记录结果。我没想到的是这个为了解决个人需求而生的工具在迭代过程中逐渐演化成了一个功能完整的“离线优先的AI提示词工作空间”。今天我就把这个项目的构建思路、核心设计、踩过的坑以及最终的实现方案完整地分享出来。无论你是想自己动手打造类似工具还是单纯想了解一个完整的本地应用如何从想法落地相信都能从中获得启发。2. 核心设计哲学为什么是“离线优先”与“工作空间”在动手写第一行代码之前我花了大量时间思考这个工具的本质。市面上已经有不少在线的提示词管理平台它们功能丰富支持协作为什么我还要做一个离线的这背后是一系列深思熟虑后的设计选择。2.1 离线优先安全、可控与性能的基石“离线优先”不是指完全不能联网而是指应用的核心数据和逻辑在本地网络只是一个可选的增强能力。我选择这条路径基于三个核心考量1. 数据主权与隐私安全提示词Prompt正在成为数字时代一种新型的“知识产权”。一个精心调校的、能稳定输出高质量内容的提示词其价值可能不亚于一段核心算法。将这样的资产托付给第三方在线服务意味着你要信任对方的数据安全策略、隐私条款并承受服务突然关闭或变更的风险。本地存储从根本上杜绝了数据泄露给未授权第三方的可能让你对自己的创作拥有百分百的控制权。2. 绝对的可用性与响应速度本地应用无需等待网络请求。创建、编辑、搜索提示词都是瞬时完成的。即使在没有互联网的环境下比如在飞机上、网络信号差的地区你依然可以顺畅地使用所有核心功能。这种“零延迟”的体验对于需要高频次修改和测试提示词的创作或工作流程来说是巨大的效率提升。3. 架构的简洁性与可靠性省去了复杂的后端服务器、数据库、用户认证和API设计整个应用的复杂度和潜在故障点大大降低。开发可以更聚焦于客户端的功能和用户体验。用户安装后就是一个可以独立运行的完整实体。注意离线优先并不意味着封闭。PromptVault 的设计预留了“同步”的扩展接口如通过WebDAV、Git或加密的云存储桶但这被严格定义为一种“将本地数据备份或复制到其他位置”的主动行为而非应用运行的必要条件。2.2 工作空间超越简单的“收藏夹”我刻意避免将它设计成一个“提示词收藏夹”或“笔记本”。“工作空间”的定位意味着它需要支持提示词从诞生、迭代到最终应用的完整生命周期。这包含了几个层次创作层提供一个专注于写作的编辑器支持Markdown、语法高亮、变量插值等。组织层引入文件夹、标签、颜色标记、全文搜索等多维度的管理方式适应从简单到复杂的项目结构。测试层集成或便捷连接到AI聊天界面能够快速发送提示词并记录对话历史方便进行A/B测试。输出层能够将调校好的提示词一键复制到剪贴板或通过快捷方式直接发送到目标应用。这个理念决定了PromptVault不能只是一个数据库前端它必须是一个集编辑器、管理器、测试台于一体的复合型应用。3. 技术栈选型与架构解析基于“离线优先”和“功能丰富”这两个可能有些矛盾的目标技术选型变得非常关键。我需要一个既能构建高性能本地应用又能拥有强大生态系统和开发效率的方案。3.1 为什么选择Tauri Rust SvelteKit我最终的核心技术栈是Tauri作为应用框架Rust处理后端核心逻辑和本地操作SvelteKit构建前端界面数据存储使用SQLite并通过libsql客户端操作。前端SvelteKit理由我需要一个反应迅速、代码简洁的前端框架。Svelte的编译时优化理念使得生成的代码量小运行时性能出色这对于一个希望保持轻量级的本地应用至关重要。SvelteKit提供了完善的路由、服务端渲染虽然我们主要用于SPA和项目结构开发体验流畅。其基于组件的响应式系统非常适合构建复杂的交互界面如拖拽排序、实时搜索、多标签编辑器等。实操要点使用SvelteKit的page.svelte和page.server.ts模式即使大部分逻辑在客户端也可以利用服务端文件进行一些初始数据加载或类型定义。状态管理上我大量使用了Svelte自带的storewritable,readable,derived对于应用全局状态如当前选中的提示词、UI主题则使用了一个简单的自定义Context API。后端与框架Rust TauriTauri的理由相比于ElectronTauri最大的优势是体积和性能。它将前端界面渲染委托给系统自带的Web引擎在macOS上是WebKitWindows上是WebView2Linux上是WebKitGTK而应用核心逻辑由Rust编写并编译为本地二进制文件。这带来的结果是最终应用的安装包体积可能只有Electron应用的十分之一内存占用更低启动速度更快。Tauri提供了强大的安全控制和对系统原生API如文件系统、剪贴板、系统托盘的便捷调用。Rust的理由Rust的内存安全性和无运行时开销的特性非常适合编写需要稳定、高效处理本地文件操作、数据库访问的核心逻辑。所有涉及数据读写、加密如果未来需要、复杂计算的操作都放在Rust端确保了应用的健壮性。Tauri的#[tauri::command]宏使得在前端调用Rust函数变得异常简单。数据层SQLite libsql理由SQLite是离线单机应用的绝配。它是一个进程内的库无需单独部署数据库服务器数据库就是一个单一的.db文件备份、迁移极其方便。它的SQL支持非常完备性能对于本地应用场景绰绰有余。工具选择在Rust端我使用了libsql的客户端库。它提供了一个异步的、符合人体工学的API来操作SQLite。相比于更底层的rusqlitelibsql在与异步Rust和Tauri的集成上更顺手。数据库设计核心表结构示意-- prompts 表存储提示词核心内容 CREATE TABLE prompts ( id TEXT PRIMARY KEY, -- UUID title TEXT NOT NULL, content TEXT NOT NULL, -- 提示词全文支持Markdown description TEXT, -- 简要描述 folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL, -- 所属文件夹 created_at INTEGER NOT NULL, -- 时间戳 updated_at INTEGER NOT NULL, color_tag TEXT, -- 颜色标签如 #3b82f6 is_favorite BOOLEAN DEFAULT 0 ); -- tags 和 prompt_tags 表多对多标签关系 CREATE TABLE tags (id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL); CREATE TABLE prompt_tags (prompt_id TEXT REFERENCES prompts(id) ON DELETE CASCADE, tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE); -- folders 表简单的文件夹树形结构支持嵌套 CREATE TABLE folders ( id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE, sort_order INTEGER DEFAULT 0 ); -- conversations 表记录测试某条提示词时的对话历史 CREATE TABLE conversations ( id TEXT PRIMARY KEY, prompt_id TEXT REFERENCES prompts(id) ON DELETE CASCADE, model_name TEXT, -- 使用的模型如 gpt-4 messages TEXT NOT NULL, -- 以JSON格式存储整个对话链 created_at INTEGER NOT NULL );3.2 项目目录结构规划一个清晰的结构是长期维护的基础。我的项目结构大致如下promptvault/ ├── src-tauri/ # Tauri后端 (Rust) │ ├── Cargo.toml │ ├── src/ │ │ ├── main.rs # 入口点Tauri配置 │ │ ├── commands.rs # 所有暴露给前端的Rust命令如 db_create_prompt │ │ ├── db.rs # 数据库连接池、初始化及核心操作函数 │ │ └── models.rs # Rust数据结构定义与数据库表对应 │ └── tauri.conf.json # Tauri应用配置文件窗口设置、权限等 ├── src/ # SvelteKit前端 │ ├── app.html │ ├── routes/ │ │ ├── layout.svelte │ │ ├── page.svelte # 主工作空间页面 │ │ ├── prompts/ │ │ │ ├── [id]/page.svelte # 单个提示词编辑页 │ │ │ └── page.svelte # 提示词列表页 │ │ └── ... │ ├── lib/ │ │ ├── stores/ # Svelte stores如 promptStore, uiStore │ │ ├── components/ # 可复用组件如 PromptEditor, TagInput │ │ └── utils/ # 工具函数 │ └── app.css ├── static/ # 静态资源 └── package.json这个结构将前后端逻辑清晰分离同时通过Tauri的桥梁紧密连接。4. 核心功能模块实现详解4.1 数据持久化与Rust命令桥接这是连接前端界面和本地数据库的关键。所有数据操作都通过Tauri命令Command来调用Rust函数完成。在src-tauri/src/commands.rs中use tauri::State; use rusqlite::params; use crate::db::DbPool; // 假设我们有一个数据库连接池结构 #[tauri::command] pub async fn create_prompt( title: String, content: String, folder_id: OptionString, state: State_, DbPool, // 通过状态管理注入数据库连接池 ) - ResultString, String { let id uuid::Uuid::new_v4().to_string(); let now chrono::Utc::now().timestamp(); let pool state.inner().lock().await; let conn pool.get().map_err(|e| e.to_string())?; conn.execute( INSERT INTO prompts (id, title, content, folder_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5), params![id, title, content, folder_id, now], ) .map_err(|e| e.to_string())?; Ok(id) // 返回新创建的提示词ID }在前端Svelte组件中调用import { invoke } from tauri-apps/api/tauri; async function handleSave() { try { const newPromptId await invoke(create_prompt, { title: promptTitle, content: promptContent, folderId: selectedFolderId || null, }); console.log(提示词已保存ID:, newPromptId); // 更新前端状态跳转或刷新列表 } catch (error) { console.error(保存失败:, error); } }这种模式确保了所有敏感的数据操作都在受控的Rust环境中执行前端只负责展示和触发。4.2 提示词编辑器与变量插值一个优秀的提示词编辑器需要支持Markdown预览和变量插值功能。变量插值允许用户在提示词中定义如{{topic}}、{{tone}}这样的占位符在实际使用时快速填充。实现思路我使用了CodeMirror或Monaco EditorVS Code的核心编辑器作为代码编辑组件因为它们对Markdown和自定义语言高亮有很好的支持。在编辑器内我编写了一个简单的解析器用于识别{{...}}格式的变量。在编辑区旁边或上方动态生成一个“变量填充”表单。当用户点击“测试”或“使用”按钮时先用表单中的实际值替换掉提示词中的所有变量再将完整的提示词发送出去。前端伪代码逻辑// 解析提示词内容中的变量 function extractVariables(content) { const variableRegex /\{\{(\w)\}\}/g; const variables new Set(); let match; while ((match variableRegex.exec(content)) ! null) { variables.add(match[1]); } return Array.from(variables); } // 使用前进行变量替换 function renderPromptWithVariables(rawPrompt, variableValues) { let rendered rawPrompt; for (const [key, value] of Object.entries(variableValues)) { const regex new RegExp(\\{\\{${key}\\}\\}, g); rendered rendered.replace(regex, value); } return rendered; }这个功能极大提升了提示词的复用性一个“文章大纲生成器”的提示词通过替换{{topic}}和{{style}}就可以无限次使用。4.3 集成AI聊天测试界面为了让工作空间“闭环”集成一个简单的聊天界面来测试提示词是必不可少的。由于需要连接不同的AI服务如OpenAI API、Claude API、本地Ollama等我设计了一个“适配器模式”。架构设计在Rust端或前端考虑到API密钥安全复杂逻辑建议放Rust端定义一个LLMProviderTrait接口。为每个支持的AI服务OpenAI, Anthropic, Ollama等实现这个Trait。前端让用户配置API Base URL和密钥密钥使用Tauri的secure-store插件加密存储。当用户选择一条提示词并点击“测试”时前端将渲染后的提示词、选定的模型和配置信息发送给Rust后端。Rust后端根据配置调用相应的LLMProvider实现完成对话并将流式或非流式响应返回给前端展示。Rust端适配器示例简化pub trait LLMProvider { async fn send_message(self, messages: VecChatMessage, model: str) - ResultString, Boxdyn std::error::Error; } pub struct OpenAIProvider { api_key: String, base_url: String, } impl LLMProvider for OpenAIProvider { async fn send_message(self, messages: VecChatMessage, model: str) - ResultString, Boxdyn std::error::Error { // 使用 reqwest 库构造请求调用 OpenAI 兼容的 API // 返回 AI 的回复内容 Ok(response_content) } } // 在Tauri命令中根据用户配置选择Provider #[tauri::command] pub async fn test_prompt(prompt: String, provider_config: ProviderConfig) - ResultString, String { let provider: Boxdyn LLMProvider match provider_config.provider_type.as_str() { openai Box::new(OpenAIProvider::new(provider_config.api_key, provider_config.base_url)), ollama Box::new(OllamaProvider::new(provider_config.base_url)), _ return Err(不支持的提供商.into()), }; let messages vec![ChatMessage::user(prompt)]; let response provider.send_message(messages, provider_config.model).await.map_err(|e| e.to_string())?; Ok(response) }这样测试功能就与核心数据管理无缝集成每次测试的对话都可以选择性地保存回conversations表方便后续回顾和对比。4.4 全文搜索与高效检索当提示词积累到数百上千条时快速找到所需内容至关重要。SQLite内置了FTS5全文搜索扩展模块这是实现高效搜索的利器。实现步骤创建虚拟表在数据库初始化时创建一个与prompts表关联的FTS5虚拟表。CREATE VIRTUAL TABLE prompts_fts USING fts5( id UNINDEXED, -- 关联ID不参与分词 title, -- 对标题分词 content, -- 对内容分词 description, -- 对描述分词 contentprompts, -- 源表 content_rowidrowid -- 关联列 );使用触发器同步数据创建触发器当prompts表发生增、删、改时自动更新prompts_fts表。CREATE TRIGGER prompts_ai AFTER INSERT ON prompts BEGIN INSERT INTO prompts_fts(rowid, title, content, description) VALUES (new.rowid, new.title, new.content, new.description); END; -- 类似地创建 AFTER UPDATE 和 AFTER DELETE 触发器执行搜索在前端搜索框输入关键词时向Rust后端发送一个命令执行FTS5查询。#[tauri::command] pub async fn search_prompts(query: String) - ResultVecPrompt, String { let conn get_db_connection()?; let mut stmt conn.prepare( SELECT p.* FROM prompts p JOIN prompts_fts f ON p.rowid f.rowid WHERE prompts_fts MATCH ?1 ORDER BY rank, )?; // ... 执行查询并映射结果 }前端实时搜索结合Svelte的响应式store在搜索输入框上绑定on:input事件去抖后例如300ms调用搜索命令并实时更新列表。这带来了媲美在线应用的流畅搜索体验。5. 打包、分发与隐私考量5.1 使用Tauri进行应用打包Tauri的打包体验非常顺畅。在src-tauri/tauri.conf.json中配置好应用信息、图标、允许的API等之后运行npm run tauri build或cargo tauri build即可。关键配置项identifier: 设置一个唯一的应用标识符如com.yourname.promptvault。bundle: 在这里配置版权信息、类别、以及要创建的安装包格式如Windows的.msi macOS的.dmg和.app Linux的.deb和.AppImage。allowlist: 精确控制前端可以调用哪些Tauri API。遵循最小权限原则只开启必要的功能如fs,shell,http用于调用AI API等。5.2 数据存储位置与备份这是用户最关心的问题之一。Tauri提供了便捷的路径解析API。数据库文件位置我将SQLite数据库文件存储在Tauri的app_data_dir下。这个目录是操作系统为每个应用分配的、适合存储用户数据的标准位置如macOS的~/Library/Application Support/com.yourname.promptvault/ Windows的%APPDATA%\com.yourname.promptvault\。这样做既符合系统规范也保证了数据在应用更新时得以保留。备份策略在应用内我实现了一个“手动导出/导入”功能。用户可以将整个数据库或选中的提示词导出为一个加密的可选JSON或SQLite备份文件存放在任何他们觉得安全的地方。恢复时再导入即可。未来可以考虑集成基于Git的版本控制为每个提示词提供修改历史。5.3 彻底的离线与网络权限为了贯彻“离线优先”在默认配置中我没有授予应用通用的网络权限。只有当用户明确配置了AI API并主动点击“测试”时相关的网络请求才会被允许通过Tauri的httpAPI allowlist精确控制。应用本身的更新检查等功能也被设计为可选项且需要用户手动触发。这最大程度地减少了应用在后台的任何网络行为让用户安心。6. 开发中遇到的典型问题与解决方案6.1 问题前端状态与本地数据库的同步延迟在复杂的交互中比如用户快速地在列表页删除一条提示词然后马上切换到编辑页可能会因为前端状态已经更新但数据库操作尚未完成或失败导致状态不一致。解决方案采用乐观更新与回滚策略。乐观更新当用户执行一个操作如删除时立即在前端状态Store中移除该项让UI瞬间响应。发起请求同时向Rust后端发送删除命令。处理结果如果后端返回成功什么也不做。如果后端返回失败网络问题、数据库错误则在前端触发一个通知并将之前移除的项重新加回状态中。Svelte Store 示例// 在 store 中 async function deletePrompt(promptId) { // 1. 乐观更新先从列表中移除 update(prompts prompts.filter(p p.id ! promptId)); try { // 2. 发起实际请求 await invoke(delete_prompt_by_id, { id: promptId }); // 3. 成功无需额外操作 showNotification(删除成功, success); } catch (error) { // 4. 失败回滚重新获取完整列表或重新插入该项 console.error(删除失败回滚:, error); await fetchPrompts(); // 重新从数据库拉取列表 showNotification(删除失败已恢复, error); } }6.2 问题SQLite并发写入冲突虽然SQLite处理单用户本地应用的并发通常没问题但在某些异步操作密集的场景下如同时自动保存多个标签仍可能遇到database is locked错误。解决方案使用连接池与串行化队列。连接池使用r2d2和r2d2_sqlite库在Rust端创建一个SQLite连接池。这避免了频繁开关连接的开销并管理了连接数量。关键操作串行化对于非读操作INSERT, UPDATE, DELETE特别是那些由前端频繁触发的自动保存我将它们放入一个异步任务队列例如使用tokio::sync::mpsc通道。确保同一时间只有一个写操作在执行从根本上避免锁冲突。读操作则可以并发执行。// 简化示例使用一个全局的写任务发送器 static WRITE_TX: tokio::sync::OnceCelltokio::sync::mpsc::SenderWriteTask tokio::sync::OnceCell::const_new(); // 在初始化时创建通道和后台任务 async fn init_write_queue(pool: DbPool) { let (tx, mut rx) tokio::sync::mpsc::channel(100); WRITE_TX.set(tx).unwrap(); tokio::spawn(async move { while let Some(task) rx.recv().await { let conn pool.get().unwrap(); // 从连接池获取连接 task.execute(conn).await; // 执行具体的写SQL } }); } // 在需要写数据库时发送任务到队列 #[tauri::command] pub async fn update_prompt_title(id: String, new_title: String) - Result(), String { let tx WRITE_TX.get().ok_or(写队列未初始化)?; let task UpdateTitleTask { id, new_title }; tx.send(Box::new(task)).await.map_err(|_| 发送任务失败)?; Ok(()) // 立即返回写操作在后台队列中执行 }6.3 问题富文本编辑器CodeMirror/Monaco在Tauri中的集成与性能这些编辑器库体积较大直接引入可能影响应用启动速度和体积。解决方案动态导入与配置优化。动态导入在Svelte组件中使用动态import()语法只在需要渲染编辑器的页面或组件时才加载编辑器库。let EditorComponent; onMount(async () { const module await import(codemirror/...); EditorComponent module.default; // 或根据具体库的导出方式调整 });按需打包配置构建工具如Vite进行Tree Shaking只打包用到的语言支持和插件。Worker化针对Monaco如果使用Monaco Editor可以将其运行在Web Worker中避免阻塞主线程。Vite有相关的插件可以简化配置。6.4 问题跨平台UI/UX的一致性Tauri使用系统WebView不同平台Windows/macOS/Linux的WebView内核和默认样式有差异可能导致UI细微不一致。解决方案重置样式与针对性适配。使用CSS Reset引入一个现代的CSS Reset如modern-normalize作为基础抹平浏览器默认样式的差异。组件库或设计系统使用一套完整的UI组件库如Skeleton UI, Shadcn-svelte等它们通常已经处理了跨平台的样式问题。我自己选择了基于Tailwind CSS构建组件因为它能提供高度一致且可定制的外观。平台特异性调整在少数情况下可以通过Tauri提供的os.platform()API检测当前平台并在CSS或逻辑中做一些微调。例如在macOS上为可拖动窗口区域添加-webkit-app-region: drag样式。7. 从工具到产品一些延伸思考PromptVault从一个自用工具演变成一个可供他人使用的产品这个过程带来了额外的考量。1. 用户引导与数据迁移新用户第一次打开应用时一个清晰的引导流程至关重要。我添加了一个欢迎页面提供创建示例提示词、导入已有数据从文本文件、JSON或CSV的选项。数据迁移工具必须健壮能处理各种格式错误。2. 设置与配置增加了完善的设置页面用于管理AI提供商配置、编辑器偏好字体、主题、备份路径等。所有配置同样使用SQLite存储。3. 性能优化当提示词数量非常多时比如超过5000条列表渲染和搜索速度需要优化。我实施了虚拟滚动列表使用svelte-virtual这类库来只渲染可视区域内的项目并对FTS5搜索结果的分页加载做了支持。4. 可扩展性设计我在架构上预留了插件系统的可能性。例如通过定义清晰的接口未来可以让社区开发支持更多AI服务的Provider或者开发新的导出格式如直接发布到某个提示词市场。构建PromptVault的旅程是一次将具体痛点转化为系统化解决方案的深度实践。它让我深刻体会到“离线优先”不仅是一种技术选择更是一种对用户数据主权和工具可靠性的承诺。这个项目几乎所有的技术决策——从Tauri到SQLite从Svelte到Rust——都围绕着“本地化”、“效率”和“控制力”这三个核心展开。如果你也受困于碎片化的提示词管理不妨尝试一下这条技术路径亲手打造一个完全属于自己的数字工作空间。