本文由 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 伺服器端就能立刻看到更新,無需重啟 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 挑戰見!