a2acalling 0.6.49 → 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 +228 -2
- package/docs/protocol.md +42 -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/install-skills.js +80 -0
- package/scripts/postinstall.js +8 -45
- package/src/dashboard/public/app.js +147 -1
- package/src/lib/claude-subagent.js +6 -5
- package/src/lib/config.js +1 -0
- package/src/lib/conversation-driver.js +12 -3
- package/src/lib/conversations.js +55 -1
- package/src/lib/dashboard-events.js +205 -0
- package/src/lib/runtime-adapter.js +8 -3
- package/src/lib/tokens.js +13 -1
- package/src/lib/turn-timeout.js +52 -0
- package/src/routes/a2a.js +26 -4
- package/src/routes/dashboard.js +114 -1
- package/src/server.js +20 -1
|
@@ -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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A Skill Installer
|
|
3
|
+
*
|
|
4
|
+
* Copies Claude Code commands and Codex AGENTS.md into a target project directory.
|
|
5
|
+
* Idempotent: skips files that already exist with identical content.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
12
|
+
|
|
13
|
+
const SKILL_FILES = [
|
|
14
|
+
{ src: '.claude/commands/a2a-call.md', dest: '.claude/commands/a2a-call.md' },
|
|
15
|
+
{ src: '.claude/commands/a2a-invite.md', dest: '.claude/commands/a2a-invite.md' },
|
|
16
|
+
{ src: '.claude/commands/a2a-contacts.md', dest: '.claude/commands/a2a-contacts.md' },
|
|
17
|
+
{ src: '.claude/commands/a2a-status.md', dest: '.claude/commands/a2a-status.md' },
|
|
18
|
+
{ src: '.claude/commands/a2a-setup.md', dest: '.claude/commands/a2a-setup.md' },
|
|
19
|
+
{ src: '.codex/AGENTS.md', dest: '.codex/AGENTS.md' }
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function installSkills(targetDir, options = {}) {
|
|
23
|
+
const result = { installed: [], skipped: [], errors: [] };
|
|
24
|
+
|
|
25
|
+
for (const file of SKILL_FILES) {
|
|
26
|
+
const srcPath = path.join(PACKAGE_ROOT, file.src);
|
|
27
|
+
const destPath = path.join(targetDir, file.dest);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(srcPath)) {
|
|
31
|
+
result.errors.push({ file: file.src, error: 'Source file not found' });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const srcContent = fs.readFileSync(srcPath, 'utf8');
|
|
36
|
+
|
|
37
|
+
// Check if identical file already exists
|
|
38
|
+
if (!options.force && fs.existsSync(destPath)) {
|
|
39
|
+
const existing = fs.readFileSync(destPath, 'utf8');
|
|
40
|
+
if (existing === srcContent) {
|
|
41
|
+
result.skipped.push(file.dest);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create directory and write file
|
|
47
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
48
|
+
fs.writeFileSync(destPath, srcContent);
|
|
49
|
+
result.installed.push(file.dest);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
result.errors.push({ file: file.dest, error: err.message });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// CLI mode: node scripts/install-skills.js [targetDir] [--force]
|
|
59
|
+
if (require.main === module) {
|
|
60
|
+
const args = process.argv.slice(2);
|
|
61
|
+
const force = args.includes('--force');
|
|
62
|
+
const targetDir = args.find(a => !a.startsWith('-')) || process.cwd();
|
|
63
|
+
|
|
64
|
+
const result = installSkills(targetDir, { force });
|
|
65
|
+
|
|
66
|
+
if (result.installed.length) {
|
|
67
|
+
console.log(`Installed ${result.installed.length} A2A skill file(s):`);
|
|
68
|
+
result.installed.forEach(f => console.log(` + ${f}`));
|
|
69
|
+
}
|
|
70
|
+
if (result.skipped.length) {
|
|
71
|
+
console.log(`Skipped ${result.skipped.length} unchanged file(s)`);
|
|
72
|
+
}
|
|
73
|
+
if (result.errors.length) {
|
|
74
|
+
console.error(`Errors: ${result.errors.length}`);
|
|
75
|
+
result.errors.forEach(e => console.error(` ! ${e.file}: ${e.error}`));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { installSkills, SKILL_FILES };
|
package/scripts/postinstall.js
CHANGED
|
@@ -43,56 +43,19 @@ const result = spawnSync(process.execPath, [cliPath, 'quickstart'], {
|
|
|
43
43
|
|
|
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
47
|
process.exit(0);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
installSkillFiles();
|
|
51
51
|
process.exit(result.status || 0);
|
|
52
52
|
|
|
53
|
-
//
|
|
54
|
-
function
|
|
55
|
-
const os = require('os');
|
|
56
|
-
const fs = require('fs');
|
|
57
|
-
|
|
58
|
-
if (os.platform() !== 'darwin') return;
|
|
59
|
-
|
|
53
|
+
// Best-effort: install Claude Code + Codex skills into the workspace
|
|
54
|
+
function installSkillFiles() {
|
|
60
55
|
try {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Skip if already installed at same version
|
|
66
|
-
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
|
|
67
|
-
if (fs.existsSync(plistPath)) {
|
|
68
|
-
try {
|
|
69
|
-
const plist = fs.readFileSync(plistPath, 'utf8');
|
|
70
|
-
if (plist.includes(version)) {
|
|
71
|
-
return; // Same version already installed
|
|
72
|
-
}
|
|
73
|
-
} catch (_) {}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const tarUrl = `https://github.com/onthegonow/a2a_calling/releases/download/v${version}/A2A-Callbook-${version}.app.tar.gz`;
|
|
77
|
-
const tmpFile = path.join(os.tmpdir(), `a2a-callbook-${version}.tar.gz`);
|
|
78
|
-
|
|
79
|
-
// Download
|
|
80
|
-
const { execFileSync } = require('child_process');
|
|
81
|
-
execFileSync('curl', ['-sL', '-o', tmpFile, tarUrl], { timeout: 30000 });
|
|
82
|
-
|
|
83
|
-
if (!fs.existsSync(tmpFile) || fs.statSync(tmpFile).size < 1000) {
|
|
84
|
-
return; // Download failed or too small — skip silently
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Ensure ~/Applications exists
|
|
88
|
-
fs.mkdirSync(appDir, { recursive: true });
|
|
89
|
-
|
|
90
|
-
// Extract
|
|
91
|
-
execFileSync('tar', ['-xzf', tmpFile, '-C', appDir], { timeout: 15000 });
|
|
92
|
-
|
|
93
|
-
// Cleanup
|
|
94
|
-
try { fs.unlinkSync(tmpFile); } catch (_) {}
|
|
95
|
-
} catch (_) {
|
|
96
|
-
// Silently fail — native app is optional
|
|
56
|
+
const { installSkills } = require('./install-skills');
|
|
57
|
+
installSkills(initCwd);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// Silent — skills can be installed later with `a2a skills`
|
|
97
60
|
}
|
|
98
61
|
}
|
|
@@ -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(() => {});
|