Skip to content

Commit 34ae551

Browse files
committed
Add reqwest dependency and implement agent API interaction
- Added `reqwest` dependency in `Cargo.toml` for making HTTP requests. - Implemented agent API interaction in `main.rs` to handle script execution and notifications based on user input. - Enhanced the handling of Fn key events to support agent mode and improved logging for better debugging. - Updated package.json with new development scripts for worker deployment and management.
1 parent 858f5e5 commit 34ae551

14 files changed

Lines changed: 444 additions & 36 deletions

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ objc = "0.2"
2323
block = "0.1"
2424
cpal = "0.15"
2525
png = "0.17"
26+
reqwest = { version = "0.12", features = ["json", "blocking"] }
2627

2728
[target.'cfg(target_os = "windows")'.dependencies]
2829
enigo = "0.2"

LICENSE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ SOFTWARE.
2828

2929

3030

31+

macos-info.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919

2020

2121

22+

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@
2525
"private": true,
2626
"scripts": {
2727
"dev": "vite dev",
28+
"dev:worker": "cd worker && bun run dev",
29+
"dev:all": "bun run dev:worker & bun tauri dev",
2830
"build": "vite build",
2931
"preview": "vite preview",
3032
"tauri": "lsof -ti tcp:5173 | xargs kill -9 && tauri",
33+
"deploy:worker": "cd worker && bun run deploy",
3134
"macos:install": "bash ./scripts/macos-install.sh"
3235
},
3336
"devDependencies": {

src/main.rs

Lines changed: 111 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,64 @@ enum AudioCmd {
3636
static AUDIO_TX: OnceCell<mpsc::Sender<AudioCmd>> = OnceCell::new();
3737
static VOLUME_LEVEL_TX: OnceCell<mpsc::Sender<f32>> = OnceCell::new();
3838

39+
// Agent API URL - dev vs prod
40+
#[cfg(debug_assertions)]
41+
const AGENT_API_URL: &str = "http://localhost:1337/agent";
42+
43+
#[cfg(not(debug_assertions))]
44+
const AGENT_API_URL: &str = "https://t2t-agent-api.YOUR_SUBDOMAIN.workers.dev/agent";
45+
46+
#[derive(serde::Deserialize)]
47+
struct AgentResponse {
48+
success: bool,
49+
script: Option<String>,
50+
blocked: Option<bool>,
51+
error: Option<String>,
52+
}
53+
54+
fn call_agent_api(transcript: &str) -> Result<AgentResponse, String> {
55+
let client = reqwest::blocking::Client::new();
56+
let resp = client
57+
.post(AGENT_API_URL)
58+
.json(&serde_json::json!({ "transcript": transcript }))
59+
.timeout(std::time::Duration::from_secs(30))
60+
.send()
61+
.map_err(|e| format!("Agent API request failed: {e}"))?;
62+
63+
if !resp.status().is_success() {
64+
return Err(format!("Agent API returned {}", resp.status()));
65+
}
66+
67+
resp.json::<AgentResponse>()
68+
.map_err(|e| format!("Failed to parse agent response: {e}"))
69+
}
70+
71+
#[cfg(target_os = "macos")]
72+
fn execute_applescript(script: &str) -> Result<String, String> {
73+
use std::process::Command;
74+
let output = Command::new("osascript")
75+
.arg("-e")
76+
.arg(script)
77+
.output()
78+
.map_err(|e| format!("Failed to run osascript: {e}"))?;
79+
80+
if output.status.success() {
81+
Ok(String::from_utf8_lossy(&output.stdout).to_string())
82+
} else {
83+
Err(String::from_utf8_lossy(&output.stderr).to_string())
84+
}
85+
}
86+
87+
#[cfg(target_os = "macos")]
88+
fn show_notification(title: &str, message: &str) {
89+
let script = format!(
90+
r#"display notification "{}" with title "{}""#,
91+
message.replace('"', "\\\""),
92+
title.replace('"', "\\\"")
93+
);
94+
let _ = execute_applescript(&script);
95+
}
96+
3997
fn log_line(msg: &str) {
4098
// Best-effort persistent log to help debug Finder vs Terminal launch differences.
4199
let ts = SystemTime::now()
@@ -598,20 +656,12 @@ mod macos_fn_key {
598656
}
599657
}
600658

601-
fn handle_fn_key(pressed: bool, control_held: bool) {
659+
fn handle_fn_key(pressed: bool, _control_held: bool) {
602660
let was_recording = IS_RECORDING.load(Ordering::SeqCst);
603661

604662
if pressed && !was_recording {
605663
IS_RECORDING.store(true, Ordering::SeqCst);
606664

607-
// Fn alone = typing mode, Fn + Control = agent mode
608-
let is_text = !control_held;
609-
IS_TEXT_INPUT_MODE.store(is_text, Ordering::SeqCst);
610-
611-
if control_held {
612-
log_line("Fn + Control detected -> agent mode");
613-
}
614-
615665
// Remember where the user was typing so we can restore focus before pasting.
616666
let pid = capture_frontmost_pid();
617667
FRONTMOST_PID.store(pid, Ordering::SeqCst);
@@ -639,49 +689,44 @@ mod macos_fn_key {
639689
.and_then(|e| focused_fingerprint(e));
640690
}
641691

642-
// Send mode to frontend
643-
let mode_str = if is_text { "typing" } else { "agent" };
692+
// Start in "pending" state - frontend shows neutral color
644693
if let Some(w) = app_clone.get_webview_window("main") {
645-
let _ = w.eval(&format!("window.__setMode && window.__setMode('{}')", mode_str));
694+
let _ = w.eval("window.__setMode && window.__setMode('typing')");
646695
}
647696

648-
log_line(&format!("Captured AX focused element (best effort), mode={mode_str}"));
697+
log_line("Captured AX focused element (best effort)");
649698
});
650699
}
651700
log_line("Fn pressed - start recording");
652701

653-
// Watchdog: poll modifier flags, update mode on-the-fly when Control changes
702+
// Watchdog: monitor for Ctrl (one-way switch to agent) and Fn release
654703
std::thread::spawn(|| {
655-
// hard cap so we never get stuck forever
656704
let max_ms = 60_000u64; // 60 seconds max recording
657705
let start = std::time::Instant::now();
658706
let control_flag: u64 = 1u64 << 18;
659-
let mut last_control = IS_TEXT_INPUT_MODE.load(Ordering::SeqCst) == false;
660707

661708
loop {
662709
std::thread::sleep(std::time::Duration::from_millis(25));
663710
if !IS_RECORDING.load(Ordering::SeqCst) {
664711
break;
665712
}
713+
714+
let elapsed_ms = start.elapsed().as_millis() as u64;
666715
let flags = unsafe { CGEventSourceFlagsState(K_CG_EVENT_SOURCE_STATE_COMBINED_SESSION_STATE) };
667716
let fn_down = (flags & K_CG_EVENT_FLAG_MASK_SECONDARY_FN) != 0;
668717
let control_down = (flags & control_flag) != 0;
669718

670-
// Update mode on-the-fly if Control state changed
671-
if control_down != last_control {
672-
last_control = control_down;
673-
let is_text = !control_down;
674-
IS_TEXT_INPUT_MODE.store(is_text, Ordering::SeqCst);
675-
let mode_str = if is_text { "typing" } else { "agent" };
676-
log_line(&format!("Mode switched to {} (Control {})", mode_str, if control_down { "pressed" } else { "released" }));
719+
// One-way switch: if Ctrl pressed at any time, switch to agent mode permanently
720+
if control_down && IS_TEXT_INPUT_MODE.load(Ordering::SeqCst) {
721+
IS_TEXT_INPUT_MODE.store(false, Ordering::SeqCst);
722+
log_line("Control pressed -> agent mode (locked)");
677723

678724
// Update frontend
679725
if let Some(app) = APP_HANDLE.get().cloned() {
680-
let mode = mode_str.to_string();
681726
let app_clone = app.clone();
682727
let _ = app.run_on_main_thread(move || {
683728
if let Some(w) = app_clone.get_webview_window("main") {
684-
let _ = w.eval(&format!("window.__setMode && window.__setMode('{}')", mode));
729+
let _ = w.eval("window.__setMode && window.__setMode('agent')");
685730
}
686731
});
687732
}
@@ -692,7 +737,7 @@ mod macos_fn_key {
692737
handle_fn_key(false, false);
693738
break;
694739
}
695-
if start.elapsed().as_millis() as u64 > max_ms {
740+
if elapsed_ms > max_ms {
696741
log_line("Fn watchdog timeout - forcing stop");
697742
handle_fn_key(false, false);
698743
break;
@@ -804,17 +849,43 @@ mod macos_fn_key {
804849
}
805850
log_line(&format!("Pasted native text len={} (clipboard preserved)", text.len()));
806851
} else {
807-
// Agent mode - emit event to frontend
808-
if let Some(app) = app.clone() {
809-
let text_clone = text.clone();
810-
let app_clone = app.clone();
811-
let _ = app.run_on_main_thread(move || {
812-
if let Some(w) = app_clone.get_webview_window("main") {
813-
let _ = w.eval(&format!("window.__agentInput && window.__agentInput('{}')", text_clone.replace('\\', "\\\\").replace('\'', "\\'")));
852+
// Agent mode - call worker API, execute AppleScript
853+
log_line(&format!("Agent mode: calling API with '{}'", text));
854+
855+
match call_agent_api(&text) {
856+
Ok(response) => {
857+
if response.success {
858+
if let Some(script) = response.script {
859+
log_line(&format!("Agent: executing script: {}", script));
860+
#[cfg(target_os = "macos")]
861+
match execute_applescript(&script) {
862+
Ok(output) => {
863+
log_line(&format!("Agent: script succeeded: {}", output));
864+
show_notification("t2t", "Done");
865+
}
866+
Err(e) => {
867+
log_line(&format!("Agent: script failed: {}", e));
868+
show_notification("t2t", &format!("Script error: {}", e));
869+
}
870+
}
871+
}
872+
} else if response.blocked == Some(true) {
873+
log_line("Agent: script blocked by safety filter");
874+
#[cfg(target_os = "macos")]
875+
show_notification("t2t", "Action blocked for safety");
876+
} else {
877+
let err = response.error.unwrap_or_else(|| "Unknown error".to_string());
878+
log_line(&format!("Agent: API error: {}", err));
879+
#[cfg(target_os = "macos")]
880+
show_notification("t2t", &format!("Error: {}", err));
814881
}
815-
});
882+
}
883+
Err(e) => {
884+
log_line(&format!("Agent: API call failed: {}", e));
885+
#[cfg(target_os = "macos")]
886+
show_notification("t2t", "Could not reach agent");
887+
}
816888
}
817-
log_line(&format!("Agent mode: text len={}", text.len()));
818889
}
819890
}
820891
}
@@ -1397,9 +1468,13 @@ fn main() {
13971468
.on_menu_event(|app, event| {
13981469
match event.id.as_ref() {
13991470
"stats" => {
1400-
// Show the stats window
1471+
// Show the stats window and bring to front
14011472
if let Some(w) = app.get_webview_window("stats") {
14021473
let _ = w.show();
1474+
let _ = w.unminimize();
1475+
// Force to front by briefly setting always-on-top
1476+
let _ = w.set_always_on_top(true);
1477+
let _ = w.set_always_on_top(false);
14031478
let _ = w.set_focus();
14041479
log_line("tray: view stats (existing window)");
14051480
} else {

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515

1616

1717

18+

worker/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
.wrangler/
3+
.alchemy/
4+
.dev.vars
5+
dist/
6+
*.log

worker/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# t2t Agent Worker
2+
3+
Cloudflare Worker that converts voice transcripts to AppleScript using Cloudflare AI.
4+
5+
## Stack
6+
7+
- **Runtime**: Cloudflare Workers
8+
- **Framework**: Hono
9+
- **AI**: Cloudflare Workers AI (Llama 3.1 8B)
10+
- **Effects**: Effect-TS
11+
- **Deploy**: Alchemy
12+
13+
## Setup
14+
15+
```bash
16+
cd worker
17+
bun install
18+
bun wrangler login # one-time auth
19+
```
20+
21+
## Development
22+
23+
```bash
24+
bun dev
25+
```
26+
27+
## Deploy
28+
29+
```bash
30+
bun deploy
31+
```
32+
33+
## API
34+
35+
### POST /agent
36+
37+
Convert a voice transcript to AppleScript.
38+
39+
**Request:**
40+
```json
41+
{
42+
"transcript": "open slack"
43+
}
44+
```
45+
46+
**Response (success):**
47+
```json
48+
{
49+
"success": true,
50+
"script": "tell application \"Slack\" to activate",
51+
"blocked": false
52+
}
53+
```
54+
55+
**Response (blocked by denylist):**
56+
```json
57+
{
58+
"success": false,
59+
"error": "Script blocked by safety filter",
60+
"blocked": true,
61+
"script": "..."
62+
}
63+
```
64+
65+
## Safety
66+
67+
Scripts are checked against a denylist before being returned. Blocked patterns include:
68+
- Destructive shell commands (rm -rf, sudo, etc.)
69+
- Mass file deletion
70+
- Credential/keychain access
71+
- Network exfiltration patterns
72+
- Privilege escalation

worker/alchemy.run.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import alchemy from "alchemy";
2+
import { Worker, Ai } from "alchemy/cloudflare";
3+
4+
const app = await alchemy("t2t-agent", {
5+
stage: process.env.ALCHEMY_STAGE ?? "dev",
6+
});
7+
8+
export const worker = await Worker("t2t-agent-api", {
9+
entrypoint: "./src/index.ts",
10+
url: true,
11+
compatibilityDate: "2024-12-01",
12+
compatibilityFlags: ["nodejs_compat"],
13+
bindings: {
14+
AI: Ai(),
15+
},
16+
dev: {
17+
port: 1337,
18+
}
19+
});
20+
21+
console.log(`Worker URL: ${worker.url}`);
22+
23+
await app.finalize();

worker/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "t2t-worker",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"scripts": {
6+
"dev": "alchemy dev",
7+
"deploy": "alchemy deploy",
8+
"destroy": "alchemy destroy",
9+
"typecheck": "tsc --noEmit"
10+
},
11+
"dependencies": {
12+
"@types/node": "^25.0.3",
13+
"alchemy": "^0.81.4",
14+
"effect": "^3.19.12",
15+
"hono": "^4.11.1"
16+
},
17+
"devDependencies": {
18+
"typescript": "^5.9.3"
19+
}
20+
}

0 commit comments

Comments
 (0)