diff --git a/docs/superpowers/plans/2026-03-27-channels-and-messaging-tabs-v2.md b/docs/superpowers/plans/2026-03-27-channels-and-messaging-tabs-v2.md new file mode 100644 index 00000000..e66a6af4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-channels-and-messaging-tabs-v2.md @@ -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(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 = { + gmail: '✉️', gmail_imap: '✉️', slack: '#', + imessage: '💬', gdrive: '📁', notion: '📄', + obsidian: '📁', granola: '🎙️', gcalendar: '📅', + gcontacts: '📇', outlook: '✉️', apple_notes: '🍎', + dropbox: '📦', whatsapp: '📱', + }; + + return ( +
+
+ Data sources your agent can search +
+ + {/* Connected sources grid */} + {connected.length > 0 && ( +
+ {connected.map((c) => ( +
+ {iconMap[c.connector_id] || '🔗'} +
+
+ {c.display_name} +
+
+ {c.chunks.toLocaleString()} chunks +
+
+ +
+ ))} +
+ )} + + {/* Not connected grid */} + {notConnected.length > 0 && ( +
+ {notConnected.map((c) => { + const meta = getMeta(c.connector_id); + const isExpanded = expandedId === c.connector_id; + + return ( +
+
+ setExpandedId(isExpanded ? null : c.connector_id) + } + > + {iconMap[c.connector_id] || '🔗'} +
+
+ {c.display_name} +
+
+ Not connected +
+
+ + {isExpanded ? '✕ Close' : '+ Add'} + +
+ + {/* Inline setup panel */} + {isExpanded && meta?.steps && ( +
+ {meta.steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + {meta.inputFields && ( + + handleConnect(c.connector_id, req) + } + /> + )} +
+ 🔒 Read-only access · No data leaves your device +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function InlineConnectForm({ + fields, + loading, + onSubmit, +}: { + fields: Array<{ name: string; placeholder: string; type?: string }>; + loading: boolean; + onSubmit: (req: ConnectRequest) => void; +}) { + const [inputs, setInputs] = useState>({}); + + 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 ( +
+ {fields.map((f) => ( + 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', + }} + /> + ))} + +
+ ); +} +``` + +- [ ] **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([]); + const [setupType, setSetupType] = useState(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 ( +
+
+ Talk to your agent from your phone or other platforms +
+ + {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 ( +
+
+ {ch.icon} +
+
{ch.name}
+
+ {binding + ? ch.activeTemplate(identifier) + : 'Not set up'} +
+
+ {binding ? ( +
+ Active + +
+ ) : ( + + )} +
+ + {isSetup && ( +
+
{ch.setupLabel}
+
+ 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); + }} + /> + +
+
+ )} +
+ ); + })} +
+ ); +} +``` + +Wire both tabs in the content rendering section: + +```typescript +{detailTab === 'channels' && ( + +)} +{detailTab === 'messaging' && ( + +)} +``` + +- [ ] **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 +``` diff --git a/docs/superpowers/plans/2026-03-27-channels-tab-and-connector-setup.md b/docs/superpowers/plans/2026-03-27-channels-tab-and-connector-setup.md new file mode 100644 index 00000000..a210f3da --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-channels-tab-and-connector-setup.md @@ -0,0 +1,1006 @@ +# Channels Tab + Connector Setup Implementation Plan + +> **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:** Add a Channels tab to every agent for bidirectional messaging (iMessage, Slack, WhatsApp, SMS), and build per-connector step-by-step setup guides with one-click OAuth in the setup wizard. + +**Architecture:** Sub-project 1 adds a React Channels tab component to AgentsPage.tsx that calls existing backend endpoints (POST/DELETE/GET channel bindings). Sub-project 2 replaces the generic OAuth panel in SourceConnectFlow.tsx with per-connector instruction panels and adds a Google OAuth callback server in Python. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS, Python 3.10+, FastAPI, Click + +--- + +## Sub-project 1: Channels Tab + +### Task 1: Add Channels tab to agent detail view + +**Files:** +- Modify: `frontend/src/pages/AgentsPage.tsx` +- Modify: `frontend/src/lib/api.ts` + +- [ ] **Step 1: Add API functions for channel binding CRUD** + +In `frontend/src/lib/api.ts`, add after the existing `fetchAgentChannels` function: + +```typescript +export async function bindAgentChannel( + agentId: string, + channelType: string, + config?: Record, +): Promise { + 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 { + const res = await fetch( + `${getBase()}/v1/managed-agents/${agentId}/channels/${bindingId}`, + { method: 'DELETE' }, + ); + if (!res.ok) throw new Error(`Failed: ${res.status}`); +} +``` + +- [ ] **Step 2: Add 'channels' to DETAIL_TABS in AgentsPage.tsx** + +Find the `DETAIL_TABS` array (line ~1315) and add a channels entry. Also find the `Wifi` icon import from `lucide-react` at the top of the file: + +Add to imports: +```typescript +import { Wifi } from 'lucide-react'; +``` + +Update `DETAIL_TABS`: +```typescript +const DETAIL_TABS = [ + { id: 'overview', label: 'Overview', icon: Activity }, + { id: 'interact', label: 'Interact', icon: MessageSquare }, + { id: 'channels', label: 'Channels', 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; +``` + +Update the `detailTab` state type to include `'channels'`: +```typescript +const [detailTab, setDetailTab] = useState< + 'overview' | 'interact' | 'channels' | 'tasks' | 'memory' | 'learning' | 'logs' +>('overview'); +``` + +- [ ] **Step 3: Add ChannelsTab component** + +Add this component inside `AgentsPage.tsx` (before the main `AgentsPage` component). This is a self-contained component that manages its own state: + +```typescript +const AVAILABLE_CHANNELS = [ + { + type: 'imessage', + name: 'iMessage', + icon: '💬', + description: 'Text from your iPhone, iPad, or Mac', + connectLabel: 'Phone number or contact to monitor', + placeholder: '+15551234567', + }, + { + type: 'slack', + name: 'Slack', + icon: '#', + description: 'Message from any Slack workspace', + connectLabel: 'Slack bot token (xoxb-...)', + placeholder: 'xoxb-...', + }, + { + type: 'whatsapp', + name: 'WhatsApp', + icon: '📱', + description: 'Message via WhatsApp', + connectLabel: 'WhatsApp access token', + placeholder: 'Access token', + }, + { + type: 'twilio', + name: 'SMS (Twilio)', + icon: '📨', + description: 'Text from any phone via Twilio', + connectLabel: 'Twilio phone number', + placeholder: '+15551234567', + }, +]; + +function ChannelsTab({ agentId }: { agentId: string }) { + const [bindings, setBindings] = useState([]); + const [connectingType, setConnectingType] = useState(null); + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + + const loadBindings = useCallback(() => { + fetchAgentChannels(agentId).then(setBindings).catch(() => setBindings([])); + }, [agentId]); + + useEffect(() => { loadBindings(); }, [loadBindings]); + + const handleConnect = async (channelType: string) => { + if (!inputValue.trim()) return; + setLoading(true); + try { + await bindAgentChannel(agentId, channelType, { + identifier: inputValue.trim(), + }); + setConnectingType(null); + setInputValue(''); + loadBindings(); + } catch { + // Could show error toast + } finally { + setLoading(false); + } + }; + + const handleDisconnect = async (bindingId: string) => { + try { + await unbindAgentChannel(agentId, bindingId); + loadBindings(); + } catch { + // Could show error toast + } + }; + + const boundTypes = new Set(bindings.map((b) => b.channel_type)); + + return ( +
+
+

+ Talk to this agent from +

+
+ + {AVAILABLE_CHANNELS.map((ch) => { + const binding = bindings.find((b) => b.channel_type === ch.type); + const isConnecting = connectingType === ch.type; + + return ( +
+
+
+ {ch.icon} +
+
+
{ch.name}
+
+ {ch.description} +
+
+ {binding ? ( + + Active + + ) : ( + + )} +
+ + {/* Expanded: connected details */} + {binding && ( +
+
+ {binding.config?.identifier + ? `Monitoring: ${binding.config.identifier}` + : `Session: ${binding.session_id}`} +
+ +
+ )} + + {/* Expanded: connect form */} + {isConnecting && ( +
+
+ {ch.connectLabel} +
+
+ 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') handleConnect(ch.type); + }} + /> + +
+
+ )} +
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 4: Wire the tab content rendering** + +In the tab content rendering section (around line 1410-1525), add between the existing tab conditionals: + +```typescript +{detailTab === 'channels' && ( + +)} +``` + +- [ ] **Step 5: Add missing imports** + +At the top of `AgentsPage.tsx`, ensure these are imported from `api.ts`: + +```typescript +import { + // ... existing imports + bindAgentChannel, + unbindAgentChannel, +} from '../lib/api'; +``` + +- [ ] **Step 6: Verify TypeScript compiles** + +```bash +cd frontend && npx tsc --noEmit 2>&1 | head -20 +``` + +Expected: No errors. + +- [ ] **Step 7: Build frontend** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Build succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/pages/AgentsPage.tsx frontend/src/lib/api.ts +git commit -m "feat: add Channels tab to agent detail view with connect/disconnect UI" +``` + +--- + +### Task 2: Start iMessage daemon when binding iMessage channel + +**Files:** +- Modify: `src/openjarvis/server/agent_manager_routes.py` + +The backend `bind_channel` endpoint currently just creates a DB record. For iMessage, it also needs to start the daemon. For other channels, it just stores the config. + +- [ ] **Step 1: Modify the bind_channel endpoint to start iMessage daemon** + +Find the `bind_channel` endpoint (around line 956). Replace it with: + +```python +@agents_router.post("/{agent_id}/channels") +async def bind_channel(agent_id: str, req: BindChannelRequest): + if not manager.get_agent(agent_id): + raise HTTPException(status_code=404, detail="Agent not found") + 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 = DeepResearchAgent( + engine=engine, + model=getattr(engine, "_model", ""), + tools=tools, + ) + + def handler(text: str) -> str: + result = agent.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 +``` + +Note: This requires adding `request: Request` to the function signature: + +```python +@agents_router.post("/{agent_id}/channels") +async def bind_channel( + agent_id: str, req: BindChannelRequest, request: Request, +): +``` + +- [ ] **Step 2: Stop daemon on unbind** + +Find the `unbind_channel` endpoint (around line 967). Add daemon stop before deleting: + +```python +@agents_router.delete("/{agent_id}/channels/{binding_id}") +async def unbind_channel(agent_id: str, binding_id: str): + # Stop iMessage daemon if applicable + binding = manager._get_binding(binding_id) + if binding and binding.get("channel_type") == "imessage": + try: + from openjarvis.channels.imessage_daemon import stop_daemon + stop_daemon() + except Exception: + pass + manager.unbind_channel(binding_id) + return {"status": "unbound"} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/openjarvis/server/agent_manager_routes.py +git commit -m "feat: start/stop iMessage daemon on channel bind/unbind" +``` + +--- + +### Task 3: E2E test — Channels tab in browser + +**Files:** None (manual testing) + +- [ ] **Step 1: Start server and open browser** + +```bash +uv run jarvis serve --host 127.0.0.1 --port 8222 +``` + +Open `http://127.0.0.1:8222`. + +- [ ] **Step 2: Create a DeepResearch agent and check Channels tab** + +1. Go to Agents tab → create from "Personal Deep Research" template +2. Open the agent → click "Channels" tab +3. Verify 4 channels listed: iMessage, Slack, WhatsApp, SMS +4. All should show "Connect" buttons + +- [ ] **Step 3: Connect iMessage channel** + +1. Click "Connect" on iMessage +2. Enter a phone number +3. Click "Connect" +4. Verify it shows "Active" with the phone number + +- [ ] **Step 4: Disconnect** + +1. Click "Disconnect" on the iMessage binding +2. Verify it returns to "Connect" state + +- [ ] **Step 5: Push** + +```bash +git push origin main +``` + +--- + +## Sub-project 2: Connector Setup Instructions + +### Task 4: Add per-connector instruction data + +**Files:** +- Modify: `frontend/src/types/connectors.ts` + +- [ ] **Step 1: Extend ConnectorMeta with setup instructions** + +In `frontend/src/types/connectors.ts`, add a `steps` field to the SOURCE_CATALOG entries. Each step has a `label`, optional `url` (opens in browser), and optional `inputField`: + +```typescript +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: string; + icon: string; + color: string; + description: string; + steps?: SetupStep[]; + inputFields?: Array<{ + name: string; + placeholder: string; + type?: 'text' | 'password'; + }>; +} +``` + +- [ ] **Step 2: Add per-connector steps to SOURCE_CATALOG** + +Update the existing SOURCE_CATALOG entries with setup instructions: + +```typescript +export const SOURCE_CATALOG: ConnectorMeta[] = [ + { + connector_id: 'gmail_imap', + display_name: 'Gmail (IMAP)', + auth_type: 'oauth', + category: 'communication', + icon: 'Mail', + color: 'text-red-400', + description: 'Email via app password', + 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', + 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', + 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', + 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: 'gmail', + display_name: 'Gmail', + auth_type: 'oauth', + category: 'communication', + icon: 'Mail', + color: 'text-red-400', + description: 'Email messages and threads (OAuth)', + steps: [ + { label: 'Click "Authorize" to open Google consent screen' }, + { label: 'Grant read-only access to your Gmail' }, + ], + }, + { + connector_id: 'imessage', + display_name: 'iMessage', + auth_type: 'local', + category: 'communication', + icon: 'MessageSquare', + color: 'text-green-400', + description: 'macOS Messages history', + steps: [ + { label: 'Open System Settings → Privacy & Security → Full Disk Access' }, + { label: 'Enable access for your terminal app or OpenJarvis' }, + ], + }, + { + connector_id: 'obsidian', + display_name: 'Obsidian', + auth_type: 'filesystem', + category: 'documents', + icon: 'FolderOpen', + color: 'text-purple-300', + description: 'Markdown vault', + steps: [ + { label: 'Enter the path to your Obsidian vault folder' }, + ], + 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', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Drive' }, + ], + }, + { + connector_id: 'gcalendar', + display_name: 'Calendar', + auth_type: 'oauth', + category: 'pim', + icon: 'Calendar', + color: 'text-blue-400', + description: 'Events and meetings', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Calendar' }, + ], + }, + { + connector_id: 'gcontacts', + display_name: 'Contacts', + auth_type: 'oauth', + category: 'pim', + icon: 'Users', + color: 'text-blue-400', + description: 'People and contact info', + steps: [ + { label: 'Click "Authorize" to grant read-only access to Google Contacts' }, + ], + }, +]; +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/types/connectors.ts +git commit -m "feat: add per-connector setup instructions and input fields to SOURCE_CATALOG" +``` + +--- + +### Task 5: Build StepByStepPanel component for SourceConnectFlow + +**Files:** +- Modify: `frontend/src/components/setup/SourceConnectFlow.tsx` + +- [ ] **Step 1: Add the StepByStepPanel component** + +Add this component inside `SourceConnectFlow.tsx`, before the main `SourceConnectFlow` component. It replaces the generic OAuth panel for connectors that have `steps` defined: + +```typescript +function StepByStepPanel({ + connector, + onConnect, + onSkip, + isConnecting, +}: { + connector: ConnectorMeta; + onConnect: (req: ConnectRequest) => void; + onSkip: () => void; + isConnecting: boolean; +}) { + const [inputs, setInputs] = useState>({}); + 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 ( +
+
+ + {connector.icon === 'Mail' ? '✉️' : + connector.icon === 'Hash' ? '#️⃣' : + connector.icon === 'FileText' ? '📄' : + connector.icon === 'Mic' ? '🎙️' : + connector.icon === 'FolderOpen' ? '📁' : '🔗'} + + + {connector.display_name} + +
+ + {steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + + {fields.length > 0 && ( +
+ {fields.map((field) => ( + 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', + }} + /> + ))} +
+ )} + +
+ 🔒 Read-only access · No data leaves your device +
+ +
+ + +
+
+ ); +} +``` + +- [ ] **Step 2: Wire StepByStepPanel into the connect flow** + +In the `SourceConnectFlow` component, find where it renders the OAuth/Local/Filesystem panels based on `auth_type`. Modify the rendering logic to check if the connector has `steps` defined, and if so, use `StepByStepPanel` instead of the generic panels: + +```typescript +// In the panel rendering section, add before the existing auth_type checks: +const connectorMeta = SOURCE_CATALOG.find( + (c) => c.connector_id === activeSource.connector_id, +); + +// If connector has steps, use StepByStepPanel regardless of auth_type +if (connectorMeta?.steps) { + return ( + handleConnect(activeSource.connector_id, req)} + onSkip={() => handleSkip(activeIndex)} + isConnecting={activeSource.state === 'connecting'} + /> + ); +} + +// Fallback to existing generic panels for connectors without steps +``` + +- [ ] **Step 3: Import ConnectorMeta and SOURCE_CATALOG** + +Ensure these are imported at the top of `SourceConnectFlow.tsx`: + +```typescript +import { ConnectorMeta, SOURCE_CATALOG, ConnectRequest } from '../../types/connectors'; +``` + +- [ ] **Step 4: Verify TypeScript compiles and build** + +```bash +cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -5 +``` + +Expected: No errors, build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/setup/SourceConnectFlow.tsx +git commit -m "feat: add StepByStepPanel with per-connector setup instructions" +``` + +--- + +### Task 6: E2E test — setup wizard with new connector flow + +**Files:** None (manual testing) + +- [ ] **Step 1: Open the setup wizard** + +Navigate to the setup wizard (or trigger it from the agent creation flow). + +- [ ] **Step 2: Test Gmail IMAP connection** + +1. Select "Gmail (IMAP)" in source picker +2. Verify 3 numbered steps appear with links to Google settings +3. Verify email + password input fields appear +4. Enter credentials and click Connect +5. Verify connection succeeds + +- [ ] **Step 3: Test Notion connection** + +1. Select "Notion" +2. Verify steps show link to Notion Integrations page +3. Verify token input field +4. Paste token, click Connect + +- [ ] **Step 4: Test Slack connection** + +1. Select "Slack" +2. Verify steps show link to Slack Apps page +3. Paste bot token, click Connect + +- [ ] **Step 5: Verify ingest dashboard works with connected sources** + +After connecting sources, verify the IngestDashboard shows sync progress for each. + +- [ ] **Step 6: Push** + +```bash +git push origin main +``` diff --git a/docs/superpowers/specs/2026-03-27-channels-and-messaging-tabs-design.md b/docs/superpowers/specs/2026-03-27-channels-and-messaging-tabs-design.md new file mode 100644 index 00000000..84999969 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-channels-and-messaging-tabs-design.md @@ -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 diff --git a/docs/superpowers/specs/2026-03-27-channels-tab-and-connector-setup-design.md b/docs/superpowers/specs/2026-03-27-channels-tab-and-connector-setup-design.md new file mode 100644 index 00000000..0c167a94 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-channels-tab-and-connector-setup-design.md @@ -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 diff --git a/docs/user-guide/channels-and-connectors.md b/docs/user-guide/channels-and-connectors.md new file mode 100644 index 00000000..99ccd605 --- /dev/null +++ b/docs/user-guide/channels-and-connectors.md @@ -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 +``` diff --git a/frontend/src/components/setup/SourceConnectFlow.tsx b/frontend/src/components/setup/SourceConnectFlow.tsx index 7ca3f52d..b977b275 100644 --- a/frontend/src/components/setup/SourceConnectFlow.tsx +++ b/frontend/src/components/setup/SourceConnectFlow.tsx @@ -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>({}); + 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 ( +
+
+ + {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'} + + + {connector.display_name} + +
+ + {steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + + {fields.length > 0 && ( +
+ {fields.map((field) => ( + 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', + }} + /> + ))} +
+ )} + +
+ Read-only access · No data leaves your device +
+ +
+ + +
+
+ ); +} + // --------------------------------------------------------------------------- // SourceConnectFlow // --------------------------------------------------------------------------- @@ -392,6 +564,13 @@ export function SourceConnectFlow({ Connected + ) : activeCard.steps ? ( + handleConnect(activeEntry.id, req)} + onSkip={() => handleSkip(activeEntry.id)} + isConnecting={activeEntry.state === 'connecting'} + /> ) : activeCard.auth_type === 'filesystem' ? ( ; onToggle: (id: string) => void; }) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9dc4b238..f6a16f45 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -434,6 +434,38 @@ export async function fetchAgentChannels(agentId: string): Promise, +): Promise { + 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 { + 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 { 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 { +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 { 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(); } diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index 1072d5d2..6428008f 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -6,6 +6,8 @@ import { fetchManagedAgents, fetchAgentTasks, fetchAgentChannels, + bindAgentChannel, + unbindAgentChannel, fetchAgentMessages, fetchTemplates, createManagedAgent, @@ -47,7 +49,14 @@ import { ChevronRight, Send, RefreshCw, + Wifi, + Database, + Copy, + Check, } from 'lucide-react'; +import { SOURCE_CATALOG } from '../types/connectors'; +import type { ConnectRequest } from '../types/connectors'; +import { listConnectors, connectSource } from '../lib/connectors-api'; // --------------------------------------------------------------------------- // Status helpers @@ -963,13 +972,22 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge // Detail view — Interact tab // --------------------------------------------------------------------------- +/** AgentMessage extended with optional response metadata for the footer. */ +type InteractMessage = AgentMessage & { _elapsed?: string; _toolCalls?: number }; + function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: string }) { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); + const [waitingForResponse, setWaitingForResponse] = useState(false); + const [progressLabel, setProgressLabel] = useState(''); + const [streamingContent, setStreamingContent] = useState(''); const [currentActivity, setCurrentActivity] = useState(''); const [liveStatus, setLiveStatus] = useState(agentStatus); + const [streamElapsedMs, setStreamElapsedMs] = useState(0); + const [copiedId, setCopiedId] = useState(null); const bottomRef = useRef(null); + const timerRef = useRef | null>(null); const loadData = useCallback(async () => { try { @@ -993,6 +1011,16 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s useEffect(() => { setLiveStatus(agentStatus); }, [agentStatus]); + // Clean up elapsed-time timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, []); + // Scroll to bottom only on initial load, not on every poll update. const hasScrolled = useRef(false); useEffect(() => { @@ -1002,17 +1030,82 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s } }, [messages]); + // Scroll to bottom when streaming content updates + useEffect(() => { + if (streamingContent) { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [streamingContent]); + async function handleSend(mode: 'immediate' | 'queued') { if (!input.trim()) return; + const text = input.trim(); + setInput(''); setSending(true); + + // Show user message immediately as a local bubble + const localMsg: AgentMessage = { + id: `local-${Date.now()}`, + agent_id: agentId, + direction: 'user_to_agent', + content: text, + mode, + status: 'delivered', + created_at: Date.now() / 1000, + }; + setMessages((prev) => [localMsg, ...prev]); + setSending(false); + setWaitingForResponse(true); + setProgressLabel('Initializing agent...'); + setStreamingContent(''); + + // Start elapsed-time timer + const startTime = Date.now(); + setStreamElapsedMs(0); + timerRef.current = setInterval(() => { + setStreamElapsedMs(Date.now() - startTime); + }, 100); + + let toolCount = 0; try { - await sendAgentMessage(agentId, input.trim(), mode); - setInput(''); + const response = await sendAgentMessage(agentId, text, mode, { + onProgress: (label) => { + setProgressLabel(label); + toolCount++; + }, + onContentDelta: (_delta, full) => setStreamingContent(full), + onDone: () => { + // Clear streaming state — final content comes from the response object + setStreamingContent(''); + }, + }); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + // Add the agent's response as a local bubble immediately + if (response && response.content) { + setMessages((prev) => [ + { + ...response, + id: response.id || `response-${Date.now()}`, + direction: 'agent_to_user' as const, + _elapsed: elapsed, + _toolCalls: toolCount, + }, + ...prev, + ]); + } + // Also refresh from server to sync any persisted messages await loadData(); } catch { // ignore } finally { - setSending(false); + setWaitingForResponse(false); + setStreamingContent(''); + setProgressLabel(''); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setStreamElapsedMs(0); } } @@ -1022,13 +1115,10 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s .filter((m) => m.direction === 'user_to_agent' || m.content.trim()) .reverse(); - const isAgentWorking = liveStatus === 'running'; - const hasPending = messages.some((m) => m.status === 'pending'); - return (
- {displayMessages.length === 0 && !isAgentWorking && ( + {displayMessages.length === 0 && !waitingForResponse && (
No messages yet. Send a message to interact with this agent.
@@ -1054,11 +1144,50 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s

{msg.status === 'pending' ? 'sending...' : new Date(msg.created_at * 1000).toLocaleTimeString()}

+ {msg.direction === 'agent_to_user' && (msg._elapsed || (msg._toolCalls && msg._toolCalls > 0)) && ( +
+ {msg._elapsed && {msg._elapsed}s} + {msg._toolCalls != null && msg._toolCalls > 0 && ( + {msg._toolCalls} tool {msg._toolCalls === 1 ? 'call' : 'calls'} + )} + +
+ )}
))} - {/* Progress indicator with live activity from the executor */} - {(isAgentWorking || hasPending) && ( + {/* Progress indicator — shown when waiting but no streamed content yet */} + {(waitingForResponse || sending) && !streamingContent && (
- {sending ? 'Sending message...' : currentActivity || 'Agent is thinking...'} + {sending + ? 'Sending message...' + : progressLabel || 'Agent is thinking...'}
)} + {/* Streaming content bubble — real-time response as it arrives */} + {waitingForResponse && streamingContent && ( +
+
+ {progressLabel && ( +
+ + {progressLabel} +
+ )} +

{streamingContent}

+

+ {streamElapsedMs > 0 && `${(streamElapsedMs / 1000).toFixed(1)}s elapsed`} +

+
+
+ )}
{/* Input area */} @@ -1098,7 +1253,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
+
+ {isReconnecting && meta?.steps && ( +
+
+ Re-enter credentials to reconnect this source. +
+ {meta.steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} → + + )} +
+ ))} + {meta.inputFields && ( + handleConnect(c.connector_id, req)} + /> + )} +
+ )} + + ); + })} + + )} + + {/* Not connected grid */} + {notConnected.length > 0 && ( +
+ {notConnected.map((c) => { + const meta = getMeta(c.connector_id); + const isExpanded = expandedId === c.connector_id; + + return ( +
+
+ setExpandedId(isExpanded ? null : c.connector_id) + } + > + {iconMap[c.connector_id] || '\uD83D\uDD17'} +
+
+ {c.display_name} +
+
+ Not connected +
+
+ + {isExpanded ? '\u2715 Close' : '+ Add'} + +
+ + {/* Inline setup panel */} + {isExpanded && meta?.steps && ( +
+ {meta.steps.map((step, i) => ( +
+
+ STEP {i + 1} +
+
+ {step.label} +
+ {step.url && ( + + {step.urlLabel || 'Open'} {'\u2192'} + + )} +
+ ))} + {meta.inputFields && ( + + handleConnect(c.connector_id, req) + } + /> + )} +
+ {'\uD83D\uDD12'} Read-only access {'\u00B7'} No data leaves your device +
+
+ )} +
+ ); + })} +
+ )} + + ); +} + +function InlineConnectForm({ + fields, + loading, + onSubmit, +}: { + fields: Array<{ name: string; placeholder: string; type?: string }>; + loading: boolean; + onSubmit: (req: ConnectRequest) => void; +}) { + const [inputs, setInputs] = useState>({}); + + 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 ( +
+ {fields.map((f) => ( + 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', + }} + /> + ))} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Messaging tab component +// --------------------------------------------------------------------------- + +interface ChannelField { + key: string; + label: string; + placeholder: string; + type?: 'text' | 'password'; + required?: boolean; +} + +interface MessagingChannelConfig { + type: string; + name: string; + icon: string; + description: string; + setupSteps: string[]; + fields: ChannelField[]; + activeLabel: (cfg: Record) => string; + howToUse: (cfg: Record) => string; +} + +const MESSAGING_CHANNELS: MessagingChannelConfig[] = [ + { + type: 'imessage', + name: 'iMessage', + icon: '\uD83D\uDCAC', + description: 'Talk to your agent via iMessage from your iPhone or another Apple device', + setupSteps: [ + 'Your agent monitors iMessage on this Mac using the Messages app.', + 'Enter your phone number or Apple ID below — your agent will watch for texts from that contact.', + 'Then open iMessage on your iPhone and text this Mac. Your agent reads the message, researches your data, and responds in the same conversation.', + 'Requires macOS Full Disk Access + Accessibility permissions (System Settings \u2192 Privacy & Security).', + ], + fields: [ + { key: 'identifier', label: 'Your phone number or Apple ID', placeholder: '+15551234567 or you@icloud.com', required: true }, + ], + activeLabel: (cfg) => `Monitoring messages from ${(cfg.identifier as string) || '?'}`, + howToUse: (cfg) => `Open iMessage on your phone and text this Mac from ${(cfg.identifier as string) || 'your phone'}. Your agent will respond automatically.`, + }, + { + type: 'slack', + name: 'Slack', + icon: '#', + description: 'DM your agent in any Slack workspace', + setupSteps: [ + '1. Go to api.slack.com/apps \u2192 Create New App \u2192 From Scratch', + '2. Under OAuth & Permissions, add bot scopes: chat:write, channels:history, im:history, im:read', + '3. Install the app to your workspace and authorize it', + '4. Copy the Bot User OAuth Token (starts with xoxb-) from the OAuth page', + '5. For real-time DMs: enable Socket Mode, create an App-Level Token (starts with xapp-)', + ], + fields: [ + { key: 'bot_token', label: 'Bot Token', placeholder: 'xoxb-...', type: 'password', required: true }, + { key: 'app_token', label: 'App Token (optional \u2014 enables receiving DMs)', placeholder: 'xapp-...', type: 'password' }, + ], + activeLabel: () => 'Connected to Slack', + howToUse: () => 'Open Slack and DM @Jarvis to talk to your agent.', + }, + { + type: 'whatsapp', + name: 'WhatsApp', + icon: '\uD83D\uDCF1', + description: 'Message your agent on WhatsApp \u2014 runs locally, no cloud API needed', + setupSteps: [ + 'OpenJarvis connects to WhatsApp using the Baileys protocol (local, on-device).', + 'Click Connect below. A QR code will appear in the server terminal.', + 'On your phone: open WhatsApp \u2192 Settings \u2192 Linked Devices \u2192 Link a Device, then scan the QR code.', + 'Once linked, send a WhatsApp message to the connected number to talk to your agent.', + ], + fields: [ + { key: 'assistant_name', label: 'Agent display name (optional)', placeholder: 'Jarvis' }, + ], + activeLabel: () => 'WhatsApp linked', + howToUse: () => 'Send a WhatsApp message to the linked number. Your agent will respond in the chat.', + }, + { + type: 'twilio', + name: 'SMS', + icon: '\uD83D\uDCE8', + description: 'Text your agent from any phone via Twilio', + setupSteps: [ + '1. Create a free Twilio account at twilio.com/try-twilio', + '2. In the Twilio Console, buy a phone number (or use the trial number)', + '3. Copy your Account SID and Auth Token from the Console Dashboard', + '4. Enter all three values below', + 'After setup, text the Twilio number from any phone to talk to your agent.', + ], + fields: [ + { key: 'account_sid', label: 'Account SID', placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', required: true }, + { key: 'auth_token', label: 'Auth Token', placeholder: 'Your Twilio auth token', type: 'password', required: true }, + { key: 'phone_number', label: 'Twilio Phone Number', placeholder: '+15551234567', required: true }, + ], + activeLabel: (cfg) => { + const num = (cfg.phone_number as string) || ''; + return num ? `SMS active on ${num}` : 'SMS connected via Twilio'; + }, + howToUse: (cfg) => { + const num = (cfg.phone_number as string) || 'your Twilio number'; + return `Text ${num} from any phone to talk to your agent.`; + }, + }, +]; + +function MessagingTab({ agentId }: { agentId: string }) { + const [bindings, setBindings] = useState([]); + const [setupType, setSetupType] = useState(null); + const [formValues, setFormValues] = useState>({}); + const [loading, setLoading] = useState(false); + + const loadBindings = useCallback(() => { + fetchAgentChannels(agentId).then(setBindings).catch(() => setBindings([])); + }, [agentId]); + + useEffect(() => { loadBindings(); }, [loadBindings]); + + const setField = (key: string, value: string) => { + setFormValues((prev) => ({ ...prev, [key]: value })); + }; + + const handleSetup = async (ch: MessagingChannelConfig) => { + // Check required fields + const missing = ch.fields.filter( + (f) => f.required && !formValues[f.key]?.trim(), + ); + if (missing.length > 0) return; + + setLoading(true); + try { + const config: Record = {}; + for (const f of ch.fields) { + const v = formValues[f.key]?.trim(); + if (v) config[f.key] = v; + } + await bindAgentChannel(agentId, ch.type, config); + setSetupType(null); + setFormValues({}); + loadBindings(); + } catch { /* */ } finally { setLoading(false); } + }; + + const handleRemove = async (bindingId: string) => { + try { + await unbindAgentChannel(agentId, bindingId); + loadBindings(); + } catch { /* */ } + }; + + const inputStyle: React.CSSProperties = { + width: '100%', padding: '6px 10px', + background: 'var(--color-bg-secondary)', + border: '1px solid var(--color-border)', + borderRadius: 4, color: 'var(--color-text)', + fontSize: 12, boxSizing: 'border-box', + }; + + return ( +
+
+ Connect a messaging platform so you can talk to your agent from your phone or other devices. +
+ + {MESSAGING_CHANNELS.map((ch) => { + const binding = bindings.find((b) => b.channel_type === ch.type); + const cfg = (binding?.config || {}) as Record; + const isSetup = setupType === ch.type; + + // Check if required fields are filled + const canConnect = ch.fields.every( + (f) => !f.required || formValues[f.key]?.trim(), + ); + + return ( +
+ {/* Header row */} +
+ {ch.icon} +
+
{ch.name}
+
+ {binding ? ch.activeLabel(cfg) : ch.description} +
+
+ {binding ? ( +
+ Active + +
+ ) : ( + + )} +
+ + {/* Active state: how to use */} + {binding && ( +
+
+ {'\u2192'} + {ch.howToUse(cfg)} +
+
+ )} + + {/* Setup form */} + {isSetup && ( +
+ {/* Setup instructions */} +
+ {ch.setupSteps.map((step, i) => ( +
+ {step} +
+ ))} +
+ + {/* Form fields */} + {ch.fields.map((field) => ( +
+ + setField(field.key, e.target.value)} + placeholder={field.placeholder} + style={inputStyle} + /> +
+ ))} + + {/* Connect button */} + +
+ )} +
+ ); + })} +
+ ); +} + // --------------------------------------------------------------------------- // Learning tab component // --------------------------------------------------------------------------- @@ -1204,29 +2059,110 @@ function LearningTab({ agentId, learningEnabled }: { agentId: string; learningEn function LogsTab({ agentId }: { agentId: string }) { const [traces, setTraces] = useState([]); + const [learningEntries, setLearningEntries] = useState([]); const [expandedTrace, setExpandedTrace] = useState(null); - useEffect(() => { - fetchAgentTraces(agentId).then(setTraces).catch(() => {}); + const loadData = useCallback(async () => { + try { + const [t, l] = await Promise.all([ + fetchAgentTraces(agentId), + fetchLearningLog(agentId), + ]); + setTraces(t); + setLearningEntries(l); + } catch { + // ignore + } }, [agentId]); + useEffect(() => { + loadData(); + const interval = setInterval(loadData, 5000); + return () => clearInterval(interval); + }, [loadData]); + + // Merge traces and learning entries into a unified timeline + type TimelineEntry = + | { kind: 'trace'; data: AgentTrace; ts: number } + | { kind: 'learning'; data: LearningLogEntry; ts: number }; + + const timeline: TimelineEntry[] = [ + ...traces.map((t): TimelineEntry => ({ kind: 'trace', data: t, ts: t.started_at })), + ...learningEntries.map((e): TimelineEntry => ({ kind: 'learning', data: e, ts: e.created_at })), + ].sort((a, b) => b.ts - a.ts); + + const learningEventColor = (eventType: string) => { + if (eventType === 'query_start') return '#3b82f6'; + if (eventType === 'query_complete') return '#22c55e'; + if (eventType === 'tool_call') return '#f59e0b'; + if (eventType === 'tool_result') return '#8b5cf6'; + if (eventType === 'query_error') return '#ef4444'; + return 'var(--color-text-secondary)'; + }; + + const learningEventLabel = (eventType: string) => { + if (eventType === 'query_start') return 'Query'; + if (eventType === 'query_complete') return 'Complete'; + if (eventType === 'tool_call') return 'Tool Call'; + if (eventType === 'tool_result') return 'Tool Result'; + if (eventType === 'query_error') return 'Error'; + return eventType; + }; + return (
- Execution Traces + Activity Log - {traces.length} trace{traces.length !== 1 ? 's' : ''} + {timeline.length} entr{timeline.length !== 1 ? 'ies' : 'y'} (auto-refreshing)
- {traces.length === 0 ? ( + {timeline.length === 0 ? (
- No execution traces yet. Run the agent to generate traces. + No activity yet. Send a message or run the agent to generate logs.
) : (
- {traces.map((t) => { + {timeline.map((entry) => { + if (entry.kind === 'learning') { + const e = entry.data; + return ( +
+
+
+ + + {learningEventLabel(e.event_type)} + +
+ + {formatRelativeTime(e.created_at)} + +
+
+ {e.description} +
+
+ ); + } + + // Trace entry + const t = entry.data; const errorDetail = t.metadata?.error_detail as | { error_type: string; error_message: string; suggested_action: string } | undefined; @@ -1235,7 +2171,7 @@ function LogsTab({ agentId }: { agentId: string }) { return (
isError && errorDetail && setExpandedTrace(isExpanded ? null : t.id)} @@ -1247,6 +2183,12 @@ function LogsTab({ agentId }: { agentId: string }) { style={{ background: t.outcome === 'success' ? '#22c55e' : '#ef4444' }} /> {t.outcome} + + Trace + {errorDetail && ( ([]); const [templates, setTemplates] = useState([]); const [showWizard, setShowWizard] = useState(false); - const [detailTab, setDetailTab] = useState<'overview' | 'interact' | 'tasks' | 'memory' | 'learning' | 'logs'>('overview'); + const [detailTab, setDetailTab] = useState<'overview' | 'interact' | 'channels' | 'messaging' | 'tasks' | 'memory' | 'learning' | 'logs'>('overview'); const refresh = useCallback(async () => { try { @@ -1436,6 +2378,8 @@ export function AgentsPage() { 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 }, @@ -1471,13 +2415,22 @@ export function AgentsPage() {
{/* Header actions */}
- + {detailTab === 'interact' ? ( + + Chat ready — just type below + + ) : ( + + )} {(selectedAgent.status === 'running' || selectedAgent.status === 'idle') && (