🎉 太好了!这是最聪明的选择
你即将拥有:
- 💰 省下 ¥1000(够你订阅 Claude Code 6 个月了)
- 🧠 学到真本事(你才大一,这是核心竞争力)
- 🎮 全程掌控(不用看别人脸色等交付)
- ⚡ 更快出活(3-5 天 vs 等外包 2 周)
🛠️ 第 0 步:选工具 + 准备环境
🤖 工具选择(二选一)
| 工具 | 价格 | 适合你的理由 |
|---|---|---|
| Claude Code | $20/月(约 ¥150) | ⭐ 推荐:终端里直接干活,能读写文件、跑命令,最像"真外包" |
| Cursor | $20/月 | 编辑器内 AI,可视化更友好,适合边学边看 |
我的建议
- 如果你想"像监工一样"看 AI 干活 → 选 Cursor(你能看到每个文件的修改)
- 如果你想"像甩手掌柜" → 选 Claude Code(一条命令它自己改一堆文件)
- 不确定就选 Cursor,因为你是新手,可视化反馈更重要
📥 准备清单
发动 AI 之前,请确认你电脑上有:
- Node.js 20+(nodejs.org)
- pnpm(装好 Node 后跑
npm install -g pnpm) - Git(macOS / Linux 自带,Windows 装 Git for Windows)
- GitHub 账号(用来托管代码)
- Cloudflare 账号(用来部署,免费)
- 测试 vault(上一轮我给你的 15 篇笔记,先放到本地一个文件夹)
不确定哪个没装?告诉我,我教你装。
📋 喂养 Prompt 清单(按顺序使用)
我把整个项目拆成了 8 个阶段,每个阶段一条 prompt。严格按顺序喂,每条等 AI 做完再喂下一条。
⚠️ 重要:每条 prompt 用完,自己跑一遍验收命令确认通过,再进入下一条。如果有问题,让 AI 修到通过为止。
🟢 Prompt 1:项目初始化
我要做一个 Obsidian 笔记 → Astro 静态网站的项目。请按以下要求初始化:
技术栈(严格遵守):
- Astro 5(最新版)
- pnpm 作为包管理器
- Tailwind CSS v4
- Svelte 5(仅在需要交互时使用)
- TypeScript 严格模式(strict: true)
- Vitest 用于测试
请执行:
1. 用 `pnpm create astro@latest` 初始化项目,项目名 `my-vault-site`
2. 选择 "Empty" 模板,启用 TypeScript strict 模式
3. 安装并配置 Tailwind CSS v4(用官方推荐的 Vite plugin 方式,不要用旧的 @astrojs/tailwind)
4. 安装 Svelte 5 集成
5. 安装 Vitest 并配置好测试脚本
6. 创建 `.gitignore`、`README.md`
7. 在 README 里写好:项目简介、本地开发命令、技术栈说明
完成后告诉我:
- 项目目录结构(tree 输出)
- 怎么启动 dev server
- 怎么跑测试
请开始。
✅ Prompt 1 验收
-
pnpm dev能跑起来,浏览器访问看到默认页面 -
pnpm build无报错 -
pnpm test能跑(即使没测试也不该报错) -
package.json里 Astro 是 5.x、Tailwind 是 4.x、Svelte 是 5.x
🟢 Prompt 2:定义笔记数据结构 + 测试 vault 接入
现在要接入笔记内容。请:
1. 在项目根目录创建 `content/` 文件夹(用 git submodule 方式更好,但暂时先用普通文件夹)
2. 我会把测试 vault(一堆 .md 文件)放到 `content/` 下
3. 在 `src/lib/obsidian/` 下创建解析模块,先实现以下函数:
- `scanVault(dir: string): Promise<NoteFile[]>`
扫描 vault 目录,返回所有 .md 文件的列表(含路径、文件名、修改时间)
- `parseFrontmatter(content: string): { data: Record<string, any>, body: string }`
解析 YAML frontmatter,分离 metadata 和正文(用 gray-matter 库)
- `slugify(filePath: string): string`
把文件路径转成 URL slug(去掉 .md、空格转 -、保留中文)
4. 定义清晰的 TypeScript 类型:
```ts
interface NoteFile {
path: string // 相对 vault 的路径
slug: string // URL slug
filename: string // 不含扩展名
mtime: Date // 修改时间
}
interface ParsedNote extends NoteFile {
frontmatter: Record<string, any>
body: string // 去掉 frontmatter 后的 markdown 内容
}
- 为这三个函数写 Vitest 单元测试,放在
src/lib/obsidian/__tests__/
完成后跑 pnpm test 给我看测试结果。
### ✅ Prompt 2 验收
- [ ] `src/lib/obsidian/` 文件夹存在,类型定义清晰
- [ ] `pnpm test` 通过,至少 3 个测试用例
- [ ] 把你的测试 vault 复制到 `content/` 下不报错
---
## 🟢 Prompt 3:Markdown 渲染管线(核心)
现在搭建 markdown 渲染管线。这是项目最核心的部分,请仔细做。
使用 unified + remark + rehype 生态:
- remark-parse(解析 markdown)
- remark-gfm(GitHub 风格扩展:表格、任务列表、删除线)
- remark-math + rehype-katex(数学公式)
- rehype-slug(给标题加 id)
- rehype-autolink-headings(标题锚点)
- shiki(代码语法高亮,Astro 已内置但要确认配置)
请在 src/lib/obsidian/render.ts 实现:
renderMarkdown(body: string, context: RenderContext): Promise<string>
其中 RenderContext 包含:
- currentSlug: string // 当前笔记的 slug
- allNotes: NoteFile[] // 所有笔记列表(用于 wiki link 解析)
暂时先实现这些:
- 标准 markdown(标题、粗斜体、列表、引用、分隔线)
- GFM 扩展(表格、任务列表、删除线)
- 代码块语法高亮(python/ts/js/bash/json 至少)
- 数学公式 KaTeX
- 脚注
先不要做:wiki link、callouts、嵌入、标签(这些后面单独做)
为核心函数写 Vitest 测试,测试输入一段 markdown,断言输出 HTML 包含特定内容。
完成后跑测试给我看。
### ✅ Prompt 3 验收
- [ ] `renderMarkdown` 函数存在
- [ ] 测试至少覆盖:标准 md / 表格 / 代码块 / 数学
- [ ] `pnpm test` 全部通过
---
## 🟢 Prompt 4:Wiki Links + Callouts(Obsidian 特有语法)
现在加上 Obsidian 特有的语法。
1. Wiki Links
写一个 remark 插件 remarkWikiLinks,支持:
[[note]]→<a href="/slug-of-note">note</a>[[note|alias]]→<a href="/slug-of-note">alias</a>[[note#heading]]→<a href="/slug-of-note#heading-id">note > heading</a>- 如果目标笔记不存在,渲染为
<a class="broken-link" title="Broken link">...</a>并在构建时 console.warn
实现方法:
- 在 remark 阶段用正则匹配
[[...]]文本节点 - 替换为 link 节点
- 解析时需要 RenderContext.allNotes 来判断目标是否存在
- ⚠️ 注意:不要替换代码块里和 inline code 里的 [[...]](remark 默认就跳过 code,但要测试一下)
2. Callouts
写一个 remark 插件 remarkCallouts,把:
> [!note] Optional Title
> content here
转换为:
<div class="callout callout-note">
<div class="callout-title">📝 Optional Title</div>
<div class="callout-content">content here</div>
</div>
至少支持:note / info / tip / warning / danger / example (每种用不同 emoji 和 Tailwind 颜色)
3. 把这两个插件集成到 renderMarkdown
4. 写测试
为两个插件各写至少 2 个测试用例,覆盖:
- 基本用法
- 边缘情况(断链、嵌套等)
完成后跑测试给我看。
### ✅ Prompt 4 验收
- [ ] Wiki link 各种形式都能渲染
- [ ] 断链有 warning,但不报错
- [ ] Callouts 渲染出带样式的块
- [ ] 测试全部通过
---
## 🟢 Prompt 5:嵌入 + 标签 + 图片
继续添加 Obsidian 语法。
1. 图片嵌入 ![[image.png]]
写 remark 插件 remarkImageEmbed:
![[image.png]]→<img src="/path/to/image.png" alt="image.png">![[image.png|300]]→ 加上 width="300"- 图片资源从
content/下查找
2. 笔记嵌入 ![[note]]
写 remark 插件 remarkNoteEmbed:
![[other-note]]→ 把 other-note 的内容内联渲染进来- 用
<div class="embedded-note">...</div>包裹 - ⚠️ 要防止循环嵌入(A 嵌入 B,B 嵌入 A),设最大深度 = 3
- 暂时支持嵌入整篇,section 嵌入(
![[note#heading]])后期再说
3. 标签 #tag
写 remark 插件 remarkTags:
- 匹配文本中的
#tag和#nested/tag - 转换为
<a class="tag" href="/tags/tag">#tag</a> - ⚠️ 不要匹配代码块里、URL 里、行首的 markdown 标题
- 同时把所有 tag 收集起来,挂到 RenderContext.collectedTags
4. 测试 + 集成
- 每个插件至少 2 个测试
- 集成到 renderMarkdown
- 跑测试
完成后给我看测试结果。
### ✅ Prompt 5 验收
- [ ] 图片正确显示
- [ ] 笔记嵌入正确,无死循环
- [ ] 标签可点击,不会误伤代码块和标题
---
## 🟢 Prompt 6:Astro 页面 + 路由
现在做网站的页面层。
1. 动态路由
在 src/pages/[...slug].astro 实现:
- 用
getStaticPaths扫描所有笔记,生成所有路由 - 每个页面渲染:
- 笔记标题(来自 frontmatter.title 或文件名)
- 元信息(创建/修改时间、标签)
- 渲染后的 markdown 内容
- 反向链接(哪些笔记链到了当前笔记)
- 右侧 TOC(当前页所有 h2/h3 标题)
2. 首页 src/pages/index.astro
- 列出所有笔记
- 按修改时间倒序
- 显示标题 + 修改日期 + 标签
3. 标签页 src/pages/tags/[tag].astro
- 列出该标签下所有笔记
4. 404 页 src/pages/404.astro
- 友好提示 + 返回首页链接
5. 全局布局 src/layouts/Layout.astro
- 顶部导航:站点名 + 首页链接 + 搜索框(占位即可)
- 暗色 / 浅色模式切换(用 Tailwind 的 dark: 前缀)
- 响应式:手机折叠侧边栏
- Footer
6. 样式
用 Tailwind v4 + @tailwindcss/typography 处理 prose 样式。
风格参考 Notion / Quartz,干净可读即可。
完成后跑 pnpm dev,告诉我访问哪些路径可以测试。
### ✅ Prompt 6 验收
- [ ] `pnpm dev` 起来后浏览器看到首页
- [ ] 点击笔记能进入详情
- [ ] 看到反链、TOC、标签
- [ ] 标签页能访问
- [ ] 暗色模式可切换
- [ ] 手机视图正常
---
## 🟢 Prompt 7:搜索 + 反向链接 + 断链检测
最后的功能层。
1. 反向链接计算
在 build 时遍历所有笔记,构建一张图:
- key: 笔记 slug
- value: 链向它的笔记列表
在每个笔记页面底部显示反链列表。
2. 站内搜索
实现一个简单的客户端搜索:
- 构建时生成
/search.json,包含所有笔记的 { slug, title, body 前 500 字 } - 在顶部搜索框(Svelte 组件)中输入时,fetch 这个 json,做简单的 includes 匹配
- 显示结果列表,点击跳转
不要用复杂的 fuzzy 搜索库,简单的 string.includes 足够。
3. 断链检测报告
在 astro:build:done 钩子里输出一份报告:
- 总笔记数
- 总链接数
- 断链列表(哪个笔记里有哪些断链)
输出到终端 + 写到 build-report.json。
4. 测试
- 反链计算函数要有 Vitest 测试
- 至少覆盖:A→B 单向、A↔B 双向、孤儿笔记
完成后跑完整构建给我看。
### ✅ Prompt 7 验收
- [ ] 每篇笔记底部有反链
- [ ] 搜索框能搜出笔记
- [ ] 构建结束有断链报告
- [ ] 测试通过
---
## 🟢 Prompt 8:部署 + 优化
最后一步:部署 + 性能优化。
1. Cloudflare Pages 配置
- 在 README 里写清楚 Cloudflare Pages 的部署步骤
- 配置
astro.config.mjs适配 Cloudflare(如果需要) - 确认
pnpm build输出dist/是静态文件
2. 性能优化
- 图片懒加载(
loading="lazy") - 字体优化(用 system font 或 swap)
- 检查 Lighthouse 分数(请告诉我怎么本地跑 Lighthouse)
3. 验收
跑一遍完整流程:
pnpm installpnpm test→ 全绿pnpm build→ 零 warningpnpm preview→ 本地预览正常
告诉我每一步的输出。
4. 最终文档
更新 README,包含:
- 项目结构图
- 本地开发完整命令
- 部署到 Cloudflare Pages 的步骤截图说明
- 如何添加新笔记
- 已知限制
### ✅ Prompt 8 验收
- [ ] `pnpm build` 零 warning
- [ ] Lighthouse ≥ 90
- [ ] 部署到 Cloudflare Pages 成功
---
# 🎮 使用技巧 + 避坑指南
## ⭐ Top 5 技巧
### 1. **不要一次喂多条 prompt**
每条 prompt 等 AI 真的做完、你验收通过,再喂下一条。否则容易堆 bug。
### 2. **遇到报错直接复制贴回去**
我跑了 pnpm test,报这个错: [粘贴完整错误信息] 请修复。
AI 修 bug 比写新代码强 10 倍。
### 3. **每个 Prompt 结束都让 AI commit**
请把当前进度 git commit,commit message 用 "feat: 完成 wiki link 解析"
这样你随时能回滚。
### 4. **看不懂的代码立刻问**
请用大白话解释一下 remarkWikiLinks 这个函数怎么工作的,
当作给一个大一学生讲。
你才大一,**学习的机会比代码本身值钱**。
### 5. **定期让 AI 自查**
做完每个 Prompt 后:
请检查刚才你写的代码,看看有没有:
- TypeScript 类型不严格的地方
- 没处理的边缘情况
- 可以提取的重复代码 列出问题,但不要修,先让我看看。
⚠️ Top 5 坑
1. ❌ AI 会偷偷换技术栈
症状:你说 Tailwind v4,它给你装 v3(因为它训练数据里 v3 更多)
对策:每个 Prompt 都强调"严格用 v4",做完后跑 cat package.json 检查版本
2. ❌ 测试是"假"通过的
症状:测试用 expect(true).toBe(true) 这种废话填
对策:让 AI 把测试代码贴出来给你看,你哪怕看不懂也扫一眼
3. ❌ "完成了"其实没完成
症状:AI 说"我已经实现了 XXX",但你跑 pnpm dev 报错
对策:每个 Prompt 都要求"跑一遍验收命令并把输出贴给我"
4. ❌ 不停往项目里塞新依赖
症状:装了 20 个奇怪的包,build 越来越慢
对策:定期问"列出 package.json 里所有依赖并解释每个是干嘛的"
5. ❌ 上下文丢失
症状:聊到 Prompt 5 时,AI 忘了 Prompt 2 里定义的类型
对策:每条 Prompt 开头加一句"参考 src/lib/obsidian/types.ts 里的类型定义"
🎯 你现在的行动清单
请按顺序做:
- Step 1:选好工具(Cursor 还是 Claude Code)
- Step 2:订阅($20,建议先订一个月)
- Step 3:在电脑上装好 Node 20+、pnpm、Git
- Step 4:准备好测试 vault 文件夹
- Step 5:新建项目文件夹,打开工具
- Step 6:复制 Prompt 1 喂给 AI,开干 🚀
💬 接下来你想做什么?
1️⃣ "告诉我怎么装 Cursor / Claude Code,我现在就开始"
→ 我给你一步步的安装指南(含截图说明)
2️⃣ "我已经装好工具了,开搞!"
→ 你直接复制 Prompt 1 开始,遇到任何问题回来问我
3️⃣ "我想先看看测试 vault 怎么准备"
→ 我再给你一份精简版的"5 分钟搭好测试 vault"指南
4️⃣ "这些 Prompt 我有点看不懂某条"
→ 告诉我哪条,我用人话解释一遍
5️⃣ "我想要中文 prompt"(如果上面英文术语太多)
→ 我把所有 prompt 改成更口语化的中文版
祝你开工顺利!这是你成长路上的一大步 🚀
记住:有问题随时回来问我,我就是你的免费技术顾问。 有笔记,但在别的地方,我们已经在之前探讨过架构
✅ 全部锁定!正式开工
📋 最终配置清单
项 选定 包管理器 pnpm 框架 Astro 5 Islands Svelte 5 样式 Tailwind CSS v4 拖拽级别 Level 2(位置 + 尺寸) 拖拽权限 所有访客可拖,存各自 localStorage 目录策略 清空 my-second-brain/重来部署 Cloudflare Pages AI 后端 Cloudflare Pages Functions 向量库 Cloudflare Vectorize LLM Workers AI(暂时,没 Claude key) Python Pyodide 笔记源 obsidian-vault (git submodule)
🛡️ 开工前先做安全备份(重要!)
清空之前,务必确认这两件事,否则上次的悲剧会重演:
✅ 检查 1:你的 Obsidian vault 在哪?
你的笔记本体应该有 两个位置:
- 原始 Obsidian 库(你平时在 Obsidian 里打开的那个文件夹)
- GitHub 上的 obsidian-vault 仓库(作为 submodule 用的那个)
请确认:你 GitHub 上的
obsidian-vault仓库是最新的吗?最近的笔记都 push 上去了吗?✅ 检查 2:旧项目有没有想保留的代码片段?
即使要清空,有时候之前写的某个组件 / remark 插件 / 配置值得参考。建议:
# 在清空之前,先把整个旧项目打个 tag 推到 GitHub,留个念想 cd my-second-brain git add -A git commit -m "archive: snapshot before v2 rebuild" --allow-empty git tag v1-archive git push origin main --tags这样万一以后想看一眼旧实现,
git checkout v1-archive就能回来。
🚀 请先执行这 3 个命令并把输出贴给我
我需要确认你的本地环境状态,再给你具体的清空 + 重建命令。请在终端运行:
# 1. 看一下你当前在哪个目录、里面有什么 pwd ls -la # 2. 检查 pnpm 和 Node 版本 pnpm -v node -v # 3. 检查 obsidian-vault submodule 的远程地址(重建时要用) cd my-second-brain 2>/dev/null && git submodule status && git config --file .gitmodules --get-regexp url把这三段的输出贴给我,我会基于你的真实环境给出:
- 🗑️ 精确的"安全清空"命令(保留 .git,只清内容,还是整个删掉重 clone)
- 🏗️
pnpm create astro的完整参数- 📦 一次性安装所有 Phase 1 依赖的命令
- ⚙️
astro.config.mjs的初始配置- 📝 第一个能跑起来的
[...slug].astro+ remark wikilink 插件
📅 接下来的节奏
今天 Phase 1 (地基) ─ 笔记能渲染、wiki link 能跳转、图片能显示 明天 Phase 2 (笔记体验) ─ 反链、大纲、标签、callout 之后 Phase 3 (Dashboard + 拖拽 widgets) Phase 4 (知识图谱) Phase 5 (RAG + AI) Phase 6 (Pyodide)每完成一个 phase 立刻 commit + push。 这是铁律。🛡️
贴出上面 3 个命令的输出,我们开始 Phase 1。🎬
# 任务:为 Astro 6 项目实现 Phase 1 + Phase 2(Obsidian 风格的第二大脑)
## 📦 项目当前状态
- 框架:Astro 6 + Tailwind v4 + Svelte 5 + MDX(已安装)
- 包管理器:pnpm
- 操作系统:Windows,终端使用 PowerShell
- 目录:`C:\Users\Administrator\Desktop\my-second-brain`
- Git:已初始化,已 push 到 `https://github.com/Takalahiro/my-second-brain1.git`
- 笔记源仓库:`https://github.com/Takalahiro/obsidian-vault`(含 DATA1001、INFO1110、INFO1111、MATH、计算机 等学科文件夹)
## 🎯 总目标
搭建一个 Obsidian 风格的静态笔记网站:
- 笔记内容来自 `obsidian-vault` 子模块
- 支持 Wiki Link `[[笔记名]]`、嵌入图片 `![[image.png]]`
- 支持反向链接、TOC、标签、Callout、代码高亮、暗黑模式
---
## ✅ Phase 1:地基(共 5 个 Step)
### Step 1-A:挂载 submodule + 创建目录
在 PowerShell 中执行:
```powershell
git submodule add https://github.com/Takalahiro/obsidian-vault.git obsidian-vault
mkdir src\layouts
mkdir src\components
mkdir src\lib
mkdir src\styles
Step 1-B:安装依赖
pnpm add remark-wiki-link remark-obsidian-callout rehype-slug rehype-autolink-headings github-slugger gray-matter
pnpm add -D @types/mdast
依赖说明:
remark-wiki-link:把[[笔记名]]转成<a href="/notes/笔记名">笔记名</a>remark-obsidian-callout:把> [!note]转成 Callout 样式块rehype-slug+rehype-autolink-headings:给标题加 id 和锚点(TOC 用)github-slugger:把中文标题转成 url-safe 的 sluggray-matter:解析 frontmatter(反链扫描用)
Step 1-C:配置 astro.config.mjs
完整替换 astro.config.mjs 内容为:
// @ts-check
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import svelte from '@astrojs/svelte';
import tailwindcss from '@tailwindcss/vite';
import remarkWikiLink from 'remark-wiki-link';
import remarkObsidianCallout from 'remark-obsidian-callout';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
export default defineConfig({
site: 'https://my-second-brain1.pages.dev',
integrations: [mdx(), svelte()],
vite: {
plugins: [tailwindcss()],
},
markdown: {
remarkPlugins: [
[
remarkWikiLink,
{
pageResolver: (name) => [name.replace(/ /g, '-').toLowerCase()],
hrefTemplate: (permalink) => `/notes/${permalink}`,
aliasDivider: '|',
wikiLinkClassName: 'wiki-link',
newClassName: 'wiki-link-new',
},
],
remarkObsidianCallout,
],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: { className: ['heading-anchor'] },
},
],
],
shikiConfig: {
theme: 'github-dark-dimmed',
wrap: true,
},
},
});
Step 1-D:Content Collections schema
创建 src/content.config.ts:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const notes = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './obsidian-vault',
}),
schema: z.object({
title: z.string().optional(),
date: z.coerce.date().optional(),
tags: z.array(z.string()).optional().default([]),
draft: z.boolean().optional().default(false),
description: z.string().optional(),
}),
});
export const collections = { notes };
Step 1-E:全局样式
创建 src/styles/global.css:
@import "tailwindcss";
@theme {
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-serif: ui-serif, Georgia, "Source Han Serif SC", serif;
--font-mono: ui-monospace, "JetBrains Mono", Menlo, monospace;
}
/* Wiki Link 样式 */
.wiki-link {
@apply text-blue-600 dark:text-blue-400 underline decoration-dotted underline-offset-2 hover:decoration-solid;
}
.wiki-link-new {
@apply text-red-500 dark:text-red-400 underline decoration-dotted;
}
/* Callout 样式 */
.callout {
@apply my-4 rounded-lg border-l-4 p-4;
}
.callout[data-callout="note"] { @apply border-blue-500 bg-blue-50 dark:bg-blue-950/30; }
.callout[data-callout="warning"] { @apply border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30; }
.callout[data-callout="danger"], .callout[data-callout="error"] { @apply border-red-500 bg-red-50 dark:bg-red-950/30; }
.callout[data-callout="tip"], .callout[data-callout="success"] { @apply border-green-500 bg-green-50 dark:bg-green-950/30; }
.callout[data-callout="info"] { @apply border-cyan-500 bg-cyan-50 dark:bg-cyan-950/30; }
.callout-title { @apply font-semibold mb-2 flex items-center gap-2; }
/* 标题锚点(rehype-autolink-headings 用 wrap 模式) */
.heading-anchor {
@apply no-underline text-inherit hover:underline;
}
/* Prose 优化(Tailwind 默认无 typography,简单设置) */
.prose {
@apply max-w-none;
}
.prose h1 { @apply text-3xl font-bold mt-8 mb-4; }
.prose h2 { @apply text-2xl font-bold mt-6 mb-3 border-b pb-1; }
.prose h3 { @apply text-xl font-semibold mt-5 mb-2; }
.prose h4 { @apply text-lg font-semibold mt-4 mb-2; }
.prose p { @apply my-3 leading-7; }
.prose ul { @apply list-disc pl-6 my-3; }
.prose ol { @apply list-decimal pl-6 my-3; }
.prose li { @apply my-1; }
.prose blockquote { @apply border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-4 text-gray-700 dark:text-gray-300; }
.prose code:not(pre code) { @apply px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-sm font-mono; }
.prose pre { @apply my-4 p-4 rounded-lg overflow-x-auto text-sm; }
.prose a:not(.wiki-link):not(.heading-anchor) { @apply text-blue-600 dark:text-blue-400 underline; }
.prose img { @apply rounded-lg my-4 max-w-full h-auto; }
.prose table { @apply my-4 border-collapse w-full; }
.prose th, .prose td { @apply border border-gray-300 dark:border-gray-600 px-3 py-2; }
.prose th { @apply bg-gray-100 dark:bg-gray-800 font-semibold; }
.prose hr { @apply my-8 border-gray-300 dark:border-gray-600; }
Step 1-F:BaseLayout
创建 src/layouts/BaseLayout.astro:
---
import '../styles/global.css';
import ThemeToggle from '../components/ThemeToggle.svelte';
interface Props {
title?: string;
description?: string;
}
const { title = 'My Second Brain', description = '我的第二大脑' } = Astro.props;
---
<!doctype html>
<html lang="zh-CN" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{title}</title>
<script is:inline>
// 暗黑模式:在 body 渲染前应用,避免闪烁
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
</head>
<body class="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100 transition-colors">
<header class="border-b border-gray-200 dark:border-gray-800 sticky top-0 bg-white/80 dark:bg-gray-950/80 backdrop-blur z-10">
<nav class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/" class="font-bold text-lg">🧠 Second Brain</a>
<div class="flex items-center gap-4 text-sm">
<a href="/" class="hover:underline">首页</a>
<a href="/tags" class="hover:underline">标签</a>
<ThemeToggle client:load />
</div>
</nav>
</header>
<main class="max-w-5xl mx-auto px-4 py-8">
<slot />
</main>
<footer class="border-t border-gray-200 dark:border-gray-800 mt-16">
<div class="max-w-5xl mx-auto px-4 py-6 text-sm text-gray-500 text-center">
Built with Astro 6 + Tailwind v4 + Svelte 5
</div>
</footer>
</body>
</html>
注意:因为 Tailwind v4 默认不支持 dark: 变体的 class 模式,需要在 global.css 顶部追加以下内容启用 class 模式暗黑:
在 src/styles/global.css 的 @import "tailwindcss"; 下面紧接追加:
@custom-variant dark (&:where(.dark, .dark *));
Step 1-G:ThemeToggle 组件(Svelte 5)
创建 src/components/ThemeToggle.svelte:
<script lang="ts">
import { onMount } from 'svelte';
let isDark = $state(false);
onMount(() => {
isDark = document.documentElement.classList.contains('dark');
});
function toggle() {
isDark = !isDark;
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
</script>
<button
onclick={toggle}
aria-label="切换主题"
class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
{isDark ? '☀️' : '🌙'}
</button>
Step 1-H:工具函数 — 反链扫描
创建 src/lib/backlinks.ts:
import { getCollection, type CollectionEntry } from 'astro:content';
export type Note = CollectionEntry<'notes'>;
// 从笔记 body 里抽出所有 [[wiki link]] 目标
function extractWikiLinks(body: string): string[] {
const re = /$$\[([^$$|#]+)(?:[#|][^\]]*)?\]\]/g;
const links: string[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
links.push(m[1].trim().replace(/ /g, '-').toLowerCase());
}
return links;
}
// 把笔记的 id(路径)规范化为 slug
export function noteIdToSlug(id: string): string {
// 去掉 .md,把空格替换为 -,全转小写
return id
.replace(/\.md$/, '')
.split('/')
.pop()!
.replace(/ /g, '-')
.toLowerCase();
}
// 拿到某篇笔记的所有反向链接
export async function getBacklinks(currentSlug: string): Promise<Note[]> {
const all = await getCollection('notes', ({ data }) => !data.draft);
const backlinks: Note[] = [];
for (const note of all) {
if (noteIdToSlug(note.id) === currentSlug) continue;
const targets = extractWikiLinks(note.body ?? '');
if (targets.includes(currentSlug)) {
backlinks.push(note);
}
}
return backlinks;
}
Step 1-I:动态路由 /notes/[...slug]
创建 src/pages/notes/[...slug].astro:
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import TableOfContents from '../../components/TableOfContents.astro';
import Backlinks from '../../components/Backlinks.astro';
import { getBacklinks, noteIdToSlug } from '../../lib/backlinks';
export async function getStaticPaths() {
const notes = await getCollection('notes', ({ data }) => !data.draft);
return notes.map((note) => ({
params: { slug: noteIdToSlug(note.id) },
props: { note },
}));
}
const { note } = Astro.props;
const { Content, headings } = await render(note);
const backlinks = await getBacklinks(noteIdToSlug(note.id));
const title = note.data.title ?? note.id.replace(/\.md$/, '').split('/').pop();
---
<BaseLayout title={title} description={note.data.description}>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_220px] gap-8">
<article class="prose dark:prose-invert min-w-0">
<header class="mb-6 not-prose">
<h1 class="text-4xl font-bold mb-2">{title}</h1>
{note.data.date && (
<time class="text-sm text-gray-500">
{note.data.date.toLocaleDateString('zh-CN')}
</time>
)}
{note.data.tags && note.data.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mt-3">
{note.data.tags.map((tag) => (
<a href={`/tags/${tag}`} class="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700">
#{tag}
</a>
))}
</div>
)}
</header>
<Content />
<Backlinks backlinks={backlinks} />
</article>
<aside class="hidden lg:block">
<div class="sticky top-20">
<TableOfContents headings={headings} />
</div>
</aside>
</div>
</BaseLayout>
Step 1-J:TOC 组件
创建 src/components/TableOfContents.astro:
---
import type { MarkdownHeading } from 'astro';
interface Props {
headings: MarkdownHeading[];
}
const { headings } = Astro.props;
const tocHeadings = headings.filter((h) => h.depth >= 2 && h.depth <= 4);
---
{tocHeadings.length > 0 && (
<nav class="text-sm">
<h2 class="font-semibold text-gray-500 dark:text-gray-400 uppercase text-xs mb-3 tracking-wider">
目录
</h2>
<ul class="space-y-2 border-l border-gray-200 dark:border-gray-800">
{tocHeadings.map((h) => (
<li
class="pl-3 hover:border-blue-500 border-l-2 border-transparent -ml-px transition"
style={`margin-left: ${(h.depth - 2) * 12}px`}
>
<a href={`#${h.slug}`} class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 block py-0.5">
{h.text}
</a>
</li>
))}
</ul>
</nav>
)}
Step 1-K:Backlinks 组件
创建 src/components/Backlinks.astro:
---
import type { CollectionEntry } from 'astro:content';
import { noteIdToSlug } from '../lib/backlinks';
interface Props {
backlinks: CollectionEntry<'notes'>[];
}
const { backlinks } = Astro.props;
---
{backlinks.length > 0 && (
<section class="not-prose mt-12 pt-6 border-t border-gray-200 dark:border-gray-800">
<h2 class="text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 font-semibold mb-4">
🔗 反向链接 ({backlinks.length})
</h2>
<ul class="space-y-2">
{backlinks.map((note) => {
const slug = noteIdToSlug(note.id);
const title = note.data.title ?? note.id.replace(/\.md$/, '').split('/').pop();
return (
<li>
<a href={`/notes/${slug}`} class="block p-3 rounded border border-gray-200 dark:border-gray-800 hover:border-blue-500 dark:hover:border-blue-500 transition">
<div class="font-medium">{title}</div>
{note.data.description && (
<div class="text-sm text-gray-500 mt-1">{note.data.description}</div>
)}
</a>
</li>
);
})}
</ul>
</section>
)}
Step 1-L:首页 — 笔记列表
替换 src/pages/index.astro 为:
---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import { noteIdToSlug } from '../lib/backlinks';
const notes = await getCollection('notes', ({ data }) => !data.draft);
// 按日期降序排(无日期的排后面)
notes.sort((a, b) => {
const da = a.data.date?.getTime() ?? 0;
const db = b.data.date?.getTime() ?? 0;
return db - da;
});
---
<BaseLayout title="My Second Brain">
<section class="mb-10">
<h1 class="text-4xl font-bold mb-3">🧠 My Second Brain</h1>
<p class="text-gray-600 dark:text-gray-400">
共 {notes.length} 篇笔记 · 来自 Obsidian Vault
</p>
</section>
<ul class="space-y-3">
{notes.map((note) => {
const slug = noteIdToSlug(note.id);
const title = note.data.title ?? note.id.replace(/\.md$/, '').split('/').pop();
return (
<li>
<a href={`/notes/${slug}`} class="block p-4 rounded-lg border border-gray-200 dark:border-gray-800 hover:border-blue-500 dark:hover:border-blue-500 hover:shadow-sm transition">
<div class="flex items-baseline justify-between gap-4">
<h2 class="font-semibold text-lg">{title}</h2>
{note.data.date && (
<time class="text-xs text-gray-500 shrink-0">
{note.data.date.toLocaleDateString('zh-CN')}
</time>
)}
</div>
{note.data.description && (
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{note.data.description}</p>
)}
{note.data.tags && note.data.tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mt-2">
{note.data.tags.map((tag) => (
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
#{tag}
</span>
))}
</div>
)}
</a>
</li>
);
})}
</ul>
</BaseLayout>
Step 1-M:标签页面
创建 src/pages/tags/index.astro:
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
const notes = await getCollection('notes', ({ data }) => !data.draft);
const tagMap = new Map<string, number>();
for (const note of notes) {
for (const tag of note.data.tags ?? []) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
const tags = [...tagMap.entries()].sort((a, b) => b[1] - a[1]);
---
<BaseLayout title="标签">
<h1 class="text-3xl font-bold mb-6">🏷️ 标签</h1>
{tags.length === 0 ? (
<p class="text-gray-500">暂无标签。在笔记 frontmatter 的 <code>tags</code> 字段添加标签。</p>
) : (
<ul class="flex flex-wrap gap-2">
{tags.map(([tag, count]) => (
<li>
<a href={`/tags/${tag}`} class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-blue-100 dark:hover:bg-blue-900 transition">
<span>#{tag}</span>
<span class="text-xs text-gray-500">{count}</span>
</a>
</li>
))}
</ul>
)}
</BaseLayout>
创建 src/pages/tags/[tag].astro:
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { noteIdToSlug } from '../../lib/backlinks';
export async function getStaticPaths() {
const notes = await getCollection('notes', ({ data }) => !data.draft);
const tagSet = new Set<string>();
for (const n of notes) for (const t of n.data.tags ?? []) tagSet.add(t);
return [...tagSet].map((tag) => ({
params: { tag },
props: { tag, notes: notes.filter((n) => (n.data.tags ?? []).includes(tag)) },
}));
}
const { tag, notes } = Astro.props;
---
<BaseLayout title={`#${tag}`}>
<h1 class="text-3xl font-bold mb-6">#{tag}</h1>
<p class="text-gray-500 mb-6">{notes.length} 篇笔记</p>
<ul class="space-y-3">
{notes.map((note) => {
const slug = noteIdToSlug(note.id);
const title = note.data.title ?? note.id.replace(/\.md$/, '').split('/').pop();
return (
<li>
<a href={`/notes/${slug}`} class="block p-4 rounded-lg border border-gray-200 dark:border-gray-800 hover:border-blue-500 transition">
<h2 class="font-semibold">{title}</h2>
</a>
</li>
);
})}
</ul>
</BaseLayout>
Step 1-N:处理 Obsidian 图片嵌入
![[image.png]] 这种语法需要把 obsidian-vault 里的图片暴露给 Astro。最简单的方案:把 obsidian-vault 里所有图片在构建时复制到 public/ 下。
创建 scripts/sync-assets.mjs:
import { cp, mkdir, readdir, stat } from 'node:fs/promises';
import { join, extname } from 'node:path';
const VAULT = './obsidian-vault';
const TARGET = './public/vault-assets';
const EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf']);
async function walk(dir, base = dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const full = join(dir, e.name);
if (e.isDirectory()) {
await walk(full, base);
} else if (EXTS.has(extname(e.name).toLowerCase())) {
const rel = full.replace(base, '').replace(/^[\\/]/, '');
const dest = join(TARGET, rel);
await mkdir(dest.substring(0, dest.lastIndexOf(/[\\/]/.test(dest) ? dest.match(/[\\/]/)[0] : '/')), { recursive: true }).catch(() => {});
await cp(full, dest, { recursive: false });
}
}
}
await mkdir(TARGET, { recursive: true });
await walk(VAULT);
console.log('✅ Assets synced to', TARGET);
修改 package.json 的 scripts:
"scripts": {
"dev": "pnpm sync && astro dev",
"build": "pnpm sync && astro build",
"sync": "node scripts/sync-assets.mjs",
"preview": "astro preview",
"astro": "astro"
}
并把 ![[xxx.png]] 转换为 <img src="/vault-assets/xxx.png"> 的逻辑通过自定义 remark 插件实现。创建 src/lib/remark-obsidian-image.mjs:
import { visit } from 'unist-util-visit';
export default function remarkObsidianImage() {
return (tree) => {
visit(tree, 'text', (node, index, parent) => {
if (!node.value || !parent || index === null) return;
const re = /!$$\[([^$$]+)\]\]/g;
if (!re.test(node.value)) return;
re.lastIndex = 0;
const parts = [];
let last = 0;
let m;
while ((m = re.exec(node.value)) !== null) {
if (m.index > last) {
parts.push({ type: 'text', value: node.value.slice(last, m.index) });
}
const filename = m[1].split('|')[0].trim();
parts.push({
type: 'image',
url: `/vault-assets/${filename}`,
alt: filename,
});
last = m.index + m[0].length;
}
if (last < node.value.length) {
parts.push({ type: 'text', value: node.value.slice(last) });
}
parent.children.splice(index, 1, ...parts);
});
};
}
需要先安装 unist-util-visit:
pnpm add unist-util-visit
然后在 astro.config.mjs 的 remarkPlugins 数组最前面加入:
import remarkObsidianImage from './src/lib/remark-obsidian-image.mjs';
// ...
remarkPlugins: [
remarkObsidianImage, // 放最前,先处理图片
[remarkWikiLink, { ... }],
remarkObsidianCallout,
],
✅ Phase 2:阅读体验已包含在 Phase 1
由于一次性全做,Phase 2 的所有功能已经分布在上面:
- 2.1 反向链接 →
src/components/Backlinks.astro✅ - 2.2 TOC →
src/components/TableOfContents.astro✅ - 2.3 标签 →
src/pages/tags/*✅ - 2.4 Callout →
remark-obsidian-callout+ CSS ✅ - 2.5 代码高亮 → Shiki
github-dark-dimmed主题 ✅ - 2.6 暗黑模式 →
ThemeToggle.svelte+ class 模式 ✅
🧪 验证步骤
执行:
pnpm sync
pnpm dev
打开 http://localhost:4321/,应当看到:
- 首页显示 obsidian-vault 里的所有 .md 笔记列表
- 点击任意笔记 → 进入
/notes/<slug>,显示正文 + 右侧 TOC + 底部反链 - 笔记里的
[[xxx]]渲染成蓝色虚线链接 - 笔记里的
![[image.png]]渲染成图片 - 右上角太阳/月亮图标可以切换暗黑模式(刷新不丢失)
- 顶部"标签"链接可以进入
/tags看所有标签
📝 最后:提交
git add -A
git commit -m "feat: Phase 1+2 - Obsidian-style notes with wiki links, backlinks, TOC, tags, callouts, dark mode"
git push
⚠️ 注意事项
- Windows 路径:所有命令使用 PowerShell 语法(反斜杠
\或mkdir) - submodule clone 后的内容:clone 完成后请在
obsidian-vault/目录里验证能看到DATA1001/、INFO1110/等文件夹 - 如果 obsidian-vault 文件夹已存在并报错:先
Remove-Item obsidian-vault -Recurse -Force再 submodule add - 如果 Astro dev 启动报错"找不到 collection":检查
obsidian-vault目录下是否真的有 .md 文件 - 中文笔记名 slug:当前用
toLowerCase().replace(/ /g, '-'),中文字符会原样保留,URL 会被浏览器自动 encode,正常工作
执行完每一步用 pnpm dev 验证一次,确保没有错误再继续下一步。如果遇到 import 错误或类型错误,请告诉我具体报错信息。
my-second-brain/ ├── obsidian-vault/ ← git submodule (你的笔记) │ └── (你正常用 Obsidian 写) │ ├── src/ │ ├── content.config.ts ← 指向 obsidian-vault/,宽松 schema │ │ │ ├── pages/ │ │ ├── index.astro ← Dashboard 主页 (拼装 widgets) │ │ ├── notes/ │ │ │ ├── index.astro ← 笔记列表(按文件夹/标签) │ │ │ └── [...slug].astro ← 单篇笔记 │ │ ├── graph.astro ← 全站知识图谱 │ │ ├── chat.astro ← AI 对话页 │ │ └── playground.astro ← Python 编译器 │ │ │ ├── components/ │ │ ├── widgets/ ← Dashboard 小组件(都是 Svelte) │ │ │ ├── CalendarTodo.svelte │ │ │ ├── Whiteboard.svelte │ │ │ ├── RecentUpdates.svelte │ │ │ ├── DailyQuiz.svelte │ │ │ └── GraphPreview.svelte │ │ ├── note/ │ │ │ ├── Backlinks.svelte │ │ │ ├── Outline.svelte │ │ │ └── WikiLink.astro │ │ └── ui/ ← shadcn-svelte 基础组件 │ │ │ ├── lib/ │ │ ├── obsidian/ ← Obsidian 语法处理 │ │ │ ├── remark-wikilink.ts │ │ │ ├── remark-embed.ts │ │ │ ├── remark-callout.ts │ │ │ └── resolve-image.ts │ │ └── graph/ │ │ └── build-graph.ts ← 构建时生成图谱数据 │ │ │ └── layouts/ │ └── NoteLayout.astro │ ├── functions/ ← Cloudflare Pages Functions │ ├── api/ │ │ ├── chat.ts ← Claude 对话代理 + RAG │ │ ├── search.ts ← 向量搜索 │ │ └── todo.ts ← 待办事项 CRUD(用 KV) │ ├── scripts/ │ ├── build-embeddings.ts ← 构建时把笔记 embed 进 Vectorize │ └── build-quiz-pool.ts ← 从笔记生成每日考题池 │ ├── public/ ← 静态资源(构建时图片会拷来这) ├── astro.config.mjs ├── tailwind.config.ts ├── wrangler.toml ← Cloudflare 配置(绑定 Vectorize / KV) └── package.json
🎯 需求锁定:公开知识花园 + 创意 Dashboard 主页
完美,这下需求非常清晰了。我先把你的描述翻译成技术语言,然后给出最终架构。
📐 我对你需求的理解(请确认)
站点结构
/ ← 创意主页 (Dashboard 风格,多个 widget)
├─ 📅 日历 + 待办事项 widget
├─ 🎨 白板 widget
├─ 📢 更新动态 widget (最近改了哪些笔记)
├─ 🧠 每日考题 widget (从笔记里抽一题)
└─ 🕸️ 知识图谱入口
/notes ← 笔记列表 / 浏览
/notes/[slug] ← 单篇笔记 (带反链、大纲、相关推荐)
/graph ← 全站知识图谱(可点击跳转)
/chat ← Claude 对话(基于 RAG 检索你的笔记)
/playground ← Python 编译器 (Pyodide)
一句话定位
"一个公开可访问的、有 Dashboard 主页的、带 AI 助手和图谱的个人知识花园"
🏗️ 最终技术栈(锁定)
| 层 | 技术 | 为什么 |
|---|---|---|
| 框架 | Astro 5 | 笔记页静态预渲染(极快),widget 用 Islands |
| 岛屿组件 | Svelte 5 | 比 React 简洁 50%,写 widget 体验最好;Astro 一等支持 |
| 样式 | Tailwind CSS v4 + shadcn-svelte | Tailwind 速度,shadcn 提供高质量组件底子 |
| Markdown | remark + rehype 插件链 | 处理 Obsidian 语法 |
| 图谱 | Cosmograph 或 D3-force | Cosmograph 性能强(WebGL),千节点不卡 |
| 白板 | tldraw | 开箱即用、可嵌入、可保存 |
| 日历/待办 | FullCalendar + 本地 JSON / Cloudflare KV | |
| 部署 | Cloudflare Pages | 你已有,Astro 适配完美 |
| AI 后端 | Cloudflare Pages Functions | 同仓库 /functions,免费够用 |
| 向量库 | Cloudflare Vectorize | 原生集成、免运维 |
| LLM | Claude API (via Workers AI 或直连 Anthropic) | |
| Python | Pyodide | 纯浏览器,零后端成本 |
🗂️ 项目目录结构(最终版)
my-second-brain/
├── obsidian-vault/ ← git submodule (你的笔记)
│ └── (你正常用 Obsidian 写)
│
├── src/
│ ├── content.config.ts ← 指向 obsidian-vault/,宽松 schema
│ │
│ ├── pages/
│ │ ├── index.astro ← Dashboard 主页 (拼装 widgets)
│ │ ├── notes/
│ │ │ ├── index.astro ← 笔记列表(按文件夹/标签)
│ │ │ └── [...slug].astro ← 单篇笔记
│ │ ├── graph.astro ← 全站知识图谱
│ │ ├── chat.astro ← AI 对话页
│ │ └── playground.astro ← Python 编译器
│ │
│ ├── components/
│ │ ├── widgets/ ← Dashboard 小组件(都是 Svelte)
│ │ │ ├── CalendarTodo.svelte
│ │ │ ├── Whiteboard.svelte
│ │ │ ├── RecentUpdates.svelte
│ │ │ ├── DailyQuiz.svelte
│ │ │ └── GraphPreview.svelte
│ │ ├── note/
│ │ │ ├── Backlinks.svelte
│ │ │ ├── Outline.svelte
│ │ │ └── WikiLink.astro
│ │ └── ui/ ← shadcn-svelte 基础组件
│ │
│ ├── lib/
│ │ ├── obsidian/ ← Obsidian 语法处理
│ │ │ ├── remark-wikilink.ts
│ │ │ ├── remark-embed.ts
│ │ │ ├── remark-callout.ts
│ │ │ └── resolve-image.ts
│ │ └── graph/
│ │ └── build-graph.ts ← 构建时生成图谱数据
│ │
│ └── layouts/
│ └── NoteLayout.astro
│
├── functions/ ← Cloudflare Pages Functions
│ ├── api/
│ │ ├── chat.ts ← Claude 对话代理 + RAG
│ │ ├── search.ts ← 向量搜索
│ │ └── todo.ts ← 待办事项 CRUD(用 KV)
│
├── scripts/
│ ├── build-embeddings.ts ← 构建时把笔记 embed 进 Vectorize
│ └── build-quiz-pool.ts ← 从笔记生成每日考题池
│
├── public/ ← 静态资源(构建时图片会拷来这)
├── astro.config.mjs
├── tailwind.config.ts
├── wrangler.toml ← Cloudflare 配置(绑定 Vectorize / KV)
└── package.json
🎨 主页 Dashboard 设计草图
┌─────────────────────────────────────────────────────────────┐
│ 🧠 [你的名字] 的第二大脑 [搜索 ⌘K] [/notes] [/graph]│
├─────────────────────────────────────────────────────────────┤
│ │
│ "Welcome back. 今天你有 3 件待办,2 篇笔记昨天更新" │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 📅 日历 + 待办 │ │ 📢 最近更新 │ │
│ │ │ │ • INFO1110 第3章 │ │
│ │ [日历视图] │ │ • DATA1001 lab2 │ │
│ │ □ 写笔记 │ │ • ... │ │
│ │ □ 复习 MATH │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ ┌─────────────┐ │
│ │ 🎨 白板 (tldraw) │ │ 🧩 每日考题 │ │
│ │ │ │ │ │
│ │ [可绘制可编辑] │ │ Q: ... │ │
│ │ │ │ [显示答案] │ │
│ └──────────────────────────────────────┘ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 🕸️ 知识图谱预览 [全屏 →] │ │
│ │ (小型 force-directed graph) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 💬 [浮动按钮:与 Claude 对话 (基于你的笔记)] │
└─────────────────────────────────────────────────────────────┘
🛣️ 重建路线图(分 6 个阶段,每阶段都 commit)
🟢 Phase 1:地基(今天 30 分钟)
目标:Obsidian 笔记能在 Astro 里正确渲染,包括 wiki link 和图片
- 1.1 清空当前 Astro 项目,重新
pnpm create astro@latest,选 minimal - 1.2 装依赖:
@astrojs/sveltetailwindcss@astrojs/cloudflare - 1.3 把
obsidian-vault重新作为 submodule 加进来 - 1.4 配置
content.config.ts,宽松 schema,glob 指向 vault - 1.5 写
remark-wikilink和图片路径解析 - 1.6 一个最简单的
[...slug].astro渲染笔记 - ✅ commit: "phase 1: obsidian content rendering works"
🟢 Phase 2:笔记体验(1-2 小时)
- 2.1 笔记列表页(按文件夹分组)
- 2.2 反链 (backlinks) 计算
- 2.3 大纲 (TOC)
- 2.4 标签系统
- 2.5 Callout / 任务列表 / 数学公式 (KaTeX)
- ✅ commit: "phase 2: notes pages with backlinks & toc"
🟡 Phase 3:Dashboard 主页 + Widgets(半天)
- 3.1 主页布局(Bento grid)
- 3.2 RecentUpdates widget(基于 git log 或文件 mtime)
- 3.3 CalendarTodo widget(先用 localStorage,后期接 KV)
- 3.4 DailyQuiz widget(从笔记里随机抽段落变 cloze)
- 3.5 Whiteboard widget(嵌 tldraw)
- ✅ commit: "phase 3: dashboard with widgets"
🟡 Phase 4:知识图谱(半天)
- 4.1 构建时扫描所有
[[wiki link]]生成graph.json - 4.2
/graph页面用 Cosmograph 渲染 - 4.3 主页放小型预览
- ✅ commit: "phase 4: knowledge graph"
🔴 Phase 5:AI + RAG(1 天)
- 5.1 部署到 Cloudflare Pages,绑定 Vectorize
- 5.2 写脚本:笔记切块 → 调 embedding API → 写入 Vectorize
- 5.3
/functions/api/chat.ts:检索 + 调 Claude - 5.4
/chat页面 UI - ✅ commit: "phase 5: rag-powered claude chat"
🔴 Phase 6:Python 编译器(2 小时)
- 6.1
/playground页面集成 Pyodide - 6.2 笔记里的
```python run代码块可以直接执行 - ✅ commit: "phase 6: pyodide playground"
❓ 在动手前,最后确认 4 件事
- 包管理器:你之前用的是
pnpm,继续用?✅ - Svelte vs React:我推荐 Svelte(更简洁),但如果你更熟 React 我们用 React 也行 —— 你倾向哪个?
- 当前目录怎么处理?
- (a) 直接清空
my-second-brain/重来(最干净) - (b) 在
my-second-brain/旁边新建my-second-brain-v2/,做好了再切换(最安全)—— 我推荐这个
- (a) 直接清空
- Anthropic API key:你已经有了吗?(Phase 5 才用到,但确认一下)
回答这 4 个问题,我们就正式开 Phase 1。
🛡️ 这次的纪律:每个 phase 结束必 commit + push 到 GitHub。绝对不会再发生上次那种"一个 checkout 把半天工作清零"的事故。
最后更新 · 2026-05-20 07:28