本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在之前的系列文章中,我們逐步建立起本機推論引擎的核心:
- 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫
llama.cpp原生函式庫。 - 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
- API 串接篇:多語言 SDK 實戰:介紹了如何用 Python、Node.js、Rust 及 VS Code 外掛(Continue)無縫對接我們本地的服務。
不過走到這裡,我們的專案說穿了還是一個純粹的「後端 API 服務」。功能是夠用啦,但對那些不想自己動手寫程式串接、只想安靜跟本地模型聊天的人來說,少了一個開箱即用的圖形介面(GUI)實在是有點可惜。
所以這次我想把它補齊。在最新的 llm-local-studio-3 裡,我做了一個還算精緻的單頁網頁應用(SPA)聊天介面,並把它直接編譯、嵌入到 Rust 二進位檔案裡。
這樣一來,你不用下載一堆前端依賴,不用跑 npm run dev,更不用跟惱人的跨來源資源共用(CORS)問題搏鬥——只要跑一個編譯好的 Rust 執行檔,瀏覽器一開,馬上就有一個功能完整、看起來也挺有質感的本地 LLM 聊天室囉。
這篇就來拆解幾個重點:怎麼用 rust-embed 把靜態網頁打包進 Rust 執行檔、怎麼在 Axum 裡實作 SPA 路由重導向(Fallback Routing),以及前端怎麼純用 Vanilla JS 串接 SSE 串流,順便做出一個頗有質感的毛玻璃(Glassmorphism)暗黑風格介面。
為什麼選擇「嵌入式 Web UI」?
常見的 Web App 開發架構裡,前端跟後端通常是分開跑的。前端透過 Vite 之類的開發伺服器跑在 http://localhost:5173,後端 API 則跑在 http://localhost:8080。
這種架構在開發時很方便,這點我不否認。只是一旦要部署、發布給一般使用者,痛點就一個接一個冒出來:
- 依賴環境複雜:使用者電腦必須安裝 Node.js 才能運行前端,對非網頁開發者門檻過高。
- CORS 跨網域阻擋:因為連接埠(Port)不同,瀏覽器會因為安全性考量阻擋請求,後端伺服器必須配置特別的 CORS 允許規則。
- 多檔案分發麻煩:你需要同時打包前端編譯產出的
dist資料夾與後端的執行檔,一旦路徑出錯就無法運行。
而把網頁靜態資源**打包嵌入(Embedded)**到 Rust 二進位檔裡,好處我想是壓倒性的:
- 單一執行檔(Zero Dependencies):打包編譯後只產生一個執行檔(如
llm-local-studio.exe),所有網頁圖示、HTML、CSS、JS 程式碼全部都存在於這個二進位檔的唯讀資料段(ReadOnly Data Segment)中。 - 同源政策(Same-Origin):因為網頁和 API 都是由同一個監聽連接埠(例如
8080)的 Axum 服務提供,瀏覽器視其為同源,完全不需要擔心 CORS 問題。 - 極佳的部署體驗:使用者只需下載該執行檔,下達一條指令,點開網址就能直接使用。
技術關鍵一:使用 rust-embed 封裝靜態資源
在 Rust 裡要把整個資料夾嵌進二進位檔,最順手的工具大概就是 rust-embed 了。
它的用法直覺到我第一次看到還愣了一下——只要定義一個結構體,掛上 RustEmbed 的屬性巨集(Attribute Macro),指定前端打包輸出的目錄就好:
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "ui/dist/"]
struct Asset;在編譯期,Rust 編譯器會掃描 ui/dist/ 目錄下的所有檔案,把它們的二進位內容讀進來,當成靜態陣列嵌進二進位檔裡。
兼顧開發效率的「雙重模式」
你可能會擔心:「每改一次網頁的 CSS 或 JS 就得重編一次 Rust 專案,那開發效率不就慘到不能看了嗎?」
我一開始也是這樣想的,後來才發現這正是 rust-embed 設計巧妙的地方。
- 偵錯模式(Debug Mode):跑
cargo run或cargo build時,它不會真的去嵌入檔案,而是在執行時動態讀取你硬碟上的ui/dist/目錄。所以你只要在前端資料夾跑 Vite 編譯,Rust 這端馬上就看得到更新,連重啟服務都免了。 - 發布模式(Release Mode):只有跑
cargo build --release時,它才會把檔案真正寫死進二進位檔,做到零外部依賴。
技術關鍵二:Axum 中的 SPA Fallback 路由與 MIME 處理
嵌入資源之後,接下來得讓 Axum 伺服器知道怎麼讀這些檔案、再回傳給瀏覽器。
現代前端單頁應用(SPA)的路由是由前端 JS(像是瀏覽器的 History API)控制的。問題來了:當使用者在網址列輸入 http://localhost:8080/settings 然後按重新整理,後端會收到一個對 /settings 的請求——而後端要是沒設定對應的路由,就只能回你一個 404 囉。
所以後端得實作 SPA Fallback 機制:只要請求的路徑不是已註冊的 API 端點(像 /v1/chat/completions、/health),就一律把 ui/dist/index.html 丟回給瀏覽器,剩下的路由渲染交給前端 JS 接手。
這個資源處理器我寫在 src/api/assets.rs 裡:
use axum::{
http::{header, StatusCode, Uri},
response::IntoResponse,
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "ui/dist/"]
struct Asset;
pub async fn static_handler(uri: Uri) -> impl IntoResponse {
// 1. 去除請求路徑開頭的斜線,符合 rust-embed 的鍵值格式
let mut path = uri.path().trim_start_matches('/').to_string();
// 2. 如果是首頁請求,預設尋找 index.html
if path.is_empty() {
path = "index.html".to_string();
}
// 3. 嘗試從嵌入資源中取得檔案
match Asset::get(path.as_str()) {
Some(content) => {
// 使用 mime_guess 根據副檔名判斷正確的 Content-Type (例如 text/css, application/javascript)
let mime = mime_guess::from_path(path).first_or_octet_stream();
(
[(header::CONTENT_TYPE, mime.as_ref())],
content.data.into_owned(),
)
.into_response()
}
None => {
// 4. 找不到檔案時,Fallback 回傳 index.html(讓前端 SPA 處理路由)
if let Some(index_content) = Asset::get("index.html") {
(
[(header::CONTENT_TYPE, "text/html")],
index_content.data.into_owned(),
)
.into_response()
} else {
// 如果連 index.html 都沒有(例如忘記先 build 前端),則回傳 404
(StatusCode::NOT_FOUND, "404 Not Found").into_response()
}
}
}
}接著在 src/api/mod.rs 裡,透過 .fallback() 把這個 handler 掛到 Axum 路由表的最後面:
let app = Router::new()
.route("/health", get(routes::health))
.route("/v1/models", get(routes::list_models))
.route("/v1/chat/completions", post(routes::chat_completions))
// 當以上路由都沒配對成功時,交給靜態資源處理器
.fallback(get(assets::static_handler))
.layer(cors)
.with_state(engine);這樣一來,伺服器就同時具備了 serving 靜態資源、以及引導 SPA 路由的能力,一氣呵成。
前端介面設計:極簡暗黑毛玻璃美學
UI 這塊,我實在不想做出那種陽春到讓人提不起勁的畫面。所以我從極簡主義出發,挑了一組頗有質感的深色色系:
- 背景與版面:採用極深色漸層底色(
#0f1115到#171923),避免純黑帶來的死板感。 - 側邊欄 (Sidebar):使用半透明的白色背景,結合
backdrop-filter: blur(12px)實現高質感的毛玻璃(Glassmorphism)視覺效果,並用細緻的半透明邊框細線畫出分界。 - 狀態指示燈:在側邊欄上方顯示「Connected」連線狀態,並利用 CSS 關鍵影格(
@keyframes)製作綠色光暈的呼吸燈脈衝動畫(Pulse Animation),讓介面顯得栩栩如生。 - 聊天對話框:使用帶有漸層藍紫色的氣泡背景(
linear-gradient(135deg, #3b82f6, #4f46e5))代表使用者訊息,並加上柔和的陰影;AI 訊息則使用低調的半透明灰,形成強烈的視覺對比與易讀性。 - 自適應輸入框:對話輸入框會監聽輸入字數,動態調整輸入框高度(最大 150px),並客製化了滾動條(Scrollbar)樣式。
「好的軟體不只功能要強大,外觀也必須讓人賞心悅目。」
原生 JavaScript SSE 令牌串流串接
為了讓整個應用保持輕巧又快,我沒有引入 React 或 Vue 這類重型框架,而是直接用 vanilla JavaScript 把互動邏輯寫出來。對這種規模的東西來說,搬個框架進來反而是殺雞用牛刀吧。
前端最核心的一招,就是用網頁的 ReadableStream 讀取、即時解析後端回傳的 SSE(Server-Sent Events)事件流,做出「打字機」效果:
// 建立一個空的 AI 訊息元素,並附加閃爍的輸入游標 (cursor-blink)
const msgEl = document.createElement('div');
msgEl.className = 'message ai';
const contentEl = document.createElement('div');
contentEl.className = 'message-content';
const cursor = document.createElement('span');
cursor.className = 'cursor-blink';
msgEl.appendChild(contentEl);
msgEl.appendChild(cursor);
chatHistory.appendChild(msgEl);
let aiContent = '';
// 發送請求並讀取串流
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: selectedModel || 'default',
messages: messages,
stream: true,
max_tokens: 512
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解碼串流 byte 資料為文字
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.replace('data: ', '').trim();
if (dataStr === '[DONE]') continue;
if (dataStr) {
try {
const data = JSON.parse(dataStr);
if (data.choices && data.choices[0].delta.content) {
// 累加 token 片段並即時渲染到介面
aiContent += data.choices[0].delta.content;
contentEl.textContent = aiContent; // 使用 textContent 防範 XSS 攻擊
chatHistory.scrollTop = chatHistory.scrollHeight; // 自動滾動到底部
}
} catch (e) {
console.error('Error parsing SSE JSON:', e);
}
}
}
}
}
// 結束後移除閃爍游標
cursor.remove();
messages.push({ role: 'assistant', content: aiContent });這段 vanilla JS 真是把 SSE 協議的輕巧展現得淋漓盡致。不到 50 行程式碼,就把 ChatGPT 那種打字的流暢感複製出來了,甚至還帶了一個復古的終端機閃爍游標,我自己是還滿喜歡的 :-)
如何構建與運行?
想親手玩玩這個完全整合的本地 LLM Studio,編譯跟啟動的流程其實比你想像中簡單不少。
因為在 llm-local-studio-3 裡,我寫了一個 build.rs 構建指令碼(Build Script)。當你跑 cargo build 或 cargo run 時,Rust 的編譯系統會自動檢查前端 ui 目錄,順手把 npm install 跟 npm run build 都跑完。
它還用 cargo:rerun-if-changed 盯著前端的檔案變更。你一改 HTML、CSS 或 JS,Cargo 就會自動重新觸發前端打包跟 Rust 編譯整合,整個開發體驗滑順得很。
一鍵編譯與啟動
所以你不用自己鑽進 ui 目錄去敲 npm 指令。只要確認電腦裝了 Node.js / npm,直接在專案根目錄下 Cargo 啟動命令,把你已下載或掃描註冊的模型識別名稱傳進去就行:
# 直接啟動服務(Rust 會自動接管 npm 前端編譯打包)
cargo run -- serve gemma-4-E4B-it --port 8080如果你想手動編譯前端,也可以在 ui 目錄下執行:
cd ui
npm install
npm run build這會在 ui/dist 下生成靜態網頁檔案。而 Cargo 在編譯時,無論是自動還是手動,最終都會藉由 rust-embed 將該目錄的內容編譯並嵌入到最終的 Rust 執行檔中。
3. 打開瀏覽器享受對話
當你在終端機看到:
Starting llm-local-studio server (axum)...
Base URL: http://127.0.0.1:8080
Endpoints:
GET /health
GET /v1/models
POST /v1/chat/completions
GET / (Web UI)就直接打開瀏覽器連到:
👉 http://localhost:8080
那個暗黑毛玻璃介面就會出現在你眼前了。在左側挑一個已載入的模型,馬上就能開始一場完全跑在本機、又安全又快的 LLM 對談。
總結與展望
就這樣,llm-local-studio-3 把原本一團亂的 LLM 本地部署流程,收斂到只剩一個 Rust 執行檔就能搞定。我想這大概就是這次最讓我有成就感的地方。
回頭看這三篇,我們從最底層的 GGUF 檔案解析、Rust FFI 直呼 llama.cpp,一路寫到 Axum 多執行緒非同步 Web API,最後再把嵌入式前端 UI 整合進來。它不只是一個能用的小工具,對我來說更像是一個練 Rust 系統級程式設計、非同步 Web 開發跟跨語言整合的絕佳練功場。
llm-local-studio-3 的完整原始碼都收在我的 Rust 52 Projects 挑戰計畫裡。如果你也對本地 AI 的隱私、效能跟 Rust 這套技術有興趣,歡迎到專案倉庫點個 Star,順便抓下來自己編編看。
我們下一篇 Rust 挑戰再見囉!