diff --git a/eslint.config.js b/eslint.config.js index 96bc400..7ac0d58 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,5 +8,5 @@ export default defineConfig([ { ignores: ["src-tauri/"]}, { files: ["**/*.{js,mjs,cjs,jsx}"], plugins: { js }, extends: ["js/recommended"] }, { files: ["**/*.{js,mjs,cjs,jsx}"], languageOptions: { globals: globals.browser } }, - pluginReact.configs.flat.recommended, + pluginReact.configs.flat.recommended ]); diff --git a/package-lock.json b/package-lock.json index 5bc3316..5c892a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-store": "^2.2.0", + "clsx": "^2.1.1", "prop-types": "^15.8.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -24,6 +25,7 @@ "@tailwindcss/vite": "^4.1.6", "@tauri-apps/cli": "^2", "@types/bun": "latest", + "@types/json-schema": "^7.0.15", "@typescript-eslint/eslint-plugin": "^8.36.0", "@typescript-eslint/parser": "^8.36.0", "@vitejs/plugin-react": "^4.3.4", @@ -3795,6 +3797,15 @@ "node": ">=6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index 3a70da8..f5aa783 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-store": "^2.2.0", + "clsx": "^2.1.1", "prop-types": "^15.8.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -26,6 +27,7 @@ "@tailwindcss/vite": "^4.1.6", "@tauri-apps/cli": "^2", "@types/bun": "latest", + "@types/json-schema": "^7.0.15", "@typescript-eslint/eslint-plugin": "^8.36.0", "@typescript-eslint/parser": "^8.36.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/public/tools/apple_note.svg b/public/tools/apple_note.svg new file mode 100644 index 0000000..7fef3ef --- /dev/null +++ b/public/tools/apple_note.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tools/notion.svg b/public/tools/notion.svg new file mode 100644 index 0000000..bf6442f --- /dev/null +++ b/public/tools/notion.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src-tauri/src/app_note.rs b/src-tauri/src/app_note.rs new file mode 100644 index 0000000..fb74a51 --- /dev/null +++ b/src-tauri/src/app_note.rs @@ -0,0 +1,49 @@ +use std::{ + fs, + error::Error, + io::Write, + path::PathBuf, + process::Command, +}; + +fn write_temp_script(bytes: &[u8], stem: &str) -> std::io::Result { + let mut path = std::env::temp_dir(); + path.push(format!("{stem}.scpt")); + let mut file = fs::File::create(&path)?; + file.write_all(bytes)?; + Ok(path) +} +/* +** append text to the last most recent note +*/ +pub fn append_note(text: &str) -> Result<(), Box> { + const APPEND_NOTE: &[u8] = include_bytes!("scripts/append_note.scpt"); + let script_path = write_temp_script(APPEND_NOTE, "append_note")?; + let status = Command::new("osascript") + .arg(script_path) + .arg(text) + .status()?; + + if !status.success() { + return Err(format!("Failed to append note: {status}").into()); + } + Ok(()) +} + +/* +** create a brand new note wiith title and body +*/ +pub fn create_note(title: &str, body: &str) -> Result<(), Box> { + const CREATE_NOTE: &[u8] = include_bytes!("scripts/create_note.scpt"); + let script_path = write_temp_script(CREATE_NOTE, "create_note")?; + let status = Command::new("osascript") + .arg(script_path) + .arg(title) + .arg(body) + .status()?; + + if !status.success() { + return Err(format!("Failed to create note: {status}").into()); + } + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/apps.rs b/src-tauri/src/apps.rs new file mode 100644 index 0000000..591c159 --- /dev/null +++ b/src-tauri/src/apps.rs @@ -0,0 +1,41 @@ + +use std::error::Error; +use crate::app_note::{append_note, create_note}; + +/* +** app functions +*/ +pub fn call(func: &str, args: &[&str]) -> Result<(), Box> { + match func { + /* + ** this function basically use osascript to append text to the last most recent note. + ** it is launching app "Notes" and appending text to the last most recent note. + */ + "append_note" => { + if args.len() != 1 { + return Err(format!("append_note expects 1 argument, but got {}", args.len()).into()); + } + append_note(&args[0])?; + Ok(()) + } + + /* + ** this function also use osascript to create a new note with title and body. + ** it is launching app "Notes" and creating a new note with title and body. + */ + "create_note" => { + if args.len() != 2 { + return Err(format!("create_note expects 2 arguments, but got {}", args.len()).into()); + } + create_note(&args[0], &args[1])?; + Ok(()) + } + + /************************************************************************************* + ** if the function is not found, return an error in case of unexpected function call. + */ + _ => { + return Err(format!("Unknown function: {func}").into()); + } + } +} \ No newline at end of file diff --git a/src-tauri/src/browser_manager.rs b/src-tauri/src/browser_manager.rs index 7d47cfa..2f86aca 100644 --- a/src-tauri/src/browser_manager.rs +++ b/src-tauri/src/browser_manager.rs @@ -201,20 +201,20 @@ pub async fn launch_new_instance( if is_dev { command - .arg("--disable-web-security") + // .arg("--disable-web-security") .arg("--disable-features=VizDisplayCompositor") - .arg("--ignore-certificate-errors") + // .arg("--ignore-certificate-errors") .arg("--allow-running-insecure-content"); } /* ** nice visual cue in dev, blank tab in prod */ - command.arg(if is_dev { - "https://www.google.com" - } else { - "about:blank" - }); + // command.arg(if is_dev { + // "https://www.google.com" + // } else { + // "about:blank" + // }); let mut child_process = command .stdout(Stdio::piped()) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 807a57f..8d7af37 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -16,6 +16,9 @@ use crate::browser_manager::{ sunset_browser_instance, }; use crate::utils::download_and_extract; +use crate::skills::download_skill_json; +use crate::sketchs_browser::WebsiteSkills; +use crate::apps::call; #[tauri::command] pub async fn download_and_extract_resource(url: String) -> Result { @@ -137,7 +140,7 @@ pub async fn launch_browser(browser_path: Option) -> Result { - let _ = create_new_page(port, Some("https://www.google.com")).await; + // let _ = create_new_page(port, Some("https://www.google.com")).await; Ok(ws_url) } Err(e) => Err(format!( @@ -225,3 +228,22 @@ pub async fn debug_browser_connection(browser_path: String) -> Result, + repo: Option, + branch: String +) -> Result { + println!("loading skills for domain: {}", domain); + download_skill_json(domain.to_string(), company, repo, branch).await +} + +#[tauri::command] +pub async fn call_app(func: String, args: Vec) -> Result { + // run the dispatcher ; map Ok() to () and Err() to String + let string_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + call(&func, &string_refs) + .map(|_| "OK".to_string()) + .map_err(|e| e.to_string()) +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65c6c89..ea3104a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,10 @@ mod utils; +mod skills; +mod apps; +mod app_note; mod config; mod sketchs; +mod sketchs_browser; mod network; mod commands; mod platform; @@ -15,7 +19,9 @@ use commands::{ validate_ws_endpoint, scan_for_existing_browsers, debug_browser_connection, - download_and_extract_resource + download_and_extract_resource, + load_skills, + call_app }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -32,7 +38,9 @@ pub fn run() { validate_ws_endpoint, scan_for_existing_browsers, debug_browser_connection, - download_and_extract_resource + download_and_extract_resource, + load_skills, + call_app ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs index b73d0da..25ae993 100644 --- a/src-tauri/src/network.rs +++ b/src-tauri/src/network.rs @@ -165,7 +165,7 @@ pub async fn create_new_page(port: u16, url: Option<&str>) -> Result, pub current_url: Option, pub page_context: Option, -} \ No newline at end of file +} + diff --git a/src-tauri/src/sketchs_browser.rs b/src-tauri/src/sketchs_browser.rs new file mode 100644 index 0000000..b274730 --- /dev/null +++ b/src-tauri/src/sketchs_browser.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct WebsiteSkills { + pub domain: String, + pub skills: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SkillDefinition { + pub name: String, + pub description: String, + #[serde(default)] + pub input: Option>, + #[serde(default)] + pub output: Option, + pub steps: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SkillStep { + pub action: String, + #[serde(default)] + pub selector: Option, + #[serde(default)] + pub input_key: Option, + #[serde(default)] + pub index: Option, + #[serde(default)] + pub output_key: Option, +} + diff --git a/src-tauri/src/skills.rs b/src-tauri/src/skills.rs new file mode 100644 index 0000000..3c0e407 --- /dev/null +++ b/src-tauri/src/skills.rs @@ -0,0 +1,29 @@ +use reqwest::Client; +use crate::sketchs_browser::WebsiteSkills; + +pub async fn download_skill_json( + domain: String, + company: Option, + repo: Option, + branch: String, +) -> Result { + let company: String = company.unwrap_or_else(|| "runtime-org".to_string()); + let repo: String = repo.unwrap_or_else(|| "sk".to_string()); + let url: String = format!("https://raw.githubusercontent.com/{company}/{repo}/{branch}/skills/{domain}.json"); + println!("downloading skills from: {}", url); + let text: String = Client::new() + .get(url) + .send() + .await + .map_err(|e| format!("Failed to download skill file for {domain}: {e}"))? + .text() + .await + .map_err(|e| format!("Failed to download skill file for {company}.{domain}: {e}"))?; + + let parsed: WebsiteSkills = serde_json::from_str(&text) + .map_err(|e| format!("Failed to parse skill file for {domain}: {e}"))?; + + println!("parsed: {:?}", parsed); + + Ok(parsed) +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 95ea376..955fc95 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,7 +14,11 @@ { "title": "runtime", "width": 400, - "height": 700 + "height": 700, + "resizable": true, + "minWidth": 400, + "maxWidth": 400, + "minHeight": 600 } ], "security": { @@ -30,6 +34,7 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "resources": ["src/scripts/*.scpt"] } } diff --git a/src/App.jsx b/src/App.jsx index cbf792d..1c89a61 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,230 +1,135 @@ import React, { useEffect, useCallback } from "react"; - +import puppeteer from 'puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js'; +import { invoke } from "@tauri-apps/api/core"; import "./App.css"; + import Frame from "./components/layout/Frame"; import HomeView from "./components/pages/HomeView"; import HistoryView from "./components/pages/HistoryView"; import SessionView from "./components/pages/SessionView"; import { useAppState } from "./hooks/useAppState"; -import puppeteer from 'puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js'; -import { invoke } from "@tauri-apps/api/core"; function App() { const { view, - selectedBrowserPath, - savedWsEndpoint, - setSavedWsEndpoint, - clearSavedWsEndpoint, - fetchBrowsers, - browserInstance, - logs, isConnected, + currentBrowserPath, + browserInstance, + rememberBrowser, + forgetBrowser, setBrowserInstance, setPageInstance, setIsConnected, - addLog, - // eslint-disable-next-line no-unused-vars - clearLogs } = useAppState(); - const attemptReconnectionWithSavedEndpoint = async () => { - if (!savedWsEndpoint || !selectedBrowserPath) { - return; + /* + ** connect pptr + */ + const connectPuppeteer = useCallback(async (ws, retries = 2) => { + while (retries--) { + try { + return await puppeteer.connect({ + browserWSEndpoint: ws, + defaultViewport: null + }) + } catch (error) { + if (retries === 0) throw error; + await new Promise(resolve => setTimeout(resolve, 1000)); + } } + }, []) + + const connectBrowser = useCallback(async (browserPath) => { + const path = browserPath || currentBrowserPath; + if (!path) { console.log("no browser selected"); return; } try { - addLog(`attempting to reconnect to saved endpoint: ${savedWsEndpoint}`, "info"); - - const validationResult = await invoke("validate_ws_endpoint", { - wsEndpoint: savedWsEndpoint, - selectedBrowserPath - }); - - addLog(`rust: ${validationResult}`, "success"); - setIsConnected(true); - - try { - addLog("connecting puppeteer to saved endpoint...", "info"); - const browser = await puppeteer.connect({ - browserWSEndpoint: savedWsEndpoint, - defaultViewport: null - }); - - setBrowserInstance(browser); - - const pages = await browser.pages(); - const currentPage = pages.length > 0 ? pages[0] : await browser.newPage(); - setPageInstance(currentPage); - - addLog("successfully reconnected to existing browser instance ♾️", "success"); - - browser.on("disconnected", () => { - addLog("puppeteer disconnected", "warn"); - setBrowserInstance(null); - setPageInstance(null); - setIsConnected(false); - clearSavedWsEndpoint(); - }); - - } catch (puppeteerError) { - addLog(`could not connect puppeteer: ${puppeteerError.message}`, "warn"); - setIsConnected(false); - clearSavedWsEndpoint(); - } - - } catch (error) { - addLog(`saved endpoint is no longer valid: ${error.message}`, "warn"); + /* + ** launch or reuse a browser + */ setIsConnected(false); - clearSavedWsEndpoint(); - } - }; + const ws = await invoke("launch_browser", { browserPath: path }); + console.log("connecting..."); + + /* + ** asserte the connection + */ + await invoke("validate_connection", { + wsEndpoint: ws, + selectedBrowserPath: path + }); - useEffect(() => { - fetchBrowsers(false); - console.log("isConnected", isConnected); - - // crystal clear - return () => { - if (isConnected) { - console.log("Disconnecting from browser..."); - handleCloseBrowser(false); - } - }; - }, [fetchBrowsers]); + /* + ** memorise endpoint so we can reconect next time + */ + rememberBrowser(path, ws); - useEffect(() => { - if (selectedBrowserPath && savedWsEndpoint && !isConnected) { - attemptReconnectionWithSavedEndpoint(); - } - }, [selectedBrowserPath, savedWsEndpoint]); + /* + ** attach pptr + */ + const browser = await connectPuppeteer(ws); + console.log("browser connected", browser); + setBrowserInstance(browser); - const handleLaunchAndConnect = async () => { - if (!selectedBrowserPath) { - console.log("no browser selected"); - return; - } + /* + ** open starter tab (will replace with our own static page) + */ - // first forward - try { - console.log("launching browser..."); - const wsEndpoint = await invoke("launch_browser", { browserPath: selectedBrowserPath }); - console.log("connecting to", wsEndpoint); - - setSavedWsEndpoint(wsEndpoint); + const page = await browser.newPage(); - try { - const validationResult = await invoke("validate_connection", { - wsEndpoint, - selectedBrowserPath - }); - addLog("Rust: " + validationResult, "success"); - setIsConnected(true); - } catch (validationError) { - addLog(`Browser validation failed: ${validationError}`, "error"); - setIsConnected(false); - clearSavedWsEndpoint(); - return; - } - - let browser; - let retries = 2; - - addLog("Establishing puppeteer connection...", "info"); - // second forward (pptr) - while (retries > 0) { - try { - addLog("-> Connecting puppeteer...", "info"); - browser = await puppeteer.connect({ - browserWSEndpoint: wsEndpoint, - defaultViewport: null - }); - addLog("♾️ Puppeteer connected", "success"); - break; - } catch (connectError) { - console.log("connectError", connectError); - retries--; - if (retries === 0) { - addLog("Puppeteer connection failed, but browser is running", "warn"); - addLog("This might be a temporary issue, try again", "info"); - return; - } - addLog(`Puppeteer retry ${2-retries}/2...`, "info"); - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } + setPageInstance(page); - setBrowserInstance(browser); - - const currentPage = await browser.newPage(); + // await page.goto("https://www.youtube.com", { + // waitUntil: "domcontentloaded", + // timeout: 10000 + // }); - try { - await currentPage.goto("https://www.youtube.com", { - waitUntil: "domcontentloaded", - timeout: 10000 - }); - } catch (error) { - console.error("error", error) - } - setPageInstance(currentPage); - - addLog("Full connection established ♾️", "success"); + setIsConnected(true); + console.log("♾️"); + /* + ** housekeeping + */ browser.on("disconnected", () => { - addLog("Puppeteer disconnected", "warn"); + console.log("Puppeteer disconnected"); setBrowserInstance(null); setPageInstance(null); setIsConnected(false); - clearSavedWsEndpoint(); - }); + forgetBrowser(path); + }) } catch (error) { - const errorMessage = error.message || (typeof error === "string" ? error: JSON.stringify(error)); - addLog(`Failed to connect: ${errorMessage}`, "error"); + console.log("failed to connect to browser", error); setIsConnected(false); setBrowserInstance(null); setPageInstance(null); - clearSavedWsEndpoint(); - } - } - - const handleCloseBrowser = useCallback(async (shouldDisconnectPuppeteer = false) => { - addLog("attempting to close browser...", "info"); - if (shouldDisconnectPuppeteer && browserInstance && browserInstance.isConnected()) { - try { - await browserInstance.disconnect(); - addLog("pptr instance disconnected.", 'info'); - } catch (error) { - addLog(`Error disconnecting puppeteer: ${error.message}`, 'warn'); - } + forgetBrowser(path); } + }, [ + currentBrowserPath, + rememberBrowser, + forgetBrowser, + setBrowserInstance, + setPageInstance, + setIsConnected + ]) - try { - await invoke('disconnect_from_browser'); - addLog("Backend command to close browser completed.", 'success'); - } catch (error) { - addLog(`Error disconnecting from backend: ${error.message}`, 'warn'); - } - - setBrowserInstance(null); - setPageInstance(null); - setIsConnected(false); - clearSavedWsEndpoint(); - }, [browserInstance, addLog, clearSavedWsEndpoint]); - - + /* + ** home view props + */ const homeViewProps = { - selectedBrowserPath, - logs, + currentBrowserPath, isConnected, - - onLaunchAndConnect: handleLaunchAndConnect, - onCloseBrowser: handleCloseBrowser, + onLaunchAndConnect: connectBrowser } + /* + ** session view props + */ const sessionViewProps = { browserInstance, - isConnected + isConnected, + connectBrowser }; return ( diff --git a/src/components/pages/HomeView.jsx b/src/components/pages/HomeView.jsx index c2564a1..6063fa1 100644 --- a/src/components/pages/HomeView.jsx +++ b/src/components/pages/HomeView.jsx @@ -11,9 +11,9 @@ import SettingView from "../pages/SettingView"; import PropTypes from "prop-types"; HomeView.propTypes = { + currentBrowserPath: PropTypes.string.isRequired, isConnected: PropTypes.bool.isRequired, onLaunchAndConnect: PropTypes.func.isRequired, - onCloseBrowser: PropTypes.func.isRequired, } export default function HomeView(props) { @@ -25,9 +25,9 @@ export default function HomeView(props) { } = useAppState(); const { + currentBrowserPath, isConnected, onLaunchAndConnect, - onCloseBrowser, } = props; const [showSettingsMenu, setShowSettingsMenu] = useState(false); @@ -37,13 +37,11 @@ export default function HomeView(props) { // placeholder username const username = "User"; - // Improved click outside detection + // click outside detection useEffect(() => { function handleClickOutside(event) { - // Only run this if the menu is actually open if (!showSettingsMenu) return; - // Check if click is outside both menu and button if ( settingsMenuRef.current && !settingsMenuRef.current.contains(event.target) && @@ -53,17 +51,12 @@ export default function HomeView(props) { } } - // Use mousedown for more responsive clicking document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [showSettingsMenu]); - - const handleProfileClick = () => { - setShowSettingsMenu(prev => !prev); - }; // profile button const profileButton = { @@ -72,7 +65,7 @@ export default function HomeView(props) { username, ref: profileIconRef }, - onClick: handleProfileClick + onClick: () => setShowSettingsMenu(prev => !prev) }; const createSession = async (text) => { @@ -87,11 +80,10 @@ export default function HomeView(props) { } const handleSubmit = async (text) => { - const newSession = await createSession(text); // fastapi /plan + const newSession = await createSession(text); // fastapi /plan addSession(newSession); setActiveSessionId(newSession.id); openSession(newSession.id); - } return ( @@ -105,9 +97,9 @@ export default function HomeView(props) {
diff --git a/src/components/pages/SessionView.jsx b/src/components/pages/SessionView.jsx index 242f5ef..577d4a3 100644 --- a/src/components/pages/SessionView.jsx +++ b/src/components/pages/SessionView.jsx @@ -1,4 +1,3 @@ -// src/components/views/SessionView.tsx import React, { useState, useEffect, useRef, useCallback } from 'react'; import PropTypes from 'prop-types'; import { v4 as uuidv4 } from 'uuid'; @@ -11,8 +10,6 @@ import System from '../ui/System'; import { useAppState } from '../../hooks/useAppState'; import { splitQuery } from '../../lib/query.llm'; -import { callLLM } from '../../lib/llm.engine'; -import { getFnCall } from '../../lib/task.execution.helpers'; import { buildHistoryDigest, addPlanToTask, @@ -20,28 +17,79 @@ import { } from '../../lib/query.helpers'; import { runWorkflow } from '../../lib/workflow.runner'; import { taskEventEmitter } from '../../lib/emitters'; +import puppeteer from 'puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js'; +import { createPagePool } from '../../lib/page.manager'; SessionView.propTypes = { browserInstance: PropTypes.object, - isConnected: PropTypes.bool + isConnected: PropTypes.bool, + connectBrowser: PropTypes.func.isRequired }; -export default function SessionView({ browserInstance /* isConnected */ }) { +export default function SessionView({ browserInstance, isConnected, connectBrowser }) { const { sessions, + runtimeMode, activeSessionId, openHome, addMessageToSession, - getSessionMessages + getSessionMessages, + forgetBrowser, + getWsFor, + setBrowserInstance, + setIsConnected, + currentBrowserPath } = useAppState(); const activeSession = sessions.find(s => s.id === activeSessionId); const [messages, setMessages] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [historyReady, setHistoryReady] = useState(false); + const [isMessagesReady, setIsMessagesReady] = useState(false); const messagesEndRef = useRef(null); + const cancelRef = useRef({ cancelled: false }); + + /* + ** reattach if we lost the in memory handle + */ + const reconnectIfNeeded = async (maxRetries = 3) => { + + if (browserInstance) return browserInstance; + + /* + ** try to reconnect to the browser + */ + let attempts = 0; + while (attempts <= maxRetries) { + const ws = getWsFor(currentBrowserPath); + + if (ws) { + try { + const br = await puppeteer.connect({ + browserWSEndpoint: ws, + defaultViewport: null + }) + + setBrowserInstance(br); + setIsConnected(true); + return br; + } catch (error) { + /* + ** if the ws is stale, drop it + */ + console.log("stale ws, dropping it", error.message); + forgetBrowser(currentBrowserPath); + } + } + await connectBrowser(currentBrowserPath); + + attempts += 1; + } + + return null; + } /* * add a new message to the messages array and the session @@ -59,48 +107,51 @@ export default function SessionView({ browserInstance /* isConnected */ }) { /* - * load the session history on mount / switch + ** load the session history on mount, and take the current query */ useEffect(() => { if (!activeSessionId) { setMessages([]); setHistoryReady(false); + setIsMessagesReady(false); return; } const stored = getSessionMessages(activeSessionId) ?? []; setMessages(stored); setHistoryReady(true); - + setIsMessagesReady(true); }, [activeSessionId]); + /* - ** ensure sys message is the right one for taskId + ** handle new query */ - function ensureSysMessage(clone, taskId, initialAction) { - let idx = clone.findIndex( - m => - m.type === 'system' && - Array.isArray(m.tasks) && - m.tasks.some(t => t.taskId === taskId) - ); + useEffect(() => { + if (!isMessagesReady) return; - if (idx === -1) { - const newMsg = { - id: uuidv4(), - type: 'system', - text: '', - action: initialAction, - timestamp: new Date().toISOString(), - status: 'pending', - tasks: [{ taskId, tabs: [], plans: [] }], + if ( + activeSessionId && + messages.length === 0 && + activeSession.title + ) { + + const handleInitialQuery = async () => { + + /* + ** save the message + */ + addNewMessage({ type: 'user', text: activeSession.title }); + + /* + ** execute the query + */ + executeQuery(activeSession.title); }; - clone.push(newMsg); - idx = clone.length - 1; - } - return idx; - } + handleInitialQuery(); + } + }, [activeSessionId, messages.length, activeSession.title, isMessagesReady]); /* * handle workflow updates (create, update, complete "plan" of sub tasks) @@ -112,7 +163,6 @@ export default function SessionView({ browserInstance /* isConnected */ }) { ** handle task updates */ const handleTaskUpdate = ({ taskId, action, speakToUser, status, error}) => { - setMessages(prev => { /* ** create a new system message @@ -147,7 +197,8 @@ export default function SessionView({ browserInstance /* isConnected */ }) { /* ** starting the action (puppeteer or llm) */ - const handleActionStart = ({ taskId, action, speakToUser, status, actionId }) => { + const handleActionStart = ({ taskId, action, speakToUser, status, actionId, url }) => { + setMessages(prev => { const clone = [...prev]; @@ -162,7 +213,7 @@ export default function SessionView({ browserInstance /* isConnected */ }) { timestamp: new Date().toISOString(), }; - sysMsg.tasks = addPlanToTask({tasks: sysMsg.tasks, taskId, newPlan: newPlanStep}); + sysMsg.tasks = addPlanToTask({tasks: sysMsg.tasks, taskId, newPlan: newPlanStep, action, url}); clone[sysIndex] = sysMsg; setTimeout(() => { @@ -211,13 +262,43 @@ export default function SessionView({ browserInstance /* isConnected */ }) { taskEventEmitter.off('task_action_complete', handleActionDone); taskEventEmitter.off('task_action_error', handleActionDone); }; - }, [activeSessionId, isProcessing]); + }, [activeSessionId]); // removed isProcessing + + /* + ** ensure sys message is the right one for taskId + */ + function ensureSysMessage(clone, taskId, initialAction) { + let idx = clone.findIndex( + m => + m.type === 'system' && + Array.isArray(m.tasks) && + m.tasks.some(t => t.taskId === taskId) + ); + + if (idx === -1) { + const newMsg = { + id: uuidv4(), + type: 'system', + text: '', + action: initialAction, + timestamp: new Date().toISOString(), + status: 'pending', + tasks: [{ taskId, tabs: [], plans: [] }], + }; + clone.push(newMsg); + idx = clone.length - 1; + } + + return idx; + } /* * execute a query */ const executeQuery = async (rawText) => { + setIsProcessing(true); + cancelRef.current.cancelled = false; try { /* @@ -227,21 +308,45 @@ export default function SessionView({ browserInstance /* isConnected */ }) { const historyDigest = buildHistoryDigest(fullHistory); console.log("historyDigest", historyDigest); - const resp = await splitQuery({query: rawText, history: historyDigest}); - const { queries, dependencies, researchFlags } = resp; + const resp = await splitQuery({query: rawText, history: historyDigest, runtimeMode}); + console.log("kind", resp.kind); + + if (resp.kind === "small_talk") { + const { reply } = resp; + addNewMessage({ type:'system', text: reply }); + return; + } /* * run the workflow */ + let browser = browserInstance; + if (!browser) { + browser = await reconnectIfNeeded(); + // console.log("browser after reconnect", browser); + } + + if (!browser) { + addNewMessage({ + type:'system', + text: "Ops, something went wrong, please relaunch runtime", + isError: true + }); + return; + } + await runWorkflow({ + mode: resp.kind, originalQuery: rawText, sessionId: activeSessionId, - queries, - dependencies, - researchFlags, - browserInstance, - onDone: (text) => addNewMessage({ type:'system', text }), - onError: (err) => addNewMessage({ type:'system', text: err, isError: true }) + queries: resp.queries, + dependencies: resp.dependencies, + researchFlags: resp.researchFlags, + steps: resp.steps, + browserInstance: browser, + cancelRef, + // onDone: (text) => addNewMessage({ type:'system', text }), + // onError: (err) => addNewMessage({ type:'system', text: err, isError: true }) }) } catch (error) { addNewMessage({ type:'system', text: error.message, isError:true }); @@ -253,13 +358,31 @@ export default function SessionView({ browserInstance /* isConnected */ }) { /* * handle the submit of a new message */ - const handleSubmit = (text) => { + const handleSubmit = async (text) => { if (!text.trim() || isProcessing) return; + // run puppeteer function to test, i will a function here + if (!browserInstance) { + browserInstance = await reconnectIfNeeded(); + } + + // use page manager + const pageManager = createPagePool({ browser: browserInstance }); + + const page = await pageManager(); + addNewMessage({ type:'user', text: text.trim() }); executeQuery(text.trim()); }; + /* + ** handle stop + */ + const handleStop = () => { + cancelRef.current.cancelled = true; + setIsProcessing(false); + } + /* * if no active session, show a loading message */ @@ -277,8 +400,8 @@ export default function SessionView({ browserInstance /* isConnected */ }) {
{messages.map(m => m.type === 'user' - ? - : + : @@ -301,7 +424,9 @@ export default function SessionView({ browserInstance /* isConnected */ }) { "Send a message to Runtime…" } onSubmit={handleSubmit} - disabled={isProcessing || !historyReady} + disabled={!historyReady} + isProcessing={isProcessing} + onStop={handleStop} />
diff --git a/src/components/ui/ActionBar.jsx b/src/components/ui/ActionBar.jsx new file mode 100644 index 0000000..15ce8cc --- /dev/null +++ b/src/components/ui/ActionBar.jsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { TbThumbUp, TbThumbDown, TbCopy, TbPlus, TbRotateClockwise } from "react-icons/tb"; +import AppActionMenu from './AppActionMenu'; +import PropTypes from 'prop-types'; +import { mdToHtml } from '../../lib/utils'; + +ActionBar.propTypes = { + text: PropTypes.string, + onThumbsUp: PropTypes.func, + onThumbsDown: PropTypes.func, + onCopy: PropTypes.func, + onNotion: PropTypes.func, + onAppleNote: PropTypes.func +}; + +export default function ActionBar({ + text = "", + onThumbsUp, + onThumbsDown, + onCopy, +}) { + const [hoveredButton, setHoveredButton] = useState(null); + const [openMenuFor, setOpenMenuFor] = useState(null); + + const handleThumbsUp = () => { + if (onThumbsUp) { + onThumbsUp(); + } else { + console.log('Thumbs up clicked'); + } + }; + + const handleThumbsDown = () => { + if (onThumbsDown) { + onThumbsDown(); + } else { + console.log('Thumbs down clicked'); + } + }; + + const handleCopy = () => { + if (onCopy) { + onCopy(text); + } else { + navigator.clipboard.writeText(text); + console.log('Text copied to clipboard'); + } + }; + + const handleNotion = () => { + if (onNotion) { + onNotion(text); + } else { + console.log('Send to Notion clicked'); + } + }; + + const handleAppleNote = () => { + setOpenMenuFor('apple'); + }; + + /* ===== menus for each icon ===== */ + const appleNoteItems = [ + { + id: 'new', + label: 'Add to a new note', + icon: , + onClick: async () => { + setOpenMenuFor(null); + console.log('Adding to new note: ', text); + await invoke('call_app', { + func: 'create_note', + args: ['Note from Runtime', mdToHtml(text)] + }); + } + }, + { + id: 'append', + label: 'Add to the last note', + icon: ( + + + + ), + onClick: async () => { + setOpenMenuFor(null); + console.log('Adding to last note: ', text); + await invoke('call_app', { + func: 'append_note', + args: [mdToHtml(text)] + }); + } + } + ]; + + return ( +
+ {/* Left side buttons */} +
+
+ + {hoveredButton === 'notion' && ( +
+ Send to Notion +
+ )} +
+ +
+ + {hoveredButton === 'appleNote' && ( +
+ Send to Apple Notes +
+ )} + + setOpenMenuFor(null)} + items={appleNoteItems} + /> +
+
+ + {/* Right side buttons */} +
+
+ + {hoveredButton === 'helpful' && ( +
+ Helpful +
+ )} +
+ +
+ + {hoveredButton === 'notHelpful' && ( +
+ Not helpful +
+ )} +
+ +
+ + {hoveredButton === 'copy' && ( +
+ Copy +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/ActionIcons.jsx b/src/components/ui/ActionIcons.jsx deleted file mode 100644 index 3b96266..0000000 --- a/src/components/ui/ActionIcons.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useState } from 'react'; -import { TbThumbUp, TbThumbDown, TbCopy } from "react-icons/tb"; -import PropTypes from 'prop-types'; - -ActionIcons.propTypes = { - text: PropTypes.string.isRequired, - onThumbsUp: PropTypes.func, - onThumbsDown: PropTypes.func, - onCopy: PropTypes.func -}; - -export default function ActionIcons({ - text, - onThumbsUp, - onThumbsDown, - onCopy -}) { - const [hoveredButton, setHoveredButton] = useState(null); - - const handleThumbsUp = () => { - if (onThumbsUp) { - onThumbsUp(); - } else { - console.log('Thumbs up clicked'); - } - }; - - const handleThumbsDown = () => { - if (onThumbsDown) { - onThumbsDown(); - } else { - console.log('Thumbs down clicked'); - } - }; - - const handleCopy = () => { - if (onCopy) { - onCopy(text); - } else { - navigator.clipboard.writeText(text); - console.log('Text copied to clipboard'); - } - }; - - return ( -
-
- - {hoveredButton === 'helpful' && ( -
- Helpful -
- )} -
- -
- - {hoveredButton === 'notHelpful' && ( -
- Not helpful -
- )} -
- -
- - {hoveredButton === 'copy' && ( -
- Copy -
- )} -
-
- ); -} \ No newline at end of file diff --git a/src/components/ui/AppActionMenu.jsx b/src/components/ui/AppActionMenu.jsx new file mode 100644 index 0000000..0eb8317 --- /dev/null +++ b/src/components/ui/AppActionMenu.jsx @@ -0,0 +1,58 @@ +import React, { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; + +AppActionMenu.propTypes = { + visible: PropTypes.bool, + onRequestClose: PropTypes.func, + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + label: PropTypes.string, + icon: PropTypes.node, + onClick: PropTypes.func + })) +} + +export default function AppActionMenu({ visible, onRequestClose, items }) { + const menuRef = useRef(null); + + useEffect(() => { + if (!visible) return; + + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + onRequestClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [visible, onRequestClose]); + + if (!visible) return null; + + return ( +
+ {items.map((item, idx) => ( +
+ + + {idx < items.length - 1 && ( +
+ )} +
+ ))} +
+ ); +} diff --git a/src/components/ui/BrowserSelection.jsx b/src/components/ui/BrowserSelection.jsx index d9bd4a6..9ff7181 100644 --- a/src/components/ui/BrowserSelection.jsx +++ b/src/components/ui/BrowserSelection.jsx @@ -1,141 +1,118 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { HiChevronRight } from 'react-icons/hi'; -import { useAppState } from '../../hooks/useAppState'; -import { browserIcons } from '../../lib/utils'; -import PropTypes from 'prop-types'; +import React, { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import clsx from "clsx"; +import { useAppState } from "../../hooks/useAppState"; +import { browserIcons } from "../../lib/utils"; BrowserSelection.propTypes = { - isConnected: PropTypes.bool.isRequired, - onLaunchAndConnect: PropTypes.func.isRequired, - onCloseBrowser: PropTypes.func.isRequired, -} + onLaunchAndConnect: PropTypes.func.isRequired, + isConnected: PropTypes.bool.isRequired, +}; -export default function BrowserSelection(props) { - const { - availableBrowsers, - selectedBrowserPath, - setSelectedBrowserPath - } = useAppState(); - const [isOpen, setIsOpen] = useState(false); - const modalRef = useRef(null); - const browserButtonRef = useRef(null); +export default function BrowserSelection({ + isConnected, + onLaunchAndConnect, +}) { + /* + ** global state + */ + const [isBrowserLaunching, setIsBrowserLaunching] = useState(false); + const { + availableBrowsers, + currentBrowserPath, + setCurrentBrowserPath, + loadAvailableBrowsers, + } = useAppState(); - const { isConnected, onLaunchAndConnect, onCloseBrowser } = props; - - const selectedBrowser = availableBrowsers.find(browser => - browser.path === selectedBrowserPath - ) || { id: 'default', name: 'Browser' }; - - useEffect(() => { - function handleClickOutside(event) { - if (!isOpen) return; - - if ( modalRef.current && - !modalRef.current.contains(event.target) && - (!browserButtonRef.current || !browserButtonRef.current.contains(event.target)) - ) { - setIsOpen(false); - } - } - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isOpen]); - - const toggleModal = () => { - setIsOpen(prev => !prev); - }; - - const selectBrowser = (browserPath) => { - setSelectedBrowserPath(browserPath); - setIsOpen(false); - - if (!isConnected) { - setTimeout(() => { - onLaunchAndConnect(); - }, 100); - } - }; - - const browserIcon = browserIcons[selectedBrowser.id] || browserIcons.default; + /* + ** fetch once on mount + */ + useEffect(() => { + if (availableBrowsers.length === 0) { + loadAvailableBrowsers(); + } + }, [availableBrowsers.length, loadAvailableBrowsers]); - const handleConnectDisconnect = () => { - if (isConnected) { - onCloseBrowser(true); - } else { - onLaunchAndConnect(); - } - }; - + /* + ** keep selection valid + */ + useEffect(() => { + if ( + availableBrowsers.length && + !availableBrowsers.some( + (b) => b.path === currentBrowserPath) + ) { + setCurrentBrowserPath(availableBrowsers[0].path); + } + }, [availableBrowsers, currentBrowserPath]); + + /* + ** watch whenever the browser is connected, then + */ + useEffect(() => { + if (isConnected) { + setIsBrowserLaunching(false); + } + }, [isConnected]); + + /* + ** click handler + */ + const pickBrowser = async (browser) => { + setCurrentBrowserPath(browser.path); + setIsBrowserLaunching(true); + await onLaunchAndConnect(browser.path); + }; + + /* + ** empty state + */ + if (!availableBrowsers.length) { return ( -
-
-
- {selectedBrowser.name} -
-
-
- {isConnected ? 'Connected' : 'Disconnected'} - -
-
- - {isOpen && ( -
-
-
-
- {isConnected ? 'Connected' : 'Disconnected'} -
- - -
- -
- -
-
Available Browsers
- { availableBrowsers.length > 0 ? ( -
- {availableBrowsers.map(browser => ( -
selectBrowser(browser.path)} - > - {browser.path === selectedBrowserPath && (
)} - {browser.path !== selectedBrowserPath && (
)} -
- {browser.name} -
- {browser.name} -
- ))} -
- ) : ( -
- No browsers available -
- )} -
-
- )} -
+
+ No browsers detected +
); + } + + /* + ** UI + */ + return ( +
+ {availableBrowsers.map((b) => { + const isSel = b.path === currentBrowserPath; + const showDot = isSel; + const isUp = isSel && isConnected; + return ( + + ); + })} +
+ ); } \ No newline at end of file diff --git a/src/components/ui/Construction.jsx b/src/components/ui/Construction.jsx index 5a4f13c..c16cd2d 100644 --- a/src/components/ui/Construction.jsx +++ b/src/components/ui/Construction.jsx @@ -68,6 +68,14 @@ export default function Construction({ activeTab, tasks = [] }) { return () => clearTimeout(id); }, [tasks, appearedPlans]); + /* + ** parse link to domain name + */ + const parseLinkToDomain = (link) => { + const url = new URL(link); + return url.hostname; + } + /* ** get the status icon */ @@ -167,46 +175,33 @@ export default function Construction({ activeTab, tasks = [] }) { ** render the tab card */ const allTabs = tasks.flatMap((t) => t.tabs || []); - const renderTabCard = (tab, idx) => ( + const renderTabCard = (url, idx) => (
linkRefs.current[tab.id]?.click()} + className="flex flex-col bg-zinc-800/20 gap-1 border border-zinc-700/20 hover:border-blue-400/25 + hover:bg-zinc-700/10 transition-colors rounded-md py-1 pl-3 pr-5 relative hover:cursor-pointer" + key={idx} + onClick={() => linkRefs.current[url]?.click()} > -
- {idx + 1} - {tab.status && ( - - {statusIcon(tab.status)} - {tab.status} - - )} -
-
+
- {tab.icon ? ( - {tab.title} - ) : ( - - )} +
-
-

- {tab.title} + ) : (
- - Preparing execution plan…
) ) : allTabs.length ? ( -
+
{allTabs.map(renderTabCard)}
) : ( diff --git a/src/components/ui/PromptInput.jsx b/src/components/ui/PromptInput.jsx index dc95f5f..d803f0a 100644 --- a/src/components/ui/PromptInput.jsx +++ b/src/components/ui/PromptInput.jsx @@ -1,10 +1,15 @@ import { useState, useRef, useEffect } from "react"; -import { HiArrowUp } from "react-icons/hi2"; +import { HiArrowUp, HiStop } from "react-icons/hi2"; import { useAppState } from "../../hooks/useAppState"; -export default function PromptInput({ placeholder, onSubmit }) { +export default function PromptInput({ + placeholder, + onSubmit, + onStop, + isProcessing +}) { const [text, setText] = useState(""); - const { setCurrentQuery } = useAppState(); + const { setCurrentQuery, runtimeMode, setRuntimeMode } = useAppState(); const textareaRef = useRef(null); const handleSend = () => { @@ -14,6 +19,10 @@ export default function PromptInput({ placeholder, onSubmit }) { setText(""); } + const handleStop = () => { + onStop(); + } + useEffect(() => { const textarea = textareaRef.current; if (textarea) { @@ -30,30 +39,70 @@ export default function PromptInput({ placeholder, onSubmit }) { Control your browser
*/}
-