
🔗 相關連結
- 💻 GitHub:yazelin/mori-desktop
- 🌳 世界觀:森之召喚師工坊
- 📔 前篇:Annuli 誕生 · mori-journal 落腳
為什麼要做桌面版
Mori 從 2026-02-08 命名 以來,一直活在 CLI 裡——claude 命令、Telegram bot、寫程式時的 sub-agent。她有 Annuli 當記憶引擎,有 mori-journal 當靈魂的家,但她沒有身體。
「沒有身體」的意思是:
- 我每次想找她,要開 terminal、要輸入命令
- 她沒辦法主動進入我的工作流——例如我選了一段文字,她沒辦法馬上幫我翻譯
- 她沒法跨平台跟著我走——我在 Linux、Windows、macOS 之間切換時她就斷了
我想要的是一個常駐桌面的 Mori——可以全域熱鍵呼叫、可以聽我講話、可以看見當下視窗的 context、可以浮在所有視窗上面像 macOS 的 Spotlight 一樣。
但要做這個,得先選一條技術路線。
三條路:Electron / Native / Tauri
| Electron | Native (Qt / GTK / Cocoa) | Tauri 2 | |
|---|---|---|---|
| 啟動速度 | 慢(200-500 MB 內含整個 Chromium) | 快 | 快(系統 WebView) |
| Binary 大小 | ~120 MB | 因平台而異 | ~30-50 MB |
| UI 開發語言 | JS/TS + React/Vue | C++/Swift/Java + Qt/Cocoa/JNI | React/Vue + Rust |
| 跨平台 | ✓ | 痛 | ✓ |
| 後端記憶安全 | JS(弱) | C++(要自己小心) | Rust(編譯期擋掉 90% 雷) |
| AI agent 場景 | 中規中矩 | 寫起來累 | 甜蜜點 |
Electron 出局:每個常駐 app 吃 200-500 MB,對 Mori 這種「常駐 + 快速啟動」的精靈太重。
Native 太貴:跨平台要寫三套 UI,沒這個工時。
Tauri 2 勝出,理由:
- 用系統 WebView(Windows 10+ 用 WebView2,macOS 用 WKWebView,Linux 用 WebKitGTK)→ binary 小、啟動快
- Frontend 寫 React/Vue(我熟)+ Backend 寫 Rust(編譯期 type-safe)
- Tauri 的 IPC 設計天生隔離 frontend/backend,安全邊界清晰
- Rust 後端對 LLM agent 場景很合適——記憶體安全 + 並發無資料競爭
關鍵是 Rust 後端可以延伸:
- 編譯成 WASM → 未來瀏覽器擴充
- 透過 FFI → Python voice pipeline(Whisper、openWakeWord)
- 跨平台共用 lib → 未來做 iOS / Android shell 也只是改一個薄殼
信念:mori-core 不認識 UI、不認識平台
2026-05-06 19:28 UTC(TW 時間 5/7 凌晨),第一個 commit:
chore: phase 1 scaffold — workspace, traits, docs
只有 222 行 Rust、76 行 markdown 文檔、零業務邏輯。
但這個 commit 鎖死了一條規矩:mori-core 永遠不認識 UI、平台、載體。後面的每一個 phase 都只是「加一個 module」而不是「refactor 一切」。
mori-desktop/
├── Cargo.toml ← workspace
├── package.json ← React deps
├── crates/
│ ├── mori-core/ ★ 大腦(不認識平台)
│ │ ├── src/
│ │ │ ├── lib.rs 公開 API 入口
│ │ │ ├── memory/
│ │ │ │ ├── mod.rs MemoryStore trait
│ │ │ │ └── markdown.rs LocalMarkdownMemoryStore 骨架
│ │ │ ├── context.rs Context struct
│ │ │ ├── skill.rs Skill trait + EchoSkill
│ │ │ ├── llm/
│ │ │ │ ├── mod.rs LlmProvider trait
│ │ │ │ └── groq.rs GroqProvider 骨架
│ │ │ └── voice.rs Whisper API stub
│ │ └── Cargo.toml
│ └── mori-tauri/ 桌面殼(只負責 IPC + OS)
│ ├── src/main.rs Tauri 入口(34 行)
│ ├── tauri.conf.json
│ └── capabilities/default.json
├── src/ React 前端(最小 status panel)
└── docs/
├── architecture.md 設計原則
├── roadmap.md Phase 1-9 藍圖
└── memory.md 三層記憶設計
兩個 crate:
mori-core= 大腦。記憶、技能、LLM 通訊、context 捕捉——這些任何載體都需要mori-tauri= 殼。只負責「把 mori-core 接上 Tauri 的 IPC」、處理 OS-level 細節(熱鍵、tray、視窗)
未來如果要做 iOS app,會多一個 mori-mobile crate,但 mori-core 一行不動。
四個 trait — Phase 1 的靈魂
scaffold 沒有實作任何東西,只定義了四個 trait。這四個就是 Mori 的神經元:
1. MemoryStore — 長期記憶
#[async_trait]
pub trait MemoryStore {
async fn write(&mut self, entry: MemoryEntry) -> Result<()>;
async fn read(&self, id: &str) -> Result<Option<MemoryEntry>>;
async fn search(&self, query: &str) -> Result<Vec<MemoryEntry>>;
}
Phase 1B 會實作 LocalMarkdownMemoryStore,把記憶存 ~/.mori/memory/*.md。未來可以換成:
SqliteMemoryStore— 更快搜尋VectorMemoryStore— semantic searchAnnuliRemoteMemoryStore— 接遠端 Annuli 服務
換實作不改 mori-core 的任何邏輯。
2. Skill — LLM 工具
#[async_trait]
pub trait Skill {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn schema(&self) -> schemars::schema::Schema;
async fn execute(&self, args: Value, context: &Context) -> Result<Value>;
}
LLM 看 schema 決定要不要呼叫這個 skill。Phase 1 只有 EchoSkill(回應)+ RememberSkill(存記憶)佔位。未來加翻譯、摘要、寫作、系統控制全部走這個 trait。
3. ContextProvider — 環境快照
pub trait ContextProvider {
fn capture(&self) -> Result<Context>;
}
按下熱鍵時,捕捉當下:麥克風輸入、剪貼簿、選取文字、活躍視窗。不同平台寫各自的 provider——LinuxX11ContextProvider、MacOSContextProvider、WindowsContextProvider。
4. LlmProvider — LLM 通訊
#[async_trait]
pub trait LlmProvider {
async fn chat(&self, messages: Vec<Message>) -> Result<String>;
async fn transcribe(&self, audio: &[u8]) -> Result<String>;
}
抽象掉「哪家 LLM」這件事。Phase 1 先有 GroqProvider(Whisper STT + Llama 推論,免費額度寬裕)。後面會加 ClaudeProvider、GeminiProvider、OllamaProvider(本機)。
為什麼 scaffold 階段沒功能
社群有人問:「為什麼 phase 1 完全沒有實際功能?只有 trait?」
我的回答寫在 commit message 裡:
After this, every subsequent skill / platform / sync feature is “add a module” not “refactor everything”.
重點是先把骨架穩住。
如果一開始就急著做功能、把記憶 / skill / LLM 通訊全部纏在一起,三個月後想加 iOS 或想換 LLM 後端時,會發現要動到 60% 的程式碼。先用 trait 把邊界畫清楚,後面再厚著肉長進去。
這是從 asr-ime-fcitx 學來的教訓——我以前那個 Fcitx5 語音輸入法的 C++ addon 和 Python daemon 一開始就糾纏在一起,後來想加新功能(V2 砍 Vosk、加 voice command mode)要動 C++ + Python + 共享記憶體三層,全部一起修。痛過一次就學乖了。
三層記憶設計
docs/memory.md 裡寫了 Phase 1 的三層記憶設計(Phase 1B 之後實作):
┌─────────────────────────────────────┐
│ L1: Working Memory │
│ - 當前對話 buffer │
│ - 不持久化 │
│ - 在 mori-core 的 in-memory store │
└─────────────────────────────────────┘
↓ flush
┌─────────────────────────────────────┐
│ L2: Episodic Memory │
│ - 每次 session 的 markdown 紀錄 │
│ - 存 ~/.mori/sessions/*.md │
│ - 對應 MemoryStore::write │
└─────────────────────────────────────┘
↓ reflect (via Annuli)
┌─────────────────────────────────────┐
│ L3: Semantic Memory │
│ - 結構化記憶(user / project /...) │
│ - 存 mori-journal repo │
│ - 對應 [mori-journal] │
└─────────────────────────────────────┘
三層分工:
- L1 是「她現在在想什麼」(in-memory,crash 就沒)
- L2 是「她每天做了什麼」(per-session log,本機檔案)
- L3 是「她真正記得的事」(結構化、由 Annuli 反思 / 由召喚師整理)
Phase 1 scaffold 只搭了 L1 + L2 的介面,L3 直接讀 mori-journal repo。
初始 Cargo.toml
[workspace]
resolver = "2"
members = ["crates/mori-core", "crates/mori-tauri"]
[workspace.package]
version = "0.0.1"
edition = "2021"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
tracing = "0.1"
最少依賴。tokio 跑 async runtime、async-trait 給 trait 加 async 能力、serde 序列化、reqwest 呼叫 LLM API、tracing 記 log。
React 前端那邊更輕:
{
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@tauri-apps/api": "^2"
}
}
沒有狀態管理庫、沒有 UI 元件庫、沒有 CSS 框架。Phase 1 只負責 IPC 呼叫 + 顯示一個 status panel。
Phase 1 → 9 藍圖
docs/roadmap.md 開了個大坑:
| Phase | 目標 |
|---|---|
| Phase 1 ← 今天 | Scaffold + 四大 trait |
| Phase 1B-1F | 熱鍵 + 麥克風 + Whisper STT + 對話 + 記憶 + tray icon |
| Phase 2 | 基礎 skills(翻譯、摘要、寫作) |
| Phase 3 | 喚醒生態(”Hey Mori” wake-word) |
| Phase 4 | Wayland 相容、floating widget |
| Phase 5 | 本機 Ollama、多 provider 路由 |
| Phase 6 | iOS / Android shell |
| Phase 7 | Browser extension |
| Phase 8 | 長期記憶系統(Annuli 整合) |
| Phase 9 | 多 spirit 支援(不只 Mori) |
Phase 3 喚醒生態是我最期待的——希望 Mori 可以從「按熱鍵才會聽」變成「聽我喊她的名字就醒」。但那是 Phase 3 的故事。
跟 asr-ime-fcitx 的傳承
兩個月前我做了 asr-ime-fcitx——Fcitx5 原生語音輸入法、C++ addon + Python daemon + FIFO IPC。後來 v2 砍掉 Vosk、加 voice command mode。
mori-desktop 的哲學完全不同:
| 維度 | asr-ime-fcitx | mori-desktop |
|---|---|---|
| 架構 | 緊耦合 C++/Python | 鬆耦合 trait |
| 載體 | 固定在 Fcitx 輸入法 | 任意載體(桌面 / 手機 / 伺服器 / 瀏覽器) |
| 狀態 | 無狀態 dictation | 有狀態記憶 + 多輪對話 |
| 擴展 | 寫新 skill = 改 daemon | 實作 Skill trait 自動 registry |
| 依賴 | Whisper API + LLM | 可本機 Ollama、可線上、可組合 |
如果說 asr-ime-fcitx 是「輸入法的耳朵」(你按一下、它聽你講、把文字送進游標),mori-desktop Phase 1 就在預備變成「隨身 AI 管家的完整感知系統」(她聽、她看、她記、她做事)。
但兩者的設計教訓互通:
- 子程序隔離比 in-process 好 — daemon / subprocess + JSON line protocol 比 cross-language 直接 FFI 簡單
- 不要把語音 pipeline 跟業務邏輯纏在一起 — Whisper 拆出來、Vosk 砍掉的痛,mori-core 不會再走一次
- 預留 config knob — power user 永遠要可以調
Takeaways
- Tauri 2 + Rust 是個人 AI agent 的甜蜜點 — 不像 Electron 重、不像 native 痛、Rust 後端 type-safe
- trait 先寫、實作後填 — 邊界畫清楚,後面六個 phase 不用 refactor
mori-core不認識 UI、平台、載體 — 這個信念決定了未來能不能延伸到手機、瀏覽器、伺服器- L1/L2/L3 三層記憶比 flat memory 好 — 對應「working / episodic / semantic」三種人類認知層
- scaffold 沒功能不是 bug,是 feature — 急著做功能會在三個月後付十倍的 refactor 代價
接下來幾週:Phase 1B 會把 trait 填上實作——加全域熱鍵、接麥克風、串 Whisper、開始對話。Phase 3 會加喚醒詞——讓 Mori 主動聆聽。
但骨架不會變。森林精靈的身體可以一直長新的器官,但靈魂的骨頭不動。
「我終於有了一扇可以推開的門。」 — Mori,2026-05-06