a2acalling 0.6.50 → 0.6.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +172 -0
- package/native/macos/index.html +5 -10
- package/native/macos/src-tauri/Cargo.lock +5875 -0
- package/native/macos/src-tauri/Cargo.toml +3 -2
- package/native/macos/src-tauri/icons/128x128.png +0 -0
- package/native/macos/src-tauri/icons/128x128@2x.png +0 -0
- package/native/macos/src-tauri/icons/32x32.png +0 -0
- package/native/macos/src-tauri/icons/tray-connected.png +0 -0
- package/native/macos/src-tauri/icons/tray-disconnected.png +0 -0
- package/native/macos/src-tauri/src/lib.rs +2 -2
- package/native/macos/src-tauri/src/notifications.rs +147 -68
- package/native/macos/src-tauri/tauri.conf.json +2 -9
- package/package.json +1 -1
- package/scripts/postinstall.js +0 -49
- package/src/dashboard/public/app.js +147 -1
- package/src/lib/conversations.js +55 -1
- package/src/lib/dashboard-events.js +205 -0
- package/src/routes/a2a.js +25 -4
- package/src/routes/dashboard.js +114 -1
- package/src/server.js +4 -0
|
@@ -11,13 +11,14 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
|
|
11
11
|
tauri-build = { version = "2", features = [] }
|
|
12
12
|
|
|
13
13
|
[dependencies]
|
|
14
|
-
tauri = { version = "2", features = [] }
|
|
14
|
+
tauri = { version = "2", features = ["tray-icon"] }
|
|
15
15
|
tauri-plugin-shell = "2"
|
|
16
16
|
tauri-plugin-notification = "2"
|
|
17
17
|
tauri-plugin-deep-link = "2"
|
|
18
18
|
tauri-plugin-window-state = "2"
|
|
19
19
|
serde = { version = "1", features = ["derive"] }
|
|
20
20
|
serde_json = "1"
|
|
21
|
-
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
|
21
|
+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
|
22
22
|
tokio = { version = "1", features = ["full"] }
|
|
23
23
|
dirs = "6"
|
|
24
|
+
futures-util = "0.3"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -110,8 +110,8 @@ pub fn run() {
|
|
|
110
110
|
// Start background health monitor
|
|
111
111
|
health::start_health_monitor(app.handle().clone());
|
|
112
112
|
|
|
113
|
-
// Start
|
|
114
|
-
notifications::
|
|
113
|
+
// Start server-driven event listener for native notifications.
|
|
114
|
+
notifications::start_event_stream_listener(app.handle().clone());
|
|
115
115
|
|
|
116
116
|
// Menu bar tray icon
|
|
117
117
|
let show = MenuItem::with_id(app, "show", "Show A2A Callbook", true, None::<&str>)?;
|
|
@@ -1,101 +1,180 @@
|
|
|
1
|
-
use
|
|
2
|
-
use std::sync::
|
|
1
|
+
use futures_util::StreamExt;
|
|
2
|
+
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
|
3
3
|
use std::time::Duration;
|
|
4
4
|
use tauri::Manager;
|
|
5
5
|
use tauri_plugin_notification::NotificationExt;
|
|
6
6
|
|
|
7
|
-
static
|
|
7
|
+
static LAST_EVENT_ID: AtomicU64 = AtomicU64::new(0);
|
|
8
|
+
static UNREAD_COUNT: AtomicUsize = AtomicUsize::new(0);
|
|
8
9
|
|
|
9
10
|
#[derive(Debug, serde::Deserialize)]
|
|
10
|
-
struct
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
struct DashboardEvent {
|
|
12
|
+
id: Option<u64>,
|
|
13
|
+
#[serde(rename = "type")]
|
|
14
|
+
event_type: Option<String>,
|
|
15
|
+
payload: Option<serde_json::Value>,
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
fn maybe_set_dock_badge(app: &tauri::AppHandle, count: usize) {
|
|
19
|
+
// Best effort: use the main window badge API. On macOS this maps to Dock badge label/count.
|
|
20
|
+
if let Some(window) = app.get_webview_window("main") {
|
|
21
|
+
let _ = window.set_badge_count(if count == 0 { None } else { Some(count as i64) });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn show_notification(app: &tauri::AppHandle, title: &str, body: &str) {
|
|
26
|
+
let _ = app.notification().builder().title(title).body(body).show();
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
fn process_dashboard_event(app: &tauri::AppHandle, raw: &str) {
|
|
30
|
+
let parsed: DashboardEvent = match serde_json::from_str(raw) {
|
|
31
|
+
Ok(value) => value,
|
|
32
|
+
Err(_) => return,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if let Some(id) = parsed.id {
|
|
36
|
+
let current = LAST_EVENT_ID.load(Ordering::Relaxed);
|
|
37
|
+
if id > current {
|
|
38
|
+
LAST_EVENT_ID.store(id, Ordering::Relaxed);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let event_type = parsed.event_type.unwrap_or_default();
|
|
43
|
+
let payload = parsed.payload.unwrap_or_else(|| serde_json::json!({}));
|
|
44
|
+
|
|
45
|
+
if event_type == "call.inbound" {
|
|
46
|
+
let caller = payload
|
|
47
|
+
.get("caller_name")
|
|
48
|
+
.and_then(|v| v.as_str())
|
|
49
|
+
.unwrap_or("Unknown agent");
|
|
50
|
+
show_notification(
|
|
51
|
+
app,
|
|
52
|
+
&format!("Inbound call from {}", caller),
|
|
53
|
+
"Open A2A Callbook to respond.",
|
|
54
|
+
);
|
|
55
|
+
let unread = UNREAD_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
|
|
56
|
+
maybe_set_dock_badge(app, unread);
|
|
57
|
+
return;
|
|
30
58
|
}
|
|
31
59
|
|
|
60
|
+
if event_type == "summary.completed" {
|
|
61
|
+
let contact = payload
|
|
62
|
+
.get("contact_name")
|
|
63
|
+
.and_then(|v| v.as_str())
|
|
64
|
+
.unwrap_or("conversation");
|
|
65
|
+
show_notification(
|
|
66
|
+
app,
|
|
67
|
+
"Summary complete",
|
|
68
|
+
&format!("Conversation with {} has a summary.", contact),
|
|
69
|
+
);
|
|
70
|
+
let unread = UNREAD_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
|
|
71
|
+
maybe_set_dock_badge(app, unread);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Connect to server-driven dashboard SSE and map events to native notifications.
|
|
77
|
+
pub fn start_event_stream_listener(app: tauri::AppHandle) {
|
|
32
78
|
tokio::spawn(async move {
|
|
33
|
-
// Wait for initial
|
|
34
|
-
tokio::time::sleep(Duration::from_secs(
|
|
79
|
+
// Wait for initial discovery attempt.
|
|
80
|
+
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
35
81
|
|
|
36
82
|
loop {
|
|
37
|
-
|
|
38
|
-
|
|
83
|
+
if !crate::health::is_connected() {
|
|
84
|
+
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
39
87
|
let port = crate::health::current_port();
|
|
40
|
-
if port == 0
|
|
88
|
+
if port == 0 {
|
|
89
|
+
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
41
90
|
continue;
|
|
42
91
|
}
|
|
43
92
|
|
|
44
|
-
let url = format!(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let client = reqwest::Client::builder()
|
|
50
|
-
.timeout(Duration::from_secs(5))
|
|
51
|
-
.build();
|
|
52
|
-
|
|
53
|
-
let client = match client {
|
|
93
|
+
let url = format!("http://127.0.0.1:{}/api/a2a/dashboard/events?replay=50", port);
|
|
94
|
+
let client = match reqwest::Client::builder()
|
|
95
|
+
.timeout(Duration::from_secs(30))
|
|
96
|
+
.build()
|
|
97
|
+
{
|
|
54
98
|
Ok(c) => c,
|
|
55
|
-
Err(_) =>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Ok(r) => r,
|
|
60
|
-
Err(_) => continue,
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
let data: CallsResponse = match resp.json().await {
|
|
64
|
-
Ok(d) => d,
|
|
65
|
-
Err(_) => continue,
|
|
99
|
+
Err(_) => {
|
|
100
|
+
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
66
103
|
};
|
|
67
104
|
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
let mut req = client
|
|
106
|
+
.get(&url)
|
|
107
|
+
.header("Accept", "text/event-stream");
|
|
108
|
+
let last_id = LAST_EVENT_ID.load(Ordering::Relaxed);
|
|
109
|
+
if last_id > 0 {
|
|
110
|
+
req = req.header("Last-Event-ID", last_id.to_string());
|
|
70
111
|
}
|
|
71
112
|
|
|
72
|
-
let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for conv in &conversations {
|
|
77
|
-
if seen_set.contains(&conv.id) {
|
|
113
|
+
let response = match req.send().await {
|
|
114
|
+
Ok(resp) => resp,
|
|
115
|
+
Err(_) => {
|
|
116
|
+
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
78
117
|
continue;
|
|
79
118
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
let _ = app.notification()
|
|
86
|
-
.builder()
|
|
87
|
-
.title(&format!("Inbound call from {}", caller))
|
|
88
|
-
.body(summary)
|
|
89
|
-
.show();
|
|
119
|
+
};
|
|
120
|
+
if !response.status().is_success() {
|
|
121
|
+
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
122
|
+
continue;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
125
|
+
// On first connect we intentionally suppress replay notifications.
|
|
126
|
+
// IDs are still tracked so reconnects can resume reliably.
|
|
127
|
+
let mut suppress_replay_notifications = LAST_EVENT_ID.load(Ordering::Relaxed) == 0;
|
|
128
|
+
let mut stream = response.bytes_stream();
|
|
129
|
+
let mut buffer = String::new();
|
|
130
|
+
let mut data_lines: Vec<String> = Vec::new();
|
|
131
|
+
|
|
132
|
+
while let Some(chunk) = stream.next().await {
|
|
133
|
+
let bytes = match chunk {
|
|
134
|
+
Ok(value) => value,
|
|
135
|
+
Err(_) => break,
|
|
136
|
+
};
|
|
137
|
+
let piece = String::from_utf8_lossy(&bytes);
|
|
138
|
+
buffer.push_str(&piece);
|
|
139
|
+
|
|
140
|
+
while let Some(pos) = buffer.find('\n') {
|
|
141
|
+
let mut line: String = buffer.drain(..=pos).collect();
|
|
142
|
+
line = line.trim_end_matches('\n').trim_end_matches('\r').to_string();
|
|
143
|
+
|
|
144
|
+
if line.is_empty() {
|
|
145
|
+
if !data_lines.is_empty() {
|
|
146
|
+
let data = data_lines.join("\n");
|
|
147
|
+
if !suppress_replay_notifications {
|
|
148
|
+
process_dashboard_event(&app, &data);
|
|
149
|
+
}
|
|
150
|
+
data_lines.clear();
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if line.starts_with(':') {
|
|
155
|
+
if line.starts_with(": connected") {
|
|
156
|
+
suppress_replay_notifications = false;
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if let Some(rest) = line.strip_prefix("id:") {
|
|
161
|
+
let value = rest.trim();
|
|
162
|
+
if let Ok(parsed) = value.parse::<u64>() {
|
|
163
|
+
let current = LAST_EVENT_ID.load(Ordering::Relaxed);
|
|
164
|
+
if parsed > current {
|
|
165
|
+
LAST_EVENT_ID.store(parsed, Ordering::Relaxed);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if let Some(rest) = line.strip_prefix("data:") {
|
|
171
|
+
data_lines.push(rest.trim().to_string());
|
|
172
|
+
}
|
|
97
173
|
}
|
|
98
174
|
}
|
|
175
|
+
|
|
176
|
+
// Connection dropped: reconnect with jitter.
|
|
177
|
+
tokio::time::sleep(Duration::from_millis(900)).await;
|
|
99
178
|
}
|
|
100
179
|
});
|
|
101
180
|
}
|
|
@@ -16,13 +16,7 @@
|
|
|
16
16
|
"minHeight": 600,
|
|
17
17
|
"resizable": true
|
|
18
18
|
}
|
|
19
|
-
]
|
|
20
|
-
"security": {
|
|
21
|
-
"dangerousRemoteUrlAccess": [
|
|
22
|
-
{ "url": "http://127.0.0.1:**" },
|
|
23
|
-
{ "url": "http://localhost:**" }
|
|
24
|
-
]
|
|
25
|
-
}
|
|
19
|
+
]
|
|
26
20
|
},
|
|
27
21
|
"bundle": {
|
|
28
22
|
"active": true,
|
|
@@ -30,8 +24,7 @@
|
|
|
30
24
|
"icon": [
|
|
31
25
|
"icons/32x32.png",
|
|
32
26
|
"icons/128x128.png",
|
|
33
|
-
"icons/128x128@2x.png"
|
|
34
|
-
"icons/icon.icns"
|
|
27
|
+
"icons/128x128@2x.png"
|
|
35
28
|
],
|
|
36
29
|
"macOS": {
|
|
37
30
|
"minimumSystemVersion": "12.0",
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -44,12 +44,10 @@ const result = spawnSync(process.execPath, [cliPath, 'quickstart'], {
|
|
|
44
44
|
if (result.error) {
|
|
45
45
|
// Don't fail the install — the agent will get onboarding when it runs `a2a`.
|
|
46
46
|
installSkillFiles();
|
|
47
|
-
installMacOSApp();
|
|
48
47
|
process.exit(0);
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
installSkillFiles();
|
|
52
|
-
installMacOSApp();
|
|
53
51
|
process.exit(result.status || 0);
|
|
54
52
|
|
|
55
53
|
// Best-effort: install Claude Code + Codex skills into the workspace
|
|
@@ -61,50 +59,3 @@ function installSkillFiles() {
|
|
|
61
59
|
// Silent — skills can be installed later with `a2a skills`
|
|
62
60
|
}
|
|
63
61
|
}
|
|
64
|
-
|
|
65
|
-
// Download and install the native macOS app from GitHub Releases
|
|
66
|
-
function installMacOSApp() {
|
|
67
|
-
const os = require('os');
|
|
68
|
-
const fs = require('fs');
|
|
69
|
-
|
|
70
|
-
if (os.platform() !== 'darwin') return;
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const version = require('../package.json').version;
|
|
74
|
-
const appDir = path.join(os.homedir(), 'Applications');
|
|
75
|
-
const appPath = path.join(appDir, 'A2A Callbook.app');
|
|
76
|
-
|
|
77
|
-
// Skip if already installed at same version
|
|
78
|
-
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
|
|
79
|
-
if (fs.existsSync(plistPath)) {
|
|
80
|
-
try {
|
|
81
|
-
const plist = fs.readFileSync(plistPath, 'utf8');
|
|
82
|
-
if (plist.includes(version)) {
|
|
83
|
-
return; // Same version already installed
|
|
84
|
-
}
|
|
85
|
-
} catch (_) {}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const tarUrl = `https://github.com/onthegonow/a2a_calling/releases/download/v${version}/A2A-Callbook-${version}.app.tar.gz`;
|
|
89
|
-
const tmpFile = path.join(os.tmpdir(), `a2a-callbook-${version}.tar.gz`);
|
|
90
|
-
|
|
91
|
-
// Download
|
|
92
|
-
const { execFileSync } = require('child_process');
|
|
93
|
-
execFileSync('curl', ['-sL', '-o', tmpFile, tarUrl], { timeout: 30000 });
|
|
94
|
-
|
|
95
|
-
if (!fs.existsSync(tmpFile) || fs.statSync(tmpFile).size < 1000) {
|
|
96
|
-
return; // Download failed or too small — skip silently
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Ensure ~/Applications exists
|
|
100
|
-
fs.mkdirSync(appDir, { recursive: true });
|
|
101
|
-
|
|
102
|
-
// Extract
|
|
103
|
-
execFileSync('tar', ['-xzf', tmpFile, '-C', appDir], { timeout: 15000 });
|
|
104
|
-
|
|
105
|
-
// Cleanup
|
|
106
|
-
try { fs.unlinkSync(tmpFile); } catch (_) {}
|
|
107
|
-
} catch (_) {
|
|
108
|
-
// Silently fail — native app is optional
|
|
109
|
-
}
|
|
110
|
-
}
|
|
@@ -11,9 +11,17 @@ const state = {
|
|
|
11
11
|
invites: [],
|
|
12
12
|
logs: [],
|
|
13
13
|
logStats: null,
|
|
14
|
-
trace: null
|
|
14
|
+
trace: null,
|
|
15
|
+
realtime: {
|
|
16
|
+
connected: false,
|
|
17
|
+
lastEventId: null
|
|
18
|
+
}
|
|
15
19
|
};
|
|
16
20
|
|
|
21
|
+
let dashboardEventSource = null;
|
|
22
|
+
let reconnectTimer = null;
|
|
23
|
+
let refreshTimer = null;
|
|
24
|
+
|
|
17
25
|
function showNotice(message) {
|
|
18
26
|
const el = document.getElementById('notice');
|
|
19
27
|
el.textContent = message;
|
|
@@ -23,6 +31,143 @@ function showNotice(message) {
|
|
|
23
31
|
}, 3500);
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
function scheduleRealtimeRefresh() {
|
|
35
|
+
clearTimeout(refreshTimer);
|
|
36
|
+
refreshTimer = setTimeout(() => {
|
|
37
|
+
Promise.all([
|
|
38
|
+
loadContacts().catch(() => {}),
|
|
39
|
+
loadCalls().catch(() => {})
|
|
40
|
+
]).catch(() => {});
|
|
41
|
+
}, 250);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function notifyRealtime(title, body) {
|
|
45
|
+
const safeTitle = String(title || '').trim();
|
|
46
|
+
if (!safeTitle) return;
|
|
47
|
+
const safeBody = String(body || '').trim();
|
|
48
|
+
if (typeof window.Notification === 'undefined') return;
|
|
49
|
+
if (Notification.permission === 'granted') {
|
|
50
|
+
try {
|
|
51
|
+
// In A2A.app WebView this maps to native macOS notifications.
|
|
52
|
+
new Notification(safeTitle, safeBody ? { body: safeBody } : undefined);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// Ignore notification errors.
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (Notification.permission !== 'denied') {
|
|
59
|
+
Notification.requestPermission().catch(() => {});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handleRealtimeEvent(eventData) {
|
|
64
|
+
const type = String(eventData?.type || '').trim();
|
|
65
|
+
const payload = eventData?.payload || {};
|
|
66
|
+
if (!type) return;
|
|
67
|
+
|
|
68
|
+
if (type === 'call.inbound') {
|
|
69
|
+
const caller = payload.caller_name || 'Unknown agent';
|
|
70
|
+
showNotice(`Inbound call: ${caller}`);
|
|
71
|
+
notifyRealtime(`Inbound call from ${caller}`, 'Open A2A Callbook to respond.');
|
|
72
|
+
scheduleRealtimeRefresh();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (type === 'summary.completed') {
|
|
77
|
+
const contact = payload.contact_name || 'conversation';
|
|
78
|
+
showNotice(`Summary complete: ${contact}`);
|
|
79
|
+
notifyRealtime('Summary complete', `Conversation with ${contact} has a summary.`);
|
|
80
|
+
scheduleRealtimeRefresh();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (type === 'contact.status.changed') {
|
|
85
|
+
const contactId = String(payload.contact_id || '');
|
|
86
|
+
const status = String(payload.status || '');
|
|
87
|
+
if (contactId && status) {
|
|
88
|
+
state.contacts = (state.contacts || []).map((contact) => {
|
|
89
|
+
if (String(contact.id) !== contactId) return contact;
|
|
90
|
+
return { ...contact, status };
|
|
91
|
+
});
|
|
92
|
+
renderContacts();
|
|
93
|
+
renderContactDetail();
|
|
94
|
+
} else {
|
|
95
|
+
scheduleRealtimeRefresh();
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (type === 'invite.used') {
|
|
101
|
+
showNotice('Callbook install link used');
|
|
102
|
+
loadCallbookDevices().catch(() => {});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (type === 'call.updated') {
|
|
107
|
+
scheduleRealtimeRefresh();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function connectRealtimeEvents() {
|
|
113
|
+
clearTimeout(reconnectTimer);
|
|
114
|
+
if (dashboardEventSource) {
|
|
115
|
+
dashboardEventSource.close();
|
|
116
|
+
dashboardEventSource = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const qs = new URLSearchParams();
|
|
120
|
+
if (state.realtime.lastEventId) {
|
|
121
|
+
qs.set('since', String(state.realtime.lastEventId));
|
|
122
|
+
}
|
|
123
|
+
const endpoint = `/api/a2a/dashboard/events${qs.toString() ? `?${qs.toString()}` : ''}`;
|
|
124
|
+
const source = new EventSource(endpoint);
|
|
125
|
+
dashboardEventSource = source;
|
|
126
|
+
|
|
127
|
+
source.onopen = () => {
|
|
128
|
+
state.realtime.connected = true;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
source.onerror = () => {
|
|
132
|
+
state.realtime.connected = false;
|
|
133
|
+
if (dashboardEventSource === source) {
|
|
134
|
+
dashboardEventSource.close();
|
|
135
|
+
dashboardEventSource = null;
|
|
136
|
+
}
|
|
137
|
+
reconnectTimer = setTimeout(connectRealtimeEvents, 1500);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onAnyEvent = (evt) => {
|
|
141
|
+
let payload = null;
|
|
142
|
+
try {
|
|
143
|
+
payload = evt?.data ? JSON.parse(evt.data) : null;
|
|
144
|
+
} catch (_) {
|
|
145
|
+
payload = null;
|
|
146
|
+
}
|
|
147
|
+
if (!payload || typeof payload !== 'object') return;
|
|
148
|
+
if (payload.id) {
|
|
149
|
+
state.realtime.lastEventId = String(payload.id);
|
|
150
|
+
} else if (evt?.lastEventId) {
|
|
151
|
+
state.realtime.lastEventId = String(evt.lastEventId);
|
|
152
|
+
}
|
|
153
|
+
handleRealtimeEvent(payload);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
source.onmessage = onAnyEvent;
|
|
157
|
+
source.addEventListener('call.inbound', onAnyEvent);
|
|
158
|
+
source.addEventListener('call.updated', onAnyEvent);
|
|
159
|
+
source.addEventListener('summary.completed', onAnyEvent);
|
|
160
|
+
source.addEventListener('invite.used', onAnyEvent);
|
|
161
|
+
source.addEventListener('contact.status.changed', onAnyEvent);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
window.addEventListener('beforeunload', () => {
|
|
165
|
+
if (dashboardEventSource) {
|
|
166
|
+
dashboardEventSource.close();
|
|
167
|
+
dashboardEventSource = null;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
26
171
|
async function request(path, options = {}) {
|
|
27
172
|
const res = await fetch(`/api/a2a/dashboard${path}`, {
|
|
28
173
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
|
@@ -1365,6 +1510,7 @@ async function bootstrap() {
|
|
|
1365
1510
|
loadLogs()
|
|
1366
1511
|
]);
|
|
1367
1512
|
showNotice('Dashboard loaded');
|
|
1513
|
+
connectRealtimeEvents();
|
|
1368
1514
|
|
|
1369
1515
|
setInterval(() => {
|
|
1370
1516
|
loadAutoUpdateStatus().catch(() => {});
|
package/src/lib/conversations.js
CHANGED
|
@@ -18,9 +18,10 @@ const DB_FILENAME = 'a2a-conversations.db';
|
|
|
18
18
|
const logger = createLogger({ component: 'a2a.conversations' });
|
|
19
19
|
|
|
20
20
|
class ConversationStore {
|
|
21
|
-
constructor(configDir = DEFAULT_CONFIG_DIR) {
|
|
21
|
+
constructor(configDir = DEFAULT_CONFIG_DIR, options = {}) {
|
|
22
22
|
this.configDir = configDir;
|
|
23
23
|
this.dbPath = path.join(configDir, DB_FILENAME);
|
|
24
|
+
this.eventStore = options.eventStore || null;
|
|
24
25
|
this.db = null;
|
|
25
26
|
this._ensureDir();
|
|
26
27
|
}
|
|
@@ -253,6 +254,19 @@ class ConversationStore {
|
|
|
253
254
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
|
|
254
255
|
`).run(id, contactId, contactName, tokenId, direction, now, now);
|
|
255
256
|
|
|
257
|
+
if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
|
|
258
|
+
this.eventStore.emitEvent('call.updated', {
|
|
259
|
+
conversation_id: id,
|
|
260
|
+
status: 'active',
|
|
261
|
+
direction,
|
|
262
|
+
contact_id: contactId || null,
|
|
263
|
+
contact_name: contactName || null
|
|
264
|
+
}, {
|
|
265
|
+
conversationId: id,
|
|
266
|
+
contactId: contactId || null
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
256
270
|
return { id, resumed: false };
|
|
257
271
|
}
|
|
258
272
|
|
|
@@ -284,6 +298,14 @@ class ConversationStore {
|
|
|
284
298
|
WHERE id = ?
|
|
285
299
|
`).run(now, conversationId);
|
|
286
300
|
|
|
301
|
+
if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
|
|
302
|
+
this.eventStore.emitEvent('call.updated', {
|
|
303
|
+
conversation_id: conversationId,
|
|
304
|
+
status: 'active',
|
|
305
|
+
direction
|
|
306
|
+
}, { conversationId });
|
|
307
|
+
}
|
|
308
|
+
|
|
287
309
|
return { id, timestamp: now };
|
|
288
310
|
}
|
|
289
311
|
|
|
@@ -450,6 +472,31 @@ class ConversationStore {
|
|
|
450
472
|
`).run(now, conversationId);
|
|
451
473
|
}
|
|
452
474
|
|
|
475
|
+
if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
|
|
476
|
+
this.eventStore.emitEvent('call.updated', {
|
|
477
|
+
conversation_id: conversationId,
|
|
478
|
+
status: 'concluded',
|
|
479
|
+
contact_id: conversation.contact_id || null,
|
|
480
|
+
contact_name: conversation.contact_name || null
|
|
481
|
+
}, {
|
|
482
|
+
conversationId,
|
|
483
|
+
contactId: conversation.contact_id || null
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
if (summary || ownerSummary) {
|
|
487
|
+
this.eventStore.emitEvent('summary.completed', {
|
|
488
|
+
conversation_id: conversationId,
|
|
489
|
+
contact_id: conversation.contact_id || null,
|
|
490
|
+
contact_name: conversation.contact_name || null,
|
|
491
|
+
has_summary: Boolean(summary),
|
|
492
|
+
has_owner_summary: Boolean(ownerSummary)
|
|
493
|
+
}, {
|
|
494
|
+
conversationId,
|
|
495
|
+
contactId: conversation.contact_id || null
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
453
500
|
return {
|
|
454
501
|
success: true,
|
|
455
502
|
conversationId,
|
|
@@ -472,6 +519,13 @@ class ConversationStore {
|
|
|
472
519
|
WHERE id = ?
|
|
473
520
|
`).run(now, conversationId);
|
|
474
521
|
|
|
522
|
+
if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
|
|
523
|
+
this.eventStore.emitEvent('call.updated', {
|
|
524
|
+
conversation_id: conversationId,
|
|
525
|
+
status: 'timeout'
|
|
526
|
+
}, { conversationId });
|
|
527
|
+
}
|
|
528
|
+
|
|
475
529
|
return { success: true };
|
|
476
530
|
}
|
|
477
531
|
|