feat: add Tauri desktop shell + desktop_bridge (url mode, no-console)
This commit is contained in:
1 parent
fe47bb29cb
commit
3be6ed35ba
16 files changed
+8743
No files matched your search
@@ -32,6 +32,7 @@ mykey.py
|
||||
tasks/
|
||||
|
||||
*.zip
|
||||
*.exe
|
||||
|
||||
memory/*
|
||||
!memory/memory_management_sop.md
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Rust build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "genericagent-web2",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
Generated
+4569
File diff suppressed because it is too large.
Load diff
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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) }));
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user