Skip to content

Commit e18251d

Browse files
Copilotyiwang
andauthored
PoC: Egui web UI via WASM with desktop code reuse (#28)
* Initial plan * Add egui web UI infrastructure with WASM support Co-authored-by: yiwang <142937+yiwang@users.noreply.github.com> * Add comprehensive egui web PoC documentation Co-authored-by: yiwang <142937+yiwang@users.noreply.github.com> * Improve build script and add visual layout documentation Co-authored-by: yiwang <142937+yiwang@users.noreply.github.com> * Address code review feedback - fix UTF-8 truncation, improve readability, and portable shebang Co-authored-by: yiwang <142937+yiwang@users.noreply.github.com> * Add comprehensive implementation summary for PoC Co-authored-by: yiwang <142937+yiwang@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yiwang <142937+yiwang@users.noreply.github.com>
1 parent 38ceeb2 commit e18251d

13 files changed

Lines changed: 972 additions & 0 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ target
2626
.*
2727
!.gitignore
2828
!.github/
29+
30+
# WASM build artifacts (generated by build-egui-web.sh)
31+
crates/server/ui/egui/*.wasm
32+
crates/server/ui/egui/*.js
33+
crates/server/ui/egui/*.d.ts

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,14 @@ When the daemon is running:
219219
| `GET /api/memory/search?q=<query>` | Search memory |
220220
| `GET /api/memory/stats` | Memory statistics |
221221

222+
### Egui Web UI (PoC)
223+
224+
LocalGPT includes a Proof of Concept for running the desktop Egui UI in the browser via WebAssembly. This enables code reuse between desktop and web interfaces.
225+
226+
**Try it**: After building the WASM UI with `./build-egui-web.sh`, visit `http://localhost:31327/egui`
227+
228+
See [`docs/egui-web-poc.md`](docs/egui-web-poc.md) for details on architecture, benefits, tradeoffs, and implementation.
229+
222230
## Blog
223231

224232
[Why I Built LocalGPT in 4 Nights](https://localgpt.app/blog/why-i-built-localgpt-in-4-nights) — the full story with commit-by-commit breakdown.

build-egui-web.sh

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env bash
2+
# Build script for LocalGPT egui web UI
3+
#
4+
# This script compiles the server crate to WASM and generates the necessary
5+
# JavaScript bindings for the egui web UI.
6+
#
7+
# Prerequisites:
8+
# - Rust with wasm32-unknown-unknown target
9+
# - wasm-bindgen-cli (installed automatically if missing)
10+
#
11+
# Output:
12+
# crates/server/ui/egui/localgpt_server_bg.wasm (~2-3 MB)
13+
# crates/server/ui/egui/localgpt_server.js
14+
# crates/server/ui/egui/localgpt_server.d.ts (if TypeScript enabled)
15+
16+
set -e
17+
18+
# Check if wasm32-unknown-unknown target is installed
19+
if ! rustup target list --installed | grep -q wasm32-unknown-unknown; then
20+
echo "Installing wasm32-unknown-unknown target..."
21+
rustup target add wasm32-unknown-unknown
22+
fi
23+
24+
# Check if wasm-bindgen-cli is installed
25+
if ! command -v wasm-bindgen &> /dev/null; then
26+
echo "wasm-bindgen-cli not found. Installing..."
27+
echo "Note: This may take a few minutes..."
28+
cargo install wasm-bindgen-cli
29+
fi
30+
31+
echo "Building WASM..."
32+
cd "$(dirname "$0")"
33+
34+
# Build the server crate for WASM with egui-web feature
35+
# Note: --release is important for acceptable binary size
36+
cargo build \
37+
--package localgpt-server \
38+
--lib \
39+
--target wasm32-unknown-unknown \
40+
--features egui-web \
41+
--release
42+
43+
echo "Generating JavaScript bindings..."
44+
# Create output directory for WASM artifacts
45+
mkdir -p crates/server/ui/egui
46+
47+
# Run wasm-bindgen to generate JS bindings
48+
# --target web: Generate ES module that can be imported
49+
# --no-typescript: Skip .d.ts generation (optional)
50+
wasm-bindgen \
51+
--out-dir crates/server/ui/egui \
52+
--target web \
53+
--no-typescript \
54+
target/wasm32-unknown-unknown/release/localgpt_server.wasm
55+
56+
# Optional: Optimize WASM with wasm-opt (from binaryen)
57+
if command -v wasm-opt &> /dev/null; then
58+
echo "Optimizing WASM with wasm-opt..."
59+
wasm-opt \
60+
-Oz \
61+
crates/server/ui/egui/localgpt_server_bg.wasm \
62+
-o crates/server/ui/egui/localgpt_server_bg.wasm
63+
fi
64+
65+
echo ""
66+
echo "✅ WASM build complete!"
67+
echo ""
68+
echo "Output files:"
69+
ls -lh crates/server/ui/egui/
70+
echo ""
71+
echo "📦 The egui web UI can now be served at /egui endpoint"
72+
echo ""
73+
echo "Next steps:"
74+
echo " 1. Start the server: localgpt daemon start"
75+
echo " 2. Open browser: http://localhost:31327/egui"
76+
echo ""

crates/server/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ license.workspace = true
66
repository.workspace = true
77
description = "LocalGPT HTTP server and Telegram bot"
88

9+
[lib]
10+
crate-type = ["cdylib", "rlib"]
11+
912
[dependencies]
1013
localgpt-core = { workspace = true }
1114

@@ -32,3 +35,16 @@ teloxide = { version = "0.17", features = ["macros"] }
3235
# Utilities
3336
tokio-stream = "0.1"
3437
async-stream = "0.3"
38+
39+
# Egui web UI (optional)
40+
eframe = { version = "0.33", optional = true, default-features = false }
41+
42+
[target.'cfg(target_arch = "wasm32")'.dependencies]
43+
wasm-bindgen = "0.2"
44+
wasm-bindgen-futures = "0.4"
45+
web-sys = { version = "0.3", features = ["HtmlCanvasElement", "Window", "Document"] }
46+
console_error_panic_hook = "0.1"
47+
48+
[features]
49+
default = []
50+
egui-web = ["eframe"]

crates/server/src/http.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ impl Server {
139139
// Web UI routes
140140
.route("/", get(serve_ui_index))
141141
.route("/ui/{*path}", get(serve_ui_file))
142+
// Egui web UI routes (PoC)
143+
.route("/egui", get(serve_egui_index))
144+
.route("/egui/{*path}", get(serve_egui_file))
142145
// API routes
143146
.route("/health", get(health_check))
144147
.route("/api/sessions", post(create_session))
@@ -364,6 +367,18 @@ fn serve_ui_asset(path: &str) -> Response {
364367
}
365368
}
366369

370+
// Serve egui web UI index
371+
async fn serve_egui_index() -> Response {
372+
serve_ui_asset("egui.html")
373+
}
374+
375+
// Serve egui web UI files (WASM, JS, etc.)
376+
async fn serve_egui_file(Path(path): Path<String>) -> Response {
377+
// Serve WASM artifacts from ui/egui/ directory
378+
let egui_path = format!("egui/{}", path);
379+
serve_ui_asset(&egui_path)
380+
}
381+
367382
// Status endpoint
368383
#[derive(Serialize)]
369384
struct StatusResponse {

crates/server/src/lib.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,39 @@ mod http;
22
pub mod telegram;
33
mod websocket;
44

5+
#[cfg(feature = "egui-web")]
6+
pub mod web;
7+
58
pub use http::Server;
9+
10+
// WASM entry point for egui web UI
11+
#[cfg(all(target_arch = "wasm32", feature = "egui-web"))]
12+
use wasm_bindgen::prelude::*;
13+
14+
#[cfg(all(target_arch = "wasm32", feature = "egui-web"))]
15+
#[wasm_bindgen]
16+
pub async fn start_web_ui(canvas_id: &str) -> Result<(), wasm_bindgen::JsValue> {
17+
// Redirect `log` message to `console.log` and friends:
18+
console_error_panic_hook::set_once();
19+
20+
let web_options = eframe::WebOptions::default();
21+
22+
let document = web_sys::window()
23+
.ok_or("No window")?
24+
.document()
25+
.ok_or("No document")?;
26+
27+
let canvas = document
28+
.get_element_by_id(canvas_id)
29+
.ok_or_else(|| format!("Failed to find canvas with id: {}", canvas_id))?
30+
.dyn_into::<web_sys::HtmlCanvasElement>()
31+
.map_err(|_| format!("{} was not a HtmlCanvasElement", canvas_id))?;
32+
33+
eframe::WebRunner::new()
34+
.start(
35+
canvas,
36+
web_options,
37+
Box::new(|cc| Ok(Box::new(web::WebApp::new(cc)))),
38+
)
39+
.await
40+
}

crates/server/src/web/app.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//! Egui web application
2+
3+
use eframe::egui;
4+
5+
/// The main web application
6+
#[derive(Default)]
7+
pub struct WebApp {
8+
message_input: String,
9+
messages: Vec<Message>,
10+
session_id: Option<String>,
11+
status: Status,
12+
}
13+
14+
#[derive(Clone)]
15+
struct Message {
16+
role: String,
17+
content: String,
18+
}
19+
20+
#[derive(Default)]
21+
struct Status {
22+
connected: bool,
23+
model: String,
24+
version: String,
25+
}
26+
27+
impl WebApp {
28+
/// Create a new web app
29+
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
30+
Self {
31+
message_input: String::new(),
32+
messages: Vec::new(),
33+
session_id: None,
34+
status: Status {
35+
connected: true,
36+
model: "claude-cli/opus".to_string(),
37+
version: "0.3.0".to_string(),
38+
},
39+
}
40+
}
41+
}
42+
43+
impl eframe::App for WebApp {
44+
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
45+
// Top panel with toolbar
46+
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
47+
ui.horizontal(|ui| {
48+
ui.heading("LocalGPT");
49+
ui.separator();
50+
51+
// Status indicator
52+
let status_color = if self.status.connected {
53+
egui::Color32::GREEN
54+
} else {
55+
egui::Color32::RED
56+
};
57+
ui.colored_label(status_color, "●");
58+
59+
ui.label(format!("Model: {}", self.status.model));
60+
61+
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
62+
if ui.button("New Session").clicked() {
63+
self.messages.clear();
64+
self.session_id = None;
65+
}
66+
67+
if let Some(ref id) = self.session_id {
68+
// Safe truncation that respects UTF-8 character boundaries
69+
let truncated: String = id.chars().take(8).collect();
70+
ui.label(format!("Session: {}...", truncated));
71+
}
72+
});
73+
});
74+
});
75+
76+
// Main chat area
77+
egui::CentralPanel::default().show(ctx, |ui| {
78+
egui::ScrollArea::vertical()
79+
.auto_shrink([false, false])
80+
.stick_to_bottom(true)
81+
.show(ui, |ui| {
82+
if self.messages.is_empty() {
83+
ui.vertical_centered(|ui| {
84+
ui.add_space(100.0);
85+
ui.heading("Welcome to LocalGPT");
86+
ui.label("This is a Proof of Concept egui web UI");
87+
ui.add_space(10.0);
88+
ui.label("Type a message below to start chatting");
89+
ui.add_space(10.0);
90+
ui.label("🚧 Note: This is a static demo without backend connection");
91+
});
92+
} else {
93+
for msg in &self.messages {
94+
self.render_message(ui, msg);
95+
}
96+
}
97+
});
98+
});
99+
100+
// Bottom input panel
101+
egui::TopBottomPanel::bottom("input").show(ctx, |ui| {
102+
ui.horizontal(|ui| {
103+
let input_response = ui.add(
104+
egui::TextEdit::multiline(&mut self.message_input)
105+
.desired_width(f32::INFINITY)
106+
.desired_rows(1)
107+
.hint_text("Type a message..."),
108+
);
109+
110+
let enter_without_shift = input_response.has_focus()
111+
&& ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
112+
113+
if ui.button("Send").clicked() || enter_without_shift {
114+
self.send_message();
115+
input_response.request_focus();
116+
}
117+
});
118+
});
119+
}
120+
}
121+
122+
impl WebApp {
123+
fn render_message(&self, ui: &mut egui::Ui, msg: &Message) {
124+
let bg_color = if msg.role == "user" {
125+
egui::Color32::from_rgb(40, 40, 60)
126+
} else {
127+
egui::Color32::from_rgb(30, 50, 40)
128+
};
129+
130+
egui::Frame::none()
131+
.fill(bg_color)
132+
.inner_margin(egui::Margin::same(10.0))
133+
.corner_radius(egui::CornerRadius::same(5.0))
134+
.show(ui, |ui| {
135+
ui.horizontal(|ui| {
136+
let role_color = if msg.role == "user" {
137+
egui::Color32::LIGHT_BLUE
138+
} else {
139+
egui::Color32::LIGHT_GREEN
140+
};
141+
ui.colored_label(role_color, &msg.role);
142+
});
143+
ui.label(&msg.content);
144+
});
145+
146+
ui.add_space(8.0);
147+
}
148+
149+
fn send_message(&mut self) {
150+
if self.message_input.trim().is_empty() {
151+
return;
152+
}
153+
154+
// Add user message
155+
self.messages.push(Message {
156+
role: "user".to_string(),
157+
content: self.message_input.clone(),
158+
});
159+
160+
// Add a mock response
161+
self.messages.push(Message {
162+
role: "assistant".to_string(),
163+
content: format!(
164+
"This is a PoC demo. Your message was: \"{}\"\n\n\
165+
In the full implementation, this would connect to the LocalGPT backend \
166+
via WebSocket or HTTP API to send your message and stream the response.",
167+
self.message_input
168+
),
169+
});
170+
171+
self.message_input.clear();
172+
}
173+
}

crates/server/src/web/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//! Egui-based web UI for LocalGPT
2+
//!
3+
//! This module provides a WASM-compiled egui UI that runs in the browser.
4+
5+
#[cfg(feature = "egui-web")]
6+
mod app;
7+
8+
#[cfg(feature = "egui-web")]
9+
pub use app::WebApp;

0 commit comments

Comments
 (0)