mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
[FEAT] New Interaction Interface UI (#237)
This commit is contained in:
Submodule
+1
Submodule Inline added at 03673aaa42
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user