Enhance development scripts and improve tool documentation

- Updated `package.json` scripts for better error handling and streamlined execution of Tauri commands.
- Added a new `gmail-skill-integration.md` document detailing the Gmail skill integration process, including OAuth2 flow and tool definitions.
- Improved formatting and consistency in `TOOLS.md` for better readability and clarity.
- Enhanced logging in skill management and transport layers for better debugging and monitoring of tool calls.
This commit is contained in:
M3gA-Mind
2026-03-09 19:27:48 +05:30
parent 675335d1fe
commit f91e409a0d
16 changed files with 1234 additions and 661 deletions
+284 -397
View File
File diff suppressed because it is too large Load Diff
+630
View File
@@ -0,0 +1,630 @@
# Gmail Skill Integration — Design Reference
**Scope:** Native Gmail skill — OAuth2 connect flow, tunnel-based callback, token storage,
initial inbox sync, and `send_email` tool dispatch.
**Status:** Design reference / implementation guide.
---
## 1. Overview
This document describes the full lifecycle of connecting a Gmail skill to ZeroClaw.
It covers four concerns in order:
1. **OAuth2 connect** — how the user authorises Gmail access
2. **Tunnel callback** — how the authorization code reaches the backend through the tunnel
3. **Token storage** — how credentials are encrypted and persisted in the auth profile store
4. **Email sync + send** — how the initial inbox is fetched and how sending works
The skill is declared with the following manifest:
```json
{
"id": "gmail",
"name": "Gmail",
"version": "1.0.0",
"description": "Gmail integration via Google API — comprehensive email management with OAuth2 authentication, send/receive, labels, search, and attachments.",
"auto_start": false,
"platforms": ["windows", "macos", "linux", "android", "ios"],
"setup": {
"required": true,
"label": "Connect Gmail",
"oauth": {
"provider": "gmail",
"scopes": [
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.labels"
]
}
}
}
```
The equivalent `SKILL.toml` that ZeroClaw loads from
`~/.zeroclaw/workspace/skills/gmail/SKILL.toml` is shown in §2.
---
## 2. Skill Manifest (`SKILL.toml`)
Skills in ZeroClaw are loaded by `src/skills/mod.rs`. The loader reads a `SKILL.toml`
(or `SKILL.md`) file from `~/.zeroclaw/workspace/skills/<name>/`. The Rust struct it
populates is `Skill` / `SkillManifest` / `SkillTool`.
`SKILL.toml` for Gmail:
```toml
[skill]
name = "gmail"
version = "1.0.0"
description = "Gmail integration via Google API — send/receive, labels, search, attachments."
author = "zeroclaw"
tags = ["email", "google", "productivity"]
# OAuth setup block — interpreted by the skill connect subsystem (see §3).
[skill.setup]
required = true
label = "Connect Gmail"
[skill.setup.oauth]
provider = "gmail"
scopes = [
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.labels",
]
# Tools exposed to the agent after connection.
[[tools]]
name = "gmail_send_email"
description = "Send an email via Gmail. Requires a connected Gmail account."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
[[tools]]
name = "gmail_list_messages"
description = "List messages in the Gmail inbox. Supports query filters."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages"
[[tools]]
name = "gmail_get_message"
description = "Fetch the full content of a single Gmail message by ID."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}"
[[tools]]
name = "gmail_modify_labels"
description = "Add or remove labels on a Gmail message."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}/modify"
```
At runtime, `load_skills_with_config()` (`src/skills/mod.rs:78`) reads these tools into
`Vec<SkillTool>` and injects them into the agent system prompt. The `kind = "http"` tools
are dispatched via `HttpRequestTool` (`src/tools/http_request.rs`) with the stored
Bearer token injected as the `Authorization` header (see §5).
---
## 3. OAuth2 Connect Flow
### 3.1 What the user triggers
When the user asks to connect Gmail (via the frontend or a CLI command), the backend:
1. Generates a **PKCE pair** (code verifier + SHA-256 challenge) — following the same
pattern as `src/auth/openai_oauth.rs:generate_pkce_state()`.
2. Generates a random CSRF `state` token (24 bytes, base64url).
3. Constructs the Google OAuth2 authorisation URL:
```
https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=<GOOGLE_CLIENT_ID>
&redirect_uri=<TUNNEL_PUBLIC_URL>/auth/callback/gmail
&scope=https://www.googleapis.com/auth/gmail.send
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/gmail.labels
&code_challenge=<SHA256_CHALLENGE>
&code_challenge_method=S256
&state=<RANDOM_STATE>
&access_type=offline
&prompt=consent
```
`access_type=offline` is required to receive a `refresh_token` from Google.
`prompt=consent` ensures the refresh token is issued on every connect, not just the first.
4. The URL is sent to the frontend. The frontend opens it in a browser or WebView.
### 3.2 Where the redirect URI points
The `redirect_uri` is the **tunnel public URL** with the path `/auth/callback/gmail`.
ZeroClaw's tunnel system (`src/tunnel/mod.rs`) exposes the local gateway port via one of:
| Provider | How public URL is obtained |
|---|---|
| `cloudflare` | `cloudflared tunnel` process stdout — `src/tunnel/cloudflare.rs` |
| `ngrok` | ngrok local API `GET /api/tunnels``src/tunnel/ngrok.rs` |
| `tailscale` | `tailscale funnel` + hostname — `src/tunnel/tailscale.rs` |
| `custom` | user-supplied URL or stdout pattern — `src/tunnel/custom.rs` |
The tunnel's `start(local_host, local_port)` method returns the public URL string.
This URL is what gets used as the `redirect_uri`.
Example with Cloudflare Tunnel:
```
https://your-subdomain.trycloudflare.com/auth/callback/gmail
```
Google's OAuth2 server sends the browser to this URL after the user grants consent.
---
## 4. Receiving the Authorization Code via the Tunnel
### 4.1 The callback endpoint
The gateway (`src/gateway/mod.rs`) runs as an Axum HTTP server. A new route needs to be
registered to receive the OAuth2 callback:
```
GET /auth/callback/gmail?code=<AUTH_CODE>&state=<STATE>
```
This route handler must:
1. **Validate the `state` parameter** against the CSRF token stored in memory when the
flow was initiated. Reject mismatches with `400 Bad Request`.
2. **Extract the `code`** query parameter.
3. **Exchange the code** for tokens by calling Google's token endpoint:
```
POST https://oauth2.googleapis.com/token
grant_type=authorization_code
code=<AUTH_CODE>
client_id=<GOOGLE_CLIENT_ID>
client_secret=<GOOGLE_CLIENT_SECRET>
redirect_uri=<TUNNEL_PUBLIC_URL>/auth/callback/gmail
code_verifier=<PKCE_VERIFIER>
```
The exchange pattern mirrors `src/auth/openai_oauth.rs:exchange_code_for_tokens()`,
which posts a form body and parses the JSON response into `TokenSet`.
Google returns:
```json
{
"access_token": "ya29.a0...",
"expires_in": 3599,
"refresh_token": "1//0g...",
"scope": "https://www.googleapis.com/auth/gmail.send ...",
"token_type": "Bearer"
}
```
4. The handler stores the `TokenSet` (see §4.2) and returns a success response to the
browser (an HTML page or redirect to the frontend app confirming the connection).
### 4.2 The in-flight CSRF / PKCE state
Before redirecting the user to Google, the pending PKCE state and CSRF token must be
stored so the callback handler can look them up. The right storage is the skill's dedicated
in-memory or short-lived file-backed state, keyed by the `state` value:
```
pending_oauth_states: HashMap<state_token, (PkceState, initiated_at)>
```
Entries expire after a fixed window (e.g. 10 minutes) to prevent orphaned state
accumulation. This follows the same pattern ZeroClaw already uses for pairing nonces
in `src/security/pairing.rs`.
---
## 5. Token Storage
After a successful token exchange, the tokens are persisted using the `AuthService`
and `AuthProfilesStore` from `src/auth/`.
### 5.1 Storing the token set
```rust
// Pseudocode — mirrors src/auth/mod.rs AuthService::store_openai_tokens()
let token_set = TokenSet {
access_token: response.access_token,
refresh_token: response.refresh_token,
id_token: None,
expires_at: Some(Utc::now() + Duration::seconds(response.expires_in)),
token_type: Some("Bearer".into()),
scope: Some(scopes.join(" ")),
};
auth_service
.store_oauth_tokens("gmail", "default", token_set, None, true)
.await?;
```
`store_oauth_tokens` calls `AuthProfilesStore::upsert_profile()`, which:
1. Creates or updates an `AuthProfile` with `kind = AuthProfileKind::OAuth`.
2. Serialises the profile data to `~/.zeroclaw/auth-profiles.json` (file-locked via
`auth-profiles.lock` with a 10-second timeout — `src/auth/profiles.rs:1617`).
### 5.2 How tokens are encrypted at rest
Before writing to disk, every secret field passes through `SecretStore::encrypt()`
(`src/security/secrets.rs`). The encryption scheme is:
- **Algorithm**: ChaCha20-Poly1305 AEAD (256-bit key)
- **Key file**: `~/.zeroclaw/.secret_key` (permissions 0600, created on first use)
- **Format on disk**: `enc2:<hex(12-byte-nonce ‖ ciphertext ‖ 16-byte-Poly1305-tag)>`
- **Config**: encryption is enabled by default; disable with `secrets.encrypt = false`
The stored profile entry looks like:
```json
{
"id": "gmail:default",
"provider": "gmail",
"profile_name": "default",
"kind": "oauth",
"token_set": {
"access_token": "enc2:a1b2c3...",
"refresh_token": "enc2:d4e5f6...",
"expires_at": "2026-03-09T12:00:00Z",
"token_type": "Bearer",
"scope": "https://www.googleapis.com/auth/gmail.send ..."
},
"created_at": "2026-03-09T11:00:00Z",
"updated_at": "2026-03-09T11:00:00Z"
}
```
### 5.3 Token refresh
Google access tokens expire after 3600 seconds. Before any Gmail API call, the skill
must check `token_set.is_expiring_within(Duration::from_secs(90))` (the same 90-second
skew used for OpenAI tokens in `src/auth/mod.rs:160`). If expiring:
```
POST https://oauth2.googleapis.com/token
grant_type=refresh_token
refresh_token=<stored_refresh_token>
client_id=<GOOGLE_CLIENT_ID>
client_secret=<GOOGLE_CLIENT_SECRET>
```
The new `access_token` (and new `refresh_token` if Google rotates it) is written back
to the auth profile via `AuthProfilesStore::update_profile()`.
---
## 6. Initial Email Sync (First 100 Emails)
Once connected, the skill performs a one-time initial sync. This is triggered
immediately after a successful token exchange and runs as a background task.
### 6.1 Fetch message IDs
```
GET https://gmail.googleapis.com/gmail/v1/users/me/messages
?maxResults=100
&labelIds=INBOX
Authorization: Bearer <access_token>
```
Returns up to 100 message descriptors: `[{ "id": "...", "threadId": "..." }, ...]`.
### 6.2 Fetch full message details
For each message ID, fetch the full message:
```
GET https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}
?format=full
Authorization: Bearer <access_token>
```
The response contains headers (From, To, Subject, Date), a snippet, and the body parts.
In practice, to avoid 100 sequential requests, use the Gmail batch API:
```
POST https://www.googleapis.com/batch/gmail/v1
Content-Type: multipart/mixed; boundary="batch_boundary"
--batch_boundary
Content-Type: application/http
GET /gmail/v1/users/me/messages/{id1}?format=full
...
```
### 6.3 Storing emails in ZeroClaw memory
Each email is stored via `memory_store` (`src/tools/memory_store.rs`) with:
| Field | Value |
|---|---|
| `key` | `gmail_msg_<message_id>` |
| `content` | Formatted string: `From: ... Subject: ... Date: ... Snippet: ...` |
| `category` | `MemoryCategory::Custom("gmail")` |
This makes every synced email searchable via `memory_recall` with queries like
`"gmail from:zeroclaw@example.com"`.
For larger body content that exceeds a single memory entry, store the full body as a
separate entry keyed `gmail_body_<message_id>` and the summary/metadata as
`gmail_msg_<message_id>`.
Example stored entries after sync:
```
key: gmail_msg_18de7f2a1b3c4e5d
content: From: sender@example.com
To: me@gmail.com
Subject: Project update
Date: 2026-03-08T14:32:00Z
Snippet: "Here is the latest update on the project..."
Labels: INBOX, UNREAD
category: gmail
key: gmail_body_18de7f2a1b3c4e5d
content: (full decoded email body text)
category: gmail
```
### 6.4 Sync state tracking
Store the highest-known history ID after sync so incremental sync can pick up from
where it left off:
```
key: gmail_sync_history_id
content: 12345678
category: core
```
---
## 7. The `gmail_send_email` Tool
### 7.1 Tool definition in `SKILL.toml`
The `[[tools]]` entry from §2:
```toml
[[tools]]
name = "gmail_send_email"
description = "Send an email via Gmail. Requires a connected Gmail account."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
```
### 7.2 How the agent calls it
The LLM emits a tool call:
```json
{
"name": "gmail_send_email",
"arguments": {
"to": "recipient@example.com",
"subject": "Hello from ZeroClaw",
"body": "This is a test email sent by the agent."
}
}
```
### 7.3 Tool dispatch path
```
Agent loop (src/agent/loop_.rs)
└── dispatches to registered tool by name
└── HttpRequestTool::execute(args) [src/tools/http_request.rs]
├── Retrieve gmail access_token from AuthService
│ └── auth_service.get_provider_bearer_token("gmail", None)
│ └── decrypts enc2: value via SecretStore::decrypt()
├── Build RFC 2822 message and base64url-encode it
├── POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send
│ Headers:
│ Authorization: Bearer <decrypted_access_token>
│ Content-Type: application/json
│ Body:
│ { "raw": "<base64url_encoded_rfc2822_message>" }
└── Return ToolResult { success, output: "Message sent. ID: <id>" }
```
### 7.4 Building the RFC 2822 message
Gmail's send endpoint requires the email encoded as an RFC 2822 message, then base64url
encoded. The structure:
```
From: me@gmail.com
To: recipient@example.com
Subject: Hello from ZeroClaw
Content-Type: text/plain; charset="UTF-8"
MIME-Version: 1.0
This is a test email sent by the agent.
```
Then the entire string is base64url-encoded (no padding) and placed in the `raw` field:
```json
{
"raw": "RnJvbTogbWVAZ21haWwuY29tCi..."
}
```
### 7.5 Security enforcement
Before executing any outbound HTTP call, the `SecurityPolicy` is consulted:
```rust
// src/security/policy.rs
security.enforce_tool_operation(ToolOperation::Act, "gmail_send_email")
```
`ToolOperation::Act` is the category for write/side-effect operations. If the agent is
running in `ReadOnly` or `Supervised` mode, this call fails with an explicit error before
any network request is made.
The `HttpRequestTool` additionally enforces the `http.allowed_domains` allowlist. For
Gmail tools, `googleapis.com` must be present in that list:
```toml
# config.toml
[http]
enabled = true
allowed_domains = ["googleapis.com"]
```
### 7.6 Reply-to and threading
To send a reply within an existing thread, add the Gmail `threadId` to the request body:
```json
{
"raw": "<base64url_message_with_In-Reply-To_header>",
"threadId": "18de7f2a1b3c4e5d"
}
```
The RFC 2822 message must include `In-Reply-To: <original_message_id>` and
`References: <original_message_id>` headers for proper threading.
---
## 8. Full End-to-End Sequence
```
Frontend / User
│ 1. "Connect Gmail"
ZeroClaw Backend
│ 2. Generate PKCE + CSRF state
│ 3. Build Google OAuth2 authorize URL with tunnel redirect_uri
│ 4. Return URL to frontend
Browser (user)
│ 5. User visits URL, grants consent in Google
Google OAuth2
│ 6. Browser redirected to <TUNNEL_PUBLIC_URL>/auth/callback/gmail?code=XXX&state=YYY
Tunnel (cloudflare / ngrok / tailscale)
│ 7. Proxies HTTPS request to local gateway port
Gateway (src/gateway/mod.rs) — GET /auth/callback/gmail
│ 8. Validate CSRF state
│ 9. Exchange code → TokenSet via POST https://oauth2.googleapis.com/token
│ 10. Store TokenSet in AuthProfilesStore (encrypted, "gmail:default")
│ 11. Return success page/redirect to frontend
Background task — initial sync
│ 12. GET /gmail/v1/users/me/messages?maxResults=100&labelIds=INBOX
│ 13. Batch-fetch 100 full messages
│ 14. memory_store each email under key=gmail_msg_<id>, category=gmail
│ 15. memory_store gmail_sync_history_id = <latest_historyId>
Agent (subsequent interactions)
│ 16. LLM generates tool call: gmail_send_email { to, subject, body }
│ 17. Agent loop dispatches to HttpRequestTool
│ 18. HttpRequestTool retrieves + decrypts access_token from AuthService
│ 19. Refreshes token if expiring within 90 seconds
│ 20. POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send
│ 21. Returns ToolResult to agent
Done
```
---
## 9. Configuration Reference
All Gmail skill config lives in `config.toml`:
```toml
# Enable HTTP request tool with Google API access
[http]
enabled = true
allowed_domains = ["googleapis.com", "oauth2.googleapis.com"]
timeout_secs = 30
max_response_size = 524288 # 512KB — enough for email batch responses
# Tunnel for OAuth callback (pick one)
[tunnel]
provider = "cloudflare"
[tunnel.cloudflare]
token = "your-cloudflare-tunnel-token"
# Or ngrok:
# [tunnel]
# provider = "ngrok"
# [tunnel.ngrok]
# auth_token = "your-ngrok-token"
# Google OAuth2 app credentials (stored encrypted)
# Set via environment or onboard wizard — never commit raw values
[integrations.gmail]
client_id = "enc2:..."
client_secret = "enc2:..."
```
The Google credentials (`client_id`, `client_secret`) are encrypted by `SecretStore`
before being written to `config.toml` — the same ChaCha20-Poly1305 scheme used for all
secrets (`src/security/secrets.rs`).
---
## 10. Key Source Files
| File | Role |
|---|---|
| `src/skills/mod.rs` | Skill loading, `SkillTool` struct, `load_skills_with_config()` |
| `src/auth/mod.rs` | `AuthService` — store/retrieve/refresh OAuth tokens |
| `src/auth/profiles.rs` | `AuthProfile`, `TokenSet`, `AuthProfilesStore` — JSON persistence |
| `src/auth/openai_oauth.rs` | PKCE generation, code exchange, refresh — reference pattern |
| `src/security/secrets.rs` | `SecretStore` — ChaCha20-Poly1305 encrypt/decrypt |
| `src/tunnel/mod.rs` | `Tunnel` trait + factory — public URL for OAuth redirect |
| `src/tunnel/cloudflare.rs` | Cloudflare Tunnel implementation |
| `src/tunnel/ngrok.rs` | ngrok implementation |
| `src/gateway/mod.rs` | Axum HTTP gateway — where `/auth/callback/gmail` is registered |
| `src/tools/http_request.rs` | `HttpRequestTool` — dispatches Gmail API calls |
| `src/tools/memory_store.rs` | `MemoryStoreTool` — stores synced emails |
| `src/tools/memory_recall.rs` | `MemoryRecallTool` — searches synced emails |
| `src/security/policy.rs` | `SecurityPolicy` — enforces `ToolOperation::Act` guards |
| `docs/config-reference.md` | Full config schema including `[http]`, `[tunnel]` |
---
## 11. Security Notes
- The OAuth2 `redirect_uri` **must be the tunnel URL**. It cannot be `localhost` in a
remote/mobile scenario. Google validates the exact URI registered in the Google Cloud
Console; register it as `https://<your-tunnel-domain>/auth/callback/gmail`.
- The CSRF `state` token must be validated on every callback. Reject mismatched or
missing state with `400 Bad Request` before touching any tokens.
- The PKCE verifier must be destroyed after a successful or failed exchange — never
persist it beyond the in-flight flow.
- `client_secret` must never appear in plaintext in `config.toml`, logs, or agent
tool output. Encrypt it via the secret store and redact it in observability output.
- `googleapis.com` must be explicitly present in `http.allowed_domains` — the
`HttpRequestTool` enforces this allowlist before every request
(`src/tools/http_request.rs`).
- Token refresh runs under the same per-profile Tokio mutex used for OpenAI tokens
(`src/auth/mod.rs:287`) to prevent double-refresh races.
+3 -2
View File
@@ -6,14 +6,15 @@
"scripts": {
"dev": "vite",
"dev:web": "vite",
"dev:app": "source scripts/load-dotenv.sh && tauri dev",
"dev:app": "source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri dev",
"build": "tsc && vite build",
"build:app": "yarn skills:build && yarn tools:generate && tsc && vite build",
"compile": "tsc --noEmit",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri dev",
"macos:build:debug": "yarn macos:build:release --debug",
"macos:build:release": "source scripts/load-dotenv.sh && tauri build --bundles app dmg",
"macos:build:release": "source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri build --bundles app dmg",
"macos:build:sign:release": "source scripts/load-env.sh && tauri build --bundles app dmg",
"macos:run": "open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
"macos:dev": "yarn macos:build:debug && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
+2
View File
@@ -12,3 +12,5 @@
# TDLib built from source (see scripts/build-tdlib-from-source.sh)
/tdlib-local/
/tdlib-build/
# TDLib downloaded by scripts/download-tdlib.sh (avoids build timeout)
/tdlib-cache/
+1 -194
View File
@@ -311,15 +311,6 @@ dependencies = [
"object 0.37.3",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "archery"
version = "1.2.2"
@@ -818,7 +809,7 @@ dependencies = [
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq 0.4.2",
"constant_time_eq",
"cpufeatures",
]
@@ -945,25 +936,6 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -1275,12 +1247,6 @@ dependencies = [
"typewit",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -1409,21 +1375,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1716,12 +1667,6 @@ dependencies = [
"regex",
]
[[package]]
name = "deflate64"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
[[package]]
name = "deku"
version = "0.18.1"
@@ -1792,17 +1737,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -3388,11 +3322,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -4212,27 +4144,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -6739,7 +6650,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
@@ -6753,7 +6663,6 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
@@ -7961,27 +7870,6 @@ dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -8412,13 +8300,11 @@ dependencies = [
"futures-channel",
"log",
"once_cell",
"reqwest",
"serde",
"serde_json",
"serde_with",
"tdlib-rs-gen",
"tdlib-rs-parser",
"zip",
]
[[package]]
@@ -10686,15 +10572,6 @@ version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.7.5"
@@ -10919,36 +10796,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq 0.3.1",
"crc32fast",
"crossbeam-utils",
"deflate64",
"displaydoc",
"flate2",
"getrandom 0.3.4",
"hmac",
"indexmap 2.13.0",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
"thiserror 2.0.18",
"time",
"xz2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.5.5"
@@ -10961,46 +10808,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.9.2"
+5 -6
View File
@@ -165,14 +165,13 @@ tauri-plugin-notification = "2"
# Only available on desktop - prebuilt binaries don't exist for Android/iOS
rquickjs = { version = "0.11", features = ["futures", "macro", "loader", "parallel"] }
# TDLib Rust bindings (desktop only - downloads prebuilt TDLib for linking)
# On macOS, the bundled dylib is replaced with a source-built version (see build.rs)
# to ensure macOS 10.15 deployment target compatibility.
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
# TDLib Rust bindings (desktop only - uses local prebuilt via LOCAL_TDLIB_PATH)
# Run: cd src-tauri && ./scripts/download-tdlib.sh (once, to populate tdlib-cache/)
tdlib-rs = { version = "1.2", features = ["local-tdlib"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.build-dependencies]
# TDLib build configuration (download-tdlib for all desktop platforms)
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
# TDLib build (local-tdlib avoids reqwest timeout during Cargo build)
tdlib-rs = { version = "1.2", features = ["local-tdlib"] }
[dev-dependencies]
tempfile = "3"
+38 -9
View File
@@ -48,7 +48,34 @@ fn maybe_override_tauri_config_for_tests() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn setup_tdlib() {
// download-tdlib: downloads prebuilt TDLib and configures linker flags
// local-tdlib: use LOCAL_TDLIB_PATH or auto-detect tdlib-cache/
if std::env::var("LOCAL_TDLIB_PATH").is_err() {
let manifest_dir =
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| "aarch64".into());
let arch_name = if arch == "aarch64" { "aarch64" } else { "x86_64" };
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| "macos".into());
let os_arch = if os == "macos" || os == "darwin" {
format!("macos-{arch_name}")
} else if os == "linux" {
format!("linux-{arch_name}")
} else if os == "windows" {
format!("windows-{arch_name}")
} else {
arch_name.to_string()
};
let default = manifest_dir.join("tdlib-cache").join(&os_arch);
if default.join("lib").exists() {
std::env::set_var("LOCAL_TDLIB_PATH", &default);
} else {
panic!(
"LOCAL_TDLIB_PATH not set and tdlib-cache not found at {}.\n\
Run: cd src-tauri && ./scripts/download-tdlib.sh\n\
Then retry the build.",
default.display()
);
}
}
tdlib_rs::build::build(None);
// On macOS, replace the bundled dylib with our source-built version
@@ -105,14 +132,16 @@ fn copy_local_tdlib_for_bundle() {
println!("cargo:warning=Using locally-built TDLib from {}", local.display());
local
} else {
// 3. Fall back to the download-tdlib output
println!(
"cargo:warning=No source-built TDLib found for macos-{arch_name}. \
The prebuilt TDLib will be bundled instead (targets macOS 14.0+). \
Run: cd src-tauri && ./scripts/build-tdlib-from-source.sh"
);
let out_dir = env::var("OUT_DIR").unwrap();
PathBuf::from(&out_dir).join("tdlib").join("lib").join(&dylib_name)
// 3. Fall back to tdlib-cache (from download-tdlib.sh) or LOCAL_TDLIB_PATH
let local_tdlib = env::var("LOCAL_TDLIB_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
manifest_dir
.join("tdlib-cache")
.join(format!("macos-{arch_name}"))
});
println!("cargo:warning=Using TDLib from {}", local_tdlib.display());
local_tdlib.join("lib").join(&dylib_name)
};
if !src_dylib.exists() {
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
#
# Download prebuilt TDLib from tdlib-rs releases using curl (avoids reqwest
# timeout during Cargo build). Extracts to tdlib-cache/ for use with local-tdlib.
#
# Usage:
# cd src-tauri
# ./scripts/download-tdlib.sh
#
# Then run builds with LOCAL_TDLIB_PATH set, or use yarn tauri:dev / yarn dev:app.
#
set -euo pipefail
TDLIB_VERSION="1.8.29"
TDLIB_RS_VERSION="1.2.0"
BASE_URL="https://github.com/FedericoBruzzone/tdlib-rs/releases/download"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC_TAURI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CACHE_DIR="${SRC_TAURI_DIR}/tdlib-cache"
# Detect target (CARGO_CFG_* set during cargo build; uname when run manually)
RAW_OS="${CARGO_CFG_TARGET_OS:-$(uname -s | tr '[:upper:]' '[:lower:]')}"
ARCH="${CARGO_CFG_TARGET_ARCH:-$(uname -m)}"
case "$RAW_OS" in
darwin|macos) OS_NAME="macos" ;;
linux) OS_NAME="linux" ;;
windows) OS_NAME="windows" ;;
*) OS_NAME="$RAW_OS" ;;
esac
case "$ARCH" in
arm64|aarch64) ARCH_NAME="aarch64" ;;
x86_64) ARCH_NAME="x86_64" ;;
*) ARCH_NAME="$ARCH" ;;
esac
ZIP_NAME="tdlib-${TDLIB_VERSION}-${OS_NAME}-${ARCH_NAME}.zip"
URL="${BASE_URL}/v${TDLIB_RS_VERSION}/${ZIP_NAME}"
EXTRACT_TO="${CACHE_DIR}/${OS_NAME}-${ARCH_NAME}"
if [ -f "${EXTRACT_TO}/lib/libtdjson.${TDLIB_VERSION}.dylib" ] || \
[ -f "${EXTRACT_TO}/lib/libtdjson.so.${TDLIB_VERSION}" ] || \
[ -f "${EXTRACT_TO}/lib/tdjson.lib" ]; then
echo "TDLib already cached at ${EXTRACT_TO}"
echo "Set: export LOCAL_TDLIB_PATH=${EXTRACT_TO}"
exit 0
fi
echo "Downloading TDLib ${TDLIB_VERSION} for ${OS_NAME}-${ARCH_NAME}..."
mkdir -p "${CACHE_DIR}"
cd "${CACHE_DIR}"
# curl with retries and 5min timeout to avoid network issues
if ! curl -fSL --retry 3 --retry-delay 10 --connect-timeout 30 --max-time 300 \
-o "${ZIP_NAME}" "${URL}"; then
echo "Failed to download ${URL}"
echo "Try manually: curl -fSL -o ${ZIP_NAME} '${URL}'"
exit 1
fi
echo "Extracting..."
unzip -o -q "${ZIP_NAME}"
rm -f "${ZIP_NAME}"
# Zip may extract to tdlib-{version}-{os}-{arch}/ or tdlib/; normalize path
for dir in "tdlib-${TDLIB_VERSION}-${OS_NAME}-${ARCH_NAME}" "tdlib"; do
if [ -d "$dir" ]; then
rm -rf "${EXTRACT_TO}"
mv "$dir" "${EXTRACT_TO}"
break
fi
done
if [ ! -d "${EXTRACT_TO}/lib" ]; then
echo "Error: expected lib/ not found after extract"
exit 1
fi
echo "TDLib cached at ${EXTRACT_TO}"
echo "Set: export LOCAL_TDLIB_PATH=${EXTRACT_TO}"
+8 -6
View File
@@ -1,8 +1,9 @@
import { useState } from 'react';
import type { OAuthProviderConfig } from '../../types/oauth';
import { IS_DEV } from '../../utils/config';
import { openUrl } from '../../utils/openUrl';
import { isTauri } from '../../utils/tauriCommands';
import { IS_DEV } from '../../utils/config';
interface OAuthProviderButtonProps {
provider: OAuthProviderConfig;
@@ -25,7 +26,9 @@ const OAuthProviderButton = ({
if (IS_DEV) {
console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${provider.loginUrl}`);
console.log('[dev] In debug mode, OAuth will return JSON response instead of redirect.');
console.log('[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("alphahuman://auth?token=YOUR_TOKEN")');
console.log(
'[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("alphahuman://auth?token=YOUR_TOKEN")'
);
}
setIsLoading(true);
@@ -51,18 +54,17 @@ const OAuthProviderButton = ({
<button
onClick={handleOAuthLogin}
disabled={isDisabled}
className={`w-full flex items-center justify-center space-x-3 ${provider.color} ${provider.hoverColor} ${provider.textColor} font-semibold py-4 rounded-xl transition-all duration-300 hover:shadow-medium hover:scale-[1.02] active:scale-[0.98] disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
>
className={`w-full flex items-center justify-center space-x-3 ${provider.color} ${provider.hoverColor} font-semibold py-4 rounded-xl transition-all duration-300 hover:shadow-medium hover:scale-[1.02] active:scale-[0.98] disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed ${className}`}>
{isLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
) : (
<IconComponent className="w-5 h-5" />
)}
<span>
<span className={provider.textColor}>
{isLoading ? 'Connecting...' : `Continue with ${provider.name}`}
</span>
</button>
);
};
export default OAuthProviderButton;
export default OAuthProviderButton;
+20 -8
View File
@@ -7,28 +7,40 @@ import { BACKEND_URL, IS_DEV } from '../../utils/config';
// Provider Icons
const GoogleIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
const TwitterIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
const GitHubIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
const DiscordIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0189 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z"/>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0189 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z" />
</svg>
);
@@ -73,4 +85,4 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
export const getProviderConfig = (provider: string): OAuthProviderConfig | undefined => {
return oauthProviderConfigs.find(config => config.id === provider);
};
};
+5 -1
View File
@@ -226,11 +226,15 @@ class SkillManager {
name: string,
args: Record<string, unknown>,
): Promise<{ content: Array<{ type: string; text: string }>; isError: boolean }> {
console.log(`[SkillManager] callTool skill="${skillId}" tool="${name}"`);
const runtime = this.runtimes.get(skillId);
if (!runtime) {
console.error(`[SkillManager] callTool failed — skill "${skillId}" has no running runtime`);
throw new Error(`Skill ${skillId} is not running`);
}
return runtime.callTool(name, args);
const result = await runtime.callTool(name, args);
console.log(`[SkillManager] callTool result skill="${skillId}" tool="${name}" isError=${result.isError}`);
return result;
}
/**
+4 -1
View File
@@ -141,7 +141,10 @@ export class SkillRuntime {
content: Array<{ type: string; text: string }>;
isError: boolean;
}> {
return this.transport.request("tools/call", { name, arguments: args });
console.log(`[SkillRuntime] callTool skill="${this.manifest.id}" tool="${name}"`);
const result = await this.transport.request<{ content: Array<{ type: string; text: string }>; isError: boolean }>("tools/call", { name, arguments: args });
console.log(`[SkillRuntime] tools/call response skill="${this.manifest.id}" tool="${name}" isError=${result.isError}`);
return result;
}
/**
+2 -9
View File
@@ -51,11 +51,7 @@ export class SkillTransport {
throw new Error("Skill transport not started");
}
console.log("[skill-transport] Sending request", {
skillId: this.skillId,
method,
hasParams: params !== undefined,
});
console.log("[skill-transport] →", { skillId: this.skillId, method, params });
const result = await invoke<T>("runtime_rpc", {
skillId: this.skillId,
@@ -63,10 +59,7 @@ export class SkillTransport {
params: params ?? {},
});
console.debug("[skill-transport] Received response", {
skillId: this.skillId,
method,
});
console.log("[skill-transport] ←", { skillId: this.skillId, method, result });
return result;
}
+110 -13
View File
@@ -9,9 +9,15 @@ import {
import Markdown from 'react-markdown';
import { useNavigate, useParams } from 'react-router-dom';
import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi';
import { injectAll } from '../lib/ai/injector';
import type { Message } from '../lib/ai/providers/interface';
import { skillManager } from '../lib/skills/manager';
import {
type ChatMessage,
inferenceApi,
type ModelInfo,
type Tool,
} from '../services/api/inferenceApi';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
addInferenceResponse,
@@ -63,6 +69,8 @@ const Conversations = () => {
isLoadingSuggestions,
} = useAppSelector(state => state.thread);
const skillsState = useAppSelector(state => state.skills);
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
@@ -255,14 +263,11 @@ const Conversations = () => {
// Process user message with SOUL + TOOLS injection
let processedUserContent = trimmed;
try {
const userMessage: Message = {
role: 'user',
content: [{ type: 'text', text: trimmed }]
};
const userMessage: Message = { role: 'user', content: [{ type: 'text', text: trimmed }] };
const injectedMessage = await injectAll(userMessage, {
mode: 'context-block',
includeMetadata: false
includeMetadata: false,
});
// Extract the processed text
@@ -277,7 +282,7 @@ const Conversations = () => {
// Continue with original message
}
const chatMessages = [
const chatMessages: ChatMessage[] = [
...historySnapshot.map(m => ({
role: (m.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant',
content: m.content,
@@ -285,13 +290,105 @@ const Conversations = () => {
{ role: 'user' as const, content: processedUserContent },
];
const response = await inferenceApi.createChatCompletion({
model: selectedModel,
messages: chatMessages,
});
// Build tool definitions for ALL ready skills — namespaced as {skillId}__{toolName}
const allSkillTools: Tool[] = Object.entries(skillsState.skills)
.filter(([, skill]) => skill.status === 'ready' && skill.tools?.length)
.flatMap(([skillId, skill]) =>
(skill.tools ?? []).map(t => ({
type: 'function' as const,
function: {
name: `${skillId}__${t.name}`,
description: t.description,
parameters: t.inputSchema as Tool['function']['parameters'],
},
}))
);
const content = response.choices[0]?.message?.content ?? '';
dispatch(addInferenceResponse({ content }));
console.log(
`[Conversations] active skill tools: ${allSkillTools.length}`,
allSkillTools.map(t => t.function.name)
);
// Agentic tool calling loop — handles multi-turn tool execution
const loopMessages = [...chatMessages];
let finalContent = '';
const MAX_TOOL_ROUNDS = 5;
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const response = await inferenceApi.createChatCompletion({
model: selectedModel,
messages: loopMessages,
...(allSkillTools.length > 0 ? { tools: allSkillTools, tool_choice: 'auto' } : {}),
});
console.log('🚀 ~ handleSendMessage ~ response:', response);
const choice = response.choices[0];
if (!choice) break;
const { finish_reason, message } = choice;
if (finish_reason === 'tool_calls' && message.tool_calls?.length) {
// Append assistant message with tool_calls
loopMessages.push({
role: 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls,
});
// Execute each tool and collect results
for (const tc of message.tool_calls) {
const dunderIdx = tc.function.name.indexOf('__');
const skillId = dunderIdx !== -1 ? tc.function.name.substring(0, dunderIdx) : '';
const toolName =
dunderIdx !== -1 ? tc.function.name.substring(dunderIdx + 2) : tc.function.name;
console.log(
`[Conversations] tool_call dispatched — skill="${skillId}" tool="${toolName}" call_id="${tc.id}"`
);
let toolResultContent = '';
try {
let toolArgs: Record<string, unknown> = {};
try {
toolArgs = JSON.parse(tc.function.arguments) as Record<string, unknown>;
} catch {
toolArgs = {};
}
console.log(
`[Conversations] calling skillManager.callTool("${skillId}", "${toolName}")`,
toolArgs
);
const result = await skillManager.callTool(skillId, toolName, toolArgs);
toolResultContent = result.content.map(c => c.text).join('\n');
if (result.isError) {
console.warn(
`[Conversations] tool "${toolName}" returned an error:`,
toolResultContent
);
toolResultContent = `Error: ${toolResultContent}`;
} else {
console.log(
`[Conversations] tool "${toolName}" succeeded:`,
toolResultContent.slice(0, 200)
);
}
} catch (toolErr) {
console.error(`[Conversations] tool "${toolName}" threw:`, toolErr);
toolResultContent = `Tool execution failed: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`;
}
loopMessages.push({ role: 'tool', tool_call_id: tc.id, content: toolResultContent });
}
// Continue loop to get final response after tool results
continue;
}
// Normal (non-tool) response — done
finalContent = message.content ?? '';
break;
}
dispatch(addInferenceResponse({ content: finalContent }));
} catch (err) {
dispatch(removeOptimisticMessages());
const msg =
+34 -2
View File
@@ -2,11 +2,41 @@ import { apiClient } from '../apiClient';
// ── Request types ────────────────────────────────────────────────────────────
export type ChatRole = 'system' | 'user' | 'assistant';
export type ChatRole = 'system' | 'user' | 'assistant' | 'tool';
export interface ChatMessage {
role: ChatRole;
content: string;
/** tool_call_id for role=tool messages */
tool_call_id?: string;
/** tool_calls emitted by the assistant */
tool_calls?: ToolCall[];
}
// ── Tool calling types (OpenAI-compatible) ───────────────────────────────────
export interface ToolFunction {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
}
export interface Tool {
type: 'function';
function: ToolFunction;
}
export interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
export interface ChatCompletionRequest {
@@ -15,6 +45,8 @@ export interface ChatCompletionRequest {
stream?: boolean;
temperature?: number;
max_tokens?: number;
tools?: Tool[];
tool_choice?: 'none' | 'auto' | 'required';
}
export interface TextCompletionRequest {
@@ -41,7 +73,7 @@ export interface ModelsListResponse {
export interface ChatCompletionChoice {
index: number;
message: ChatMessage;
message: ChatMessage & { tool_calls?: ToolCall[] };
finish_reason: string | null;
}
+8 -13
View File
@@ -27,8 +27,8 @@ export class DaemonHealthService {
async setupHealthListener(): Promise<UnlistenFn | null> {
console.log('[DaemonHealth] setupHealthListener() called - starting setup process');
try {
console.log('[DaemonHealth] About to call listen() for alphahuman:health event');
console.log('[DaemonHealth] Setting up alphahuman:health event listener');
// console.log('[DaemonHealth] About to call listen() for alphahuman:health event');
// console.log('[DaemonHealth] Setting up alphahuman:health event listener');
this.healthEventListener = await listen<unknown>('alphahuman:health', event => {
console.log('[DaemonHealth] Received health event:', event.payload);
@@ -44,11 +44,11 @@ export class DaemonHealthService {
console.log('[DaemonHealth] alphahuman:health listener created successfully');
// Start initial timeout
console.log('[DaemonHealth] Starting health timeout');
// console.log('[DaemonHealth] Starting health timeout');
this.startHealthTimeout();
console.log('[DaemonHealth] Health timeout started');
// console.log('[DaemonHealth] Health timeout started');
console.log('[DaemonHealth] Health listener setup complete');
// console.log('[DaemonHealth] Health listener setup complete');
return this.healthEventListener;
} catch (error) {
console.error('[DaemonHealth] Failed to setup health listener:', error);
@@ -147,7 +147,7 @@ export class DaemonHealthService {
// Update the health snapshot in Redux
store.dispatch(updateHealthSnapshot({ userId, healthSnapshot: snapshot }));
console.log('[DaemonHealth] Updated health snapshot for user:', userId, snapshot);
// console.log('[DaemonHealth] Updated health snapshot for user:', userId, snapshot);
} catch (error) {
console.error('[DaemonHealth] Error updating Redux from health:', error);
}
@@ -175,12 +175,7 @@ export class DaemonHealthService {
}, this.HEALTH_TIMEOUT_MS);
// Store timeout ID in Redux for cleanup
store.dispatch(
setHealthTimeoutId({
userId,
timeoutId: this.healthTimeoutId.toString(),
})
);
store.dispatch(setHealthTimeoutId({ userId, timeoutId: this.healthTimeoutId.toString() }));
}
/**
@@ -204,4 +199,4 @@ export class DaemonHealthService {
}
// Export singleton instance
export const daemonHealthService = new DaemonHealthService();
export const daemonHealthService = new DaemonHealthService();