← 返回

笔记

🎉 太好了!这是最聪明的选择

你即将拥有:

  • 💰 省下 ¥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 内容
   }
  1. 为这三个函数写 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 解析)

暂时先实现这些

  1. 标准 markdown(标题、粗斜体、列表、引用、分隔线)
  2. GFM 扩展(表格、任务列表、删除线)
  3. 代码块语法高亮(python/ts/js/bash/json 至少)
  4. 数学公式 KaTeX
  5. 脚注

先不要做:wiki link、callouts、嵌入、标签(这些后面单独做)

为核心函数写 Vitest 测试,测试输入一段 markdown,断言输出 HTML 包含特定内容。

完成后跑测试给我看。


### ✅ Prompt 3 验收
- [ ] `renderMarkdown` 函数存在
- [ ] 测试至少覆盖:标准 md / 表格 / 代码块 / 数学
- [ ] `pnpm test` 全部通过

---

## 🟢 Prompt 4:Wiki Links + Callouts(Obsidian 特有语法)

现在加上 Obsidian 特有的语法。

写一个 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 install
  • pnpm test → 全绿
  • pnpm build → 零 warning
  • pnpm 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 后:

请检查刚才你写的代码,看看有没有:

  1. TypeScript 类型不严格的地方
  2. 没处理的边缘情况
  3. 可以提取的重复代码 列出问题,但不要修,先让我看看。

⚠️ 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
IslandsSvelte 5
样式Tailwind CSS v4
拖拽级别Level 2(位置 + 尺寸)
拖拽权限所有访客可拖,存各自 localStorage
目录策略清空 my-second-brain/ 重来
部署Cloudflare Pages
AI 后端Cloudflare Pages Functions
向量库Cloudflare Vectorize
LLMWorkers AI(暂时,没 Claude key)
PythonPyodide
笔记源obsidian-vault (git submodule)

🛡️ 开工前先做安全备份(重要!)

清空之前,务必确认这两件事,否则上次的悲剧会重演:

✅ 检查 1:你的 Obsidian vault 在哪?

你的笔记本体应该有 两个位置

  1. 原始 Obsidian 库(你平时在 Obsidian 里打开的那个文件夹)
  2. 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

这三段的输出贴给我,我会基于你的真实环境给出:

  1. 🗑️ 精确的"安全清空"命令(保留 .git,只清内容,还是整个删掉重 clone)
  2. 🏗️ pnpm create astro 的完整参数
  3. 📦 一次性安装所有 Phase 1 依赖的命令
  4. ⚙️ astro.config.mjs 的初始配置
  5. 📝 第一个能跑起来的 [...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 的 slug
  • gray-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>
)}

创建 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.jsonscripts

"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.mjsremarkPlugins 数组最前面加入:

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/,应当看到:

  1. 首页显示 obsidian-vault 里的所有 .md 笔记列表
  2. 点击任意笔记 → 进入 /notes/<slug>,显示正文 + 右侧 TOC + 底部反链
  3. 笔记里的 [[xxx]] 渲染成蓝色虚线链接
  4. 笔记里的 ![[image.png]] 渲染成图片
  5. 右上角太阳/月亮图标可以切换暗黑模式(刷新不丢失)
  6. 顶部"标签"链接可以进入 /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

⚠️ 注意事项

  1. Windows 路径:所有命令使用 PowerShell 语法(反斜杠 \mkdir
  2. submodule clone 后的内容:clone 完成后请在 obsidian-vault/ 目录里验证能看到 DATA1001/INFO1110/ 等文件夹
  3. 如果 obsidian-vault 文件夹已存在并报错:先 Remove-Item obsidian-vault -Recurse -Force 再 submodule add
  4. 如果 Astro dev 启动报错"找不到 collection":检查 obsidian-vault 目录下是否真的有 .md 文件
  5. 中文笔记名 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-svelteTailwind 速度,shadcn 提供高质量组件底子
Markdownremark + rehype 插件链处理 Obsidian 语法
图谱CosmographD3-forceCosmograph 性能强(WebGL),千节点不卡
白板tldraw开箱即用、可嵌入、可保存
日历/待办FullCalendar + 本地 JSON / Cloudflare KV
部署Cloudflare Pages你已有,Astro 适配完美
AI 后端Cloudflare Pages Functions同仓库 /functions,免费够用
向量库Cloudflare Vectorize原生集成、免运维
LLMClaude API (via Workers AI 或直连 Anthropic)
PythonPyodide纯浏览器,零后端成本

🗂️ 项目目录结构(最终版)

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/svelte tailwindcss @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 件事

  1. 包管理器:你之前用的是 pnpm,继续用?✅
  2. Svelte vs React:我推荐 Svelte(更简洁),但如果你更熟 React 我们用 React 也行 —— 你倾向哪个?
  3. 当前目录怎么处理?
    • (a) 直接清空 my-second-brain/ 重来(最干净)
    • (b) 在 my-second-brain/ 旁边新建 my-second-brain-v2/,做好了再切换(最安全)—— 我推荐这个
  4. Anthropic API key:你已经有了吗?(Phase 5 才用到,但确认一下)

回答这 4 个问题,我们就正式开 Phase 1

🛡️ 这次的纪律:每个 phase 结束必 commit + push 到 GitHub。绝对不会再发生上次那种"一个 checkout 把半天工作清零"的事故。

最后更新 · 2026-05-20 07:28