featured.svg

本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。

在之前的系列文章中,我們逐步建立起本機推論引擎的核心:

  1. 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫 llama.cpp 原生函式庫。
  2. 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
  3. 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

這種架構在開發時很方便,這點我不否認。只是一旦要部署、發布給一般使用者,痛點就一個接一個冒出來:

  1. 依賴環境複雜:使用者電腦必須安裝 Node.js 才能運行前端,對非網頁開發者門檻過高。
  2. CORS 跨網域阻擋:因為連接埠(Port)不同,瀏覽器會因為安全性考量阻擋請求,後端伺服器必須配置特別的 CORS 允許規則。
  3. 多檔案分發麻煩:你需要同時打包前端編譯產出的 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 runcargo 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 buildcargo run 時,Rust 的編譯系統會自動檢查前端 ui 目錄,順手把 npm installnpm 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
 Tip

如果你想手動編譯前端,也可以在 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 挑戰再見囉!