Atlas —— 从零搭建一个 AI 增强的个人知识与生产力系统
本文介绍了从零开始搭建Atlas个人生产力系统的全过程。Atlas是一个纯前端的本地优先工具,整合了知识管理、任务跟踪、习惯养成、番茄钟和AI助手等功能。采用Vue3+TypeScript+Pinia技术栈,数据存储在IndexedDB中,确保隐私和离线可用性。文章详细阐述了项目的分层架构设计(模型层、存储层、服务层、状态管理层和视图层),技术选型理由,以及各模块的实现思路。重点介绍了知识库系统的
Atlas —— 从零搭建一个 AI 增强的个人知识与生产力系统
最后更新:2026-03-06
这篇博客会跟着项目一起更新,每次有大的改动我都会在这里补上。
写在前面
大家好,这篇文章是记录我从零开始搭建 Atlas 这个项目的全过程。Atlas 是一个纯前端的个人效率工具,集成了知识笔记、任务管理、习惯追踪、番茄钟、还有 AI 助手。
说白了就是——我想要一个「什么都有」的个人工作台,而且数据全在本地,不用担心隐私问题。
技术栈选的是 Vue 3 + TypeScript + Pinia + IndexedDB + Tailwind CSS,后面还接入了 AI(通过硅基流动 SiliconFlow 调用 DeepSeek、Qwen 等模型)。
这篇博客不会只贴代码,更重要的是讲为什么这么做、思路是什么、踩了什么坑。尽量用大白话说明白。
目录
- 项目整体架构——为什么这么设计
- 技术选型——为什么选这些
- 项目启动与基础搭建
- 领域驱动设计——分层架构详解
- 知识库模块——笔记系统
- 任务管理模块
- 习惯追踪模块
- 专注计时(番茄钟)模块
- 仪表盘——数据汇总展示
- 共享 UI 组件库
- 主题系统(明暗模式)
- AI 接入——从零到可用
- DeepSeek-OCR 图片识别
- 动画与交互效果
- 踩坑记录与经验总结
- 后续计划
- 更新日志
1. 项目整体架构——为什么这么设计
先看一眼项目的文件结构:
src/
├── main.ts # 入口文件
├── App.vue # 根组件
├── app/
│ ├── bootstrap.ts # 应用初始化(Pinia、Router 等)
│ └── router.ts # 路由配置
├── domains/ # 业务领域(核心)
│ ├── knowledge/ # 知识库
│ │ ├── model.ts # 数据模型定义
│ │ ├── repository.ts # 数据库操作(IndexedDB)
│ │ ├── service.ts # 业务逻辑
│ │ ├── store.ts # 状态管理(Pinia)
│ │ └── views/ # 页面组件
│ │ ├── KnowledgeList.vue
│ │ └── KnowledgeEditor.vue
│ ├── tasks/ # 任务管理(同样的分层)
│ ├── habits/ # 习惯追踪
│ ├── focus/ # 专注计时
│ ├── dashboard/ # 仪表盘
│ └── ai/ # AI 模块
│ ├── model.ts
│ ├── service.ts
│ ├── store.ts
│ └── views/
│ └── AIChatPanel.vue
├── shared/
│ └── ui/ # 共享 UI 组件
│ ├── AppSidebar.vue
│ ├── AppHeader.vue
│ ├── SearchModal.vue
│ ├── PomodoroTimer.vue
│ ├── CalendarHeatmap.vue
│ ├── WeeklyChart.vue
│ ├── StreakBadge.vue
│ ├── ThemeToggle.vue
│ ├── TagInput.vue
│ ├── ConfirmModal.vue
│ ├── AISettingsModal.vue
│ └── ...
└── styles/
└── tailwind.css # 全局样式 + 主题变量
核心设计思路
我采用的是领域驱动设计(DDD) 的思路,但不是那种企业级的重量级 DDD,而是适合前端的轻量版本。简单说就是:
每个业务模块(知识、任务、习惯、专注)都是一个独立的「领域」,每个领域内部都有自己完整的分层:
model.ts → 定义数据长什么样(TypeScript 类型)
repository.ts → 和数据库打交道(IndexedDB 读写)
service.ts → 业务逻辑(创建、更新、删除的规则)
store.ts → 状态管理(Pinia,给 Vue 组件用的)
views/ → 页面组件(用户看到的界面)
为什么要分这么多层?说白了就是关注点分离。如果以后我要把 IndexedDB 换成别的存储(比如接一个后端 API),我只需要改 repository.ts,其他层完全不用动。如果我要换 UI 框架,只需要改 views/ 里的文件,业务逻辑不受影响。
2. 技术选型——为什么选这些
Vue 3 + Composition API
选 Vue 3 主要是因为用着顺手,Composition API 写起来逻辑复用特别方便。<script setup> 语法糖让代码量少了很多。
TypeScript
必须用 TS。这个项目有好几个模块,数据结构之间有关联(比如任务可以关联笔记),不用 TS 后面一定会出各种类型错误。而且写代码的时候有自动补全和类型检查,效率高很多。
Pinia
Vue 3 官方推荐的状态管理。比 Vuex 好用太多了,没有那些 mutation 的概念,直接写函数就行。而且 TypeScript 支持是一流的。
IndexedDB(通过 idb 库)
这是这个项目最核心的决策之一——Local-first(本地优先)。
为什么不用后端?因为:
- 不想部署服务器,纯前端就能用
- 数据隐私——所有数据都在用户自己的浏览器里
- 离线也能用
- 速度快——不需要网络请求
idb 是一个很薄的 IndexedDB 封装库,让 IndexedDB 用起来像 Promise 一样简单,不用写那些恶心的回调。
Tailwind CSS 4
用 Tailwind 写样式效率真的很高,不用来回切换文件写 CSS。而且通过 CSS 变量实现主题切换非常优雅。
SiliconFlow(硅基流动)
AI 接口选的硅基流动,原因很简单:
- 有免费模型可以用(Qwen3-8B、DeepSeek-V3 等)
- API 格式兼容 OpenAI,不用写特殊的适配代码
- 有 DeepSeek-OCR 可以做图片文字识别
3. 项目启动与基础搭建
入口文件
项目从 main.ts 开始:
// src/main.ts
import { bootstrap } from './app/bootstrap'
bootstrap()
很简洁,具体的初始化逻辑放在 bootstrap.ts 里:
// src/app/bootstrap.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '../App.vue'
import { router } from './router'
import '../styles/tailwind.css'
export function bootstrap() {
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
}
为什么把初始化逻辑抽出来?因为以后如果要加插件(比如 i18n 国际化)、或者写测试的时候需要自定义初始化,这样更方便。
路由配置
// src/app/router.ts
import { createRouter, createWebHistory } from 'vue-router'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', component: () => import('@/domains/dashboard/views/DashboardView.vue') },
{ path: '/knowledge', component: () => import('@/domains/knowledge/views/KnowledgeEditor.vue') },
{ path: '/knowledge/:id', component: () => import('@/domains/knowledge/views/KnowledgeEditor.vue') },
{ path: '/tasks', component: () => import('@/domains/tasks/views/TaskEditor.vue') },
{ path: '/tasks/:id', component: () => import('@/domains/tasks/views/TaskEditor.vue') },
{ path: '/habits', component: () => import('@/domains/habits/views/HabitTracker.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
],
})
所有路由组件都用了懒加载(() => import(...)),这样首屏只加载仪表盘的代码,其他模块用到的时候才加载,提升首屏速度。
根组件 App.vue
根组件的布局是一个经典的「侧边栏 + 主内容区」:
┌──────────┬──────────────────────────┐
│ │ AppHeader │
│ Sidebar ├────────┬─────────────────┤
│ │ List │ Content │
│ │ Panel │ (RouterView) │
│ │ │ │
│ ├────────┴─────────────────┤
│ │ AI Chat Panel → │
└──────────┴──────────────────────────┘
[AI FAB 🤖]
- 左侧侧边栏:导航菜单,可以折叠
- 顶部头栏:显示当前页面名称和搜索按钮
- 中间列表面板:在知识库、任务、习惯页面时显示左侧列表
- 右侧内容区:
RouterView,展示具体页面 - AI 聊天面板:从右侧滑出
- 右下角 AI 按钮:浮动按钮,点击打开 AI 面板
还有全局搜索弹窗(⌘K 快捷键触发)和 AI 设置弹窗。
4. 领域驱动设计——分层架构详解
这是整个项目最重要的设计模式,我来详细讲一下每一层到底干什么。
Model 层——数据长什么样
以知识笔记为例:
// src/domains/knowledge/model.ts
export type KnowledgeStatus = 'draft' | 'understood' | 'actionable'
export interface KnowledgeNote {
id: string
title: string
content: string
tags: string[]
status: KnowledgeStatus
createdAt: number
updatedAt: number
}
这层很简单,就是用 TypeScript 定义数据结构。但这很重要,因为整个模块的其他层都围绕这个类型来工作。
我给笔记设计了三个状态:
draft(草稿):刚记下来,还没整理understood(已理解):整理过了,理解了actionable(可行动):可以转化为具体行动
这其实是费曼学习法的思路——学了东西不能只是记下来,要理解,最好能转化为行动。
Repository 层——和数据库打交道
// src/domains/knowledge/repository.ts
import { openDB } from 'idb'
const DB_NAME = 'atlas-db'
const STORE_NAME = 'knowledge'
function getDB() {
return openDB(DB_NAME, 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
store.createIndex('updatedAt', 'updatedAt')
}
if (oldVersion < 2) {
const store = db.transaction(STORE_NAME).objectStore(STORE_NAME)
store.createIndex('status', 'status')
}
},
})
}
这里用了 idb 库来操作 IndexedDB。核心概念:
- ObjectStore:相当于数据库的表,比如
knowledge就是存笔记的表 - keyPath:主键,用
id字段 - Index:索引,加了
updatedAt和status的索引,方便按更新时间排序和按状态筛选 - upgrade:数据库版本升级时执行,用来创建表和索引
每个领域都有自己独立的 IndexedDB 数据库,互不干扰。任务用 atlas-db-tasks,习惯用 atlas-db-habits,专注用 atlas-db-focus。
Service 层——业务逻辑
// src/domains/knowledge/service.ts
import * as repo from './repository'
import type { KnowledgeNote } from './model'
export async function createNote(): Promise<KnowledgeNote> {
const note: KnowledgeNote = {
id: crypto.randomUUID(),
title: '',
content: '',
tags: [],
status: 'draft',
createdAt: Date.now(),
updatedAt: Date.now(),
}
await repo.saveNote(note)
return note
}
export async function updateNote(
id: string,
patch: Partial<Pick<KnowledgeNote, 'title' | 'content' | 'tags' | 'status'>>
): Promise<KnowledgeNote | undefined> {
const note = await repo.getNote(id)
if (!note) return undefined
Object.assign(note, patch, { updatedAt: Date.now() })
await repo.saveNote(note)
return note
}
Service 层不关心数据怎么存的,也不关心 UI 怎么展示的,它只管业务规则。比如:
- 创建笔记时自动设置为
draft状态 - 更新笔记时自动更新
updatedAt时间戳 - 生成 ID 用
crypto.randomUUID()
Store 层——状态管理
// src/domains/knowledge/store.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as service from './service'
export const useKnowledgeStore = defineStore('knowledge', () => {
const notes = ref<KnowledgeNote[]>([])
const current = ref<KnowledgeNote | null>(null)
const searchQuery = ref('')
const filterStatus = ref<KnowledgeStatus | 'all'>('all')
const filteredNotes = computed(() => {
let list = notes.value
if (filterStatus.value !== 'all') {
list = list.filter(n => n.status === filterStatus.value)
}
const q = searchQuery.value.trim().toLowerCase()
if (q) {
list = list.filter(n =>
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q) ||
n.tags.some(t => t.toLowerCase().includes(q))
)
}
return list
})
async function loadList() { ... }
async function open(id: string) { ... }
async function create() { ... }
async function save(note: KnowledgeNote) { ... }
async function remove(id: string) { ... }
return { notes, current, filteredNotes, loadList, open, create, save, remove, ... }
})
Store 层是 Vue 组件和业务逻辑之间的桥梁。它做两件事:
- 持有状态:当前笔记列表、当前打开的笔记、搜索关键词、筛选条件
- 封装操作:加载列表、打开笔记、创建笔记、保存、删除
Vue 组件只需要调用 Store 的方法就行了,不需要关心底层的 service 和 repository。
Views 层——用户界面
这一层就是 Vue 组件了,负责渲染 UI 和处理用户交互。组件里直接使用 Store:
<script setup>
const store = useKnowledgeStore()
// 调用 store 的方法和数据
</script>
5. 知识库模块——笔记系统
知识库是整个项目最核心的模块。我的设计思路是做一个轻量级的 Markdown 笔记系统。
数据模型
每条笔记有:标题、正文内容(Markdown)、标签数组、状态(草稿/已理解/可行动)、创建和更新时间。
编辑器
KnowledgeEditor.vue 是知识编辑器,功能包括:
- 标题输入:无边框的大标题输入框
- 标签管理:用
TagInput组件添加/删除标签 - Markdown 编辑:纯文本编辑,等宽字体
- Markdown 预览:点击预览按钮可以看渲染后的效果
- 自动保存:输入后 800ms 自动保存(防抖),不需要手动保存
- 状态切换:顶部下拉选择草稿/已理解/可行动
- 派生任务:一键从笔记创建关联任务
- AI 功能:总结、续写、润色、生成标签、OCR 识别
- 双向链接:在笔记中写
[[关键词]]可以创建可点击的链接
自动保存的实现很简单,就是一个防抖函数:
let saveTimer: ReturnType<typeof setTimeout> | null = null
function scheduleSave(patch) {
saveStatus.value = 'saving'
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(async () => {
await store.updateCurrent(patch)
saveStatus.value = 'saved'
setTimeout(() => { saveStatus.value = null }, 1500)
}, 800)
}
用户输入的时候显示「保存中…」,保存完了显示「已保存」,1.5 秒后隐藏。这个交互体验参考了 Notion。
Markdown 渲染
没有引入第三方 Markdown 库(比如 marked),自己写了一个简单的渲染器,用正则表达式处理:
- 标题(
# ## ###) - 粗体 / 斜体(
**bold** *italic*) - 行内代码(
`code`) - 列表(
- item) - 链接(
[text](url)) - 双向链接(
[[keyword]])
为什么不用 marked?因为这个项目目前不需要完整的 Markdown 支持,自己写的够用了,而且少了一个依赖,包体积更小。
6. 任务管理模块
数据模型
export interface Task {
id: string
title: string
description: string
status: TaskStatus // 'todo' | 'in-progress' | 'done' | 'cancelled'
priority: TaskPriority // 'low' | 'medium' | 'high'
tags: string[]
relatedNoteId?: string // 可以关联知识笔记
projectId?: string // 预留项目分组字段
dueDate?: number // 截止日期
createdAt: number
updatedAt: number
}
任务有几个重要的设计决策:
- 四种状态:待办 → 进行中 → 完成 / 取消。这比简单的「完成/未完成」更实用
- 三级优先级:高/中/低,显示时用红色/橙色/蓝色区分
- 关联笔记:可以把任务和知识笔记关联起来,形成「学习 → 行动」的闭环
- 截止日期:可选的,显示时会计算逾期天数
任务列表
TaskList.vue 展示在左侧面板中,功能有:
- 搜索和筛选(按状态)
- 显示优先级标签(颜色区分)
- 显示截止日期(逾期的标红)
- 点击勾选完成/取消完成
- 新建任务按钮
任务编辑器
TaskEditor.vue 的功能:
- 编辑标题、描述
- 设置状态、优先级
- 选择截止日期
- 管理标签
- 查看关联笔记(如果有的话,可以跳转过去)
- 集成番茄钟——可以针对某个任务开始专注
- 删除任务(带确认弹窗)
7. 习惯追踪模块
设计思路
习惯追踪的核心理念是「连续打卡」。人的行为养成靠的是重复,所以这个模块的重点是让用户看到自己的连续天数和完成率。
数据模型
export interface Habit {
id: string
title: string
icon: string // 预设的 emoji 图标
frequency: HabitFrequency // 'daily' | 'weekdays' | 'weekends'
createdAt: number
updatedAt: number
}
export interface HabitLog {
id: string
habitId: string
date: string // '2026-03-06' 格式
completedAt: number
}
注意这里用了两个 ObjectStore:
habits:存习惯定义habit-logs:存每日打卡记录
为什么分开存?因为一个习惯可能有几百条打卡记录,如果全部嵌套在 habit 对象里,每次更新都要读写整个大对象,性能会很差。
频率设置
习惯有三种频率:
- 每天
- 工作日(周一到周五)
- 周末
Service 层会根据频率判断今天需不需要追踪这个习惯:
export function shouldTrackToday(frequency: HabitFrequency): boolean {
const day = new Date().getDay() // 0=周日, 6=周六
if (frequency === 'daily') return true
if (frequency === 'weekdays') return day >= 1 && day <= 5
if (frequency === 'weekends') return day === 0 || day === 6
return true
}
连续天数计算
这是一个有趣的算法问题。从今天/昨天开始往前数,看连续有多少天有打卡记录:
export function calculateStreak(logs: HabitLog[]): number {
// 先找出所有不重复的日期,排序
// 从最近的日期开始,连续往前数
// 如果某天断了,返回当前计数
}
日历热力图
CalendarHeatmap.vue 组件展示过去 365 天的打卡热力图(类似 GitHub 的 contribution graph)。用不同深浅的绿色表示完成程度。
习惯追踪主页
HabitTracker.vue 包含:
- 今日进度条
- 习惯卡片列表(点击打勾/取消)
- 日历热力图
- 新建/编辑/删除习惯的弹窗
8. 专注计时(番茄钟)模块
设计思路
番茄工作法:25 分钟专注工作,然后休息 5 分钟。这个模块就是一个番茄钟。
数据模型
export interface FocusSession {
id: string
taskId?: string // 可以关联到某个任务
taskTitle?: string
duration: number // 设定时长(分钟)
actualMinutes: number // 实际完成的分钟数
completedAt: number
date: string
}
Store 的实现
专注 Store 是这个项目里最复杂的 Store,因为它要管理定时器:
export const useFocusStore = defineStore('focus', () => {
const isRunning = ref(false)
const mode = ref<'work' | 'break'>('work')
const remainingSeconds = ref(25 * 60)
const currentTaskId = ref<string | undefined>()
let timer: ReturnType<typeof setInterval> | null = null
function start(taskId?: string, taskTitle?: string) {
isRunning.value = true
currentTaskId.value = taskId
timer = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
complete()
}
}, 1000)
}
function complete() {
clearInterval(timer!)
isRunning.value = false
// 记录这次专注会话
service.recordSession({ ... })
// 播放提示音
playNotificationSound()
// 切换到休息模式
mode.value = 'break'
remainingSeconds.value = 5 * 60
}
// ...
})
PomodoroTimer 组件
PomodoroTimer.vue 展示一个圆形的倒计时器,用 SVG 画的进度环:
- 工作模式:红色主题
- 休息模式:绿色主题
- 显示剩余分秒
- 开始/暂停/重置按钮
- 完成时播放声音提醒
和任务的联动
任务编辑器里集成了番茄钟,可以针对某个任务开始专注。专注完成后,这次的会话记录会关联到那个任务上,方便统计每个任务花了多少时间。
9. 仪表盘——数据汇总展示
DashboardView.vue 是应用的首页,进来就能看到今天的概览。
布局
用了一个 3 列的网格布局:
- 左边两列(大区域):
- 今日任务列表(最多显示 5 条,按优先级排序)
- 今日习惯打卡
- 本周专注时长柱状图
- 右边一列(小区域):
- 番茄钟
- 本周统计(任务完成数、专注分钟数、笔记数、习惯完成率)
- 最近笔记
问候语
页面顶部有一个根据时间段变化的问候语:
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '夜深了'
if (hour < 12) return '早上好'
if (hour < 14) return '中午好'
if (hour < 18) return '下午好'
return '晚上好'
})
连续活跃天数
侧边显示一个「连续活跃 X 天」的徽章。原理是每次打开仪表盘,都会把今天的日期记到 localStorage 里,然后从最近一天开始往前数连续天数。
10. 共享 UI 组件库
shared/ui/ 下面有很多可复用的组件,这里挑几个重要的说。
AppSidebar —— 侧边栏
可折叠的导航侧边栏,包含:
- 折叠/展开按钮
- 导航链接(仪表盘、知识库、任务、习惯)
- AI 助手入口
- 主题切换
折叠时只显示图标,展开时显示图标+文字。用 CSS transition 做的宽度动画。
SearchModal —— 全局搜索
⌘K(Mac)或 Ctrl+K(Windows)打开的全局搜索弹窗。会同时搜索笔记和任务,支持键盘上下导航和回车打开。
TagInput —— 标签输入
自定义的标签输入组件:
- 输入关键词后按回车添加标签
- 点击标签上的 × 删除
- 支持 v-model 双向绑定
ConfirmModal —— 确认弹窗
通用的确认弹窗组件,删除操作的时候弹出来让用户再确认一下。用 <Teleport to="body"> 挂载到 body 上,避免被父元素的 overflow: hidden 裁剪。
WeeklyChart —— 周图表
用纯 CSS 画的柱状图,展示本周每天的专注时长。没有引入 Chart.js,因为这个图表太简单了,几个 div 就能搞定。
CalendarHeatmap —— 日历热力图
类似 GitHub contribution graph 的热力图,展示过去一年的习惯打卡情况。
11. 主题系统(明暗模式)
主题系统全部通过 CSS 变量实现,核心在 tailwind.css 里:
@theme {
--color-surface: #ffffff;
--color-surface-alt: #f5f5f5;
--color-surface-hover: #eeeeee;
--color-border: #e5e5e5;
--color-text-primary: #171717;
--color-text-secondary: #737373;
--color-text-muted: #a3a3a3;
--color-accent: #6366f1;
}
.dark {
--color-surface: #0a0a0a;
--color-surface-alt: #171717;
--color-surface-hover: #262626;
--color-border: #262626;
--color-text-primary: #fafafa;
--color-text-secondary: #a3a3a3;
--color-text-muted: #525252;
--color-accent: #818cf8;
}
ThemeToggle.vue 组件负责切换暗色模式,实际就是给 <html> 元素添加/移除 dark 类名,然后把偏好存到 localStorage 里。
用 CSS 变量的好处是:所有颜色都通过变量引用,切换主题只需要改变量值,不需要写两套样式。
12. AI 接入——从零到可用
这是整个项目最有意思的部分。我来详细讲一下怎么在一个纯前端项目里接入 AI。
整体思路
用户操作 → AI Store → AI Service → SiliconFlow API → 流式响应 → 实时渲染
API 服务商选择
选的是硅基流动(SiliconFlow),主要原因:
- 免费模型:Qwen3-8B、DeepSeek-V3 等都可以免费用
- OpenAI 兼容格式:API 接口和 OpenAI 一模一样,学过 OpenAI 的直接能用
- 流式输出:支持 SSE(Server-Sent Events),可以一个字一个字地显示
- 视觉模型:有 DeepSeek-OCR 可以识别图片文字
AI 模块的分层
AI 模块也遵循同样的分层思路:
model.ts → 定义类型(AIConfig、ChatMessage、服务商配置)
service.ts → 封装 API 调用(聊天、快捷操作、OCR)
store.ts → 管理状态(消息列表、流式状态、面板开关)
model.ts —— 服务商配置
export const PROVIDER_CONFIG = {
siliconflow: {
label: '硅基流动',
defaultModel: 'Qwen/Qwen3-8B',
baseUrl: 'https://api.siliconflow.cn',
ocrModel: 'deepseek-ai/DeepSeek-OCR',
models: [
{ id: 'Qwen/Qwen3-8B', label: 'Qwen3-8B', tag: 'free' },
{ id: 'deepseek-ai/DeepSeek-V3', label: 'DeepSeek-V3', tag: 'free' },
{ id: 'deepseek-ai/DeepSeek-R1', label: 'DeepSeek-R1', tag: 'reasoning' },
// ... 更多模型
],
},
deepseek: { ... },
qwen: { ... },
}
预设了三个服务商,用户可以在设置里切换。API Key 存在 localStorage 里,只在浏览器本地,不会上传到任何服务器。
service.ts —— 核心:流式 API 调用
这是最关键的文件。调用 AI API 用的是 fetch + ReadableStream,实现流式接收响应:
export async function chatStream(config, messages, callbacks, signal, options) {
const url = `${config.baseUrl}/v1/chat/completions`
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: options?.model || config.model,
messages,
stream: true, // 关键:开启流式
temperature: 0.7,
max_tokens: 4096,
}),
signal, // 用于取消请求
})
// 读取 SSE 流
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') { callbacks.onDone(); return }
const json = JSON.parse(data)
const delta = json.choices?.[0]?.delta?.content
if (delta) callbacks.onChunk(delta) // 每收到一块文字就回调
}
}
}
流式输出的原理是 SSE(Server-Sent Events)。服务器不是等 AI 想完了才一次性返回,而是 AI 每想出一个字就发一个字过来。前端通过 ReadableStream 不断读取这些数据,每收到一块就通过 onChunk 回调更新界面。这样用户就能看到 AI 一个字一个字地打出来,体验好很多。
store.ts —— AI 状态管理
Store 管理了:
config:当前的 AI 配置(服务商、API Key、模型)messages:聊天消息列表isStreaming:是否正在流式输出panelOpen:AI 面板是否打开settingsOpen:设置弹窗是否打开abortController:用于取消正在进行的 AI 请求
几个关键方法:
sendMessage —— 发送聊天消息:
async function sendMessage(content: string) {
// 1. 添加用户消息到列表
messages.value.push({ role: 'user', content, ... })
// 2. 添加一个空的 AI 回复(loading 状态)
const assistantMsg = { role: 'assistant', content: '', loading: true, ... }
messages.value.push(assistantMsg)
// 3. 调用 API,每收到一块就追加到 AI 消息内容里
await chatStream(config.value, [...history], {
onChunk(text) {
assistantMsg.content += text // 实时更新
},
onDone() {
assistantMsg.loading = false
},
onError(error) {
assistantMsg.content = `⚠️ ${error}`
},
})
}
runQuickAction —— 执行快捷 AI 操作(总结、续写等):
async function runQuickAction(systemPrompt, userContent, onChunk) {
// 单次调用,不走聊天历史
await quickPrompt(config.value, systemPrompt, userContent, {
onChunk(text) { onChunk(text) },
// ...
})
}
runOCR —— 图片文字识别:
async function runOCR(imageData, onChunk) {
await ocrImage(config.value, imageData, {
onChunk(text) { onChunk(text) },
// ...
})
}
AIChatPanel.vue —— 聊天面板
这是 AI 最主要的交互界面。从右侧滑出的面板,包含:
- 头部:显示标题、状态(思考中…)、设置/清空/关闭按钮
- 消息区域:消息气泡列表
- 用户消息:靠右,蓝色背景
- AI 消息:靠左,灰色背景,支持 Markdown 渲染
- loading 时显示三个跳动的点
- 流式输出时末尾有闪烁的光标
- 空状态:没有消息时显示引导和快捷建议按钮
- 输入区域:多行输入框,Enter 发送,Shift+Enter 换行,停止生成按钮
AISettingsModal.vue —— 设置弹窗
让用户配置 AI 服务:
- 选择服务商(硅基流动 / DeepSeek / 通义千问)
- 输入 API Key(密码模式,可以切换显示/隐藏)
- 选择模型(每个服务商有不同的模型列表,标注了免费/推理等标签)
- 显示就绪状态
知识编辑器里的 AI 功能
在知识编辑器的顶部有一个 AI 按钮,点击弹出菜单:
| 功能 | 说明 |
|---|---|
| AI 总结 | 自动提取笔记关键点,追加到文末 |
| AI 续写 | 基于当前内容继续写(流式,实时追加) |
| AI 润色 | 优化文笔和逻辑,替换原内容 |
| AI 生成标签 | 分析内容生成标签关键词,合并到现有标签 |
| OCR 图片识别 | 选择图片,提取其中的文字 |
每个功能的实现思路都差不多:构造一个 system prompt,把当前笔记内容作为 user content,调用 AI,然后处理返回结果。
比如 AI 总结:
if (key === 'summarize') {
let summary = ''
await aiStore.runQuickAction(
'请用简洁的中文总结以下内容,提取关键要点,用 Markdown 列表格式输出:',
content,
(chunk) => { summary += chunk }
)
// 追加到笔记末尾
store.current.content += '\n\n---\n\n## AI 总结\n\n' + summary
scheduleSave({ content: store.current.content })
}
13. DeepSeek-OCR 图片识别
这个是一个比较特别的功能,利用了 SiliconFlow 平台上的 deepseek-ai/DeepSeek-OCR 模型。
什么是 VLM
DeepSeek-OCR 是一个 VLM(Vision Language Model,视觉语言模型)。普通的聊天模型只能处理文字,VLM 还能「看」图片。
API 调用格式
VLM 的消息格式和普通聊天不一样。普通聊天的 content 是字符串,VLM 的 content 是一个数组,包含文字和图片:
const messages = [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,...' }
},
{
type: 'text',
text: '请识别这张图片中的文字'
}
]
}
]
图片支持两种格式:
- URL(在线图片)
- Base64 data URI(本地图片转码后)
在编辑器中使用
用户点击 AI 菜单里的「OCR 图片识别」→ 选择图片 → 图片被读取为 base64 → 发送给 DeepSeek-OCR → 识别结果追加到笔记里。
async function handleOCRFile(e: Event) {
const file = input.files?.[0]
const reader = new FileReader()
reader.onload = async () => {
const base64 = reader.result as string
let ocrText = ''
await aiStore.runOCR(base64, (chunk) => { ocrText += chunk })
// 追加到笔记
store.current.content += '\n\n---\n\n## OCR 识别结果\n\n' + ocrText
}
reader.readAsDataURL(file)
}
14. 动画与交互效果
为了让界面更有「质感」,我加了不少动画效果:
AI 浮动按钮
右下角的 AI 按钮有三个效果:
- 入场动画:从底部弹出,带缩放效果
- 发光边框:一直在缓慢呼吸的渐变光晕
- 悬浮效果:hover 时微微上浮,阴影增强
.ai-fab {
animation: ai-fab-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.ai-fab::before {
/* 渐变光晕伪元素 */
background: linear-gradient(135deg, #8b5cf6, #6366f1, #8b5cf6);
background-size: 200% 200%;
animation: ai-border-glow 3s ease-in-out infinite;
}
AI 面板滑入动画
.ai-panel-enter-active,
.ai-panel-leave-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}
.ai-panel-enter-from,
.ai-panel-leave-to {
transform: translateX(100%);
opacity: 0;
}
用了 cubic-bezier(0.16, 1, 0.3, 1) 这个缓动曲线,效果是快速滑入然后轻微回弹,比线性动画感觉好很多。
消息气泡入场
每条消息出现时从下方淡入:
@keyframes msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
流式打字光标
AI 输出文字时末尾有一个闪烁的光标:
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
卡片悬浮效果
仪表盘上的卡片 hover 时微微上浮:
section[class*="rounded-2xl"]:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
全局过渡
所有元素的背景色、文字色、边框色变化都有 0.2s 的过渡,切换主题时不会突兀:
* {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
15. 踩坑记录与经验总结
1. IndexedDB 的版本升级
IndexedDB 有个坑:创建索引只能在 upgrade 回调里做。如果你已经创建了数据库,后面想加索引,必须升级版本号。
function getDB() {
return openDB('atlas-db', 2, { // 版本号从 1 升到 2
upgrade(db, oldVersion) {
if (oldVersion < 1) {
// 第一版:创建表
}
if (oldVersion < 2) {
// 第二版:补充索引
}
},
})
}
2. Pinia Store 的响应式陷阱
在 Store 里直接修改 ref 的嵌套属性是可以响应式更新的,但如果你把 ref 的值 reassign 了,需要用 .value。
3. SSE 流式解析的 buffer 处理
SSE 数据可能在任意位置被截断(一个 JSON 可能分两次到达),所以必须维护一个 buffer:
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 最后一个可能是不完整的行,留到下次
4. AI 请求的取消
用 AbortController 来取消进行中的 AI 请求。这很重要,因为 AI 请求可能持续几十秒,用户需要能随时停止。
5. 自动保存的防抖
不能每输入一个字就保存一次,那太频繁了。用防抖(800ms 无输入后才保存)是最佳实践。
16. 后续计划
接下来打算做的事情:
- 数据导出/导入:把数据导出为 JSON,方便备份和迁移
- PWA 支持:做成可以安装的网页应用,支持离线访问
- AI 多轮上下文优化:让 AI 聊天时能记住更多上下文
- AI 对话历史持久化:目前 AI 聊天记录刷新就没了,后面存到 IndexedDB
- 更丰富的 Markdown 支持:引入 markdown-it 或 unified
- 拖拽排序:任务和习惯支持拖拽调整顺序
- 数据同步:探索 WebRTC 或 CRDTs 实现多端同步
- 更多 AI 模型:接入更多 SiliconFlow 上的模型,包括多模态
17. 更新日志
2026-03-06 —— AI 模块上线
新增功能:
- 接入 SiliconFlow(硅基流动)AI 平台
- 支持 DeepSeek、Qwen、GLM 等多种模型
- AI 聊天面板(流式对话、打字效果、Markdown 渲染)
- AI 设置弹窗(服务商切换、API Key 管理、模型选择)
- 知识编辑器 AI 工具(总结、续写、润色、标签生成)
- DeepSeek-OCR 图片文字识别
- 右下角 AI 浮动按钮(带发光动画)
- 侧边栏 AI 助手入口
UI/交互增强:
- AI 面板滑入/滑出动画
- 消息气泡入场动画
- AI 流式打字光标效果
- 卡片悬浮微抬升效果
- 全局过渡动画优化
默认 AI 配置:
- 服务商:硅基流动
- API Key:已预填
- 默认模型:Qwen/Qwen3-8B(免费)
- OCR 模型:deepseek-ai/DeepSeek-OCR(免费)
可用免费模型列表:
| 模型 | 用途 |
|---|---|
| Qwen/Qwen3-8B | 通用对话(默认) |
| Qwen/Qwen3-30B-A3B | 通用对话(更强) |
| deepseek-ai/DeepSeek-V3 | 通用对话 |
| THUDM/glm-4-9b-chat | 通用对话 |
| Qwen/Qwen2.5-7B-Instruct | 通用对话 |
| deepseek-ai/DeepSeek-R1 | 深度推理 |
| deepseek-ai/DeepSeek-OCR | 图片文字识别 |
2026-03-06 —— 项目初始版本
核心功能:
- 知识库笔记系统(Markdown、标签、状态流转、双向链接)
- 任务管理(四种状态、三级优先级、截止日期、关联笔记)
- 习惯追踪(每日打卡、连续天数、日历热力图)
- 番茄钟计时器(25 分钟工作、5 分钟休息)
- 仪表盘(数据汇总、今日任务、习惯进度、专注统计)
- 全局搜索(⌘K)
- 明暗主题切换
- 数据全部存储在浏览器本地(IndexedDB)
这篇博客会持续更新,记录项目的每一次迭代。如果你有任何问题或建议,欢迎交流!
更多推荐



所有评论(0)