大家好,我是长期分享开发实战经验的博主。在日常工作中,无论是写技术文档、整理学习笔记还是撰写项目报告,Markdown 都是我的首选工具。然而,传统的 Markdown 编辑器在智能化辅助方面往往有所欠缺,比如语法检查、内容润色、格式优化等,都需要手动完成,效率上不去。最近,我结合 AI 大模型的能力,开发并开源了一款桌面应用,旨在将 AI 的智能写作与编辑能力深度集成到 Markdown 工作流中。本文将详细介绍这款应用的设计思路、技术实现、核心功能以及如何从零开始搭建和运行它。无论你是想了解 AI 与桌面应用结合的实践,还是希望获得一个强大的个人写作工具,这篇文章都能为你提供完整的指南。
1. 背景与核心概念
在深入代码之前,我们有必要厘清几个核心概念,并理解这个项目要解决的根本问题。
1.1 Markdown 与 AI 结合的痛点
Markdown 是一种轻量级标记语言,以其简洁的语法和强大的可读性,深受开发者、写作者和技术博主的喜爱。我们用它来写博客、记笔记、编写 API 文档。然而,随着内容创作的深入,一些痛点逐渐浮现:
- 格式纠错繁琐:忘记关闭列表、标题层级混乱、链接格式错误等,需要肉眼检查。
- 内容优化依赖人工:想让一段描述更精炼、更专业,或者检查错别字和语病,往往需要反复斟酌或借助其他工具。
- 结构化生成能力弱:从零开始撰写一篇结构清晰的技术文章大纲,或者将杂乱的想法整理成有条理的列表,比较耗时。
而近年来,AI 大模型,特别是大型语言模型(LLM),在自然语言处理上展现出惊人能力,能够很好地理解、生成和优化文本。将 AI 能力引入 Markdown 编辑器,理论上可以自动化解决上述大部分问题。
1.2 项目定位:AI-Native Markdown 编辑器
本项目并非一个简单的“编辑器+聊天框”拼接。它的核心定位是AI-Native,即 AI 能力不是外挂功能,而是深度融入编辑器的每一个核心交互环节。目标是打造一个“懂写作”的桌面应用,让 AI 成为你的写作助手,而非一个需要频繁切换界面的独立工具。
核心设计理念包括:
- 上下文感知:AI 的操作基于你当前正在编辑的文档、选中的文本或光标位置,提供精准的辅助。
- 低摩擦交互:通过快捷键、右键菜单、侧边栏指令等方式,让 AI 功能触手可及,无需打断写作流。
- 结果可控:所有 AI 的修改或生成内容,都需经过用户确认(如应用、替换、插入),用户拥有最终控制权。
- 离线与隐私:支持连接本地部署的大模型(如通过 Ollama),保障敏感或私有文档的内容安全。
1.3 技术栈选型理由
为了实现一个跨平台、高性能、且易于集成的桌面应用,我们选择了以下技术栈:
- 前端/界面:
Electron+React+TypeScript。Electron 允许我们使用 Web 技术(HTML, CSS, JS)构建跨平台(Windows, macOS, Linux)桌面应用。React 提供了高效的 UI 组件化开发体验,TypeScript 则能极大地提升代码的可维护性和开发体验,减少类型错误。 - 编辑器核心:
CodeMirror 6或Monaco Editor。两者都是优秀的基于 Web 的代码编辑器。CodeMirror 更轻量,定制化程度高;Monaco Editor(VS Code 所用)功能更强大,开箱即用。本项目基于对 Markdown 特定语法高亮、折叠、缩进等功能的深度定制需求,选择了 CodeMirror 6。 - AI 集成:
OpenAI API(GPT系列) 或Ollama(本地模型)。通过标准的 HTTP API 调用,我们可以灵活接入云端或本地的 AI 模型服务。为了演示的通用性,本文将主要围绕 OpenAI API 进行,但架构设计上完全支持切换为任何兼容的 API 端点。 - 状态与数据管理:
Zustand或Valtio。对于中小型桌面应用,这些轻量级的状态管理库比 Redux 更简洁高效。 - 构建工具:
Vite。提供极速的启动和热更新,提升开发效率。
2. 环境准备与版本说明
在开始编码前,请确保你的开发环境已就绪。以下版本是本文撰写时的稳定版本,你可以根据实际情况调整。
- 操作系统:Windows 10/11, macOS 10.15+, 或主流的 Linux 发行版(如 Ubuntu 20.04+)。本教程在 macOS 和 Windows 上均测试通过。
- Node.js:
v18.0.0或更高版本(推荐 LTS 版本,如 v20.x)。这是 Electron 和前端工具链的运行时基础。 - 包管理器:
npm(随 Node.js 安装) 或yarn/pnpm。本文示例使用npm。 - 代码编辑器:
Visual Studio Code。强烈推荐,因其对 TypeScript、React 和 Electron 生态有极佳的支持。 - AI 服务准备:
- (云端方案)OpenAI API Key:你需要一个有效的 OpenAI 账号并获取 API Key。请注意保管,不要将其硬编码在客户端代码中。
- (本地方案)Ollama:如果你希望本地运行模型,需要安装 Ollama ,并拉取一个合适的模型,如
llama3.2或mistral。
项目结构预览:在开始前,我们先看一下最终的项目目录结构,以便有个全局认识。
ai-markdown-desktop/ ├── src/ │ ├── main/ # Electron 主进程代码 │ │ ├── index.ts │ │ ├── preload.ts │ │ └── ... │ ├── renderer/ # React 渲染进程代码 │ │ ├── App.tsx │ │ ├── components/ # React 组件 │ │ ├── stores/ # 状态管理 │ │ ├── hooks/ # 自定义 Hooks │ │ └── ... │ └── shared/ # 主进程和渲染进程共享的类型/工具 ├── public/ # 静态资源 ├── package.json ├── tsconfig.json ├── vite.config.ts └── ...3. 核心原理与架构拆解
一个 Electron 应用通常包含两个进程:主进程和渲染进程。理解它们的分工是开发的关键。
3.1 主进程与渲染进程通信
- 主进程:
src/main/index.ts。这是一个 Node.js 环境,负责管理应用生命周期(创建窗口、菜单、托盘)、处理系统原生事件(文件读写、系统对话框)以及一些需要更高权限或 Node API 的操作。它不能直接操作 DOM。 - 渲染进程:
src/renderer/。每个窗口都是一个独立的渲染进程,是 Chromium 浏览器环境,运行我们的 React 应用,负责 UI 展示和用户交互。出于安全考虑,它默认不能直接访问 Node.js API。 - 通信桥梁:
Preload 脚本和IPC(进程间通信)。Preload 脚本(src/main/preload.ts):在主进程的上下文中运行,但在渲染进程加载页面之前注入到页面中。它的核心作用是将一些安全的、受控的 Node.js API 或自定义功能通过contextBridge暴露给渲染进程的window对象。IPC:渲染进程通过window.api(由 preload 暴露) 发送消息 (ipcRenderer.send) 到主进程,主进程通过ipcMain.handle监听并处理这些消息,然后将结果返回给渲染进程。
这种架构确保了安全性(渲染进程受限)和功能性(通过主进程访问系统资源)的平衡。
3.2 AI 能力集成的设计
AI 功能作为核心,其调用链路设计至关重要。我们采用“渲染进程发起 -> 主进程代理 -> 网络请求”的模式。
- 为什么由主进程代理?直接在前端调用 API 会暴露 API Key,非常不安全。主进程作为后端,可以安全地管理密钥(从环境变量或加密配置文件中读取),并处理网络请求。
- 流程:
- 用户在渲染进程的编辑器中选中文本,点击“优化语法”按钮。
- React 组件调用一个自定义 Hook(如
useAIProcessor)。 - Hook 通过
window.api.invokeAI发送 IPC 请求,包含指令(如polish)和选中文本。 - 主进程的 IPC 处理器接收到请求,从安全位置读取 API Key,构造请求体,调用 OpenAI API。
- 主进程收到 AI 响应后,通过 IPC 将结果返回给渲染进程。
- 渲染进程收到结果,更新编辑器状态,将 AI 生成的内容插入或替换原文本。
3.3 编辑器状态管理
编辑器内容、光标位置、AI 处理状态等都需要集中管理。我们使用 Zustand 创建一个 Store。
- 文档状态:存储当前的 Markdown 原始文本。
- 编辑器实例引用:存储 CodeMirror 编辑器的实例,以便在非 React 事件(如 IPC 回调)中操作编辑器。
- AI 处理状态:存储当前是否正在处理 AI 请求、错误信息等,用于显示加载状态或错误提示。
4. 完整实战:从零构建应用
接下来,我们一步步实现这个应用。请跟随操作,所有代码均可复制运行。
4.1 初始化项目与基础配置
首先,创建项目目录并初始化。
mkdir ai-markdown-desktop cd ai-markdown-desktop npm init -y安装主要的开发依赖:
npm install electron react react-dom typescript @types/node @types/react @types/react-dom npm install vite @vitejs/plugin-react --save-dev npm install electron-builder --save-dev # 用于打包创建基本的配置文件tsconfig.json:
{ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["src/*"], "@main/*": ["src/main/*"], "@renderer/*": ["src/renderer/*"], "@shared/*": ["src/shared/*"] } }, "include": ["src"] }创建 Vite 配置文件vite.config.ts:
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], base: './', // 确保资源使用相对路径 resolve: { alias: { '@': path.resolve(__dirname, './src'), '@main': path.resolve(__dirname, './src/main'), '@renderer': path.resolve(__dirname, './src/renderer'), '@shared': path.resolve(__dirname, './src/shared'), }, }, build: { outDir: 'dist/renderer', // 渲染进程构建输出目录 emptyOutDir: true, }, });更新package.json,添加必要的脚本和 Electron 入口:
{ "name": "ai-markdown-desktop", "version": "1.0.0", "private": true, "main": "dist/main/index.js", "scripts": { "dev": "concurrently -k \"npm run dev:vite\" \"npm run dev:electron\"", "dev:vite": "vite", "dev:electron": "wait-on tcp:5173 && electron .", "build": "npm run build:renderer && npm run build:main", "build:renderer": "vite build", "build:main": "tsc -p tsconfig.main.json", "postinstall": "electron-builder install-app-deps", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder" }, "dependencies": { "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.26.0", "@codemirror/lang-markdown": "^6.2.2", "@codemirror/commands": "^6.3.3", "zustand": "^4.5.0", "axios": "^1.6.0" }, "devDependencies": { "@types/electron": "^1.6.10", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "concurrently": "^8.2.0", "electron": "^28.0.0", "electron-builder": "^24.0.0", "typescript": "^5.0.0", "vite": "^5.0.0", "@vitejs/plugin-react": "^4.0.0", "wait-on": "^7.0.0" } }我们需要为 Electron 主进程单独创建一个 TypeScript 配置文件tsconfig.main.json:
{ "extends": "./tsconfig.json", "compilerOptions": { "module": "CommonJS", "outDir": "dist/main", "noEmit": false }, "include": ["src/main/**/*"] }4.2 实现 Electron 主进程
创建主进程入口文件src/main/index.ts:
import { app, BrowserWindow, ipcMain, dialog } from 'electron'; import path from 'path'; import { fileURLToPath } from 'url'; import { invokeAIHandler } from './ai-handler'; // 稍后实现 const __dirname = path.dirname(fileURLToPath(import.meta.url)); let mainWindow: BrowserWindow | null = null; const createWindow = () => { mainWindow = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // 必须开启,安全关键 nodeIntegration: false, // 必须关闭,安全关键 }, }); // 开发环境下加载 Vite 开发服务器地址 if (process.env.NODE_ENV === 'development') { mainWindow.loadURL('http://localhost:5173'); mainWindow.webContents.openDevTools(); } else { // 生产环境加载构建后的文件 mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); } }; app.whenReady().then(() => { // 注册 AI 处理的 IPC 处理器 ipcMain.handle('invoke-ai', invokeAIHandler); createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });创建 Preload 脚本src/main/preload.ts,定义安全的 API 接口:
import { contextBridge, ipcRenderer } from 'electron'; // 暴露给渲染进程的 API contextBridge.exposeInMainWorld('api', { // 调用 AI 功能 invokeAI: (payload: { instruction: string; text: string }) => ipcRenderer.invoke('invoke-ai', payload), // 可以在此添加其他安全的方法,如文件操作 // openFile: () => ipcRenderer.invoke('dialog:openFile'), });4.3 实现 AI 处理模块
这是核心的后端逻辑。创建src/main/ai-handler.ts:
import { ipcMainInvokeEvent } from 'electron/main'; import axios from 'axios'; // 从环境变量获取 API Key,生产环境请使用更安全的方式管理密钥 const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'; // 指令到系统 Prompt 的映射 const instructionToSystemPrompt: Record<string, string> = { polish: '你是一位专业的文本编辑助手。请润色用户提供的 Markdown 文本,修正语法错误,优化表达使其更流畅、专业,但保持其原意和 Markdown 格式。直接返回润色后的文本,不要添加解释。', summarize: '你是一位专业的总结助手。请用简洁的语言总结用户提供的 Markdown 文本的核心内容。直接返回总结文本。', expand: '你是一位写作助手。请根据用户提供的 Markdown 文本片段或主题,进行合理的扩展和阐述,使其内容更丰富、完整。直接返回扩展后的文本。', translateToChinese: '你是一位翻译助手。将用户提供的英文 Markdown 文本准确、流畅地翻译成中文,并保留原有的 Markdown 格式。直接返回翻译后的文本。', // 可以继续添加更多指令... }; export const invokeAIHandler = async ( _event: ipcMainInvokeEvent, payload: { instruction: string; text: string } ): Promise<{ success: boolean; data?: string; error?: string }> => { const { instruction, text } = payload; if (!OPENAI_API_KEY) { return { success: false, error: 'OpenAI API Key 未配置。请设置 OPENAI_API_KEY 环境变量。' }; } const systemPrompt = instructionToSystemPrompt[instruction]; if (!systemPrompt) { return { success: false, error: `不支持的指令: ${instruction}` }; } if (!text || text.trim().length === 0) { return { success: false, error: '输入文本不能为空。' }; } try { const response = await axios.post( OPENAI_API_URL, { model: 'gpt-3.5-turbo', // 可根据需要更换模型,如 gpt-4 messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: text }, ], temperature: 0.7, max_tokens: 2000, }, { headers: { 'Authorization': `Bearer ${OPENAI_API_KEY}`, 'Content-Type': 'application/json', }, } ); const aiResponse = response.data.choices[0]?.message?.content?.trim(); if (!aiResponse) { throw new Error('AI 返回内容为空'); } return { success: true, data: aiResponse }; } catch (error: any) { console.error('AI 调用失败:', error); const errorMsg = error.response?.data?.error?.message || error.message || '未知错误'; return { success: false, error: `AI 处理失败: ${errorMsg}` }; } };重要安全提示:在实际项目中,绝对不要将 API Key 硬编码在客户端或提交到代码仓库。上述示例从环境变量读取。更安全的生产环境做法是:开发一个简单的后端服务(如使用 Express.js),将 API Key 保存在服务器端,桌面应用通过该服务代理请求。本文为简化演示,采用了主进程环境变量方案,请务必妥善保管你的.env文件(需自行创建,并加入.gitignore)。
4.4 构建 React 渲染进程应用
首先,创建 HTML 入口index.html于项目根目录:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>AI Markdown Editor</title> </head> <body> <div id="root"></div> <script type="module" src="/src/renderer/main.tsx"></script> </body> </html>创建 React 应用入口src/renderer/main.tsx:
import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <App /> </React.StrictMode> );创建主应用组件src/renderer/App.tsx:
import React from 'react'; import MarkdownEditor from './components/MarkdownEditor'; import AIOperationsPanel from './components/AIOperationsPanel'; import './App.css'; function App() { return ( <div className="app-container"> <header className="app-header"> <h1>🤖 AI Markdown Editor</h1> <p>智能写作,触手可及</p> </header> <main className="app-main"> <div className="editor-section"> <MarkdownEditor /> </div> <div className="sidebar"> <AIOperationsPanel /> </div> </main> </div> ); } export default App;创建编辑器组件src/renderer/components/MarkdownEditor.tsx:
import React, { useEffect, useRef } from 'react'; import { EditorState } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; import { defaultKeymap } from '@codemirror/commands'; import { markdown } from '@codemirror/lang-markdown'; import { useEditorStore } from '../stores/editorStore'; import './MarkdownEditor.css'; const MarkdownEditor: React.FC = () => { const editorRef = useRef<HTMLDivElement>(null); const viewRef = useRef<EditorView | null>(null); const { setEditorView, content, setContent } = useEditorStore(); useEffect(() => { if (!editorRef.current) return; // 初始化编辑器状态 const startState = EditorState.create({ doc: content, extensions: [ markdown(), keymap.of(defaultKeymap), EditorView.updateListener.of((update) => { if (update.docChanged) { const newContent = update.state.doc.toString(); setContent(newContent); } }), EditorView.theme({ '&': { height: '100%', fontSize: '16px' }, '.cm-scroller': { overflow: 'auto' }, '.cm-content': { fontFamily: 'Menlo, Monaco, Consolas, monospace' }, }), ], }); // 创建编辑器视图 const view = new EditorView({ state: startState, parent: editorRef.current, }); viewRef.current = view; setEditorView(view); // 组件卸载时销毁编辑器 return () => { view.destroy(); viewRef.current = null; }; }, []); // 只在挂载时初始化 // 当外部 content 变化时(如 AI 替换后),更新编辑器 useEffect(() => { const view = viewRef.current; if (view && content !== view.state.doc.toString()) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content, }, }); } }, [content]); return <div ref={editorRef} className="markdown-editor" />; }; export default MarkdownEditor;创建状态管理 Storesrc/renderer/stores/editorStore.ts:
import { create } from 'zustand'; import { EditorView } from '@codemirror/view'; interface EditorStore { content: string; editorView: EditorView | null; isAIProcessing: boolean; aiError: string | null; setContent: (content: string) => void; setEditorView: (view: EditorView) => void; setAIProcessing: (processing: boolean) => void; setAIError: (error: string | null) => void; // 一个工具函数:获取当前选中的文本 getSelectedText: () => string; // 一个工具函数:用新文本替换选中部分 replaceSelection: (newText: string) => void; } export const useEditorStore = create<EditorStore>((set, get) => ({ content: '# 欢迎使用 AI Markdown 编辑器\n\n在这里开始你的写作...\n\n- AI 可以帮助你**润色**、**总结**、**扩展**内容。\n- 试试选中一些文本,然后点击侧边栏的按钮。', editorView: null, isAIProcessing: false, aiError: null, setContent: (content) => set({ content }), setEditorView: (editorView) => set({ editorView }), setAIProcessing: (isAIProcessing) => set({ isAIProcessing }), setAIError: (aiError) => set({ aiError }), getSelectedText: () => { const view = get().editorView; if (!view) return ''; const selection = view.state.selection; if (selection.main.empty) return ''; // 没有选中文本 return view.state.sliceDoc(selection.main.from, selection.main.to); }, replaceSelection: (newText) => { const view = get().editorView; if (!view) return; const selection = view.state.selection; view.dispatch({ changes: { from: selection.main.from, to: selection.main.to, insert: newText, }, // 将光标移动到插入文本的末尾 selection: { anchor: selection.main.from + newText.length }, }); }, }));创建 AI 操作面板组件src/renderer/components/AIOperationsPanel.tsx:
import React from 'react'; import { useAIProcessor } from '../hooks/useAIProcessor'; import './AIOperationsPanel.css'; const AIOperationsPanel: React.FC = () => { const { processWithAI, isProcessing, error } = useAIProcessor(); const handleAIClick = async (instruction: string) => { await processWithAI(instruction); }; const operations = [ { id: 'polish', label: '✨ 润色语法', desc: '优化表达,修正错误' }, { id: 'summarize', label: '📋 总结内容', desc: '提取核心要点' }, { id: 'expand', label: '🔍 扩展阐述', desc: '丰富内容细节' }, { id: 'translateToChinese', label: '🇨🇳 翻译成中文', desc: '英译中' }, ]; return ( <div className="ai-panel"> <h3>AI 智能助手</h3> <p className="ai-hint">选中编辑器中的文本,然后点击下方功能。</p> {error && <div className="ai-error">{error}</div>} <div className="ai-buttons"> {operations.map((op) => ( <button key={op.id} onClick={() => handleAIClick(op.id)} disabled={isProcessing} className="ai-button" > <span className="button-label">{op.label}</span> <span className="button-desc">{op.desc}</span> {isProcessing && <span className="processing-indicator">处理中...</span>} </button> ))} </div> <div className="ai-tips"> <h4>使用技巧</h4> <ul> <li>选中段落进行润色或总结。</li> <li>选中标题或列表项进行扩展。</li> <li>翻译功能对整段英文效果更好。</li> <li>所有操作结果都需要你确认后才应用。</li> </ul> </div> </div> ); }; export default AIOperationsPanel;创建自定义 Hooksrc/renderer/hooks/useAIProcessor.ts:
import { useCallback } from 'react'; import { useEditorStore } from '../stores/editorStore'; // 扩展 Window 接口以包含我们通过 preload 暴露的 api declare global { interface Window { api: { invokeAI: (payload: { instruction: string; text: string }) => Promise<{ success: boolean; data?: string; error?: string; }>; }; } } export const useAIProcessor = () => { const { getSelectedText, replaceSelection, setAIProcessing, setAIError, isAIProcessing, aiError, } = useEditorStore(); const processWithAI = useCallback( async (instruction: string) => { const selectedText = getSelectedText(); if (!selectedText) { setAIError('请先在编辑器中选中一些文本。'); return; } setAIProcessing(true); setAIError(null); try { // 通过预加载脚本暴露的 API 调用主进程 const result = await window.api.invokeAI({ instruction, text: selectedText, }); if (result.success && result.data) { // 在实际应用中,这里可以弹出一个预览对话框让用户确认 // 本例中我们直接替换,但强烈建议添加用户确认环节 if (confirm(`AI 建议如下:\n\n${result.data}\n\n是否替换选中文本?`)) { replaceSelection(result.data); } } else { setAIError(result.error || 'AI 处理失败,未知错误。'); } } catch (err: any) { setAIError(`请求失败: ${err.message}`); } finally { setAIProcessing(false); } }, [getSelectedText, replaceSelection, setAIProcessing, setAIError] ); return { processWithAI, isProcessing: isAIProcessing, error: aiError, }; };4.5 运行与验证
现在,所有核心部分已完成。让我们启动应用。
- 设置环境变量:在项目根目录创建
.env文件(确保已加入.gitignore),并填入你的 OpenAI API Key。OPENAI_API_KEY=sk-your-actual-api-key-here - 安装依赖并启动:
这个命令会同时启动 Vite 开发服务器(在npm install npm run devhttp://localhost:5173)和 Electron 应用。 - 验证功能:
- Electron 窗口应成功打开,并加载 React 应用界面。
- 编辑器内应有预设的 Markdown 文本。
- 在编辑器中选中一段文本。
- 点击侧边栏的“润色语法”等按钮。
- 主进程会调用 OpenAI API,返回结果后,会弹出确认对话框。
- 点击“确定”后,编辑器中的选中文本将被 AI 生成的内容替换。
5. 常见问题与排查思路
在开发和运行过程中,你可能会遇到以下问题:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 应用启动失败,白屏或报错 | 1. 依赖未安装完全。 2. TypeScript 编译错误。 3. 主进程或渲染进程代码有语法错误。 | 1. 删除node_modules和package-lock.json,重新npm install。2. 运行 npm run build:main检查主进程 TS 错误。3. 查看终端或 Electron 开发者工具控制台(Ctrl+Shift+I)的具体报错信息。 |
| 侧边栏 AI 按钮点击无反应 | 1. 未选中文本。 2. window.api未定义,Preload 脚本注入失败。3. IPC 通信处理器未在主进程注册。 | 1. 确保在编辑器中选中了文本。 2. 检查 src/main/preload.ts是否正确暴露了invokeAI方法,以及BrowserWindow的preload路径是否正确。3. 检查 src/main/index.ts中是否调用了ipcMain.handle('invoke-ai', ...)。 |
| 调用 AI 时提示 “API Key 未配置” | 1..env文件不存在或位置不对。2. .env文件中的变量名错误。3. 主进程未正确加载环境变量。 | 1. 确保.env文件在项目根目录。2. 确保变量名为 OPENAI_API_KEY。3. 在启动 Electron 时,环境变量需被加载。使用 cross-env包或在启动脚本中设置。可以尝试在package.json的dev:electron脚本前添加cross-env。 |
| AI 请求超时或网络错误 | 1. 网络连接问题。 2. API Key 无效或余额不足。 3. OpenAI API 服务暂时不可用。 | 1. 检查网络。 2. 登录 OpenAI 平台检查 API Key 状态和余额。 3. 查看 OpenAI 状态页面或稍后重试。错误信息会在侧边栏显示。 |
| 编辑器样式异常或无法输入 | 1. CodeMirror 扩展未正确引入。 2. CSS 样式冲突。 | 1. 检查MarkdownEditor.tsx中extensions数组是否包含了必要的扩展(如markdown(),keymap)。2. 检查浏览器开发者工具的元素样式,看是否有全局 CSS 覆盖。 |
| 打包后应用无法运行 | 1. 资源路径错误。 2. 环境变量在打包后未携带。 3. 原生模块兼容性问题。 | 1. 确保vite.config.ts中base设置为./,并且主进程加载生产环境 HTML 的路径正确。2. 环境变量需通过 extraResources或构建时注入等方式提供给打包后应用,这需要更复杂的配置。3. 如果使用了原生 Node 模块,需在 package.json的build配置中正确设置。 |
6. 最佳实践与工程建议
将一个小 demo 变成一个健壮、可维护的开源项目,还需要考虑很多工程化细节。
6.1 项目结构与代码组织
- 清晰的模块边界:如我们所示,严格区分
main、renderer、shared。共享的类型定义(如 IPC 通信的消息格式)应放在shared目录。 - 组件化与复用:将 UI 拆分为更小的、可复用的组件(如
Button、Modal、SettingItem)。 - 自定义 Hooks:将数据获取、事件监听等逻辑封装成 Hooks(如
useAIProcessor、useFileOperations),使组件更纯粹。
6.2 配置与安全管理
- 配置文件:使用
config目录存放不同环境(开发、生产)的配置文件。对于 API Key 等敏感信息,永远不要提交到代码仓库。使用.env.local并加入.gitignore。 - 安全的密钥管理:对于生产级应用,考虑实现一个轻量级后端服务(如用 Express 或 Next.js API Routes),桌面应用只与该服务通信,由服务端持有并调用 AI API。这是最安全的做法。
- 支持多模型后端:抽象 AI 调用层,使其易于切换不同的提供商(OpenAI, Anthropic, 本地 Ollama 等)。可以设计一个
AIClient接口和多个实现。
6.3 用户体验与交互优化
- 撤销/重做:集成 CodeMirror 的历史扩展,确保 AI 操作可以被撤销。
- AI 操作预览:不要直接替换文本。弹出一个模态框(Modal)展示 AI 建议,并提供“应用”、“插入”、“取消”等选项。
- 自定义指令:允许用户自定义一些常用的 Prompt 模板,并保存为快捷指令。
- 流式响应:对于较长的 AI 生成,可以尝试接入支持流式响应的 API,实现打字机效果,提升体验。
- 离线模式与本地模型:将 Ollama 集成作为一等公民支持。检测网络状况,允许用户选择使用云端模型还是本地模型。
6.4 性能与可维护性
- 防抖与节流:对频繁触发的事件(如编辑器内容变化自动保存)进行防抖处理。
- 错误边界:在 React 中使用 Error Boundary 捕获并优雅地处理组件渲染错误。
- 日志记录:在主进程中集成日志库(如
winston),记录应用运行日志和错误信息,便于排查问题。 - 自动化测试:为关键的业务逻辑(如 AI 指令映射、文本处理)编写单元测试,为组件编写集成测试。
6.5 开源与社区建设
- 完善的 README:项目根目录的
README.md应包含项目简介、功能特性、截图、安装指南、开发指南、贡献指南等。 - 清晰的许可证:选择合适的开源许可证(如 MIT、GPL-3.0),并在
LICENSE文件中明确。 - Issue 与 PR 模板:在
.github/目录下创建模板,规范社区反馈和贡献流程。 - 持续集成:使用 GitHub Actions 或 Travis CI 自动化运行测试、构建和发布流程。
通过以上步骤,我们不仅实现了一个功能可用的 AI Markdown 桌面应用,更搭建了一个具备良好工程实践基础的项目骨架。你可以在此基础上,继续深化功能,例如添加文件管理、主题切换、导出 PDF、多标签页、更丰富的 AI 指令集等,将其打造成一个真正强大的生产力工具。