Merge branch 'feat/deep-research-setup'

This commit is contained in:
Jon Saad-Falcon
2026-03-27 19:46:55 -07:00
22 changed files with 4715 additions and 93 deletions
@@ -0,0 +1,672 @@
# Channels + Messaging Tabs Implementation Plan (v2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the existing Channels tab with two tabs — **Channels** (data sources with chunk counts and inline "+ Add" setup) and **Messaging** (clear "Text this number from your iPhone" instructions) — on every agent detail page.
**Architecture:** Refactor the existing `ChannelsTab` component in `AgentsPage.tsx` into two separate components. The Channels tab calls existing connector API endpoints + a new chunk-count endpoint. The Messaging tab reuses the existing channel binding API. The `StepByStepPanel` from `SourceConnectFlow.tsx` is extracted for reuse in the Channels tab inline setup.
**Tech Stack:** React 19, TypeScript, existing FastAPI endpoints
---
### Task 1: Add connector chunk counts to backend API
**Files:**
- Modify: `src/openjarvis/server/connectors_router.py`
The Channels tab needs to show chunk counts per source. Add a `chunks` field to the connector summary by querying the KnowledgeStore.
- [ ] **Step 1: Modify `_connector_summary` to include chunk count**
In `src/openjarvis/server/connectors_router.py`, find the `_connector_summary` function (line ~102). Add a chunk count from the KnowledgeStore:
```python
def _connector_summary(connector_id: str, instance: Any) -> Dict[str, Any]:
"""Build the dict returned by GET /connectors."""
chunks = 0
try:
from openjarvis.connectors.store import KnowledgeStore
store = KnowledgeStore()
rows = store._conn.execute(
"SELECT COUNT(*) FROM knowledge_chunks WHERE source = ?",
(connector_id,),
).fetchone()
chunks = rows[0] if rows else 0
except Exception:
pass
return {
"connector_id": connector_id,
"display_name": getattr(instance, "display_name", connector_id),
"auth_type": getattr(instance, "auth_type", "unknown"),
"connected": instance.is_connected(),
"chunks": chunks,
}
```
- [ ] **Step 2: Verify endpoint returns chunks**
```bash
uv run python3 -c "
import httpx
r = httpx.get('http://127.0.0.1:8222/connectors', timeout=5)
for c in r.json().get('connectors', [])[:5]:
print(f'{c[\"connector_id\"]}: connected={c[\"connected\"]}, chunks={c.get(\"chunks\", \"?\")}')
"
```
- [ ] **Step 3: Lint + commit**
```bash
uv run ruff check src/openjarvis/server/connectors_router.py
git add src/openjarvis/server/connectors_router.py
git commit -m "feat: include chunk counts in connector list API response"
```
---
### Task 2: Rewrite Channels tab as data sources view
**Files:**
- Modify: `frontend/src/pages/AgentsPage.tsx`
- Modify: `frontend/src/lib/connectors-api.ts`
Replace the existing `ChannelsTab` (messaging channels) with a new `ChannelsTab` (data sources).
- [ ] **Step 1: Update ConnectorInfo type in connectors-api.ts to include chunks**
In `frontend/src/lib/connectors-api.ts`, find the `ConnectorInfo` type (or wherever it's defined) and add `chunks`:
If there's an interface in `types/connectors.ts`:
```typescript
export interface ConnectorInfo {
connector_id: string;
display_name: string;
auth_type: 'oauth' | 'local' | 'bridge' | 'filesystem';
connected: boolean;
auth_url?: string;
mcp_tools?: string[];
chunks?: number; // ADD THIS
}
```
- [ ] **Step 2: Replace the ChannelsTab component**
In `AgentsPage.tsx`, find the existing `AVAILABLE_CHANNELS` array and `ChannelsTab` component (around lines 1000-1170). Replace the entire block with a new `ChannelsTab` that shows data sources:
```typescript
import { SOURCE_CATALOG, ConnectorMeta, ConnectRequest } from '../types/connectors';
import { listConnectors, connectSource } from '../lib/connectors-api';
function ChannelsTab({ agentId }: { agentId: string }) {
const [connectors, setConnectors] = useState<
Array<{ connector_id: string; display_name: string; connected: boolean; chunks: number }>
>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loadConnectors = useCallback(() => {
listConnectors()
.then((list) =>
setConnectors(
list.map((c) => ({
connector_id: c.connector_id,
display_name: c.display_name,
connected: c.connected,
chunks: (c as any).chunks || 0,
})),
),
)
.catch(() => {});
}, []);
useEffect(() => { loadConnectors(); }, [loadConnectors]);
const handleConnect = async (id: string, req: ConnectRequest) => {
setLoading(true);
try {
await connectSource(id, req);
setExpandedId(null);
loadConnectors();
} catch {
// error handling
} finally {
setLoading(false);
}
};
const connected = connectors.filter((c) => c.connected);
const notConnected = connectors.filter((c) => !c.connected);
// Merge with SOURCE_CATALOG for icons/descriptions
const getMeta = (id: string) =>
SOURCE_CATALOG.find((s) => s.connector_id === id);
const iconMap: Record<string, string> = {
gmail: '✉️', gmail_imap: '✉️', slack: '#',
imessage: '💬', gdrive: '📁', notion: '📄',
obsidian: '📁', granola: '🎙️', gcalendar: '📅',
gcontacts: '📇', outlook: '✉️', apple_notes: '🍎',
dropbox: '📦', whatsapp: '📱',
};
return (
<div style={{ padding: 16 }}>
<div style={{
color: 'var(--color-text-secondary)',
fontSize: 12, marginBottom: 12,
}}>
Data sources your agent can search
</div>
{/* Connected sources grid */}
{connected.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8, marginBottom: 12,
}}>
{connected.map((c) => (
<div
key={c.connector_id}
style={{
background: 'var(--color-bg-secondary)',
border: '1px solid #2a5a3a',
borderRadius: 6, padding: '10px 12px',
display: 'flex', alignItems: 'center', gap: 8,
}}
>
<span>{iconMap[c.connector_id] || '🔗'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>
{c.display_name}
</div>
<div style={{ fontSize: 10, color: '#4ade80' }}>
{c.chunks.toLocaleString()} chunks
</div>
</div>
<span style={{ color: '#4ade80', fontSize: 12 }}></span>
</div>
))}
</div>
)}
{/* Not connected grid */}
{notConnected.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
}}>
{notConnected.map((c) => {
const meta = getMeta(c.connector_id);
const isExpanded = expandedId === c.connector_id;
return (
<div
key={c.connector_id}
style={{
background: 'var(--color-bg-secondary)',
border: '1px dashed var(--color-border)',
borderRadius: 6, overflow: 'hidden',
opacity: isExpanded ? 1 : 0.6,
gridColumn: isExpanded ? '1 / -1' : undefined,
}}
>
<div
style={{
padding: '10px 12px', display: 'flex',
alignItems: 'center', gap: 8,
cursor: 'pointer',
}}
onClick={() =>
setExpandedId(isExpanded ? null : c.connector_id)
}
>
<span>{iconMap[c.connector_id] || '🔗'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500,
color: 'var(--color-text-secondary)' }}>
{c.display_name}
</div>
<div style={{ fontSize: 10,
color: 'var(--color-text-secondary)' }}>
Not connected
</div>
</div>
<span style={{
color: '#7c3aed', fontSize: 11, fontWeight: 500,
}}>
{isExpanded ? '✕ Close' : '+ Add'}
</span>
</div>
{/* Inline setup panel */}
{isExpanded && meta?.steps && (
<div style={{
borderTop: '1px solid var(--color-border)',
padding: 12,
}}>
{meta.steps.map((step, i) => (
<div
key={i}
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 6, padding: 10,
marginBottom: 8,
}}
>
<div style={{
color: '#7c3aed', fontSize: 10,
fontWeight: 600, marginBottom: 3,
}}>
STEP {i + 1}
</div>
<div style={{
fontSize: 12, marginBottom: step.url ? 4 : 0,
}}>
{step.label}
</div>
{step.url && (
<a
href={step.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: '#60a5fa', fontSize: 11,
textDecoration: 'underline',
}}
>
{step.urlLabel || 'Open'}
</a>
)}
</div>
))}
{meta.inputFields && (
<InlineConnectForm
fields={meta.inputFields}
loading={loading}
onSubmit={(req) =>
handleConnect(c.connector_id, req)
}
/>
)}
<div style={{
fontSize: 10, color: 'var(--color-text-secondary)',
textAlign: 'center', marginTop: 8,
}}>
🔒 Read-only access · No data leaves your device
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
function InlineConnectForm({
fields,
loading,
onSubmit,
}: {
fields: Array<{ name: string; placeholder: string; type?: string }>;
loading: boolean;
onSubmit: (req: ConnectRequest) => void;
}) {
const [inputs, setInputs] = useState<Record<string, string>>({});
const update = (name: string, value: string) =>
setInputs((p) => ({ ...p, [name]: value }));
const allFilled = fields.every((f) => inputs[f.name]?.trim());
const submit = () => {
const req: ConnectRequest = {};
for (const f of fields) {
if (f.name === 'email') req.email = inputs.email;
else if (f.name === 'password') req.password = inputs.password;
else if (f.name === 'token') req.token = inputs.token;
else if (f.name === 'path') req.path = inputs.path;
}
if (req.email && req.password) {
req.token = `${req.email}:${req.password}`;
req.code = req.token;
}
if (req.token && !req.code) req.code = req.token;
onSubmit(req);
};
return (
<div>
{fields.map((f) => (
<input
key={f.name}
value={inputs[f.name] || ''}
onChange={(e) => update(f.name, e.target.value)}
placeholder={f.placeholder}
type={f.type || 'text'}
style={{
width: '100%', padding: '7px 10px',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 4, color: 'var(--color-text)',
fontSize: 12, marginBottom: 6,
boxSizing: 'border-box',
}}
/>
))}
<button
onClick={submit}
disabled={loading || !allFilled}
style={{
width: '100%', padding: 8,
background: loading || !allFilled ? '#444' : '#7c3aed',
color: 'white', border: 'none',
borderRadius: 6, fontSize: 12, cursor: 'pointer',
}}
>
{loading ? 'Connecting...' : 'Connect'}
</button>
</div>
);
}
```
- [ ] **Step 3: Update DETAIL_TABS to rename and add Messaging**
Find `DETAIL_TABS` (line ~1553). Update to:
```typescript
const DETAIL_TABS = [
{ id: 'overview', label: 'Overview', icon: Activity },
{ id: 'interact', label: 'Interact', icon: MessageSquare },
{ id: 'channels', label: 'Channels', icon: Database },
{ id: 'messaging', label: 'Messaging', icon: Wifi },
{ id: 'tasks', label: 'Tasks', icon: ListTodo },
{ id: 'memory', label: 'Memory', icon: Brain },
{ id: 'learning', label: 'Learning', icon: Settings },
{ id: 'logs', label: 'Logs', icon: FileText },
] as const;
```
Add `Database` to lucide-react imports. Update detailTab type:
```typescript
const [detailTab, setDetailTab] = useState<
'overview' | 'interact' | 'channels' | 'messaging' | 'tasks' | 'memory' | 'learning' | 'logs'
>('overview');
```
- [ ] **Step 4: Add MessagingTab component and wire both tabs**
Add the `MessagingTab` component (reuses the old `AVAILABLE_CHANNELS` data but with clear UX):
```typescript
const MESSAGING_CHANNELS = [
{
type: 'imessage', name: 'iMessage', icon: '💬',
description: 'Text from your iPhone, iPad, or Mac',
activeTemplate: (id: string) => `Text ${id} from your iPhone`,
setupLabel: 'Phone number or email for the agent to use',
placeholder: '+15551234567',
},
{
type: 'slack', name: 'Slack', icon: '#',
description: 'Message from any Slack workspace',
activeTemplate: (id: string) => `DM @jarvis in ${id}`,
setupLabel: 'Slack bot token (xoxb-...)',
placeholder: 'xoxb-...',
},
{
type: 'whatsapp', name: 'WhatsApp', icon: '📱',
description: 'Message via WhatsApp',
activeTemplate: (id: string) => `Message ${id} on WhatsApp`,
setupLabel: 'WhatsApp access token',
placeholder: 'Access token',
},
{
type: 'twilio', name: 'SMS (Twilio)', icon: '📨',
description: 'Text from any phone via Twilio',
activeTemplate: (id: string) => `Text ${id} from any phone`,
setupLabel: 'Twilio phone number',
placeholder: '+15551234567',
},
];
function MessagingTab({ agentId }: { agentId: string }) {
const [bindings, setBindings] = useState<ChannelBinding[]>([]);
const [setupType, setSetupType] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const loadBindings = useCallback(() => {
fetchAgentChannels(agentId).then(setBindings).catch(() => setBindings([]));
}, [agentId]);
useEffect(() => { loadBindings(); }, [loadBindings]);
const handleSetup = async (channelType: string) => {
if (!inputValue.trim()) return;
setLoading(true);
try {
await bindAgentChannel(agentId, channelType, {
identifier: inputValue.trim(),
});
setSetupType(null);
setInputValue('');
loadBindings();
} catch { /* */ } finally { setLoading(false); }
};
const handleRemove = async (bindingId: string) => {
try {
await unbindAgentChannel(agentId, bindingId);
loadBindings();
} catch { /* */ }
};
return (
<div style={{ padding: 16 }}>
<div style={{
color: 'var(--color-text-secondary)',
fontSize: 12, marginBottom: 12,
}}>
Talk to your agent from your phone or other platforms
</div>
{MESSAGING_CHANNELS.map((ch) => {
const binding = bindings.find((b) => b.channel_type === ch.type);
const identifier = binding?.config?.identifier as string || binding?.session_id || '';
const isSetup = setupType === ch.type;
return (
<div
key={ch.type}
style={{
background: 'var(--color-bg-secondary)',
border: binding
? '1px solid #2a5a3a'
: '1px dashed var(--color-border)',
borderRadius: 8, marginBottom: 8,
opacity: binding ? 1 : 0.6,
overflow: 'hidden',
}}
>
<div style={{
display: 'flex', alignItems: 'center',
padding: '12px 14px',
}}>
<span style={{ fontSize: 16, marginRight: 10 }}>{ch.icon}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{ch.name}</div>
<div style={{ fontSize: 11,
color: binding ? '#4ade80' : 'var(--color-text-secondary)',
}}>
{binding
? ch.activeTemplate(identifier)
: 'Not set up'}
</div>
</div>
{binding ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
background: '#2a5a3a', color: '#4ade80',
padding: '2px 8px', borderRadius: 10,
fontSize: 10,
}}>Active</span>
<button
onClick={() => handleRemove(binding.id)}
style={{
fontSize: 10, padding: '2px 8px',
background: 'transparent',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)',
borderRadius: 4, cursor: 'pointer',
}}
>Remove</button>
</div>
) : (
<button
onClick={() => {
setSetupType(isSetup ? null : ch.type);
setInputValue('');
}}
style={{
fontSize: 10, padding: '3px 12px',
background: '#7c3aed', color: 'white',
border: 'none', borderRadius: 5,
cursor: 'pointer',
}}
>
{isSetup ? 'Cancel' : 'Set Up'}
</button>
)}
</div>
{isSetup && (
<div style={{
borderTop: '1px solid var(--color-border)',
padding: '10px 14px',
background: 'var(--color-bg)',
}}>
<div style={{
fontSize: 11, marginBottom: 6,
color: 'var(--color-text-secondary)',
}}>{ch.setupLabel}</div>
<div style={{ display: 'flex', gap: 6 }}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={ch.placeholder}
style={{
flex: 1, padding: '6px 10px',
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
borderRadius: 4, color: 'var(--color-text)',
fontSize: 12,
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSetup(ch.type);
}}
/>
<button
onClick={() => handleSetup(ch.type)}
disabled={loading || !inputValue.trim()}
style={{
fontSize: 11, padding: '6px 14px',
background: '#7c3aed', color: 'white',
border: 'none', borderRadius: 4,
cursor: 'pointer',
opacity: loading || !inputValue.trim() ? 0.5 : 1,
}}
>
{loading ? '...' : 'Connect'}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
```
Wire both tabs in the content rendering section:
```typescript
{detailTab === 'channels' && (
<ChannelsTab agentId={selectedAgent.id} />
)}
{detailTab === 'messaging' && (
<MessagingTab agentId={selectedAgent.id} />
)}
```
- [ ] **Step 5: Add required imports**
At the top of `AgentsPage.tsx`:
```typescript
import { Database } from 'lucide-react';
import { SOURCE_CATALOG } from '../types/connectors';
import type { ConnectRequest } from '../types/connectors';
import { listConnectors, connectSource } from '../lib/connectors-api';
```
- [ ] **Step 6: TypeScript check + build**
```bash
cd frontend && npx tsc --noEmit 2>&1 | head -20
cd frontend && npm run build 2>&1 | tail -10
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/pages/AgentsPage.tsx frontend/src/lib/connectors-api.ts frontend/src/types/connectors.ts
git commit -m "feat: replace Channels tab with data sources + add Messaging tab"
```
---
### Task 3: E2E test both tabs
**Files:** None (manual testing)
- [ ] **Step 1: Restart server to pick up new static files**
```bash
# Kill existing server, rebuild frontend, restart
kill $(pgrep -f "jarvis serve") 2>/dev/null
cd frontend && npm run build
uv run jarvis serve --port 8222 --model qwen3.5:9b &
```
- [ ] **Step 2: Test Channels tab**
1. Open http://127.0.0.1:8222 → Agents → open agent
2. Click "Channels" tab
3. Verify connected sources show with green border + chunk counts
4. Verify unconnected sources show with dashed border + "+ Add"
5. Click "+ Add" on an unconnected source → verify step-by-step instructions appear inline
- [ ] **Step 3: Test Messaging tab**
1. Click "Messaging" tab
2. Verify active channels show with "Text +1... from your iPhone" instructions
3. Verify inactive channels show "Set Up" button
4. Click "Set Up" → verify input field appears with clear label
- [ ] **Step 4: Push**
```bash
git push origin feat/deep-research-setup
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
# Channels + Messaging Tabs — Unified Agent Setup
## Goal
Replace the current disconnected setup experience with two new tabs on every agent detail page: **Channels** (data sources the agent can search) and **Messaging** (ways to talk to the agent from your phone). Clicking "+ Add" on an unconnected source opens step-by-step setup inline.
## Tab Structure
```
Overview | Interact | Channels | Messaging | Tasks | Memory | Learning | Logs
```
### Channels Tab
Shows all data source connectors with their connection status and chunk counts.
**Connected sources** — green border, checkmark, chunk count:
- Gmail (1,076 chunks) ✓
- iMessage (52,956 chunks) ✓
- Granola (1,144 chunks) ✓
- etc.
**Not connected** — dashed border, "+ Add" button:
- Google Drive — Not connected [+ Add]
- Calendar — Not connected [+ Add]
- Outlook — Not connected [+ Add]
Clicking "+ Add" expands inline with the StepByStepPanel (per-connector instructions, links to settings pages, input fields for credentials). Same flow currently in the setup wizard but embedded in the tab.
**Data shown per connected source:**
- Icon + name
- Chunk count (from KnowledgeStore)
- Connection status
- Disconnect option (on hover/click)
### Messaging Tab
Shows ways to reach the agent from your phone or other platforms.
**Active channels** — green border, clear instructions:
- iMessage: "Text +1 (408) 981-9553 from your iPhone" [Active]
- Slack: "DM @jarvis in your workspace" [Active]
**Not set up** — dashed border, "Set Up" button:
- WhatsApp — Not set up [Set Up]
- SMS (Twilio) — Not set up [Set Up]
The key UX principle: **tell the user what to do, don't ask them for config.** Active channels show the action ("Text this number"), not the plumbing ("Enter phone number to monitor").
### What Changes vs Current
| Before | After |
|--------|-------|
| Separate "Channels" tab with confusing "phone number to monitor" prompt | **Messaging** tab with clear "Text this number" instructions |
| Setup wizard only during onboarding, hidden after | **Channels** tab shows all sources with inline "+ Add" |
| No way to see connected data sources on agent page | **Channels** tab shows all with chunk counts |
| Connector setup disconnected from agent | Unified on the agent page |
### What Does NOT Change
- Overview tab (stats, config, instruction)
- Interact tab (chat)
- Tasks, Memory, Learning, Logs tabs
- Backend API endpoints (already exist)
- Connector sync logic
- iMessage daemon / ChannelBridge / webhook routes
### Backend Requirements
One new endpoint needed:
`GET /v1/connectors/status` — returns all connectors with connection status AND chunk counts from KnowledgeStore. The Channels tab calls this to populate the grid.
Everything else (bind/unbind channel, connect/disconnect source, sync status) already exists.
## Test Plan
- Channels tab shows connected sources with chunk counts
- Channels tab shows unconnected sources with "+ Add"
- Clicking "+ Add" shows step-by-step instructions inline
- Completing setup adds the source and starts ingestion
- Messaging tab shows active channels with clear action text
- Messaging tab shows "Set Up" for inactive channels
- Setting up iMessage starts the daemon
- Both tabs appear on ALL agents, not just DeepResearch
@@ -0,0 +1,120 @@
# Channels Tab + Seamless Connector Setup
## Goal
Add a Channels tab to every agent for bidirectional messaging (iMessage, Slack, WhatsApp, SMS), and build per-connector step-by-step setup guides in the wizard with one-click OAuth where available.
## Sub-project 1: Channels Tab on Every Agent
### What it does
A new "Channels" tab on the agent detail page showing messaging interfaces to reach the agent. Users can connect/disconnect channels from the UI. Appears on ALL agents.
### Frontend
Add a 7th tab "Channels" to the agent detail view in `AgentsPage.tsx`. Content:
- Header: "Talk to this agent from" + "+ Add Channel" button
- Channel list: iMessage, Slack, WhatsApp, SMS (Twilio)
- Each channel shows: icon, name, description, status badge (Active/Not connected)
- Connected channels expand to show: monitored contact/workspace, instructions, Stop/Settings buttons
- Disconnected channels show a "Connect" button
**"+ Add Channel" / "Connect" flow:**
- Opens a modal/inline form specific to the channel type:
- **iMessage:** prompt for phone number or contact to monitor → starts daemon
- **Slack:** prompt for bot token → registers webhook
- **WhatsApp:** prompt for access token + phone number ID → registers webhook
- **SMS:** prompt for Twilio account SID + auth token + phone number → registers webhook
### Backend
- `POST /v1/managed-agents/{id}/channels` — bind a channel (creates binding + starts daemon/webhook)
- `DELETE /v1/managed-agents/{id}/channels/{binding_id}` — unbind (stops daemon/webhook)
- `AgentManager.add_channel_binding()` already exists — wire it to the new endpoint
- For iMessage: the endpoint starts/stops the `imessage_daemon` via the existing `run_daemon`/`stop_daemon` functions
- For Slack/WhatsApp/SMS: the endpoint updates `ChannelBridge` channel config
### No new daemon code
`imessage_daemon.py`, `ChannelBridge`, `webhook_routes.py`, and `channels_cmd.py` already exist. This sub-project wires them to the frontend UI.
---
## Sub-project 2: Seamless Connector Setup
### What it does
Replace the current generic "paste token" connect flow with per-connector step-by-step guides. Two patterns:
**Pattern A — Step-by-step guide (for token/password auth):**
Numbered steps with direct links to settings pages, then paste credentials.
**Pattern B — One-click OAuth + manual fallback:**
Primary "Authorize" button opens the service in browser. Manual token paste as secondary option.
### Per-connector flows
| Connector | Pattern | UX |
|-----------|---------|-----|
| Gmail IMAP | A | Steps: enable 2FA → generate app password → paste email:password |
| Outlook | A | Steps: enable 2FA → generate app password → paste email:password |
| Slack | B | "Open Slack App Settings" button → paste bot token (xoxb-...) |
| Notion | B | "Open Notion Integrations" button → paste token (ntn_...) → remind to share pages |
| Granola | A | Steps: open Granola app → Settings → API → paste key (grn_...) |
| Google Drive | B | "Authorize" opens OAuth consent URL → localhost callback captures code → exchanges for tokens |
| Google Calendar | B | Same as Drive (shared OAuth client, different scope) |
| Google Contacts | B | Same as Drive (shared OAuth client, different scope) |
| Obsidian | Filesystem | Path input field |
| Apple Notes | Auto | Check Full Disk Access, auto-connect |
| iMessage | Auto | Check Full Disk Access, auto-connect |
### Google OAuth proper flow (new)
The current Gmail OAuth connector saves the raw auth code without exchanging it. Fix:
1. Add `OAuthCallbackServer` — a tiny localhost HTTP server at `http://localhost:8789/callback` that:
- Waits for Google to redirect with `?code=...`
- Exchanges the code for access_token + refresh_token via `POST https://oauth2.googleapis.com/token`
- Stores tokens in `~/.openjarvis/connectors/{connector}.json`
- Returns a "Success! You can close this tab" page to the browser
2. Update `oauth.py` with `exchange_google_token(code, client_id, client_secret, redirect_uri)` function
3. Update `handle_callback()` in Gmail, Drive, Calendar, Contacts connectors to use the exchange flow
4. Google OAuth client ID/secret stored in `~/.openjarvis/config.toml` under `[connectors.google]` — user provides their own from Google Cloud Console
### Frontend changes
In `SourceConnectFlow.tsx`, replace the generic OAuth panel with connector-specific panels:
- Each connector gets its own instructions component
- Step-by-step connectors show numbered cards with links
- OAuth connectors show the "Authorize" button + manual fallback input
- All connectors show a "Read-only access · No data leaves your device" badge
### What does NOT change
- SourcePicker (already lists all connectors)
- IngestDashboard (already shows progress)
- ReadyScreen (already shows suggestions)
- Backend connector sync logic (already works)
- KnowledgeStore / ingestion pipeline
## Test Plan
### Channels Tab
- Channels tab appears on all agents (not just DeepResearch)
- Binding iMessage channel starts daemon and shows "Active" status
- Unbinding stops daemon
- Channel status updates in real-time
- Multiple agents can bind different channels
### Connector Setup
- Gmail IMAP: step-by-step flow → paste credentials → connected
- Notion: one-click opens integration page → paste token → connected
- Slack: paste bot token → connected
- Google Drive: OAuth consent URL opens → localhost callback exchanges token → connected
- Setup wizard completes with all connected sources ingested
- Read-only guarantee maintained across all connectors
+351
View File
@@ -0,0 +1,351 @@
# Channels & Connectors
OpenJarvis connects to your personal data sources to power Deep Research. This guide walks you through setting up each connector and troubleshooting common issues.
---
## Gmail
**What it indexes:** Email messages and threads from your Gmail inbox.
### Setup (App Password — recommended)
1. **Enable 2-Factor Authentication** on your Google account:
[Open Google Security Settings →](https://myaccount.google.com/signinoptions/two-step-verification)
2. **Generate an App Password** for "Mail":
[Open App Passwords →](https://myaccount.google.com/apppasswords)
- Select "Mail" as the app
- Copy the 16-character password (e.g. `qpde kebj evhy zljc`)
3. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → your agent → Channels tab → Gmail → Reconnect
- CLI: `uv run jarvis connect gmail_imap`
- Enter your email address and the app password
### Troubleshooting
| Issue | Solution |
|-------|----------|
| "App Passwords" page not available | Enable 2-Factor Authentication first |
| Login failed | Make sure you're using the app password, not your regular Google password |
| No emails syncing | Check that IMAP is enabled: [Gmail Settings → Forwarding and POP/IMAP](https://mail.google.com/mail/u/0/#settings/fwdandpop) |
| Only getting recent emails | By default, the last 500 emails are synced. Increase with `max_messages` config |
---
## Google Drive
**What it indexes:** Documents, Sheets, PDFs, and other files from your Drive.
### Setup
1. **Go to Google Cloud Console** and create a project (or use an existing one):
[Create Project →](https://console.cloud.google.com/projectcreate)
2. **Enable the Google Drive API:**
[Enable Drive API →](https://console.cloud.google.com/apis/library/drive.googleapis.com)
3. **Create OAuth credentials:**
[Open Credentials →](https://console.cloud.google.com/apis/credentials)
- Click "Create Credentials" → "OAuth 2.0 Client ID"
- Choose "Desktop app" as the application type
- Copy the **Client ID** and **Client Secret**
4. **Add yourself as a test user** (required while app is unverified):
[Open OAuth Consent Screen →](https://console.cloud.google.com/apis/credentials/consent)
- Scroll to "Test users" → click "+ Add Users"
- Add your Gmail address (e.g. `jonsaadfalcon@gmail.com`)
5. **Add the redirect URI:**
[Open Credentials →](https://console.cloud.google.com/apis/credentials)
- Click your OAuth Client → Authorized redirect URIs
- Add: `http://localhost:8789/callback`
6. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → Channels tab → Google Drive → paste Client ID and Client Secret
- Your browser will open Google's consent page → grant read-only access
- You'll see "Authorization successful!" → Drive data starts syncing
### Troubleshooting
| Issue | Solution |
|-------|----------|
| "Access blocked: app has not completed verification" | Add your email as a test user (step 4 above) |
| "Error 400: redirect_uri_mismatch" | Add `http://localhost:8789/callback` as an authorized redirect URI (step 5) |
| "Error 403: access_denied" | Make sure you selected "Desktop app" when creating the OAuth client |
| Connected but 0 files | Check that you granted Drive read access in the consent screen. Try reconnecting. |
| Token expired | Access tokens expire after 1 hour. Reconnect to get a new one. (Auto-refresh coming soon.) |
---
## Google Calendar
**What it indexes:** Events, meetings, and calendar entries.
### Setup
Same as Google Drive — use the same Google Cloud project and OAuth client.
1. **Enable the Google Calendar API:**
[Enable Calendar API →](https://console.cloud.google.com/apis/library/calendar-json.googleapis.com)
2. Follow steps 3-6 from the Google Drive section above (same Client ID/Secret works)
### Troubleshooting
Same as Google Drive. Additionally:
| Issue | Solution |
|-------|----------|
| Only seeing primary calendar | The connector reads all calendars you have access to |
| Missing shared calendars | Shared calendars from other users may require additional permissions |
---
## Google Contacts
**What it indexes:** People, phone numbers, emails, and contact information.
### Setup
Same as Google Drive — use the same Google Cloud project and OAuth client.
1. **Enable the People API:**
[Enable People API →](https://console.cloud.google.com/apis/library/people.googleapis.com)
2. Follow steps 3-6 from the Google Drive section above
---
## Slack
**What it indexes:** Channel messages and threads from your Slack workspace.
### Setup
1. **Go to Slack App Settings:**
[Open Slack Apps →](https://api.slack.com/apps)
2. **Create a new app** (or use existing):
- Click "Create New App" → "From scratch"
- Name it (e.g. "OpenJarvis") and select your workspace
3. **Add OAuth scopes:**
- Go to "OAuth & Permissions"
- Under "Bot Token Scopes", add: `channels:history`, `channels:read`, `users:read`
4. **Install to workspace:**
- Click "Install to Workspace" → Authorize
- Copy the **Bot User OAuth Token** (starts with `xoxb-`)
5. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → Channels tab → Slack → paste the bot token
- CLI: `uv run jarvis connect slack`
### Troubleshooting
| Issue | Solution |
|-------|----------|
| "not_allowed_token_type" | Make sure you're using the **Bot** token (`xoxb-...`), not a user token (`xoxp-`) or session token (`xoxe-`) |
| No messages found | The bot can only see channels it's been added to. Invite it: `/invite @OpenJarvis` in the channel |
| Missing private channels | Add `groups:history` and `groups:read` scopes, then reinstall the app |
---
## Notion
**What it indexes:** Pages, databases, and their content.
### Setup
1. **Create an internal integration:**
[Open Notion Integrations →](https://www.notion.so/profile/integrations)
- Click "New integration"
- Name it (e.g. "OpenJarvis")
- Select your workspace
- Copy the **Internal Integration Secret** (starts with `ntn_`)
2. **Share pages with your integration:**
- Open any Notion page you want indexed
- Click "..." (top right) → "Connections" → find your integration → click it
- Repeat for each page or database
3. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → Channels tab → Notion → paste the token
- CLI: `uv run jarvis connect notion`
### Troubleshooting
| Issue | Solution |
|-------|----------|
| 0 pages found | You must explicitly share pages with the integration (step 2). The integration can only see pages you've connected. |
| Missing database content | Share the database page itself, not just individual entries |
| Token expired | Notion integration tokens don't expire. If it stops working, regenerate at the integrations page. |
---
## Granola
**What it indexes:** AI meeting notes and transcripts from the Granola app.
### Setup
1. **Open the Granola desktop app** → Settings → API
2. **Copy your API key** (starts with `grn_`)
3. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → Channels tab → Granola → paste the key
- CLI: `uv run jarvis connect granola`
### Troubleshooting
| Issue | Solution |
|-------|----------|
| No API key in settings | Granola API is available on Business and Enterprise plans |
| 0 meeting notes | Check that you have meetings recorded in Granola |
---
## Apple Notes
**What it indexes:** Notes from the macOS Notes app.
### Setup (automatic)
1. **Grant Full Disk Access** to your terminal app:
- Open System Settings → Privacy & Security → Full Disk Access
- Enable access for Terminal, iTerm, Warp, or the OpenJarvis desktop app
2. Apple Notes is detected automatically when Full Disk Access is granted
### Troubleshooting
| Issue | Solution |
|-------|----------|
| "Not connected" despite Full Disk Access | Restart your terminal app after granting access |
| Notes content is garbled | Some very old notes may have encoding issues. Most notes should be clean. |
| Missing notes | Only notes stored locally or in iCloud are indexed. Notes in third-party accounts (Gmail, Exchange) may not appear. |
---
## iMessage
**What it indexes:** Text messages from the macOS Messages app.
### Setup (automatic)
Same as Apple Notes — requires Full Disk Access.
### Troubleshooting
| Issue | Solution |
|-------|----------|
| "Not connected" | Grant Full Disk Access (see Apple Notes above) |
| Very slow sync | iMessage databases can be large (50K+ messages). First sync may take 10-30 seconds. |
| Missing recent messages | Messages sync from the local database. If Messages.app hasn't synced from iCloud yet, recent messages may be missing. |
---
## Outlook / Microsoft 365
**What it indexes:** Email messages via IMAP.
### Setup
1. **Enable 2-Factor Authentication** on your Microsoft account:
[Open Microsoft Security →](https://account.microsoft.com/security)
2. **Generate an App Password:**
- Go to Security → Advanced security options → App passwords
- Create a new app password
3. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → Channels tab → Outlook → enter email + app password
- CLI: `uv run jarvis connect outlook`
### Troubleshooting
| Issue | Solution |
|-------|----------|
| Login failed | Use the app password, not your regular Microsoft password |
| "Authentication failed" | Some Microsoft 365 organizations disable IMAP. Check with your IT admin. |
| Only getting Inbox | Currently only the Inbox folder is synced |
---
## Obsidian
**What it indexes:** Markdown files from your Obsidian vault.
### Setup
1. Find your Obsidian vault folder (the folder containing the `.obsidian` directory)
2. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → Channels tab → Obsidian → paste the vault path
- CLI: `uv run jarvis connect obsidian --path /path/to/vault`
### Troubleshooting
| Issue | Solution |
|-------|----------|
| "Not connected" | Double-check the path exists and contains a `.obsidian` folder |
| Missing files | Only `.md`, `.markdown`, and `.txt` files are indexed. Binary files and images are skipped. |
| Slow sync for large vaults | Vaults with 1000+ files may take a minute to sync |
---
## Dropbox
**What it indexes:** Files and documents from your Dropbox.
### Setup
1. **Create a Dropbox app:**
[Open Dropbox App Console →](https://www.dropbox.com/developers/apps/create)
- Choose "Scoped access" → "Full Dropbox"
2. **Set permissions:**
- Under Permissions tab, enable `files.metadata.read` and `files.content.read`
3. **Generate an access token:**
- Go to Settings tab → "Generated access token" → Generate
4. **Connect in OpenJarvis:**
- Desktop/Browser: Agents → Channels tab → Dropbox → paste the token
- CLI: `uv run jarvis connect dropbox`
### Troubleshooting
| Issue | Solution |
|-------|----------|
| "Invalid access token" | Dropbox short-lived tokens expire after 4 hours. Generate a new one. |
| Missing files | Check that you enabled the correct permissions (step 2) |
---
## General Troubleshooting
### All connectors
| Issue | Solution |
|-------|----------|
| "Connected — no data synced yet" | The connector authenticated but hasn't synced. Try running `uv run jarvis deep-research-setup --skip-chat` to trigger a sync. |
| Data seems stale | Connectors sync on demand. Run the setup command or click "Reconnect" to re-sync. |
| Want to reset a connector | Click "Reconnect" in the Channels tab, or delete the credential file at `~/.openjarvis/connectors/{connector}.json` |
### Where credentials are stored
All credentials are saved locally at `~/.openjarvis/connectors/` with file permissions `0600` (owner-only read/write). No credentials are sent to any server — everything stays on your device.
```
~/.openjarvis/connectors/
├── gmail_imap.json # Gmail email + app password
├── gdrive.json # Google Drive OAuth tokens
├── gcalendar.json # Google Calendar OAuth tokens
├── gcontacts.json # Google Contacts OAuth tokens
├── slack.json # Slack bot token
├── notion.json # Notion integration token
├── granola.json # Granola API key
├── outlook.json # Outlook email + app password
└── dropbox.json # Dropbox access token
```
@@ -9,7 +9,7 @@ import {
} from 'lucide-react';
import { SOURCE_CATALOG } from '../../types/connectors';
import { connectSource } from '../../lib/connectors-api';
import type { ConnectRequest } from '../../types/connectors';
import type { ConnectRequest, ConnectorMeta } from '../../types/connectors';
// ---------------------------------------------------------------------------
// Types
@@ -285,6 +285,178 @@ function LocalPanel({
);
}
// ---------------------------------------------------------------------------
// StepByStepPanel — per-connector numbered setup instructions
// ---------------------------------------------------------------------------
function StepByStepPanel({
connector,
onConnect,
onSkip,
isConnecting,
}: {
connector: ConnectorMeta;
onConnect: (req: ConnectRequest) => void;
onSkip: () => void;
isConnecting: boolean;
}) {
const [inputs, setInputs] = useState<Record<string, string>>({});
const steps = connector.steps || [];
const fields = connector.inputFields || [];
const updateInput = (name: string, value: string) => {
setInputs((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = () => {
const req: ConnectRequest = {};
for (const field of fields) {
if (field.name === 'email') req.email = inputs.email;
else if (field.name === 'password') req.password = inputs.password;
else if (field.name === 'token') req.token = inputs.token;
else if (field.name === 'path') req.path = inputs.path;
}
// For email+password connectors, also set token as email:password
if (req.email && req.password) {
req.token = `${req.email}:${req.password}`;
req.code = req.token;
}
if (req.token && !req.code) {
req.code = req.token;
}
onConnect(req);
};
const allFilled = fields.every((f) => inputs[f.name]?.trim());
return (
<div style={{ padding: '0 4px' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
marginBottom: 16,
}}>
<span style={{ fontSize: 20 }}>
{connector.icon === 'Mail' ? '\u2709\uFE0F' :
connector.icon === 'Hash' ? '#\uFE0F\u20E3' :
connector.icon === 'FileText' ? '\uD83D\uDCC4' :
connector.icon === 'Mic' ? '\uD83C\uDF99\uFE0F' :
connector.icon === 'FolderOpen' ? '\uD83D\uDCC1' : '\uD83D\uDD17'}
</span>
<span style={{ fontWeight: 600, fontSize: 15 }}>
{connector.display_name}
</span>
</div>
{steps.map((step, i) => (
<div
key={i}
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: 12,
marginBottom: 10,
}}
>
<div style={{
color: '#7c3aed', fontSize: 11,
fontWeight: 600, marginBottom: 4,
}}>
STEP {i + 1}
</div>
<div style={{
color: 'var(--color-text)',
fontSize: 13, marginBottom: step.url ? 6 : 0,
}}>
{step.label}
</div>
{step.url && (
<a
href={step.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: '#60a5fa', fontSize: 12,
textDecoration: 'underline',
}}
>
{step.urlLabel || 'Open'} &rarr;
</a>
)}
</div>
))}
{fields.length > 0 && (
<div style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: 12,
marginBottom: 10,
}}>
{fields.map((field) => (
<input
key={field.name}
value={inputs[field.name] || ''}
onChange={(e) => updateInput(field.name, e.target.value)}
placeholder={field.placeholder}
type={field.type || 'text'}
style={{
width: '100%',
padding: '8px 10px',
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
borderRadius: 4,
color: 'var(--color-text)',
fontSize: 13,
marginBottom: 8,
boxSizing: 'border-box',
}}
/>
))}
</div>
)}
<div style={{
fontSize: 11, color: 'var(--color-text-secondary)',
marginBottom: 12, textAlign: 'center',
}}>
Read-only access &middot; No data leaves your device
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleSubmit}
disabled={isConnecting || (fields.length > 0 && !allFilled)}
style={{
flex: 1, padding: 10,
background: isConnecting || (fields.length > 0 && !allFilled)
? '#444' : '#7c3aed',
color: 'white', border: 'none',
borderRadius: 6, fontSize: 13,
cursor: 'pointer',
}}
>
{isConnecting ? 'Connecting...' : `Connect ${connector.display_name}`}
</button>
<button
onClick={onSkip}
style={{
padding: '10px 16px',
background: 'transparent',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)',
borderRadius: 6, fontSize: 13,
cursor: 'pointer',
}}
>
Skip
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SourceConnectFlow
// ---------------------------------------------------------------------------
@@ -392,6 +564,13 @@ export function SourceConnectFlow({
<CheckCircle2 size={18} />
Connected
</div>
) : activeCard.steps ? (
<StepByStepPanel
connector={activeCard}
onConnect={(req) => handleConnect(activeEntry.id, req)}
onSkip={() => handleSkip(activeEntry.id)}
isConnecting={activeEntry.state === 'connecting'}
/>
) : activeCard.auth_type === 'filesystem' ? (
<FilesystemPanel
displayName={activeCard.display_name}
@@ -11,7 +11,7 @@ import {
Users,
CheckCircle2,
} from 'lucide-react';
import { SOURCE_CATALOG, type SourceCard } from '../../types/connectors';
import { SOURCE_CATALOG, type ConnectorMeta, type SourceCard } from '../../types/connectors';
// ---------------------------------------------------------------------------
// Icon map
@@ -42,7 +42,7 @@ function CategorySection({
}: {
category: string;
label: string;
cards: SourceCard[];
cards: ConnectorMeta[];
selected: Set<string>;
onToggle: (id: string) => void;
}) {
+90 -2
View File
@@ -434,6 +434,38 @@ export async function fetchAgentChannels(agentId: string): Promise<ChannelBindin
return data.bindings || [];
}
export async function bindAgentChannel(
agentId: string,
channelType: string,
config?: Record<string, unknown>,
): Promise<ChannelBinding> {
const res = await fetch(
`${getBase()}/v1/managed-agents/${agentId}/channels`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_type: channelType,
config: config || {},
routing_mode: 'dedicated',
}),
},
);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
export async function unbindAgentChannel(
agentId: string,
bindingId: string,
): Promise<void> {
const res = await fetch(
`${getBase()}/v1/managed-agents/${agentId}/channels/${bindingId}`,
{ method: 'DELETE' },
);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function fetchTemplates(): Promise<AgentTemplate[]> {
const res = await fetch(`${getBase()}/v1/templates`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
@@ -470,13 +502,69 @@ export async function fetchAgentState(agentId: string): Promise<{
return res.json();
}
export async function sendAgentMessage(agentId: string, content: string, mode: 'immediate' | 'queued' = 'queued'): Promise<AgentMessage> {
export async function sendAgentMessage(
agentId: string,
content: string,
mode: 'immediate' | 'queued' = 'queued',
callbacks?: {
onProgress?: (label: string) => void;
onContentDelta?: (delta: string, fullContent: string) => void;
onDone?: (fullContent: string) => void;
},
): Promise<AgentMessage> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mode }),
body: JSON.stringify({ content, mode, stream: true }),
});
if (!res.ok) throw new Error(`Failed: ${res.status}`);
// If streaming, consume the SSE response so the agent runs
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('text/event-stream') && res.body) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
try {
const chunk = JSON.parse(line.slice(6));
// Check for tool progress events
const toolProgress = chunk.choices?.[0]?.tool_progress;
if (toolProgress) {
callbacks?.onProgress?.(toolProgress);
}
const delta = chunk.choices?.[0]?.delta?.content || '';
if (delta) {
fullContent += delta;
callbacks?.onContentDelta?.(delta, fullContent);
}
} catch { /* skip malformed chunks */ }
}
}
} catch { /* stream ended */ }
callbacks?.onDone?.(fullContent);
return {
id: '',
agent_id: agentId,
direction: 'agent_to_user',
content: fullContent,
mode,
status: 'delivered',
created_at: Date.now() / 1000,
};
}
return res.json();
}
File diff suppressed because it is too large Load Diff
+365 -20
View File
@@ -1,3 +1,26 @@
export interface SetupStep {
label: string;
url?: string;
urlLabel?: string;
}
export interface ConnectorMeta {
connector_id: string;
display_name: string;
auth_type: 'oauth' | 'local' | 'bridge' | 'filesystem';
category: 'communication' | 'documents' | 'pim';
icon: string;
color: string;
description: string;
unitLabel?: string; // "emails", "messages", "meeting notes", "pages", "notes", etc.
steps?: SetupStep[];
inputFields?: Array<{
name: string;
placeholder: string;
type?: 'text' | 'password';
}>;
}
export interface ConnectorInfo {
connector_id: string;
display_name: string;
@@ -5,6 +28,7 @@ export interface ConnectorInfo {
connected: boolean;
auth_url?: string;
mcp_tools?: string[];
chunks?: number;
}
export interface SyncStatus {
@@ -25,25 +49,346 @@ export interface ConnectRequest {
export type WizardStep = "pick" | "connect" | "ingest" | "ready";
export interface SourceCard {
connector_id: string;
display_name: string;
auth_type: string;
category: "communication" | "documents" | "pim";
icon: string;
color: string;
description: string;
}
// Backward-compatible alias
export type SourceCard = ConnectorMeta;
export const SOURCE_CATALOG: SourceCard[] = [
{ connector_id: "gmail", display_name: "Gmail", auth_type: "oauth", category: "communication", icon: "Mail", color: "text-red-400", description: "Email messages and threads" },
{ connector_id: "gmail_imap", display_name: "Gmail (IMAP)", auth_type: "oauth", category: "communication", icon: "Mail", color: "text-red-400", description: "Email via app password" },
{ connector_id: "slack", display_name: "Slack", auth_type: "oauth", category: "communication", icon: "Hash", color: "text-purple-400", description: "Channel messages and threads" },
{ connector_id: "imessage", display_name: "iMessage", auth_type: "local", category: "communication", icon: "MessageSquare", color: "text-green-400", description: "macOS Messages history" },
{ connector_id: "gdrive", display_name: "Google Drive", auth_type: "oauth", category: "documents", icon: "FolderOpen", color: "text-blue-400", description: "Docs, Sheets, and files" },
{ connector_id: "notion", display_name: "Notion", auth_type: "oauth", category: "documents", icon: "FileText", color: "text-gray-300", description: "Pages and databases" },
{ connector_id: "obsidian", display_name: "Obsidian", auth_type: "filesystem", category: "documents", icon: "Diamond", color: "text-violet-400", description: "Markdown vault" },
{ connector_id: "granola", display_name: "Granola", auth_type: "oauth", category: "documents", icon: "Mic", color: "text-amber-400", description: "AI meeting notes" },
{ connector_id: "gcalendar", display_name: "Calendar", auth_type: "oauth", category: "pim", icon: "Calendar", color: "text-blue-400", description: "Events and meetings" },
{ connector_id: "gcontacts", display_name: "Contacts", auth_type: "oauth", category: "pim", icon: "Users", color: "text-blue-400", description: "People and contact info" },
export const SOURCE_CATALOG: ConnectorMeta[] = [
{
connector_id: 'gmail',
display_name: 'Gmail',
auth_type: 'oauth',
category: 'communication',
icon: 'Mail',
color: 'text-red-400',
description: 'Email messages and threads',
unitLabel: 'emails',
steps: [
{
label: 'Enable 2-Factor Authentication on your Google account',
url: 'https://myaccount.google.com/signinoptions/two-step-verification',
urlLabel: 'Open Google Security',
},
{
label: 'Generate an App Password for "Mail"',
url: 'https://myaccount.google.com/apppasswords',
urlLabel: 'Open App Passwords',
},
{ label: 'Paste your credentials below' },
],
inputFields: [
{ name: 'email', placeholder: 'Email address', type: 'text' },
{ name: 'password', placeholder: 'App password (xxxx xxxx xxxx xxxx)', type: 'password' },
],
},
{
connector_id: 'slack',
display_name: 'Slack',
auth_type: 'oauth',
category: 'communication',
icon: 'Hash',
color: 'text-purple-400',
description: 'Channel messages and threads',
unitLabel: 'messages',
steps: [
{
label: 'Go to your Slack App settings and copy the Bot User OAuth Token',
url: 'https://api.slack.com/apps',
urlLabel: 'Open Slack Apps',
},
{ label: 'Paste the bot token below (starts with xoxb-)' },
],
inputFields: [
{ name: 'token', placeholder: 'xoxb-...', type: 'password' },
],
},
{
connector_id: 'notion',
display_name: 'Notion',
auth_type: 'oauth',
category: 'documents',
icon: 'FileText',
color: 'text-gray-300',
description: 'Pages and databases',
unitLabel: 'pages',
steps: [
{
label: 'Create an internal integration and copy the secret',
url: 'https://www.notion.so/profile/integrations',
urlLabel: 'Open Notion Integrations',
},
{ label: 'Paste the integration token below (starts with ntn_)' },
{ label: 'Then share pages with your integration: Page → ... → Connections → Add' },
],
inputFields: [
{ name: 'token', placeholder: 'ntn_...', type: 'password' },
],
},
{
connector_id: 'granola',
display_name: 'Granola',
auth_type: 'oauth',
category: 'documents',
icon: 'Mic',
color: 'text-amber-400',
description: 'AI meeting notes',
unitLabel: 'meeting notes',
steps: [
{ label: 'Open the Granola desktop app → Settings → API' },
{ label: 'Copy your API key and paste below' },
],
inputFields: [
{ name: 'token', placeholder: 'grn_...', type: 'password' },
],
},
{
connector_id: 'imessage',
display_name: 'iMessage',
auth_type: 'local',
category: 'communication',
icon: 'MessageSquare',
color: 'text-green-400',
description: 'macOS Messages history',
unitLabel: 'messages',
steps: [
{
label: 'Open System Settings → Privacy & Security → Full Disk Access',
url: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
urlLabel: 'Open System Settings',
},
{
label: 'Enable Full Disk Access for your terminal app (Terminal, iTerm, or Warp)',
},
{
label: 'iMessage history will be detected automatically once access is granted',
},
],
},
{
connector_id: 'obsidian',
display_name: 'Obsidian',
auth_type: 'filesystem',
category: 'documents',
icon: 'FolderOpen',
color: 'text-purple-300',
description: 'Markdown vault',
unitLabel: 'notes',
steps: [
{
label: 'Find your Obsidian vault folder on your computer. It\'s the folder containing the .obsidian directory.',
},
{
label: 'Paste the full path below (e.g. /Users/you/Documents/MyVault)',
},
],
inputFields: [
{ name: 'path', placeholder: '/Users/you/Documents/MyVault', type: 'text' },
],
},
{
connector_id: 'gdrive',
display_name: 'Google Drive',
auth_type: 'oauth',
category: 'documents',
icon: 'FolderOpen',
color: 'text-blue-400',
description: 'Docs, Sheets, and files',
unitLabel: 'files',
steps: [
{
label: 'Go to the Google Cloud Console and create a project (or use an existing one)',
url: 'https://console.cloud.google.com/projectcreate',
urlLabel: 'Open Google Cloud Console',
},
{
label: 'Enable the Google Drive API for your project',
url: 'https://console.cloud.google.com/apis/library/drive.googleapis.com',
urlLabel: 'Enable Drive API',
},
{
label: 'Go to Credentials → Create OAuth 2.0 Client ID (choose "Desktop app")',
url: 'https://console.cloud.google.com/apis/credentials',
urlLabel: 'Open Credentials',
},
{
label: 'Copy the Client ID and Client Secret from your OAuth client, then paste each one below',
},
],
inputFields: [
{ name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' },
{ name: 'password', placeholder: 'Client Secret', type: 'password' },
],
},
{
connector_id: 'gcalendar',
display_name: 'Calendar',
auth_type: 'oauth',
category: 'pim',
icon: 'Calendar',
color: 'text-blue-400',
description: 'Events and meetings',
unitLabel: 'events',
steps: [
{
label: 'Go to the Google Cloud Console and create a project (or use an existing one)',
url: 'https://console.cloud.google.com/projectcreate',
urlLabel: 'Open Google Cloud Console',
},
{
label: 'Enable the Google Calendar API for your project',
url: 'https://console.cloud.google.com/apis/library/calendar-json.googleapis.com',
urlLabel: 'Enable Calendar API',
},
{
label: 'Go to Credentials → Create OAuth 2.0 Client ID (choose "Desktop app")',
url: 'https://console.cloud.google.com/apis/credentials',
urlLabel: 'Open Credentials',
},
{
label: 'Copy the Client ID and Client Secret from your OAuth client, then paste each one below',
},
],
inputFields: [
{ name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' },
{ name: 'password', placeholder: 'Client Secret', type: 'password' },
],
},
{
connector_id: 'gcontacts',
display_name: 'Contacts',
auth_type: 'oauth',
category: 'pim',
icon: 'Users',
color: 'text-blue-400',
description: 'People and contact info',
unitLabel: 'contacts',
steps: [
{
label: 'Go to the Google Cloud Console and create a project (or use an existing one)',
url: 'https://console.cloud.google.com/projectcreate',
urlLabel: 'Open Google Cloud Console',
},
{
label: 'Enable the People API for your project',
url: 'https://console.cloud.google.com/apis/library/people.googleapis.com',
urlLabel: 'Enable People API',
},
{
label: 'Go to Credentials → Create OAuth 2.0 Client ID (choose "Desktop app")',
url: 'https://console.cloud.google.com/apis/credentials',
urlLabel: 'Open Credentials',
},
{
label: 'Copy the Client ID and Client Secret from your OAuth client, then paste each one below',
},
],
inputFields: [
{ name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' },
{ name: 'password', placeholder: 'Client Secret', type: 'password' },
],
},
{
connector_id: 'apple_notes',
display_name: 'Apple Notes',
auth_type: 'local',
category: 'documents',
icon: 'FileText',
color: 'text-yellow-400',
description: 'macOS Notes app',
unitLabel: 'notes',
steps: [
{
label: 'Open System Settings → Privacy & Security → Full Disk Access',
url: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
urlLabel: 'Open System Settings',
},
{
label: 'Enable Full Disk Access for your terminal app (Terminal, iTerm, or Warp)',
},
{
label: 'Apple Notes will be detected automatically once access is granted',
},
],
},
{
connector_id: 'outlook',
display_name: 'Outlook',
auth_type: 'oauth',
category: 'communication',
icon: 'Mail',
color: 'text-blue-400',
description: 'Microsoft email and calendar',
unitLabel: 'emails',
steps: [
{
label: 'Go to the Azure Portal and register a new application',
url: 'https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
urlLabel: 'Open Azure App Registrations',
},
{
label: 'Under API Permissions, add Microsoft Graph → Mail.Read',
},
{
label: 'Create a client secret and paste the Application (client) ID and secret below',
},
],
inputFields: [
{ name: 'email', placeholder: 'Client ID (e.g. 123456-abc.apps.googleusercontent.com)', type: 'text' },
{ name: 'password', placeholder: 'Client Secret', type: 'password' },
],
},
{
connector_id: 'dropbox',
display_name: 'Dropbox',
auth_type: 'oauth',
category: 'documents',
icon: 'FolderOpen',
color: 'text-blue-300',
description: 'Cloud file storage',
unitLabel: 'files',
steps: [
{
label: 'Go to the Dropbox App Console and create a new app',
url: 'https://www.dropbox.com/developers/apps/create',
urlLabel: 'Open Dropbox App Console',
},
{
label: 'Choose "Scoped access" → "Full Dropbox" → name your app',
},
{
label: 'Under Permissions, enable "files.metadata.read" and "files.content.read"',
},
{
label: 'Go to Settings → Generate an access token and paste it below',
},
],
inputFields: [
{ name: 'token', placeholder: 'Access token (sl.u...)', type: 'password' },
],
},
{
connector_id: 'whatsapp',
display_name: 'WhatsApp',
auth_type: 'oauth',
category: 'communication',
icon: 'MessageSquare',
color: 'text-green-400',
description: 'WhatsApp messages',
unitLabel: 'messages',
steps: [
{
label: 'Go to Meta for Developers and create a WhatsApp Business app',
url: 'https://developers.facebook.com/apps/',
urlLabel: 'Open Meta Developer Portal',
},
{
label: 'Set up WhatsApp → Get a temporary access token from the API Setup page',
},
{
label: 'Copy your Phone Number ID and Access Token, paste below as "phone_id:token"',
},
],
inputFields: [
{ name: 'token', placeholder: 'Phone Number ID:Access Token', type: 'password' },
],
},
];
+1
View File
@@ -165,6 +165,7 @@ nav:
- Design Principles: architecture/design-principles.md
- API Reference: api-reference/
- User Guide:
- Channels & Connectors: user-guide/channels-and-connectors.md
- Tools: user-guide/tools.md
- External MCP Servers: user-guide/mcp-external-servers.md
- Leaderboard: leaderboard.md
+2
View File
@@ -122,6 +122,8 @@ class DeepResearchAgent(ToolUsingAgent):
max_tokens: Optional[int] = None,
interactive: bool = False,
confirm_callback=None,
system_prompt: Optional[str] = None,
**kwargs: Any,
) -> None:
super().__init__(
engine,
+45 -8
View File
@@ -17,6 +17,7 @@ from openjarvis.connectors.oauth import (
build_google_auth_url,
delete_tokens,
load_tokens,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -214,11 +215,12 @@ class GCalendarConnector(BaseConnector):
# ------------------------------------------------------------------
def is_connected(self) -> bool:
"""Return ``True`` if a credentials file with a valid token exists."""
"""Return ``True`` if a credentials file with a valid access token exists."""
tokens = load_tokens(self._credentials_path)
if tokens is None:
return False
return bool(tokens)
# Must have an actual access_token, not just a client_id
return bool(tokens.get("access_token") or tokens.get("token"))
def disconnect(self) -> None:
"""Delete the stored credentials file."""
@@ -226,18 +228,53 @@ class GCalendarConnector(BaseConnector):
def auth_url(self) -> str:
"""Return a Google OAuth consent URL requesting ``calendar.readonly`` scope."""
tokens = load_tokens(self._credentials_path)
client_id = ""
if tokens:
client_id = tokens.get("client_id", "")
if not client_id:
return "https://console.cloud.google.com/apis/credentials"
return build_google_auth_url(
client_id="", # placeholder — real client_id from config
client_id=client_id,
scopes=[_GCAL_SCOPE],
)
def handle_callback(self, code: str) -> None:
"""Handle the OAuth callback by persisting the authorization code.
"""Handle the OAuth callback.
In a full implementation this would exchange the code for tokens.
For now the code is saved directly as the token value.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
"""
save_tokens(self._credentials_path, {"token": code})
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
save_tokens(
self._credentials_path,
{
"client_id": client_id.strip(),
"client_secret": client_secret.strip(),
},
)
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=[_GCAL_SCOPE],
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
def sync(
self,
@@ -261,7 +298,7 @@ class GCalendarConnector(BaseConnector):
if not tokens:
return
token: str = tokens.get("token", tokens.get("access_token", ""))
token: str = tokens.get("access_token", tokens.get("token", ""))
if not token:
return
+45 -10
View File
@@ -17,6 +17,7 @@ from openjarvis.connectors.oauth import (
build_google_auth_url,
delete_tokens,
load_tokens,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -161,13 +162,12 @@ class GContactsConnector(BaseConnector):
# ------------------------------------------------------------------
def is_connected(self) -> bool:
"""Return ``True`` if a credentials file with a valid token exists."""
"""Return ``True`` if a credentials file with a valid access token exists."""
tokens = load_tokens(self._credentials_path)
if tokens is None:
return False
# Accept any non-empty dict that contains at least one key
# (simplified: real impl would also check expiry / refresh token)
return bool(tokens)
# Must have an actual access_token, not just a client_id
return bool(tokens.get("access_token") or tokens.get("token"))
def disconnect(self) -> None:
"""Delete the stored credentials file."""
@@ -175,18 +175,53 @@ class GContactsConnector(BaseConnector):
def auth_url(self) -> str:
"""Return a Google OAuth consent URL requesting ``contacts.readonly`` scope."""
tokens = load_tokens(self._credentials_path)
client_id = ""
if tokens:
client_id = tokens.get("client_id", "")
if not client_id:
return "https://console.cloud.google.com/apis/credentials"
return build_google_auth_url(
client_id="", # placeholder — real client_id from config
client_id=client_id,
scopes=[_GCONTACTS_SCOPE],
)
def handle_callback(self, code: str) -> None:
"""Handle the OAuth callback by persisting the authorization code.
"""Handle the OAuth callback.
In a full implementation this would exchange the code for tokens.
For now the code is saved directly as the token value.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
"""
save_tokens(self._credentials_path, {"token": code})
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
save_tokens(
self._credentials_path,
{
"client_id": client_id.strip(),
"client_secret": client_secret.strip(),
},
)
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=[_GCONTACTS_SCOPE],
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
def sync(
self,
@@ -211,7 +246,7 @@ class GContactsConnector(BaseConnector):
if not tokens:
return
token: str = tokens.get("token", tokens.get("access_token", ""))
token: str = tokens.get("access_token", tokens.get("token", ""))
if not token:
return
+47 -10
View File
@@ -17,6 +17,7 @@ from openjarvis.connectors.oauth import (
build_google_auth_url,
delete_tokens,
load_tokens,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -144,13 +145,12 @@ class GDriveConnector(BaseConnector):
# ------------------------------------------------------------------
def is_connected(self) -> bool:
"""Return ``True`` if a credentials file with a valid token exists."""
"""Return ``True`` if a credentials file with a valid access token exists."""
tokens = load_tokens(self._credentials_path)
if tokens is None:
return False
# Accept any non-empty dict that contains at least one key
# (simplified: real impl would also check expiry / refresh token)
return bool(tokens)
# Must have an actual access_token, not just a client_id
return bool(tokens.get("access_token") or tokens.get("token"))
def disconnect(self) -> None:
"""Delete the stored credentials file."""
@@ -158,18 +158,55 @@ class GDriveConnector(BaseConnector):
def auth_url(self) -> str:
"""Return a Google OAuth consent URL requesting ``drive.readonly`` scope."""
tokens = load_tokens(self._credentials_path)
client_id = ""
if tokens:
client_id = tokens.get("client_id", "")
if not client_id:
return "https://console.cloud.google.com/apis/credentials"
return build_google_auth_url(
client_id="", # placeholder — real client_id from config
client_id=client_id,
scopes=[_GDRIVE_SCOPE],
)
def handle_callback(self, code: str) -> None:
"""Handle the OAuth callback by persisting the authorization code.
"""Handle the OAuth callback.
In a full implementation this would exchange the code for tokens.
For now the code is saved directly as the token value.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
"""
save_tokens(self._credentials_path, {"token": code})
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
# Save credentials immediately
save_tokens(
self._credentials_path,
{
"client_id": client_id.strip(),
"client_secret": client_secret.strip(),
},
)
# Run OAuth flow in background thread to avoid blocking
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=[_GDRIVE_SCOPE],
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
def sync(
self,
@@ -194,7 +231,7 @@ class GDriveConnector(BaseConnector):
if not tokens:
return
token: str = tokens.get("token", tokens.get("access_token", ""))
token: str = tokens.get("access_token", tokens.get("token", ""))
if not token:
return
+199
View File
@@ -95,3 +95,202 @@ def delete_tokens(path: str) -> None:
p = Path(path)
if p.exists():
p.unlink()
# ---------------------------------------------------------------------------
# Token exchange & full OAuth flow
# ---------------------------------------------------------------------------
def exchange_google_token(
code: str,
client_id: str,
client_secret: str,
redirect_uri: str = _DEFAULT_REDIRECT_URI,
) -> Dict[str, Any]:
"""Exchange an authorization code for access + refresh tokens.
Parameters
----------
code:
The authorization code received from Google's consent redirect.
client_id:
OAuth 2.0 client ID.
client_secret:
OAuth 2.0 client secret.
redirect_uri:
Must match the redirect URI used when obtaining the auth code.
Returns
-------
dict
Token response containing ``access_token``, ``refresh_token``,
``token_type``, and ``expires_in``.
"""
import httpx
resp = httpx.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
def run_oauth_flow(
client_id: str,
client_secret: str,
scopes: List[str],
credentials_path: str,
redirect_uri: str = _DEFAULT_REDIRECT_URI,
) -> Dict[str, Any]:
"""Run the full OAuth flow: browser consent, callback, token exchange.
Steps:
1. Build consent URL
2. Start localhost callback server
3. Open browser to consent URL
4. Wait for Google to redirect with ``?code=...``
5. Exchange code for ``access_token`` + ``refresh_token``
6. Save tokens to *credentials_path*
7. Return the tokens dict
Parameters
----------
client_id:
OAuth 2.0 client ID.
client_secret:
OAuth 2.0 client secret.
scopes:
List of OAuth scopes to request.
credentials_path:
Where to persist the resulting tokens.
redirect_uri:
Local callback URI. Defaults to ``http://localhost:8789/callback``.
Returns
-------
dict
Token response from Google (``access_token``, ``refresh_token``, etc.).
Raises
------
RuntimeError
If the user denies authorization or the callback times out.
"""
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
auth_url = build_google_auth_url(
client_id=client_id,
redirect_uri=redirect_uri,
scopes=scopes,
)
# Mutable containers used by the callback handler closure.
auth_code: List[str] = []
error: List[str] = []
class _CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 — required override name
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
if "code" in params:
auth_code.append(params["code"][0])
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h2>Authorization successful!</h2>"
b"<p>You can close this tab and return to OpenJarvis.</p>"
b"</body></html>"
)
elif "error" in params:
error.append(params["error"][0])
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h2>Authorization failed</h2>"
b"<p>Please try again.</p></body></html>"
)
else:
self.send_response(400)
self.end_headers()
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
pass # Suppress HTTP request logs
# Parse port from redirect_uri
port = int(urlparse(redirect_uri).port or 8789)
# Kill any stale listener on the port before starting
import socket
test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
test_sock.bind(("127.0.0.1", port))
test_sock.close()
except OSError:
# Port in use — try to free it
test_sock.close()
import subprocess
subprocess.run(
["lsof", "-t", "-i", f":{port}"],
capture_output=True,
)
# Wait briefly and retry
import time
time.sleep(1)
server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
server.timeout = 120 # 2 minute timeout
# Open the consent page in the user's default browser
webbrowser.open(auth_url)
# Wait for the callback (blocking, with per-request timeout)
while not auth_code and not error:
server.handle_request()
server.server_close()
if error:
raise RuntimeError(f"OAuth authorization failed: {error[0]}")
if not auth_code:
raise RuntimeError("OAuth authorization timed out")
# Exchange the authorization code for tokens
tokens = exchange_google_token(
code=auth_code[0],
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
# Persist tokens together with client credentials (needed for refresh)
save_tokens(
credentials_path,
{
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"token_type": tokens.get("token_type", "Bearer"),
"expires_in": tokens.get("expires_in", 3600),
"client_id": client_id,
"client_secret": client_secret,
},
)
return tokens
+313 -2
View File
@@ -479,6 +479,48 @@ def _get_mcp_tools(app_state: Any) -> Tuple[List[Dict[str, Any]], Dict[str, Any]
return openai_tools, adapters_by_name
def _sse_chunk(chunk_id: str, model: str, content: str) -> str:
"""Build a single SSE content chunk."""
import json as _json
data = {
"id": chunk_id,
"object": "chat.completion.chunk",
"model": model,
"choices": [
{
"index": 0,
"delta": {"content": content},
"finish_reason": None,
}
],
}
return f"data: {_json.dumps(data)}\n\n"
def _tool_progress_label(tool_name: str, args: str) -> str:
"""Human-readable label for a tool call in progress."""
labels = {
"knowledge_search": "Searching your knowledge base",
"knowledge_sql": "Querying data with SQL",
"scan_chunks": "Scanning documents for semantic matches",
"think": "Planning next step",
}
label = labels.get(tool_name, f"Using {tool_name}")
if args and tool_name != "think":
# Try to extract the query/question from args JSON
try:
import json as _json
parsed = _json.loads(args)
q = parsed.get("query") or parsed.get("question") or ""
if q:
label += f'"{q[:50]}"'
except Exception:
pass
return label
async def _stream_managed_agent(
*,
manager: AgentManager,
@@ -541,6 +583,215 @@ async def _stream_managed_agent(
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
# For deep_research agents: run the full agent loop, not raw streaming
if agent_type == "deep_research" and app_state is not None:
dr_tools = getattr(app_state, "_dr_tools", None)
if dr_tools:
async def generate_deep_research():
"""Run DeepResearchAgent in thread, stream progress + result."""
import asyncio
import queue
import threading
import time as _dr_time
from openjarvis.agents.deep_research import DeepResearchAgent
progress_q: queue.Queue = queue.Queue()
# Log query start
_dr_start = _dr_time.time()
try:
manager.add_learning_log(
agent_id,
"query_start",
f"Query: {user_content[:100]}",
{"full_query": user_content},
)
except Exception as _log_exc:
logger.warning(
"Failed to log query_start: %s",
_log_exc,
)
# Patch the agent's tool executor to emit progress
dr_agent = DeepResearchAgent(
engine=engine,
model=model,
tools=dr_tools,
max_turns=int(config.get("max_turns", 8)),
temperature=float(config.get("temperature", 0.3)),
)
# Wrap the executor to capture tool calls
original_execute = dr_agent._executor.execute
def _tracked_execute(tc):
tool_name = tc.name
args_str = (
tc.arguments[:80] if tc.arguments else ""
)
# Log tool call start
try:
manager.add_learning_log(
agent_id,
"tool_call",
f"Calling {tool_name}: {args_str}",
{"tool": tool_name, "arguments": tc.arguments or ""},
)
except Exception as _tc_exc:
logger.warning("Log tool_call failed: %s", _tc_exc)
progress_q.put({
"type": "tool_start",
"tool": tool_name,
"args": args_str,
})
result = original_execute(tc)
# Log tool result
try:
_ok = "succeeded" if result.success else "failed"
_clen = len(result.content) if result.content else 0
manager.add_learning_log(
agent_id,
"tool_result",
f"{tool_name} {_ok} ({_clen} chars)",
{
"tool": tool_name,
"success": result.success,
"output_length": _clen,
},
)
except Exception as _tr_exc:
logger.warning("Log tool_result failed: %s", _tr_exc)
progress_q.put({
"type": "tool_end",
"tool": tool_name,
"success": result.success,
})
return result
dr_agent._executor.execute = _tracked_execute
def _run_agent():
try:
result = dr_agent.run(user_content)
content = (
result.content or "No results found."
)
# Log query completion
try:
elapsed = _dr_time.time() - _dr_start
manager.add_learning_log(
agent_id,
"query_complete",
f"Response: {len(content)} chars in {elapsed:.1f}s",
{
"response_length": len(content),
"elapsed_seconds": round(elapsed, 2),
},
)
except Exception as _qc_exc:
logger.warning("Log query_complete failed: %s", _qc_exc)
progress_q.put({
"type": "done",
"content": content,
})
except Exception as exc:
# Log query error
try:
elapsed = _dr_time.time() - _dr_start
manager.add_learning_log(
agent_id,
"query_error",
f"Error after {elapsed:.1f}s: {exc}",
{
"error": str(exc),
"elapsed_seconds": round(elapsed, 2),
},
)
except Exception:
pass
progress_q.put({
"type": "error",
"content": f"Error: {exc}",
})
thread = threading.Thread(target=_run_agent, daemon=True)
thread.start()
# Stream progress events and final content
while True:
try:
event = await asyncio.to_thread(progress_q.get, timeout=120)
except Exception:
# Timeout
yield _sse_chunk(chunk_id, model, "Agent timed out.")
break
if event["type"] == "tool_start":
tool = event["tool"]
args = event.get("args", "")
label = _tool_progress_label(tool, args)
progress_data = {
"id": chunk_id,
"object": "chat.completion.chunk",
"model": model,
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": None,
"tool_progress": label,
}
],
}
yield f"data: {json.dumps(progress_data)}\n\n"
elif event["type"] == "tool_end":
pass # Could emit completion signal
elif event["type"] in ("done", "error"):
content = event["content"]
# Stream content word-by-word
words = content.split(" ")
for i, word in enumerate(words):
token = word if i == 0 else " " + word
yield _sse_chunk(chunk_id, model, token)
# Final chunk
finish_data = {
"id": chunk_id,
"object": "chat.completion.chunk",
"model": model,
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
}
yield f"data: {json.dumps(finish_data)}\n\n"
yield "data: [DONE]\n\n"
# Persist
manager.store_agent_response(
agent_id, content,
)
break
return StreamingResponse(
generate_deep_research(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
# Build extra kwargs for stream_full (e.g. tools from config)
stream_kwargs: Dict[str, Any] = {}
if config.get("tools"):
@@ -967,18 +1218,78 @@ def create_agent_manager_router(
return {"bindings": manager.list_channel_bindings(agent_id)}
@agents_router.post("/{agent_id}/channels")
async def bind_channel(agent_id: str, req: BindChannelRequest):
async def bind_channel(
agent_id: str, req: BindChannelRequest, request: Request,
):
if not manager.get_agent(agent_id):
raise HTTPException(status_code=404, detail="Agent not found")
return manager.bind_channel(
binding = manager.bind_channel(
agent_id,
channel_type=req.channel_type,
config=req.config,
routing_mode=req.routing_mode,
)
# Start iMessage daemon if binding iMessage
if req.channel_type == "imessage":
identifier = (req.config or {}).get("identifier", "")
if identifier:
try:
from openjarvis.channels.imessage_daemon import (
is_running,
run_daemon,
)
if not is_running():
import threading
engine = getattr(request.app.state, "engine", None)
if engine:
from openjarvis.server.agent_manager_routes import (
_build_deep_research_tools,
)
tools = _build_deep_research_tools(engine=engine, model="")
if tools:
from openjarvis.agents.deep_research import (
DeepResearchAgent,
)
agent_inst = DeepResearchAgent(
engine=engine,
model=getattr(engine, "_model", ""),
tools=tools,
)
def handler(text: str) -> str:
result = agent_inst.run(text)
return result.content or "No results."
t = threading.Thread(
target=run_daemon,
kwargs={
"chat_identifier": identifier,
"handler": handler,
},
daemon=True,
)
t.start()
except Exception as exc:
logger.warning("Failed to start iMessage daemon: %s", exc)
return binding
@agents_router.delete("/{agent_id}/channels/{binding_id}")
async def unbind_channel(agent_id: str, binding_id: str):
# Stop iMessage daemon if applicable
try:
binding = manager._get_binding(binding_id)
if binding and binding.get("channel_type") == "imessage":
from openjarvis.channels.imessage_daemon import stop_daemon
stop_daemon()
except Exception:
pass
manager.unbind_channel(binding_id)
return {"status": "unbound"}
+47 -1
View File
@@ -2,8 +2,11 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Module-level cache of connector instances (keyed by connector_id).
_instances: Dict[str, Any] = {}
@@ -86,7 +89,7 @@ def create_connectors_router():
from openjarvis.core.registry import ConnectorRegistry
router = APIRouter(prefix="/connectors", tags=["connectors"])
router = APIRouter(prefix="/v1/connectors", tags=["connectors"])
# ------------------------------------------------------------------
# Helpers
@@ -101,11 +104,25 @@ def create_connectors_router():
def _connector_summary(connector_id: str, instance: Any) -> Dict[str, Any]:
"""Build the dict returned by GET /connectors."""
chunks = 0
try:
from openjarvis.connectors.store import KnowledgeStore
store = KnowledgeStore()
rows = store._conn.execute(
"SELECT COUNT(*) FROM knowledge_chunks WHERE source = ?",
(connector_id,),
).fetchone()
chunks = rows[0] if rows else 0
except Exception:
pass
return {
"connector_id": connector_id,
"display_name": getattr(instance, "display_name", connector_id),
"auth_type": getattr(instance, "auth_type", "unknown"),
"connected": instance.is_connected(),
"chunks": chunks,
}
# ------------------------------------------------------------------
@@ -210,6 +227,35 @@ def create_connectors_router():
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
# Auto-ingest after successful connection
if instance.is_connected():
import threading
def _ingest() -> None:
try:
from openjarvis.connectors.pipeline import (
IngestionPipeline,
)
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.connectors.sync_engine import SyncEngine
store = KnowledgeStore()
pipeline = IngestionPipeline(store)
engine = SyncEngine(pipeline)
engine.sync(instance)
logger.info(
"Auto-ingested %s after connect",
connector_id,
)
except Exception as exc:
logger.warning(
"Auto-ingest failed for %s: %s",
connector_id,
exc,
)
threading.Thread(target=_ingest, daemon=True).start()
return {
"connector_id": connector_id,
"connected": instance.is_connected(),
+3 -3
View File
@@ -79,11 +79,11 @@ def test_not_connected(connector) -> None:
def test_auth_url(connector) -> None:
"""auth_url() returns a URL to Google's OAuth endpoint with calendar scope."""
"""auth_url() returns the credentials page when no client_id is stored."""
url = connector.auth_url()
assert isinstance(url, str)
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
assert "calendar.readonly" in url
# Without a stored client_id, points to the Cloud Console credentials page
assert url == "https://console.cloud.google.com/apis/credentials"
# ---------------------------------------------------------------------------
+3 -3
View File
@@ -77,11 +77,11 @@ def test_not_connected(connector) -> None:
def test_auth_url(connector) -> None:
"""auth_url() returns a URL to Google's OAuth endpoint with contacts scope."""
"""auth_url() returns the credentials page when no client_id is stored."""
url = connector.auth_url()
assert isinstance(url, str)
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
assert "contacts.readonly" in url
# Without a stored client_id, points to the Cloud Console credentials page
assert url == "https://console.cloud.google.com/apis/credentials"
# ---------------------------------------------------------------------------
+3 -3
View File
@@ -74,11 +74,11 @@ def test_not_connected(connector) -> None:
def test_auth_url(connector) -> None:
"""auth_url() returns a Google OAuth URL with drive.readonly scope."""
"""auth_url() returns the credentials page when no client_id is stored."""
url = connector.auth_url()
assert isinstance(url, str)
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
assert "drive.readonly" in url
# Without a stored client_id, points to the Cloud Console credentials page
assert url == "https://console.cloud.google.com/apis/credentials"
# ---------------------------------------------------------------------------
+145
View File
@@ -0,0 +1,145 @@
"""Tests for OAuth token exchange and Google connector integration."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
def test_exchange_google_token_calls_endpoint() -> None:
from openjarvis.connectors.oauth import exchange_google_token
mock_resp = MagicMock()
mock_resp.json.return_value = {
"access_token": "ya29.test",
"refresh_token": "1//test",
"token_type": "Bearer",
"expires_in": 3600,
}
mock_resp.raise_for_status = MagicMock()
with patch("httpx.post", return_value=mock_resp) as mock_post:
tokens = exchange_google_token(
code="4/test-code",
client_id="test-id.apps.googleusercontent.com",
client_secret="test-secret",
)
assert tokens["access_token"] == "ya29.test"
assert tokens["refresh_token"] == "1//test"
mock_post.assert_called_once()
def test_gdrive_handle_callback_triggers_oauth(tmp_path: Path) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
with patch("openjarvis.connectors.gdrive.run_oauth_flow") as mock_flow:
mock_flow.return_value = {"access_token": "ya29.test"}
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_flow.assert_called_once()
call_kwargs = mock_flow.call_args
assert "test-id.apps.googleusercontent.com" in str(call_kwargs)
def test_gdrive_is_connected_requires_access_token(tmp_path: Path) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import save_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
# Just client_id is not "connected"
save_tokens(creds, {"client_id": "test-id"})
assert conn.is_connected() is False
# With access_token IS connected
save_tokens(creds, {"access_token": "ya29.test", "client_id": "test-id"})
assert conn.is_connected() is True
def test_gcalendar_handle_callback_triggers_oauth(tmp_path: Path) -> None:
from openjarvis.connectors.gcalendar import GCalendarConnector
creds = str(tmp_path / "gcalendar.json")
conn = GCalendarConnector(credentials_path=creds)
with patch("openjarvis.connectors.gcalendar.run_oauth_flow") as mock_flow:
mock_flow.return_value = {"access_token": "ya29.test"}
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_flow.assert_called_once()
def test_gcontacts_handle_callback_triggers_oauth(tmp_path: Path) -> None:
from openjarvis.connectors.gcontacts import GContactsConnector
creds = str(tmp_path / "gcontacts.json")
conn = GContactsConnector(credentials_path=creds)
with patch("openjarvis.connectors.gcontacts.run_oauth_flow") as mock_flow:
mock_flow.return_value = {"access_token": "ya29.test"}
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_flow.assert_called_once()
def test_gdrive_handle_callback_fallback_on_failure(tmp_path: Path) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
with patch("openjarvis.connectors.gdrive.run_oauth_flow") as mock_flow:
mock_flow.side_effect = RuntimeError("OAuth failed")
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
# Should have saved client_id and client_secret as fallback
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["client_id"] == "test-id.apps.googleusercontent.com"
assert tokens["client_secret"] == "test-secret"
def test_gdrive_handle_callback_raw_token(tmp_path: Path) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
conn.handle_callback("some-raw-token-value")
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["token"] == "some-raw-token-value"
def test_gdrive_auth_url_returns_credentials_page_without_client_id(
tmp_path: Path,
) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
url = conn.auth_url()
assert url == "https://console.cloud.google.com/apis/credentials"
def test_gdrive_auth_url_returns_consent_url_with_client_id(
tmp_path: Path,
) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import save_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
save_tokens(creds, {"client_id": "test-id.apps.googleusercontent.com"})
url = conn.auth_url()
assert "accounts.google.com" in url
assert "test-id.apps.googleusercontent.com" in url