feat: add Tauri desktop shell + desktop_bridge (url mode, no-console)

This commit is contained in:
Liang Jiaqing committed 2026-05-15 18:29:00 +08:00
1 parent fe47bb29cb
commit 3be6ed35ba
16 files changed
+8743

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ mykey.py
tasks/
*.zip
*.exe
memory/*
!memory/memory_management_sop.md
+7
View File
@@ -0,0 +1,7 @@
# Rust build artifacts
src-tauri/target/
src-tauri/gen/
# Node
node_modules/
package-lock.json
+10
View File
@@ -0,0 +1,10 @@
{
"name": "genericagent-web2",
"version": "0.1.0",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "tauri-hello"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lib]
name = "tauri_hello_lib"
crate-type = ["lib", "cdylib", "staticlib"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+86
View File
@@ -0,0 +1,86 @@
use std::process::{Command, Child};
use std::sync::Mutex;
use std::net::TcpStream;
use std::time::{Duration, Instant};
use std::thread;
use std::path::PathBuf;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
static BRIDGE_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
fn find_bridge_script() -> PathBuf {
// exe is at frontends/GenericAgent.exe
// bridge is at frontends/desktop_bridge.py
let exe_dir = std::env::current_exe()
.expect("cannot get exe path")
.parent()
.expect("cannot get exe dir")
.to_path_buf();
exe_dir.join("desktop_bridge.py")
}
fn wait_for_port(port: u16, timeout: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return true;
}
thread::sleep(Duration::from_millis(100));
}
false
}
fn start_bridge() {
let script = find_bridge_script();
if !script.exists() {
eprintln!("[tauri] bridge script not found: {:?}", script);
return;
}
let show_console = std::env::args().any(|a| a == "--console");
let mut cmd = Command::new("python");
cmd.arg(&script)
.current_dir(script.parent().unwrap());
#[cfg(windows)]
if !show_console {
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let child = cmd.spawn().expect("failed to start desktop_bridge.py");
eprintln!("[tauri] started bridge PID={}", child.id());
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
if !wait_for_port(14168, Duration::from_secs(15)) {
eprintln!("[tauri] WARNING: bridge did not become ready within 15s");
}
}
fn stop_bridge() {
if let Some(mut child) = BRIDGE_PROCESS.lock().unwrap().take() {
eprintln!("[tauri] stopping bridge PID={}", child.id());
let _ = child.kill();
let _ = child.wait();
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
start_bridge();
tauri::Builder::default()
.on_window_event(|_window, event| {
if let tauri::WindowEvent::Destroyed = event {
stop_bridge();
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
stop_bridge();
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri_hello_lib::run()
}
@@ -0,0 +1,29 @@
{
"productName": "GenericAgent",
"version": "0.1.0",
"identifier": "com.genericagent.app",
"build": {
"frontendDist": "../static"
},
"app": {
"withGlobalTauri": true,
"security": {
"csp": null
},
"windows": [
{
"title": "GenericAgent",
"label": "main",
"width": 1280,
"height": 800,
"resizable": true,
"url": "http://127.0.0.1:14168/"
}
]
},
"bundle": {
"active": true,
"targets": "nsis",
"icon": ["icons/icon.ico"]
}
}
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
// GenericAgent Web2 browser bridge adapter.
// HTTP is the command/data channel. WebSocket only carries small state events.
(() => {
'use strict';
const listeners = new Map();
let ws = null;
let cachedBridgeReady = null;
const bridgeBase = `${location.protocol}//${location.hostname}:14168`;
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname}:14168/ws`;
function on(channel, cb) {
if (typeof cb !== 'function') return () => {};
if (!listeners.has(channel)) listeners.set(channel, new Set());
listeners.get(channel).add(cb);
if (channel === 'bridge-ready' && cachedBridgeReady) {
try { cb(cachedBridgeReady); } catch (err) { console.error('[ga-web2 listener] replay bridge-ready', err); }
}
return () => listeners.get(channel)?.delete(cb);
}
function emit(channel, payload) {
if (channel === 'bridge-ready') cachedBridgeReady = payload;
const set = listeners.get(channel);
if (!set) return;
for (const cb of Array.from(set)) {
try { cb(payload); } catch (err) { console.error('[ga-web2 listener]', channel, err); }
}
}
async function http(path, options = {}) {
const headers = Object.assign({}, options.headers || {});
const init = Object.assign({}, options, { headers });
if (init.body && typeof init.body !== 'string') {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
init.body = JSON.stringify(init.body);
}
const res = await fetch(`${bridgeBase}${path}`, init);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
if (!res.ok) {
const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
function connectWs() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
try {
ws = new WebSocket(wsUrl);
ws.addEventListener('open', () => emit('bridge-log', 'WS state channel connected'));
ws.addEventListener('message', (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (msg.type === 'bridge-ready') {
emit('bridge-ready', msg);
} else if (msg.type === 'session-state') {
emit('bridge-notification', msg);
} else if (msg.type === 'bridge-log') {
emit('bridge-log', msg.payload || msg);
} else if (msg.type === 'bridge-error') {
emit('bridge-error', msg.payload || msg);
}
});
ws.addEventListener('close', () => emit('bridge-closed', { reason: 'ws-closed' }));
ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket state channel error' }));
} catch (err) {
emit('bridge-error', { type: 'ws-error', message: err.message || String(err) });
}
}
async function rpc(method, params = {}) {
switch (method) {
case 'app/status':
return http('/status');
case 'app/config/get':
return http('/config');
case 'app/config/save':
return http('/config', { method: 'POST', body: params || {} });
case 'get/model-profiles':
return http('/model-profiles');
case 'session/new':
return http('/session/new', { method: 'POST', body: params || {} });
case 'session/prompt': {
const sid = params.sessionId || params.id || params.bridgeSessionId;
if (!sid) throw new Error('session/prompt missing sessionId');
return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} });
}
case 'session/poll': {
const sid = params.sessionId || params.id || params.bridgeSessionId;
if (!sid) throw new Error('session/poll missing sessionId');
const after = params.afterId ?? params.after ?? 0;
const limit = params.limit ?? 200;
return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`);
}
case 'session/cancel': {
const sid = params.sessionId || params.id || params.bridgeSessionId;
if (!sid) throw new Error('session/cancel missing sessionId');
return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} });
}
case 'app/path/open':
return http('/path/open', { method: 'POST', body: params || {} });
case 'app/path/selectGaRoot':
return http('/config');
case 'list_continuable_sessions':
return { sessions: [] };
case 'restore_session':
throw new Error('restore_session is not implemented in web2 bridge');
default:
throw new Error(`Unknown RPC method: ${method}`);
}
}
window.ga = {
platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
startBridge: async () => { connectWs(); return http('/status'); },
stopBridge: async () => ({ ok: true }),
checkStatus: () => rpc('app/status', {}),
getConfig: () => rpc('app/config/get', {}),
saveConfig: (cfg) => rpc('app/config/save', cfg || {}),
getModelProfiles: () => rpc('get/model-profiles', {}),
selectGaRoot: () => rpc('app/path/selectGaRoot', {}),
openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }),
openMykey: () => rpc('app/path/open', { kind: 'mykey' }),
pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }),
rpc,
onBridgeMessage: (cb) => on('bridge-message', cb),
onBridgeNotification: (cb) => on('bridge-notification', cb),
onBridgeError: (cb) => on('bridge-error', cb),
onBridgeClosed: (cb) => on('bridge-closed', cb),
onBridgeReady: (cb) => on('bridge-ready', cb),
onBridgeLog: (cb) => on('bridge-log', cb),
onOpenSearch: (cb) => on('open-search', cb)
};
connectWs();
http('/status').then(status => emit('bridge-ready', status)).catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) }));
})();
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>GenericAgent</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<!-- Main area -->
<main id="main">
<header id="topbar">
<div class="topbar-left">
<div id="session-list" class="tab-strip" role="tablist" aria-label="Chats">
<button id="new-session-btn" class="tab-new-btn" title="New chat" aria-label="New chat">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
</div>
</div>
<div class="topbar-right">
<h1 id="session-title" class="sr-only">GenericAgent</h1>
<span id="status-badge" class="badge">
<span class="status-dot"></span>
<span id="status-text">Starting…</span>
</span>
</div>
</header>
<div id="search-bar" class="search-bar hidden">
<input id="search-input" type="text" placeholder="Search messages…" aria-label="Search messages">
<span id="search-count" class="search-count"></span>
<button id="search-prev" class="search-nav-btn" title="Previous (Shift+Enter)" aria-label="Previous match">▲</button>
<button id="search-next" class="search-nav-btn" title="Next (Enter)" aria-label="Next match">▼</button>
<button id="search-close" class="search-nav-btn" title="Close (Escape)" aria-label="Close search">✕</button>
</div>
<section id="messages" class="empty">
<div class="empty-state">
<div class="empty-title">New task</div>
<div class="empty-sub">Task me anything. Type <code>/help</code> for commands.</div>
</div>
</section>
<div id="composer-wrap">
<div id="composer">
<div id="image-previews" class="image-previews"></div>
<textarea id="input" rows="1" placeholder="Type a message, or /help"></textarea>
<div class="composer-bottom">
<div class="composer-hints">
<button id="settings-btn" class="icon-btn ghost" title="Settings" aria-label="Settings">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="hint-label">Settings</span>
</button>
</div>
<button id="send-btn" class="send-btn" title="Send (Enter)">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6"/>
</svg>
</button>
</div>
</div>
</div>
</main>
<!-- Settings modal -->
<div id="settings-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-body">
<div class="modal-header">
<h2>Settings</h2>
<button class="icon-btn ghost" id="close-settings">✕</button>
</div>
<div class="modal-content">
<div class="form-row">
<label for="cfg-llm">Model</label>
<select id="cfg-llm">
<option value="0">Default / Auto</option>
</select>
<small>Select the LLM profile from mykey.py.</small>
</div>
<div class="form-row">
<label>API keys</label>
<div class="btn-row">
<button class="btn" id="open-mykey">Open mykey.py</button>
</div>
<small>Configure API keys in <code>mykey.py</code> at the GA project root.</small>
</div>
</div>
<div class="modal-footer">
<button class="btn ghost" id="cancel-settings">Cancel</button>
<button class="btn primary" id="save-settings">Save</button>
</div>
</div>
</div>
<!-- Error banner -->
<div id="error-banner" class="error-banner hidden">
<span id="error-text"></span>
<button class="icon-btn ghost" id="error-action"></button>
<button class="icon-btn ghost" id="error-dismiss">✕</button>
</div>
</div>
<script src="vendor/marked.min.js"></script>
<script src="ga-web.js?v=14168-2"></script>
<script src="app.js?v=poll-fold-2"></script>
</body>
</html>
+897
View File
@@ -0,0 +1,897 @@
/* GenericAgent Desktop — Minimal Codex-inspired styling */
:root {
--bg: #ffffff;
--bg-subtle: #fafafa;
--bg-hover: #f3f3f3;
--bg-active: #ededed;
--border: #e8e8e8;
--border-strong: #d4d4d4;
--text: #0a0a0a;
--text-muted: #6b6b6b;
--text-faint: #9a9a9a;
--accent: #0a0a0a;
--accent-text: #ffffff;
--user-bg: #f5f5f5;
--code-bg: #f5f5f5;
--code-text: #1a1a1a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05);
--radius: 10px;
--radius-sm: 6px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: "SF Mono", "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--ok: #16a34a;
--warn: #f59e0b;
--err: #dc2626;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--bg-subtle: #1f1f1f;
--bg-hover: #262626;
--bg-active: #2d2d2d;
--border: #2a2a2a;
--border-strong: #3a3a3a;
--text: #ededed;
--text-muted: #a0a0a0;
--text-faint: #6b6b6b;
--accent: #ededed;
--accent-text: #0a0a0a;
--user-bg: #262626;
--code-bg: #0f0f0f;
--code-text: #e0e0e0;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
--shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
}
@media (prefers-color-scheme: dark) {
[data-theme="auto"] {
--bg: #1a1a1a;
--bg-subtle: #1f1f1f;
--bg-hover: #262626;
--bg-active: #2d2d2d;
--border: #2a2a2a;
--border-strong: #3a3a3a;
--text: #ededed;
--text-muted: #a0a0a0;
--text-faint: #6b6b6b;
--accent: #ededed;
--accent-text: #0a0a0a;
--user-bg: #262626;
--code-bg: #0f0f0f;
--code-text: #e0e0e0;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
--shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
}
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%;
font-family: var(--font);
font-size: 14px;
letter-spacing: -0.01em;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
#app {
display: flex;
height: 100vh;
width: 100vw;
}
/* ─── Top tabs ───────────────────────────────────────────────────────── */
button, input, textarea, select, .icon-btn, .btn, .send-btn, .session-tab, .tab-new-btn {
-webkit-app-region: no-drag;
}
.tab-strip {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-end;
gap: 2px;
overflow: hidden;
padding: 8px 0 0 0;
scrollbar-width: none;
}
/* macOS: leave space for traffic-light buttons */
body.platform-darwin .tab-strip {
padding-left: 80px;
}
.tab-strip::-webkit-scrollbar { display: none; }
/* Chrome-like chat tabs */
.session-tab {
display: flex;
align-items: center;
gap: 4px;
flex: 0 1 180px;
min-width: 60px;
height: 32px;
padding: 0 6px 0 10px;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 10px 10px 0 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 13px;
text-align: left;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
}
.session-tab .tab-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.session-tab .tab-dot {
flex-shrink: 0;
width: 7px; height: 7px;
border-radius: 50%;
background: var(--ok);
margin-right: 2px;
}
.session-tab .tab-dot.busy { background: var(--warn); animation: pulse 1.2s infinite; }
.session-tab .tab-dot.err { background: var(--err); }
.session-tab .tab-dot.warn { background: var(--warn); }
.session-tab .tab-close {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
color: var(--text-faint);
opacity: 0;
transition: opacity 0.1s, background 0.1s;
cursor: pointer;
}
.session-tab:hover .tab-close { opacity: 1; }
.session-tab .tab-close:hover {
background: var(--bg-active);
color: var(--text);
}
.session-tab:hover { background: var(--bg-hover); color: var(--text); }
.session-tab.active {
background: var(--bg);
border-color: var(--border);
color: var(--text);
font-weight: 600;
}
.session-tab.active .tab-close { opacity: 0.6; }
.session-tab.active .tab-close:hover { opacity: 1; background: var(--bg-active); }
.tab-new-btn {
width: 28px;
height: 28px;
margin: 0 8px 2px 2px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
align-self: flex-end;
}
.tab-new-btn:hover { background: var(--bg-hover); color: var(--text); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ─── Main ────────────────────────────────────────────────────────────── */
#main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
#topbar {
height: 44px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: stretch;
justify-content: space-between;
padding: 0 12px 0 0;
-webkit-app-region: drag;
flex-shrink: 0;
background: var(--bg-subtle);
}
.topbar-left, .topbar-right {
display: flex;
align-items: center;
gap: 0;
}
.topbar-right { display: none; }
.topbar-left { flex: 1; min-width: 0; align-items: stretch; }
.topbar-right { flex-shrink: 0; }
#session-title {
font-size: 13px;
font-weight: 600;
margin: 0;
letter-spacing: -0.01em;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 10px;
background: var(--bg-subtle);
border: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
.status-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-faint);
}
.badge.ok .status-dot { background: var(--ok); }
.badge.warn .status-dot { background: var(--warn); }
.badge.err .status-dot { background: var(--err); }
.badge.busy .status-dot { background: var(--warn); animation: pulse 1.2s infinite; }
@keyframes pulse { 50% { opacity: 0.3; } }
/* ─── Messages ─────────────────────────────────────────────────────────── */
#messages {
flex: 1;
overflow-y: auto;
padding: 24px 20px 8px;
scroll-behavior: smooth;
}
#messages.empty {
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
color: var(--text-muted);
max-width: 400px;
}
.empty-logo {
font-size: 36px;
color: var(--text-muted);
margin-bottom: 12px;
letter-spacing: 0.1em;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.empty-sub {
font-size: 13px;
}
.empty-sub code {
background: var(--bg-subtle);
padding: 1px 6px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
}
.msg {
max-width: 780px;
margin: 0 auto 18px;
font-size: 14.5px;
line-height: 1.66;
}
.msg-user {
display: flex;
justify-content: flex-end;
}
.msg-user .bubble {
background: transparent;
padding: 0;
border-radius: 0;
max-width: 85%;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--text);
font-weight: 500;
}
.msg-assistant {
color: var(--text);
}
.assistant-response {
padding: 0;
color: var(--text);
}
.msg-system {
color: var(--text-faint);
font-size: 12px;
text-align: left;
padding: 6px 10px;
font-family: var(--font-mono);
white-space: pre-wrap;
}
.msg-error {
color: var(--err);
background: rgba(220, 38, 38, 0.06);
border-left: 3px solid var(--err);
padding: 8px 14px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
}
/* Turn (collapsible segment of assistant reply) */
.turn {
margin: 6px 0 10px;
border: 0;
border-radius: var(--radius-sm);
overflow: hidden;
background: transparent;
}
.msg-assistant .turn[data-kind="agent_message_chunk"] {
border: 0;
background: transparent;
}
.turn-header {
width: max-content;
max-width: 100%;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-subtle);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font);
overflow: hidden;
white-space: nowrap;
}
.turn-header:hover { background: var(--bg-hover); }
.turn-caret {
display: inline-block;
transition: transform 0.15s;
font-size: 10px;
}
.turn.collapsed .turn-caret { transform: rotate(-90deg); }
.turn.collapsed .turn-body { display: none; }
.turn-body {
padding: 8px 0 0 18px;
color: var(--text-muted);
}
.turn-tag {
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.08em;
color: var(--text-faint);
font-weight: 600;
flex-shrink: 0;
}
.turn-status {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.tool-detail-turn {
margin: 0 0 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface-raised) 82%, transparent);
}
.tool-detail-header {
min-height: 28px;
padding: 5px 8px;
}
.tool-detail-body {
padding: 8px;
}
.tool-detail-body pre {
margin: 0;
max-height: 240px;
overflow: auto;
}
.turn-rendered-md:empty { display: none; }
/* Markdown content */
.md {
line-height: 1.72;
letter-spacing: -0.006em;
}
.md p { margin: 0 0 10px; }
.md p:last-child { margin-bottom: 0; }
.md h1, .md h2, .md h3 { margin: 16px 0 8px; font-family: var(--font-display); font-weight: 600; letter-spacing: -0.02em; }
.md h1 { font-size: 18px; }
.md h2 { font-size: 16px; }
.md h3 { font-size: 14px; }
.md ul, .md ol { margin: 0 0 10px; padding-left: 22px; }
.md li { margin: 2px 0; }
.md code {
font-family: var(--font-mono);
font-size: 12.5px;
background: var(--code-bg);
padding: 1px 5px;
border-radius: 3px;
}
.md pre {
background: var(--code-bg);
padding: 10px 12px;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 8px 0;
border: 1px solid var(--border);
}
.md pre code {
background: transparent;
padding: 0;
color: var(--code-text);
font-size: 12.5px;
line-height: 1.5;
}
.md blockquote {
border-left: 3px solid var(--border-strong);
padding-left: 12px;
color: var(--text-muted);
margin: 8px 0;
}
.md a { color: var(--text); text-decoration: underline; }
.md hr { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }
.md table { border-collapse: collapse; margin: 8px 0; }
.md th, .md td { border: 1px solid var(--border); padding: 4px 8px; font-size: 12.5px; }
/* Streaming cursor */
.cursor {
display: inline-block;
width: 6px;
height: 1em;
background: var(--text-muted);
vertical-align: text-bottom;
margin-left: 2px;
animation: blink 1s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* ─── Composer ─────────────────────────────────────────────────────────── */
#composer-wrap {
padding: 8px 20px 16px;
background: var(--bg);
}
#composer {
max-width: 780px;
margin: 0 auto;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: border-color 0.15s, box-shadow 0.15s;
}
#composer:focus-within {
border-color: var(--accent);
box-shadow: var(--shadow-strong);
}
#input {
width: 100%;
border: 0;
outline: 0;
resize: none;
padding: 12px 14px 4px;
background: transparent;
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
max-height: 200px;
}
#input::placeholder { color: var(--text-faint); }
/* Image previews in composer */
.image-previews {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 8px;
padding: 8px 12px 0;
}
.image-previews:empty { display: none; }
.image-preview-item {
position: relative;
width: 64px;
height: 64px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
}
.image-preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-preview-item .remove-img {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0,0,0,0.6);
color: #fff;
border: 0;
font-size: 12px;
line-height: 18px;
text-align: center;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview-item .remove-img:hover {
background: rgba(0,0,0,0.85);
}
/* User message image thumbnails */
.user-images {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
justify-content: flex-end;
}
.user-msg-thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border);
}
.composer-bottom {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 8px 14px;
}
.composer-hints {
display: flex;
align-items: center;
gap: 4px;
}
.composer-hints .icon-btn.ghost {
font-size: 11px;
padding: 3px 7px;
gap: 4px;
}
.hint-label {
font-size: 11px;
color: var(--text-muted);
}
.send-btn {
width: 30px; height: 30px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: var(--accent-text);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.send-btn:hover { opacity: 0.85; }
.send-btn:active { transform: scale(0.96); }
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.send-btn.stop { background: var(--err); }
/* ─── Buttons ──────────────────────────────────────────────────────────── */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 10px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
font-family: var(--font);
transition: background 0.15s;
}
.icon-btn:hover { background: var(--bg-hover); }
.icon-btn.ghost {
border: 0;
background: transparent;
color: var(--text-muted);
}
.icon-btn.ghost:hover { background: var(--bg-hover); color: var(--text); }
.icon-btn.full { width: 100%; justify-content: flex-start; }
.btn {
padding: 7px 14px;
border: 1px solid var(--border-strong);
background: var(--bg);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
font-family: var(--font);
}
.btn:hover { background: var(--bg-hover); }
.btn.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.btn.primary:hover { opacity: 0.88; }
.btn.ghost { border: 0; background: transparent; }
.hidden { display: none !important; }
/* ─── Modal ────────────────────────────────────────────────────────────── */
.modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(2px);
}
.modal-body {
position: relative;
background: var(--bg);
border-radius: var(--radius);
box-shadow: var(--shadow-strong);
width: 460px;
max-width: 90vw;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
.modal-content { padding: 16px 18px; overflow-y: auto; }
.modal-footer {
padding: 12px 18px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.form-row {
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.form-row label {
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.form-row input, .form-row select {
padding: 7px 10px;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--font);
font-size: 13px;
outline: 0;
transition: border-color 0.15s;
}
.form-row input:focus, .form-row select:focus {
border-color: var(--border-strong);
}
.form-row small {
color: var(--text-faint);
font-size: 11px;
}
.form-row small code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-subtle);
padding: 1px 4px;
border-radius: 3px;
}
.input-row {
display: flex;
gap: 8px;
align-items: center;
}
.input-row input { min-width: 0; }
.input-row .btn { flex: 0 0 auto; }
.btn-row { display: flex; gap: 8px; }
/* ─── Diagnostics panel ────────────────────────────────────────────────── */
.diagnostics-panel {
position: fixed;
top: 56px;
right: 16px;
width: min(520px, calc(100vw - 32px));
max-height: calc(100vh - 88px);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-strong);
z-index: 60;
display: flex;
flex-direction: column;
}
.diagnostics-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.diagnostics-header h2 { margin: 0; font-size: 14px; font-weight: 600; }
.diagnostics-header p { margin: 3px 0 0; color: var(--text-faint); font-size: 11px; }
.diagnostics-log {
margin: 0;
padding: 12px 14px;
min-height: 180px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--code-bg);
color: var(--code-text);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
}
.diagnostics-footer {
padding: 10px 12px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ─── Error banner ─────────────────────────────────────────────────────── */
.error-banner {
position: fixed;
top: 56px;
right: 16px;
max-width: 420px;
background: var(--bg);
border: 1px solid var(--err);
border-left: 3px solid var(--err);
border-radius: var(--radius-sm);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
font-size: 12.5px;
box-shadow: var(--shadow-strong);
z-index: 50;
}
/* Scrollbars (WebKit) */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 5px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
/* Structured GA action cards */
.structured-turn { margin: 8px 0; }
.structured-turn .turn-header { width: fit-content; max-width: min(100%, 760px); background: rgba(248, 248, 250, 0.92); }
.turn-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 12px; }
.structured-turn .turn-body { margin-top: 6px; padding: 10px 12px; border-left: 2px solid var(--border); color: var(--text); }
.task-elapsed { display: block; width: max-content; color: var(--accent); font-size: 12px; font-weight: 600; margin: 0 0 6px 0; }
.tool-detail-turn { margin: 8px 0 0 16px; border-left: 2px solid var(--accent); padding-left: 8px; }
.tool-detail-body .tool-args-code { margin: 6px 0 0; white-space: pre-wrap; }
.summary-hint { font-style: italic; color: var(--text-muted, #888); opacity: 0.7; margin-bottom: 4px; font-size: 0.9em; }
.think-hint { font-style: italic; color: var(--text-muted, #888); opacity: 0.6; margin-bottom: 4px; font-size: 0.85em; }
/* ─── Copy Button for code blocks / pre blocks ─── */
pre { position: relative; }
pre .copy-btn {
position: absolute; top: 6px; right: 6px;
background: rgba(128,128,128,0.15); border: 1px solid rgba(128,128,128,0.25);
border-radius: 4px; padding: 2px 8px; font-size: 12px;
color: var(--text-muted, #888); cursor: pointer;
opacity: 0; transition: opacity 0.2s;
z-index: 2; user-select: none;
}
pre:hover .copy-btn { opacity: 1; }
pre .copy-btn:hover { background: rgba(128,128,128,0.3); color: var(--text, #333); }
pre .copy-btn.copied { color: #4caf50; border-color: #4caf50; opacity: 1; }
/* ─── Search Bar ─────────────────────────────────────────────────────── */
#search-bar {
display: none; position: fixed; top: 40px; right: 16px;
background: var(--bg, #fff); border: 1px solid var(--border, #ddd);
border-radius: 8px; padding: 6px 10px; gap: 6px; align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 9999;
font-size: 13px;
}
#search-bar.visible { display: flex; }
#search-input {
border: 1px solid var(--border, #ccc); border-radius: 4px;
padding: 4px 8px; font-size: 13px; width: 200px;
background: var(--input-bg, #fff); color: var(--text, #333);
outline: none;
}
#search-input:focus { border-color: var(--accent, #4a9eff); }
#search-count { min-width: 40px; text-align: center; color: var(--text-muted, #888); font-size: 12px; }
#search-bar button {
background: none; border: none; cursor: pointer; font-size: 16px;
color: var(--text, #333); padding: 2px 6px; border-radius: 4px;
}
#search-bar button:hover { background: rgba(128,128,128,0.15); }
mark.search-highlight { background: #ffeb3b; color: #000; border-radius: 2px; padding: 0 1px; }
mark.search-highlight.active { background: #ff9800; color: #fff; }
/* ─── Tab Drag ───────────────────────────────────────────────────────── */
.tab-btn.drag-over-left { box-shadow: inset 3px 0 0 var(--accent, #4a9eff); }
.tab-btn.drag-over-right { box-shadow: inset -3px 0 0 var(--accent, #4a9eff); }
+6
View File
@@ -0,0 +1,6 @@
/**
* marked v15.0.7 - a markdown parser
* Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
* https://github.com/markedjs/marked
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,c=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),h=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),p=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,g=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),k=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),d="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",f=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,x=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|<![A-Z][\\s\\S]*?(?:>\\n*|$)|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",f).replace("tag",d).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegexLine truncated
+616
View File
@@ -0,0 +1,616 @@
#!/usr/bin/env python3
"""
GenericAgent Web2 Bridge.
Clear split:
1) AgentManager: owns GenericAgent instances, sessions and histories.
2) Transport: HTTP is the command/data channel; WebSocket only pushes small
session-state notifications.
HTTP API:
GET /status
GET /config
POST /config
GET /model-profiles
GET /sessions
POST /session/new
GET /session/{sid}
DELETE /session/{sid}
POST /session/{sid}/prompt
GET /session/{sid}/messages?after=0&limit=200
POST /session/{sid}/cancel
WS API:
GET /ws -> events only, e.g.
{"type":"session-state","sessionId":"sess-...","state":"running","seq":3,"updatedAt":...}
"""
from __future__ import annotations
import asyncio
import contextlib
import importlib
import json
import os
import sys
import threading
import time
import traceback
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from aiohttp import web, WSMsgType
APP_DIR = Path(__file__).resolve().parent
def find_default_ga_root() -> Path:
"""Find GenericAgent root near this bridge.
Probe, in order:
../
../../
../GenericAgent
../../GenericAgent
and return the first directory containing agentmain.py.
"""
candidates = [
APP_DIR / "..",
APP_DIR / ".." / "..",
APP_DIR / ".." / "GenericAgent",
APP_DIR / ".." / ".." / "GenericAgent",
]
for p in candidates:
root = p.resolve()
if (root / "agentmain.py").exists():
return root
return APP_DIR.parent.parent.resolve()
DEFAULT_GA_ROOT = find_default_ga_root()
for _s in (sys.stdout, sys.stderr):
with contextlib.suppress(Exception):
_s.reconfigure(encoding="utf-8", errors="replace")
# ---------------------------------------------------------------------------
# Agent management layer
# ---------------------------------------------------------------------------
@dataclass
class Session:
id: str
title: str = "New chat"
cwd: str = ""
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
messages: List[dict] = field(default_factory=list)
msg_seq: int = 0
partial: Optional[dict] = None
status: str = "idle" # idle|running|error|cancelled
agent: Any = None
thread: Optional[threading.Thread] = None
cancel_requested: bool = False
last_error: str = ""
class AgentManager:
def __init__(self):
self.lock = threading.RLock()
self.ga_root = str(DEFAULT_GA_ROOT)
self.config: Dict[str, Any] = {}
self.sessions: Dict[str, Session] = {}
self.active_session_id: Optional[str] = None
@property
def mykey_path(self) -> str:
return str(Path(self.ga_root) / "mykey.txt")
def ensure_ga_import_path(self) -> Path:
root = Path(self.ga_root).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
return root
def make_agent(self, sess: Session):
root = self.ensure_ga_import_path()
old_cwd = os.getcwd()
try:
os.chdir(sess.cwd or str(root))
agentmain = importlib.import_module("agentmain")
GA = getattr(agentmain, "GenericAgent")
agent = GA()
agent.inc_out = True
agent.verbose = True
threading.Thread(target=agent.run, daemon=True, name=f"GA-{sess.id}").start()
return agent
finally:
with contextlib.suppress(Exception):
os.chdir(old_cwd)
def list_model_profiles(self):
self.ensure_ga_import_path()
try:
agentmain = importlib.import_module("agentmain")
agent = agentmain.GenericAgent()
if hasattr(agent, "list_llms"):
return [{"id": i, "name": name, "active": active} for i, name, active in agent.list_llms()]
except Exception as e:
print(f"get model profiles failed: {e}", file=sys.stderr)
return []
def snapshot(self, sess: Session, include_messages: bool = True) -> dict:
out = {
"sessionId": sess.id,
"id": sess.id,
"title": sess.title,
"cwd": sess.cwd,
"status": sess.status,
"createdAt": sess.created_at,
"updatedAt": sess.updated_at,
"lastError": sess.last_error,
"msgSeq": sess.msg_seq,
}
if include_messages:
out["messages"] = list(sess.messages)
out["partial"] = dict(sess.partial) if sess.partial else None
return out
def add_message(self, sess: Session, role: str, content: str, **extra) -> dict:
sess.msg_seq += 1
msg = {"id": sess.msg_seq, "role": role, "content": content, "ts": time.time()}
msg.update(extra)
sess.messages.append(msg)
sess.updated_at = time.time()
if role == "user" and content.strip() and sess.title == "New chat":
sess.title = content.strip().replace("\n", " ")[:40]
return msg
def create_session(self, cwd: Optional[str] = None) -> Session:
sid = "sess-" + uuid.uuid4().hex[:12]
sess = Session(id=sid, cwd=str(cwd or self.ga_root))
with self.lock:
self.sessions[sid] = sess
self.active_session_id = sid
emit_session_state(sess, "created")
return sess
def get_session(self, sid: str) -> Session:
with self.lock:
sess = self.sessions.get(sid)
if not sess:
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
return sess
def delete_session(self, sid: str) -> dict:
with self.lock:
sess = self.sessions.pop(sid, None)
if not sess:
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
if self.active_session_id == sid:
self.active_session_id = next(iter(self.sessions), None)
if sess.agent and hasattr(sess.agent, "cancel"):
with contextlib.suppress(Exception):
sess.agent.cancel()
emit_session_state(sess, "closed")
return {"ok": True, "sessionId": sid}
def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None) -> dict:
prompt, image_ids = normalize_prompt(prompt, images)
with self.lock:
sess = self.sessions.get(sid)
if not sess:
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
if sess.status == "running":
raise web.HTTPConflict(text=json.dumps({"error": "session is already running"}, ensure_ascii=False), content_type="application/json")
extra = {}
if image_ids:
extra["image_ids"] = image_ids
user_msg = self.add_message(sess, "user", prompt, **extra)
sess.status = "running"
sess.cancel_requested = False
sess.last_error = ""
sess.partial = {"id": sess.msg_seq + 1, "role": "assistant", "content": "", "ts": time.time(), "partial": True}
t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None), daemon=True, name=f"Turn-{sid}")
sess.thread = t
t.start()
seq = sess.msg_seq
emit_session_state(sess, "running")
return {"ok": True, "sessionId": sid, "accepted": True, "userMessageId": user_msg["id"], "seq": seq}
def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None):
try:
if sess.agent is None:
sess.agent = self.make_agent(sess)
agent = sess.agent
full = ""
if hasattr(agent, "put_task"):
display_q = agent.put_task(prompt, images=images or [])
pieces = []
while True:
item = display_q.get()
if isinstance(item, dict):
if item.get("next"):
text = str(item["next"])
pieces.append(text)
with self.lock:
if sess.partial is not None:
sess.partial["content"] = "".join(pieces) if getattr(agent, "inc_out", False) else text
sess.partial["ts"] = time.time()
sess.updated_at = time.time()
if "done" in item:
full = str(item.get("done") or "")
break
else:
pieces.append(str(item))
if not full and pieces:
full = pieces[-1] if not getattr(agent, "inc_out", False) else "".join(pieces)
elif hasattr(agent, "run"):
ret = agent.run(prompt)
if isinstance(ret, str):
full = ret
else:
full = "GenericAgent object has no put_task/run method"
if not full:
full = "(completed)"
with self.lock:
sess.partial = None
# Strip trailing [Info] Final response to user. marker
import re as _re
full = _re.sub(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$', '', full)
self.add_message(sess, "assistant", full)
sess.status = "idle"
sess.last_error = ""
emit_session_state(sess, "idle")
except Exception as e:
tb = traceback.format_exc()
with self.lock:
sess.partial = None
sess.status = "error"
sess.last_error = str(e)
self.add_message(sess, "error", str(e))
print(tb, file=sys.stderr)
emit_session_state(sess, "error")
def messages(self, sid: str, after: int = 0, limit: int = 200) -> dict:
with self.lock:
sess = self.sessions.get(sid)
if not sess:
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
msgs = [m for m in sess.messages if int(m.get("id", 0)) > after]
if limit > 0:
msgs = msgs[-limit:]
return {
"sessionId": sid,
"status": sess.status,
"messages": msgs,
"partial": dict(sess.partial) if sess.partial else None,
"msgSeq": sess.msg_seq,
"updatedAt": sess.updated_at,
"lastError": sess.last_error,
}
def cancel(self, sid: str) -> dict:
with self.lock:
sess = self.sessions.get(sid)
if not sess:
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
sess.cancel_requested = True
if sess.agent and hasattr(sess.agent, "cancel"):
with contextlib.suppress(Exception):
sess.agent.cancel()
sess.status = "cancelled"
sess.partial = None
sess.updated_at = time.time()
emit_session_state(sess, "cancelled")
return {"ok": True, "sessionId": sid}
import base64
import tempfile
# Shared temp dir for image uploads (persists for process lifetime)
_UPLOAD_DIR = Path(tempfile.gettempdir()) / "ga_web2_uploads"
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def _save_image_data(data_url: str, img_id: str) -> str:
"""Save a data URL to disk, return absolute path."""
# data:image/png;base64,xxxxx
if "," in data_url:
header, b64 = data_url.split(",", 1)
else:
b64 = data_url
header = ""
ext = "png"
if "jpeg" in header or "jpg" in header:
ext = "jpg"
elif "webp" in header:
ext = "webp"
elif "gif" in header:
ext = "gif"
fpath = _UPLOAD_DIR / f"{img_id}.{ext}"
fpath.write_bytes(base64.b64decode(b64))
return str(fpath)
def normalize_prompt(prompt: Any, images: Optional[list] = None):
"""Normalize prompt and images.
images: list of dicts {"id": "img-xxx", "dataUrl": "data:..."} or plain data URLs.
Returns: (prompt_text_with_image_tags, image_ids_list)
"""
images = list(images or [])
if isinstance(prompt, list):
text_parts = []
for part in prompt:
if isinstance(part, str):
text_parts.append(part)
elif isinstance(part, dict):
if part.get("type") in ("text", "input_text"):
text_parts.append(str(part.get("text") or part.get("content") or ""))
elif part.get("type") in ("image", "input_image"):
url = part.get("image_url") or part.get("url") or part.get("data")
if isinstance(url, dict):
url = url.get("url")
if url:
images.append(url)
prompt = "\n".join([p for p in text_parts if p])
# Process images: save to disk, build [image:path] tags
image_ids = []
image_tags = []
for img in images:
if isinstance(img, dict):
img_id = img.get("id") or f"img-{uuid.uuid4().hex[:8]}"
data_url = img.get("dataUrl") or img.get("data_url") or ""
else:
# Plain data URL string
img_id = f"img-{uuid.uuid4().hex[:8]}"
data_url = str(img)
if data_url:
path = _save_image_data(data_url, img_id)
image_tags.append(f"[image:{path}]")
image_ids.append(img_id)
# Append image tags to prompt
final_prompt = str(prompt or "")
if image_tags:
final_prompt = final_prompt + "\n" + "\n".join(image_tags)
return final_prompt, image_ids
manager = AgentManager()
# ---------------------------------------------------------------------------
# Transport layer: WS notification only
# ---------------------------------------------------------------------------
class WsHub:
def __init__(self):
self.websockets: Set[web.WebSocketResponse] = set()
self.loop: Optional[asyncio.AbstractEventLoop] = None
def emit(self, obj: dict):
if self.loop and self.loop.is_running():
asyncio.run_coroutine_threadsafe(self._broadcast(obj), self.loop)
async def _broadcast(self, obj: dict):
data = json.dumps(obj, ensure_ascii=False, default=str)
dead = set()
for ws in list(self.websockets):
try:
await ws.send_str(data)
except Exception:
dead.add(ws)
self.websockets.difference_update(dead)
hub = WsHub()
def emit_session_state(sess: Session, state_name: str):
hub.emit({
"type": "session-state",
"sessionId": sess.id,
"state": state_name,
"status": sess.status,
"seq": sess.msg_seq,
"updatedAt": sess.updated_at,
"title": sess.title,
})
async def ws_handler(request):
ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request)
hub.websockets.add(ws)
await ws.send_str(json.dumps({
"type": "bridge-ready",
"gaRoot": manager.ga_root,
"mykeyPath": manager.mykey_path,
"http": True,
"wsEventsOnly": True,
}, ensure_ascii=False))
async for msg in ws:
if msg.type == WSMsgType.TEXT:
# WS is intentionally not a data/command channel anymore.
with contextlib.suppress(Exception):
data = json.loads(msg.data)
if data.get("action") == "ping":
await ws.send_str(json.dumps({"type": "pong", "ts": time.time()}, ensure_ascii=False))
hub.websockets.discard(ws)
return ws
# ---------------------------------------------------------------------------
# Transport layer: HTTP command/data API
# ---------------------------------------------------------------------------
def cors_headers():
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
@web.middleware
async def cors_middleware(request, handler):
if request.method == "OPTIONS":
return web.Response(status=204, headers=cors_headers())
resp = await handler(request)
for k, v in cors_headers().items():
resp.headers[k] = v
return resp
def json_ok(data: dict, status: int = 200):
return web.json_response(data, status=status, headers=cors_headers(), dumps=lambda x: json.dumps(x, ensure_ascii=False, default=str))
async def read_json(request) -> dict:
if request.can_read_body:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
async def status_handler(request):
return json_ok({
"ok": True,
"running": True,
"ready": True,
"gaRoot": manager.ga_root,
"mykeyPath": manager.mykey_path,
"sessionCount": len(manager.sessions),
"activeSessionId": manager.active_session_id,
"ws": "/ws",
"transport": {"http": True, "wsEventsOnly": True},
})
async def get_config_handler(request):
return json_ok({"gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": manager.config})
async def save_config_handler(request):
data = await read_json(request)
cfg = data.get("config", data)
if isinstance(cfg, dict):
manager.config.update(cfg)
return json_ok({"ok": True, "gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": manager.config})
async def model_profiles_handler(request):
return json_ok({"profiles": manager.list_model_profiles()})
async def list_sessions_handler(request):
with manager.lock:
sessions = [manager.snapshot(s, include_messages=False) for s in manager.sessions.values()]
return json_ok({"sessions": sessions, "activeSessionId": manager.active_session_id})
async def new_session_handler(request):
data = await read_json(request)
sess = manager.create_session(cwd=data.get("cwd") or data.get("path"))
return json_ok({"ok": True, "sessionId": sess.id, "session": manager.snapshot(sess)}, status=201)
async def get_session_handler(request):
sid = request.match_info["sid"]
sess = manager.get_session(sid)
return json_ok({"sessionId": sid, "session": manager.snapshot(sess), "messages": list(sess.messages), "partial": sess.partial})
async def delete_session_handler(request):
sid = request.match_info["sid"]
return json_ok(manager.delete_session(sid))
async def prompt_handler(request):
sid = request.match_info["sid"]
data = await read_json(request)
prompt = data.get("prompt", data.get("content", data.get("message", "")))
images = data.get("images") or []
return json_ok(manager.submit_prompt(sid, prompt, images))
async def messages_handler(request):
sid = request.match_info["sid"]
after = int(request.query.get("after") or request.query.get("afterId") or 0)
limit = int(request.query.get("limit") or 200)
return json_ok(manager.messages(sid, after=after, limit=limit))
async def cancel_handler(request):
sid = request.match_info["sid"]
return json_ok(manager.cancel(sid))
async def path_open_handler(request):
data = await read_json(request)
kind = data.get("kind", "")
if kind == "mykey":
target = Path(manager.ga_root) / "mykey.py"
else:
target = Path(data.get("path") or data.get("target") or manager.ga_root)
target = target.resolve()
if not target.exists():
return json_ok({"ok": False, "error": f"File not found: {target}"})
# Actually open the file with the system default editor
import subprocess, platform
if platform.system() == "Windows":
os.startfile(str(target))
elif platform.system() == "Darwin":
subprocess.Popen(["open", str(target)])
else:
subprocess.Popen(["xdg-open", str(target)])
return json_ok({"ok": True, "path": str(target)})
def create_app():
app = web.Application(middlewares=[cors_middleware])
app.router.add_get("/ws", ws_handler)
app.router.add_get("/status", status_handler)
app.router.add_get("/config", get_config_handler)
app.router.add_post("/config", save_config_handler)
app.router.add_get("/model-profiles", model_profiles_handler)
app.router.add_get("/sessions", list_sessions_handler)
app.router.add_post("/session/new", new_session_handler)
app.router.add_get("/session/{sid}", get_session_handler)
app.router.add_delete("/session/{sid}", delete_session_handler)
app.router.add_post("/session/{sid}/prompt", prompt_handler)
app.router.add_get("/session/{sid}/messages", messages_handler)
app.router.add_post("/session/{sid}/cancel", cancel_handler)
app.router.add_post("/path/open", path_open_handler)
# Serve static frontend (desktop/static/)
static_dir = APP_DIR / "desktop" / "static"
async def index_handler(request):
return web.FileResponse(static_dir / "index.html")
app.router.add_get("/", index_handler)
app.router.add_static("/", static_dir, show_index=False)
async def on_startup(app):
hub.loop = asyncio.get_running_loop()
app.on_startup.append(on_startup)
return app
if __name__ == "__main__":
host = os.environ.get("BRIDGE_HOST", "127.0.0.1")
port = int(os.environ.get("BRIDGE_PORT", "14168"))
print(f"GenericAgent Web2 bridge: http://{host}:{port} ws://{host}:{port}/ws", file=sys.stderr)
web.run_app(create_app(), host=host, port=port, print=None)