[FEAT] New Interaction Interface UI (#237)

This commit is contained in:
Tanvir Bhathal
2026-04-13 14:23:15 -07:00
committed by GitHub
parent f9fcb39601
commit 9c15b25f90
8 changed files with 930 additions and 1 deletions
Submodule
+1
Submodule Inline added at 03673aaa42
+20
View File
@@ -2109,6 +2109,15 @@ dependencies = [
"time",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2329,6 +2338,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -2575,6 +2593,8 @@ dependencies = [
name = "openjarvis-desktop"
version = "0.1.0"
dependencies = [
"dispatch",
"objc",
"reqwest 0.12.28",
"serde",
"serde_json",
+4
View File
@@ -22,6 +22,10 @@ serde_json = "1"
reqwest = { version = "0.12", features = ["json", "multipart"] }
tokio = { version = "1", features = ["full"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2"
dispatch = "0.2"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+414
View File
@@ -1195,6 +1195,393 @@ async fn speech_health(api_url: String) -> Result<serde_json::Value, String> {
Ok(body)
}
// ---------------------------------------------------------------------------
// Native macOS overlay — NSPanel + WKWebView, entirely bypassing Tauri's
// window management so we get proper always-on-top, transparency, non-
// activating panel behaviour and cross-Space support.
// ---------------------------------------------------------------------------
#[cfg(target_os = "macos")]
mod native_overlay {
use objc::declare::ClassDecl;
use objc::runtime::{Class, Object, Sel, BOOL, NO, YES};
use objc::{class, msg_send, sel, sel_impl};
use std::sync::atomic::{AtomicUsize, Ordering};
/// Raw pointer to the NSPanel, stored as usize for atomicity.
static PANEL_PTR: AtomicUsize = AtomicUsize::new(0);
/// Raw pointer to the WKWebView inside the panel.
static WEBVIEW_PTR: AtomicUsize = AtomicUsize::new(0);
/// Raw pointer to the previously-frontmost NSRunningApplication.
static PREV_APP: AtomicUsize = AtomicUsize::new(0);
// CoreGraphics geometry types expected by AppKit.
#[repr(C)]
#[derive(Copy, Clone)]
struct CGPoint {
x: f64,
y: f64,
}
#[repr(C)]
#[derive(Copy, Clone)]
struct CGSize {
width: f64,
height: f64,
}
#[repr(C)]
#[derive(Copy, Clone)]
struct CGRect {
origin: CGPoint,
size: CGSize,
}
/// Create an autoreleased NSString from a Rust &str.
unsafe fn nsstring(s: &str) -> *mut Object {
let obj: *mut Object = msg_send![class!(NSString), alloc];
msg_send![obj,
initWithBytes: s.as_ptr()
length: s.len()
encoding: 4usize // NSUTF8StringEncoding
]
}
// ------------------------------------------------------------------
// Conversation persistence
// ------------------------------------------------------------------
fn conversation_path() -> std::path::PathBuf {
std::path::PathBuf::from(super::home_dir())
.join(".openjarvis")
.join("overlay-conversation.json")
}
pub fn load_conversation() -> String {
std::fs::read_to_string(conversation_path()).unwrap_or_else(|_| "[]".into())
}
/// Read cloud API keys and return a JSON array of model IDs
/// whose provider has a key configured.
fn cloud_models_json() -> String {
let keys = super::read_cloud_keys();
let mut models: Vec<&str> = Vec::new();
for (name, value) in &keys {
if value.is_empty() {
continue;
}
match name.as_str() {
"OPENAI_API_KEY" => models.extend(["gpt-4o", "gpt-4o-mini"]),
"ANTHROPIC_API_KEY" => {
models.extend(["claude-sonnet-4-20250514", "claude-haiku-4-20250414"])
}
"GEMINI_API_KEY" | "GOOGLE_API_KEY" => {
models.extend(["gemini-2.5-flash", "gemini-2.5-pro"])
}
_ => {}
}
}
serde_json::to_string(&models).unwrap_or_else(|_| "[]".into())
}
fn save_conversation(json: &str) {
let path = conversation_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, json);
}
/// Apply every transparency trick to the WKWebView.
/// Called once at creation and again after the page finishes loading.
unsafe fn force_transparent(wv: *mut Object) {
let clear: *mut Object = msg_send![class!(NSColor), clearColor];
let _: () = msg_send![wv, _setDrawsBackground: NO];
let no_num: *mut Object = msg_send![class!(NSNumber), numberWithBool: NO];
let _: () = msg_send![wv, setValue: no_num forKey: nsstring("drawsBackground")];
let _: () = msg_send![wv, setUnderPageBackgroundColor: clear];
// Also inject CSS to nuke any remaining background
let js = nsstring(
"document.documentElement.style.background='transparent';\
document.body.style.background='transparent';"
);
let nil: *mut Object = std::ptr::null_mut();
let _: () = msg_send![wv, evaluateJavaScript: js completionHandler: nil];
}
// ------------------------------------------------------------------
// Public API (must be called on the main thread)
// ------------------------------------------------------------------
/// Build the native overlay panel. Call once during app setup.
pub unsafe fn create(html: &str, api_port: u16) {
// --- Custom NSPanel subclass that accepts keyboard input ------
if Class::get("JarvisOverlayPanel").is_none() {
let sup = Class::get("NSPanel").unwrap();
let mut decl = ClassDecl::new("JarvisOverlayPanel", sup).unwrap();
extern "C" fn yes(_: &Object, _: Sel) -> BOOL {
YES
}
decl.add_method(
sel!(canBecomeKeyWindow),
yes as extern "C" fn(&Object, Sel) -> BOOL,
);
decl.register();
}
// --- WKNavigationDelegate — re-apply transparency after load --
if Class::get("JarvisOverlayNavDelegate").is_none() {
let sup = Class::get("NSObject").unwrap();
let mut decl = ClassDecl::new("JarvisOverlayNavDelegate", sup).unwrap();
extern "C" fn did_finish(_: &Object, _: Sel, wv: *mut Object, _nav: *mut Object) {
unsafe { force_transparent(wv); }
}
decl.add_method(
sel!(webView:didFinishNavigation:),
did_finish as extern "C" fn(&Object, Sel, *mut Object, *mut Object),
);
decl.register();
}
// --- WKScriptMessageHandler so JS can call hide() ------------
if Class::get("JarvisOverlayMsgHandler").is_none() {
let sup = Class::get("NSObject").unwrap();
let mut decl = ClassDecl::new("JarvisOverlayMsgHandler", sup).unwrap();
extern "C" fn on_msg(_: &Object, _: Sel, _ctrl: *mut Object, msg: *mut Object) {
unsafe {
let body: *mut Object = msg_send![msg, body];
if body.is_null() {
return;
}
let c: *const std::os::raw::c_char = msg_send![body, UTF8String];
if c.is_null() {
return;
}
if let Ok(s) = std::ffi::CStr::from_ptr(c).to_str() {
if s == "hide" {
hide();
} else if let Some(json) = s.strip_prefix("save:") {
save_conversation(json);
} else if let Some(coords) = s.strip_prefix("drag:") {
drag(coords);
}
}
}
}
decl.add_method(
sel!(userContentController:didReceiveScriptMessage:),
on_msg as extern "C" fn(&Object, Sel, *mut Object, *mut Object),
);
decl.register();
}
// --- Create the NSPanel --------------------------------------
let frame = CGRect {
origin: CGPoint { x: 0.0, y: 0.0 },
size: CGSize {
width: 560.0,
height: 400.0,
},
};
// NSWindowStyleMaskNonactivatingPanel = 1 << 7
let style: u64 = 1 << 7;
let cls = Class::get("JarvisOverlayPanel").unwrap();
let panel: *mut Object = msg_send![cls, alloc];
let panel: *mut Object = msg_send![panel,
initWithContentRect: frame
styleMask: style
backing: 2u64 // NSBackingStoreBuffered
defer: NO
];
// Window level — NSFloatingWindowLevel (3).
let _: () = msg_send![panel, setLevel: 3_i64];
// canJoinAllSpaces (1) | fullScreenAuxiliary (1<<8)
let _: () = msg_send![panel, setCollectionBehavior: 257_u64];
let _: () = msg_send![panel, setHidesOnDeactivate: NO];
let _: () = msg_send![panel, setOpaque: NO];
let _: () = msg_send![panel, setHasShadow: NO];
let _: () = msg_send![panel, setMovableByWindowBackground: YES];
let clear: *mut Object = msg_send![class!(NSColor), clearColor];
let _: () = msg_send![panel, setBackgroundColor: clear];
let _: () = msg_send![panel, center];
// --- WKWebView -----------------------------------------------
let cfg: *mut Object = msg_send![class!(WKWebViewConfiguration), alloc];
let cfg: *mut Object = msg_send![cfg, init];
// Attach message handler ("overlay" channel)
let hcls = Class::get("JarvisOverlayMsgHandler").unwrap();
let handler: *mut Object = msg_send![hcls, alloc];
let handler: *mut Object = msg_send![handler, init];
let uc: *mut Object = msg_send![cfg, userContentController];
let _: () = msg_send![uc,
addScriptMessageHandler: handler
name: nsstring("overlay")
];
let wv: *mut Object = msg_send![class!(WKWebView), alloc];
let wv: *mut Object = msg_send![wv,
initWithFrame: frame
configuration: cfg
];
// ---- Make the webview fully transparent ----
force_transparent(wv);
// Set navigation delegate so we re-apply after page loads
let nav_cls = Class::get("JarvisOverlayNavDelegate").unwrap();
let nav_del: *mut Object = msg_send![nav_cls, alloc];
let nav_del: *mut Object = msg_send![nav_del, init];
let _: () = msg_send![wv, setNavigationDelegate: nav_del];
let _: () = msg_send![panel, setContentView: wv];
WEBVIEW_PTR.store(wv as usize, Ordering::SeqCst);
// Inject saved conversation into the HTML template, then load it.
// Use the API server as the base URL so fetch() is same-origin.
// Escape "</" so the JSON can't prematurely close the <script> tag.
// ("\/" is valid JSON — resolves back to "/" when parsed.)
let saved = load_conversation().replace("</", "<\\/");
let cloud = cloud_models_json();
let filled = html
.replace("__SAVED_MESSAGES__", &saved)
.replace("__CLOUD_MODELS__", &cloud);
let base_str = nsstring(&format!("http://127.0.0.1:{}", api_port));
let base_url: *mut Object = msg_send![class!(NSURL), URLWithString: base_str];
let _: () = msg_send![wv,
loadHTMLString: nsstring(&filled)
baseURL: base_url
];
PANEL_PTR.store(panel as usize, Ordering::SeqCst);
}
pub unsafe fn toggle() {
let ptr = PANEL_PTR.load(Ordering::SeqCst);
if ptr == 0 {
return;
}
let panel = ptr as *mut Object;
let vis: BOOL = msg_send![panel, isVisible];
if vis != NO {
hide();
} else {
show();
}
}
pub unsafe fn show() {
let ptr = PANEL_PTR.load(Ordering::SeqCst);
if ptr == 0 {
return;
}
let panel = ptr as *mut Object;
// Re-apply transparency every time (the webview can reset it)
let wv_ptr = WEBVIEW_PTR.load(Ordering::SeqCst);
if wv_ptr != 0 {
force_transparent(wv_ptr as *mut Object);
}
// Remember the currently-frontmost app so we can restore it.
let ws: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace];
let front: *mut Object = msg_send![ws, frontmostApplication];
if !front.is_null() {
let _: () = msg_send![front, retain];
let old = PREV_APP.swap(front as usize, Ordering::SeqCst);
if old != 0 {
let _: () = msg_send![(old as *mut Object), release];
}
}
// Activate our process so the panel receives keyboard input.
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let _: () = msg_send![app, activateIgnoringOtherApps: YES];
let nil: *mut Object = std::ptr::null_mut();
let _: () = msg_send![panel, makeKeyAndOrderFront: nil];
// Focus the text field inside the webview.
let wv: *mut Object = msg_send![panel, contentView];
let js = nsstring("document.getElementById('input').focus()");
let _: () = msg_send![wv, evaluateJavaScript: js completionHandler: nil];
}
/// Move the panel by a screen-space delta (called from JS drag handler).
unsafe fn drag(coords: &str) {
let ptr = PANEL_PTR.load(Ordering::SeqCst);
if ptr == 0 {
return;
}
let panel = ptr as *mut Object;
let Some((dxs, dys)) = coords.split_once(',') else {
return;
};
let Ok(dx) = dxs.parse::<f64>() else { return };
let Ok(dy) = dys.parse::<f64>() else { return };
// NSWindow frame origin is bottom-left; screen Y increases upward,
// but mouse screenY increases downward, so invert dy.
let frame: CGRect = msg_send![panel, frame];
let origin = CGPoint {
x: frame.origin.x + dx,
y: frame.origin.y - dy,
};
let _: () = msg_send![panel, setFrameOrigin: origin];
}
pub unsafe fn hide() {
let ptr = PANEL_PTR.load(Ordering::SeqCst);
if ptr == 0 {
return;
}
let panel = ptr as *mut Object;
let nil: *mut Object = std::ptr::null_mut();
let _: () = msg_send![panel, orderOut: nil];
// Give focus back to whatever app was frontmost before.
let prev = PREV_APP.swap(0, Ordering::SeqCst);
if prev != 0 {
let prev_app = prev as *mut Object;
let _: BOOL = msg_send![prev_app, activateWithOptions: 2_u64];
let _: () = msg_send![prev_app, release];
}
}
}
/// Dispatch a closure onto the main thread via GCD.
#[cfg(target_os = "macos")]
fn on_main_thread(f: impl FnOnce() + Send + 'static) {
dispatch::Queue::main().exec_async(f);
}
// ---------------------------------------------------------------------------
// Overlay Tauri commands (thin wrappers that dispatch to the main thread)
// ---------------------------------------------------------------------------
#[tauri::command]
async fn get_overlay_conversation() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
return Ok(native_overlay::load_conversation());
}
#[cfg(not(target_os = "macos"))]
Ok("[]".into())
}
#[tauri::command]
async fn toggle_overlay() -> Result<(), String> {
#[cfg(target_os = "macos")]
on_main_thread(|| unsafe { native_overlay::toggle() });
Ok(())
}
#[tauri::command]
async fn hide_overlay() -> Result<(), String> {
#[cfg(target_os = "macos")]
on_main_thread(|| unsafe { native_overlay::hide() });
Ok(())
}
// ---------------------------------------------------------------------------
// App entry point
// ---------------------------------------------------------------------------
@@ -1262,6 +1649,30 @@ pub fn run() {
})
.build(app)?;
// Create native macOS overlay panel
#[cfg(target_os = "macos")]
unsafe {
native_overlay::create(include_str!("overlay.html"), JARVIS_PORT);
}
// Register Cmd+Shift+Space to toggle the overlay
{
use tauri_plugin_global_shortcut::{
Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState,
};
let sc = Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::Space);
if let Err(e) = app.global_shortcut().on_shortcut(sc, |_app, _sc, ev| {
if ev.state == ShortcutState::Pressed {
#[cfg(target_os = "macos")]
unsafe {
native_overlay::toggle();
}
}
}) {
eprintln!("Warning: could not register Cmd+Shift+Space: {e}");
}
}
// Auto-start backend services on launch
tauri::async_runtime::spawn(boot_backend(boot_backend_ref, boot_status_ref));
@@ -1292,6 +1703,9 @@ pub fn run() {
delete_ollama_model,
save_cloud_key,
get_cloud_key_status,
toggle_overlay,
hide_overlay,
get_overlay_conversation,
])
.build(tauri::generate_context!())
.expect("error while building OpenJarvis Desktop")
+450
View File
@@ -0,0 +1,450 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body,:root{
background:transparent !important;
background-color:transparent !important;
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",sans-serif;
color:#fff;height:100%;overflow:hidden;
}
#container{
display:flex;flex-direction:column;
height:100%;padding:8px 12px;
}
#messages{
flex:1;overflow-y:auto;
display:flex;flex-direction:column;gap:6px;
margin-bottom:8px;padding:12px;
border-radius:16px;
background:rgba(30,30,30,0.88);
border:1px solid rgba(255,255,255,0.20);
}
#messages:empty{display:none}
#messages::-webkit-scrollbar{width:6px}
#messages::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.15);border-radius:3px}
#messages::-webkit-scrollbar-track{background:transparent}
.msg{
padding:8px 12px;border-radius:12px;
font-size:13px;line-height:1.55;max-width:90%;
white-space:pre-wrap;word-wrap:break-word;
-webkit-user-select:text;user-select:text;
}
.msg.user{
align-self:flex-end;
background:rgba(59,130,246,0.35);
color:rgba(255,255,255,0.95);
}
.msg.assistant{
align-self:flex-start;
background:rgba(255,255,255,0.20);
color:rgba(255,255,255,0.88);
white-space:normal;
}
.msg.assistant > p{margin:0 0 6px 0;white-space:pre-wrap}
.msg.assistant > p:last-child{margin-bottom:0}
.msg.assistant h1,.msg.assistant h2,.msg.assistant h3{
font-size:14px;font-weight:600;margin:6px 0 4px 0;
}
.msg.assistant ul,.msg.assistant ol{margin:2px 0 6px 18px;padding:0}
.msg.assistant li{margin:1px 0}
.msg.assistant a{color:#93c5fd;text-decoration:underline}
.msg.assistant code{
background:rgba(0,0,0,0.35);
padding:1px 5px;border-radius:4px;
font-family:"SF Mono",Menlo,Monaco,monospace;
font-size:12px;
}
.msg.assistant pre{
background:rgba(0,0,0,0.40);
padding:8px 10px;border-radius:8px;
margin:4px 0;overflow-x:auto;
border:1px solid rgba(255,255,255,0.08);
}
.msg.assistant pre code{
background:transparent;padding:0;border-radius:0;
font-size:11.5px;line-height:1.4;white-space:pre;
}
.msg.assistant strong{font-weight:600;color:#fff}
.msg.assistant em{font-style:italic}
.msg.assistant del{opacity:0.6;text-decoration:line-through}
/* Streaming caret — pulses at the end of the in-progress bubble */
.caret{
display:inline-block;width:6px;height:13px;
vertical-align:text-bottom;margin-left:2px;
background:rgba(255,255,255,0.85);
animation:caret-blink 1s steps(1) infinite;
}
@keyframes caret-blink{50%{opacity:0}}
/* Thinking dots — shown while waiting for the first token */
.thinking{
display:inline-flex;gap:4px;align-items:center;padding:2px 0;
}
.thinking span{
width:6px;height:6px;border-radius:50%;
background:rgba(255,255,255,0.65);
animation:thinking-bounce 1.2s ease-in-out infinite;
}
.thinking span:nth-child(2){animation-delay:0.15s}
.thinking span:nth-child(3){animation-delay:0.30s}
@keyframes thinking-bounce{
0%,60%,100%{transform:translateY(0);opacity:0.4}
30%{transform:translateY(-4px);opacity:1}
}
#input-bar{
display:flex;align-items:center;gap:6px;
border-radius:16px;padding:6px 8px;
background:rgba(30,30,30,0.88);
border:1px solid rgba(255,255,255,0.20);
flex-shrink:0;
}
#model-wrap{
position:relative;flex-shrink:0;
}
#model-select{
background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.6);
border:none;border-radius:8px;padding:4px 22px 4px 8px;
font-size:11px;outline:none;cursor:pointer;
max-width:140px;
-webkit-appearance:none;appearance:none;
}
#model-select:hover{background:rgba(255,255,255,0.14);color:#fff}
#model-wrap .arrow{
position:absolute;right:7px;top:50%;transform:translateY(-50%);
pointer-events:none;color:rgba(255,255,255,0.35);
}
#model-select option,#model-select optgroup{
background:#1e1e1e;color:#eee;
}
#input{
flex:1;background:transparent;border:none;outline:none;
color:#fff;font-size:14px;padding:6px 10px;
}
#input::placeholder{color:rgba(255,255,255,0.35)}
.btn{
display:flex;align-items:center;justify-content:center;
width:30px;height:30px;border-radius:50%;border:none;
background:rgba(255,255,255,0.10);
color:rgba(255,255,255,0.6);cursor:pointer;
transition:background .15s,color .15s;flex-shrink:0;
}
.btn:hover{background:rgba(255,255,255,0.20);color:#fff}
.btn:disabled{opacity:0.25;cursor:default}
.btn:disabled:hover{background:rgba(255,255,255,0.10)}
</style>
</head>
<body>
<div id="container">
<div id="messages"></div>
<div id="input-bar">
<button id="new-btn" class="btn" title="New conversation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div id="model-wrap">
<select id="model-select"><option>loading...</option></select>
<svg class="arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<input id="input" type="text" placeholder="Ask Jarvis anything..." autofocus>
<button id="send-btn" class="btn" disabled title="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
</div>
</div>
<script type="application/json" id="saved-data">__SAVED_MESSAGES__</script>
<script type="application/json" id="cloud-data">__CLOUD_MODELS__</script>
<script>
const input=document.getElementById('input');
const sendBtn=document.getElementById('send-btn');
const messagesEl=document.getElementById('messages');
let streaming=false,abort=null,model='qwen3.5:4b';
let convId='', convTitle='Overlay chat', convCreated=Date.now();
let messages=[];
function genId(){return Date.now().toString(36)+Math.random().toString(36).slice(2,8)}
// Restore previous conversation
try{
const raw=document.getElementById('saved-data').textContent.trim();
if(raw&&raw!=='__SAVED_PLACEHOLDER__'){
const saved=JSON.parse(raw);
if(saved.id){convId=saved.id;convTitle=saved.title||convTitle;convCreated=saved.createdAt||convCreated;messages=saved.messages||[]}
else if(Array.isArray(saved)){messages=saved}
}
}catch{}
if(!convId) convId=genId();
if(messages.length) renderAll();
// Build model dropdown: local (installed) + cloud (keyed)
const modelSelect=document.getElementById('model-select');
let cloudModels=[];
try{
const cd=document.getElementById('cloud-data').textContent.trim();
if(cd&&cd!=='__CLOUD_PLACEHOLDER__') cloudModels=JSON.parse(cd);
}catch{}
fetch('/v1/models').then(r=>r.json()).then(d=>{
const local=(Array.isArray(d)?d:(d.data||d.models||[])).map(m=>m.id||m.name).filter(Boolean);
while(modelSelect.firstChild) modelSelect.removeChild(modelSelect.firstChild);
if(local.length){
const g=document.createElement('optgroup');g.label='Local';
local.forEach(id=>{const o=document.createElement('option');o.value=id;o.textContent=id;g.appendChild(o)});
modelSelect.appendChild(g);
}
if(cloudModels.length){
const g=document.createElement('optgroup');g.label='Cloud';
cloudModels.forEach(id=>{const o=document.createElement('option');o.value=id;o.textContent=id;g.appendChild(o)});
modelSelect.appendChild(g);
}
// Restore saved model or pick first available
const saved=messages.length&&messages[0].model;
if(saved&&modelSelect.querySelector('option[value="'+CSS.escape(saved)+'"]')){modelSelect.value=saved}
model=modelSelect.value||model;
}).catch(()=>{});
modelSelect.addEventListener('change',()=>{model=modelSelect.value});
const SEND='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
const STOP='<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
function renderAll(){
while(messagesEl.firstChild) messagesEl.removeChild(messagesEl.firstChild);
for(const m of messages) bubble(m.role,m.content);
scroll();
}
function bubble(role,text){
const d=document.createElement('div');
d.className='msg '+(role==='user'?'user':'assistant');
if(role==='assistant') setHtml(d, md(text||''));
else d.textContent=text;
messagesEl.appendChild(d);
return d;
}
function scroll(){messagesEl.scrollTop=messagesEl.scrollHeight}
// Safe HTML injection helper. All incoming LLM/user text is escaped
// via escHtml() before any markdown transformations, so the string
// reaching this function only contains tags from our controlled
// regex replacements. We use Range.createContextualFragment which is
// the W3C-recommended way to construct a DocumentFragment from HTML.
function setHtml(el,html){
while(el.firstChild) el.removeChild(el.firstChild);
const range=document.createRange();
range.selectNodeContents(el);
el.appendChild(range.createContextualFragment(html));
}
// --- Minimal markdown renderer (inline, no deps) ---
// Handles: fenced code, inline code, headings, bold, italic,
// strikethrough, links, ordered/unordered lists, paragraphs.
function escHtml(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function md(src){
if(!src)return '';
const blocks=[],inlines=[];
// 1. Extract fenced code blocks first so their contents aren't touched.
src=src.replace(/```(\w*)\n?([\s\S]*?)```/g,(_,lang,code)=>{
blocks.push({lang,code});
return '\u0000CB'+(blocks.length-1)+'\u0000';
});
// 2. Extract inline code (single-line backticks).
src=src.replace(/`([^`\n]+)`/g,(_,c)=>{
inlines.push(c);
return '\u0000IC'+(inlines.length-1)+'\u0000';
});
// 3. Escape everything else.
src=escHtml(src);
// 4. Headings.
src=src.replace(/^###\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^##\s+(.+)$/gm,'<h2>$1</h2>')
.replace(/^#\s+(.+)$/gm,'<h1>$1</h1>');
// 5. Bold / italic / strikethrough.
src=src.replace(/\*\*([^*\n]+)\*\*/g,'<strong>$1</strong>')
.replace(/__([^_\n]+)__/g,'<strong>$1</strong>')
.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\*)/g,'$1<em>$2</em>')
.replace(/(^|[^_\w])_([^_\n]+)_(?!_)/g,'$1<em>$2</em>')
.replace(/~~([^~\n]+)~~/g,'<del>$1</del>');
// 6. Links — url is escaped above, so quotes are safe.
src=src.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
// 7. Unordered + ordered lists.
src=src.replace(/(?:^[-*]\s+.+(?:\n|$))+/gm,block=>{
const items=block.trim().split('\n')
.map(l=>'<li>'+l.replace(/^[-*]\s+/,'')+'</li>').join('');
return '<ul>'+items+'</ul>';
});
src=src.replace(/(?:^\d+\.\s+.+(?:\n|$))+/gm,block=>{
const items=block.trim().split('\n')
.map(l=>'<li>'+l.replace(/^\d+\.\s+/,'')+'</li>').join('');
return '<ol>'+items+'</ol>';
});
// 8. Paragraphs: split on blank lines, wrap non-block chunks in <p>.
src=src.split(/\n{2,}/).map(chunk=>{
const t=chunk.trim();
if(!t)return '';
if(/^<(h\d|ul|ol|pre|blockquote)/.test(t))return t;
if(t.startsWith('\u0000CB'))return t;
return '<p>'+t.replace(/\n/g,'<br>')+'</p>';
}).join('');
// 9. Restore inline code.
src=src.replace(/\u0000IC(\d+)\u0000/g,(_,i)=>'<code>'+escHtml(inlines[+i])+'</code>');
// 10. Restore fenced code blocks.
src=src.replace(/\u0000CB(\d+)\u0000/g,(_,i)=>{
const b=blocks[+i];
const cls=b.lang?' class="lang-'+escHtml(b.lang)+'"':'';
return '<pre><code'+cls+'>'+escHtml(b.code)+'</code></pre>';
});
return src;
}
const THINKING='<span class="thinking"><span></span><span></span><span></span></span>';
const CARET='<span class="caret"></span>';
const CLOUD_PFX=['gpt-','o1-','o3-','o4-','claude-','gemini-','openrouter/','chatgpt-'];
function save(){
const conv={id:convId,title:convTitle,createdAt:convCreated,updatedAt:Date.now(),model,
messages:messages.map((m,i)=>{
const o={id:convId+'_'+i,role:m.role,content:m.content,timestamp:m.timestamp||Date.now()};
if(m.usage)o.usage=m.usage;
if(m.telemetry)o.telemetry=m.telemetry;
return o;
})};
try{window.webkit.messageHandlers.overlay.postMessage('save:'+JSON.stringify(conv))}catch{}
}
input.addEventListener('input',()=>{sendBtn.disabled=!input.value.trim()||streaming});
input.addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}
});
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){
if(streaming){abort&&abort.abort()}
else{try{window.webkit.messageHandlers.overlay.postMessage('hide')}catch{}}
}
});
sendBtn.addEventListener('click',()=>{if(streaming){abort&&abort.abort()}else send()});
document.getElementById('new-btn').addEventListener('click',()=>{
convId=genId();convTitle='Overlay chat';convCreated=Date.now();
messages=[];
while(messagesEl.firstChild) messagesEl.removeChild(messagesEl.firstChild);
save();input.focus();
});
async function send(){
const text=input.value.trim();
if(!text||streaming)return;
input.value='';sendBtn.disabled=true;
messages.push({role:'user',content:text,timestamp:Date.now()});
if(messages.length===1) convTitle=text.slice(0,50)+(text.length>50?'...':'');
bubble('user',text);scroll();save();
streaming=true;abort=new AbortController();
setHtml(sendBtn,STOP);sendBtn.disabled=false;
const b=bubble('assistant','');
// Show thinking dots until the first token arrives.
setHtml(b,THINKING);
scroll();
let acc='',usage=null,complexity=null,ttft=0;
const t0=Date.now();
// Throttle markdown re-renders to ~30fps so tight streams don't
// rebuild the DOM on every single token.
let pending=false;
const render=()=>{
if(pending)return;
pending=true;
requestAnimationFrame(()=>{
pending=false;
setHtml(b, md(acc)+CARET);
scroll();
});
};
try{
const r=await fetch('/v1/chat/completions',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model,messages,stream:true}),
signal:abort.signal
});
if(!r.ok)throw new Error(r.status);
const reader=r.body.getReader(),dec=new TextDecoder();
let buf='';
for(;;){
const{done,value}=await reader.read();
if(done)break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n');buf=lines.pop()||'';
for(const ln of lines){
if(!ln.startsWith('data: '))continue;
const d=ln.slice(6);if(d==='[DONE]')break;
try{
const p=JSON.parse(d);
if(p.usage)usage=p.usage;
if(p.complexity)complexity=p.complexity;
const c=p.choices?.[0]?.delta?.content;
if(c){if(!ttft)ttft=Date.now()-t0;acc+=c;render()}
}catch{}
}
}
}catch(e){
if(e.name!=='AbortError'){
acc='Could not get a response. Is the backend running?';
b.textContent=acc;
}
}finally{
streaming=false;abort=null;
setHtml(sendBtn,SEND);sendBtn.disabled=!input.value.trim();
input.focus();
// Final render without the caret.
if(acc) setHtml(b, md(acc));
else if(b.querySelector('.thinking')) b.textContent='';
}
if(acc){
const totalMs=Date.now()-t0;
const engine=CLOUD_PFX.some(p=>model.startsWith(p))?'cloud':'ollama';
const telem={engine,model_id:model,total_ms:totalMs,ttft_ms:ttft||undefined,
tokens_per_sec:usage?.completion_tokens?usage.completion_tokens/(totalMs/1000):undefined,
complexity_score:complexity?.score,complexity_tier:complexity?.tier,
suggested_max_tokens:complexity?.suggested_max_tokens};
messages.push({role:'assistant',content:acc,timestamp:Date.now(),usage:usage||undefined,telemetry:telem});
save();
}
}
window.addEventListener('focus',()=>input.focus());
// --- Drag-to-move: click anywhere to drag, with threshold so
// clicks on inputs/buttons still work normally ---
(function(){
let down=false,dragging=false,sx=0,sy=0;
const THRESH=3;
const INTERACTIVE='input,select,button,textarea,a,label,[contenteditable="true"],[role="button"]';
document.addEventListener('mousedown',e=>{
if(e.target.closest('select'))return;
down=true;dragging=false;sx=e.screenX;sy=e.screenY;
});
// macOS native select menus swallow the mouseup — reset on change too
document.getElementById('model-select').addEventListener('mousedown',()=>{down=false;dragging=false});
document.getElementById('model-select').addEventListener('change',()=>{down=false;dragging=false});
document.addEventListener('mousemove',e=>{
if(!down)return;
// If the mouse button is no longer pressed (e.g. the user released
// it over a native menu that swallowed mouseup), abort the drag.
if(e.buttons===0){down=false;dragging=false;return;}
const dx=e.screenX-sx,dy=e.screenY-sy;
if(!dragging){
if(Math.abs(dx)+Math.abs(dy)<THRESH)return;
dragging=true;
}
sx=e.screenX;sy=e.screenY;
try{window.webkit.messageHandlers.overlay.postMessage('drag:'+dx+','+dy)}catch{}
});
const reset=()=>{down=false;dragging=false};
document.addEventListener('mouseup',reset);
window.addEventListener('blur',reset);
document.addEventListener('mouseleave',reset);
})();
</script>
</body>
</html>
+9
View File
@@ -45,6 +45,15 @@ export default function App() {
else if (settings.theme === 'light') root.classList.add('light');
}, [settings.theme]);
// Sync overlay conversations into the main app
const importOverlay = useAppStore((s) => s.importOverlayConversation);
useEffect(() => {
if (!isTauri()) return;
importOverlay();
const interval = setInterval(importOverlay, 5000);
return () => clearInterval(interval);
}, [importOverlay]);
// Fetch models on mount
useEffect(() => {
fetchModels()
+31
View File
@@ -138,6 +138,7 @@ interface AppState {
// Actions: conversations
loadConversations: () => void;
importOverlayConversation: () => Promise<void>;
createConversation: (model?: string) => string;
selectConversation: (id: string) => void;
deleteConversation: (id: string) => void;
@@ -247,6 +248,36 @@ export const useAppStore = create<AppState>((set, get) => {
});
},
importOverlayConversation: async () => {
try {
const { invoke } = await import('@tauri-apps/api/core');
const raw = await invoke<string>('get_overlay_conversation');
if (!raw || raw === '[]') return;
const overlay = JSON.parse(raw);
if (!overlay.id || !overlay.messages?.length) return;
const store = loadConversations();
const existing = store.conversations[overlay.id];
// Only update if the overlay has newer/more messages
if (existing && existing.messages.length >= overlay.messages.length) return;
store.conversations[overlay.id] = {
id: overlay.id,
title: overlay.title || 'Overlay chat',
createdAt: overlay.createdAt || Date.now(),
updatedAt: overlay.updatedAt || Date.now(),
model: overlay.model || 'default',
messages: overlay.messages,
};
saveConversations(store);
set({
conversations: Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
),
});
} catch {
// Overlay command unavailable (non-Tauri or no overlay data)
}
},
createConversation: (model?: string) => {
const store = loadConversations();
const conv: Conversation = {
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/audioplayer.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"}