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