mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-31 03:12:16 +00:00
Merge branch 'feat/deep-research-setup'
This commit is contained in:
@@ -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
|
||||
@@ -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'} →
|
||||
</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 · 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
@@ -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
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user