mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
+284
-397
File diff suppressed because it is too large
Load Diff
@@ -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:16–17`).
|
||||
|
||||
### 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
@@ -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'",
|
||||
|
||||
@@ -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/
|
||||
|
||||
Generated
+1
-194
@@ -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"
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Executable
+80
@@ -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}"
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user