From f22beb82d276042a7fa158138ac6069e0d4d0819 Mon Sep 17 00:00:00 2001 From: yawbtng <154343001+yawbtng@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:59:15 -0500 Subject: [PATCH 1/2] Add LangGraph agent backend with SSE streaming, notifications UI, and workspace orchestrator - Implement custom LangGraph StateGraph agent with intent routing, tool execution nodes, and human-in-the-loop approval flow (backend/app/agent/) - Add FastAPI SSE streaming endpoints for real-time chat (backend/app/api/chat.py) - Add session, task, health, and notification API routes (backend/app/api/) - Implement GWS CLI runner for Google Workspace tools (backend/app/core/gws_runner.py) - Add heartbeat service for proactive nudges (backend/app/services/heartbeat.py) - Add in-memory store for session/task state (backend/app/services/store.py) - Add Pydantic settings config loading from .env (backend/app/config.py) - Add frontend Notifications panel with real-time polling (frontend/src/components/Notifications/) - Add NotificationsContext for global notification state (frontend/src/contexts/) - Update layout and sidebar to integrate notifications - Add start.sh convenience script for running both services - Add .langgraph_api/ to gitignore --- .gitignore | 1 + backend/app/__init__.py | 0 backend/app/agent/__init__.py | 0 backend/app/agent/graph.py | 47 ++++ backend/app/agent/nodes.py | 223 ++++++++++++++++ backend/app/agent/prompts.py | 241 ++++++++++++++++++ backend/app/agent/state.py | 20 ++ backend/app/agent/tools/__init__.py | 0 backend/app/agent/tools/data_tools.py | 139 ++++++++++ backend/app/agent/tools/exa_tools.py | 67 +++++ backend/app/agent/tools/gws.py | 174 +++++++++++++ backend/app/agent/tools/memory_tools.py | 93 +++++++ backend/app/agent/tools/notification_tools.py | 48 ++++ backend/app/agent/tools/registry.py | 24 ++ backend/app/api/__init__.py | 0 backend/app/api/chat.py | 73 ++++++ backend/app/api/health.py | 37 +++ backend/app/api/notifications.py | 75 ++++++ backend/app/api/sessions.py | 36 +++ backend/app/api/tasks.py | 50 ++++ backend/app/config.py | 39 +++ backend/app/core/__init__.py | 0 backend/app/core/gws_runner.py | 176 +++++++++++++ backend/app/core/sse.py | 146 +++++++++++ backend/app/main.py | 57 ++++- backend/app/services/__init__.py | 0 backend/app/services/heartbeat.py | 109 ++++++++ backend/app/services/store.py | 194 ++++++++++++++ backend/langgraph_entry.py | 24 ++ backend/requirements.txt | 12 +- frontend/src/app/layout.tsx | 53 ++-- .../Notifications/NotificationCard.tsx | 92 +++++++ .../Notifications/NotificationsPanel.tsx | 40 +++ frontend/src/components/Sidebar/index.tsx | 40 ++- .../src/contexts/NotificationsContext.tsx | 130 ++++++++++ start.sh | 14 + 36 files changed, 2443 insertions(+), 31 deletions(-) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/agent/__init__.py create mode 100644 backend/app/agent/graph.py create mode 100644 backend/app/agent/nodes.py create mode 100644 backend/app/agent/prompts.py create mode 100644 backend/app/agent/state.py create mode 100644 backend/app/agent/tools/__init__.py create mode 100644 backend/app/agent/tools/data_tools.py create mode 100644 backend/app/agent/tools/exa_tools.py create mode 100644 backend/app/agent/tools/gws.py create mode 100644 backend/app/agent/tools/memory_tools.py create mode 100644 backend/app/agent/tools/notification_tools.py create mode 100644 backend/app/agent/tools/registry.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/chat.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/api/notifications.py create mode 100644 backend/app/api/sessions.py create mode 100644 backend/app/api/tasks.py create mode 100644 backend/app/config.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/gws_runner.py create mode 100644 backend/app/core/sse.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/heartbeat.py create mode 100644 backend/app/services/store.py create mode 100644 backend/langgraph_entry.py create mode 100644 frontend/src/components/Notifications/NotificationCard.tsx create mode 100644 frontend/src/components/Notifications/NotificationsPanel.tsx create mode 100644 frontend/src/contexts/NotificationsContext.tsx create mode 100755 start.sh diff --git a/.gitignore b/.gitignore index 0ec2a59..35c9e40 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ Cargo.lock # Environment files with credentials temp.env *.env.local +.langgraph_api/ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agent/graph.py b/backend/app/agent/graph.py new file mode 100644 index 0000000..4579c38 --- /dev/null +++ b/backend/app/agent/graph.py @@ -0,0 +1,47 @@ +"""FRIDAY agent graph — ReAct loop architecture. + +Graph structure: + START → preprocess → agent ←→ tools (loop) → postprocess → END + +The agent autonomously decides when to call tools and when to respond. +No separate intent classifier LLM call — just keyword-based triage detection. +""" + +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import StateGraph, START, END + +from .state import FridayState +from .nodes import ( + preprocess_node, + agent_node, + tool_executor_node, + postprocess_node, + should_continue, +) + +# Build the graph +graph = StateGraph(FridayState) + +# Nodes +graph.add_node("preprocess", preprocess_node) +graph.add_node("agent", agent_node) +graph.add_node("tools", tool_executor_node) +graph.add_node("postprocess", postprocess_node) + +# Edges +graph.add_edge(START, "preprocess") +graph.add_edge("preprocess", "agent") +graph.add_conditional_edges( + "agent", + should_continue, + {"tools": "tools", "postprocess": "postprocess"}, +) +graph.add_edge("tools", "agent") # ReAct loop: tools always go back to agent +graph.add_edge("postprocess", END) + +# Compile with checkpointer for FastAPI standalone mode +checkpointer = MemorySaver() +friday_graph = graph.compile(checkpointer=checkpointer) + +# For LangGraph Platform (it provides its own checkpointer) +friday_graph_platform = graph.compile() diff --git a/backend/app/agent/nodes.py b/backend/app/agent/nodes.py new file mode 100644 index 0000000..d93140e --- /dev/null +++ b/backend/app/agent/nodes.py @@ -0,0 +1,223 @@ +"""FRIDAY agent nodes — ReAct loop with autonomous tool execution.""" + +import json +import logging + +from langchain_core.messages import AIMessage, SystemMessage, ToolMessage +from langchain_openai import ChatOpenAI + +from config import settings +from services.store import store + +from .prompts import build_system_prompt, TRIAGE_KEYWORDS +from .tools.registry import ALL_TOOLS + +logger = logging.getLogger(__name__) + +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + +# ---------- LLM singletons ---------- + +_llm = None + + +def _get_llm(): + global _llm + if _llm is None: + _llm = ChatOpenAI( + model="google/gemini-2.5-flash", + api_key=settings.openrouter_api_key, + base_url=OPENROUTER_BASE_URL, + temperature=0.3, + ) + return _llm + + +# ---------- Helper ---------- + +def _extract_text(msg) -> str: + """Extract plain text from a message, handling multimodal content blocks.""" + raw = msg.content if hasattr(msg, "content") else str(msg) + if isinstance(raw, list): + return " ".join( + block.get("text", "") if isinstance(block, dict) else str(block) + for block in raw + ) + return str(raw) + + +def _detect_intent(text: str) -> str: + """Fast keyword-based intent detection. No LLM call needed.""" + text_lower = text.lower() + for keyword in TRIAGE_KEYWORDS: + if keyword in text_lower: + return "triage" + return "chat" + + +# ---------- Nodes ---------- + +async def preprocess_node(state: dict) -> dict: + """Load user context, tasks, and semantic memory.""" + user_id = state.get("user_id", "default") + + # Load user context from in-memory store + user_context_list = store.get_user_context(user_id) + if isinstance(user_context_list, list): + user_context = { + entry.get("context_key", str(i)): entry + for i, entry in enumerate(user_context_list) + } + else: + user_context = user_context_list if isinstance(user_context_list, dict) else {} + + # Load active tasks + active_tasks = store.get_tasks(user_id, status="pending", limit=10) + active_tasks += store.get_tasks(user_id, status="in_progress", limit=10) + + # Search semantic memory + semantic_context = [] + messages = state.get("messages", []) + if messages: + content = _extract_text(messages[-1]) + try: + from .tools.memory_tools import _sm_client + if _sm_client: + results = await _sm_client.search.execute( + q=content[:500], + container_tags=[user_id], + limit=3, + ) + if hasattr(results, "results"): + semantic_context = [ + {"content": r.content or "", "score": getattr(r, "score", None)} + for r in results.results + if r.content + ] + except Exception as e: + logger.warning(f"Semantic memory search failed: {e}") + + # Detect triage intent from last message + last_text = _extract_text(messages[-1]) if messages else "" + intent = _detect_intent(last_text) + + # Trim messages to last 20 + trimmed = messages[-20:] if len(messages) > 20 else messages + + return { + "user_context": user_context, + "active_tasks": active_tasks, + "semantic_context": semantic_context, + "messages": trimmed, + "intent": intent, + } + + +async def agent_node(state: dict) -> dict: + """Core ReAct agent: call LLM with tools, let it decide what to do. + + The LLM either: + - Returns tool_calls → routes to tool_executor (loop continues) + - Returns text response → routes to postprocess (loop ends) + """ + llm = _get_llm() + llm_with_tools = llm.bind_tools(ALL_TOOLS) + + system_prompt = build_system_prompt(state) + msgs = [SystemMessage(content=system_prompt)] + list(state.get("messages", [])) + + response = await llm_with_tools.ainvoke(msgs) + + return {"messages": [response]} + + +async def tool_executor_node(state: dict) -> dict: + """Execute tool calls from the agent's last message.""" + messages = state.get("messages", []) + last_msg = messages[-1] + + if not hasattr(last_msg, "tool_calls") or not last_msg.tool_calls: + return {} + + tool_map = {t.name: t for t in ALL_TOOLS} + new_messages = [] + user_id = state.get("user_id", "default") + + for tc in last_msg.tool_calls: + tool_name = tc["name"] + tool_args = tc["args"] + + if tool_name not in tool_map: + new_messages.append(ToolMessage( + content=f"Unknown tool: '{tool_name}'. Available tools: {', '.join(tool_map.keys())}", + tool_call_id=tc["id"], + )) + continue + + try: + config = {"configurable": {"user_id": user_id}} + result = await tool_map[tool_name].ainvoke(tool_args, config=config) + new_messages.append(ToolMessage( + content=str(result), + tool_call_id=tc["id"], + )) + except Exception as e: + logger.error(f"Tool error ({tool_name}): {e}") + new_messages.append(ToolMessage( + content=f"Error executing {tool_name}: {e}. Try a different approach or check the command syntax with gws_schema.", + tool_call_id=tc["id"], + )) + + return {"messages": new_messages} + + +def should_continue(state: dict) -> str: + """Route after agent: if tool calls exist, loop back; otherwise finish.""" + messages = state.get("messages", []) + if not messages: + return "postprocess" + + last_msg = messages[-1] + if hasattr(last_msg, "tool_calls") and last_msg.tool_calls: + return "tools" + return "postprocess" + + +async def postprocess_node(state: dict) -> dict: + """Save session data and learn patterns.""" + session_id = state.get("session_id", "") + user_id = state.get("user_id", "default") + + # Save last assistant message + messages = state.get("messages", []) + if messages: + last_msg = messages[-1] + if hasattr(last_msg, "type") and last_msg.type == "ai" and last_msg.content: + store.add_message(session_id, "assistant", last_msg.content) + + store.update_session(session_id) + + # Store session summary to semantic memory if tools were used + tool_msgs = [m for m in messages[-10:] if hasattr(m, "type") and m.type == "tool"] + if tool_msgs: + try: + from .tools.memory_tools import _sm_client + if _sm_client: + # Build a brief summary from the last AI message + last_ai = None + for m in reversed(messages): + if hasattr(m, "type") and m.type == "ai" and m.content: + last_ai = m + break + + if last_ai: + summary = f"Session {session_id}: {last_ai.content[:200]}" + await _sm_client.add( + content=summary, + container_tag=user_id, + metadata={"type": "session_summary", "session_id": session_id}, + ) + except Exception as e: + logger.warning(f"Failed to store session summary: {e}") + + return {} diff --git a/backend/app/agent/prompts.py b/backend/app/agent/prompts.py new file mode 100644 index 0000000..b448dc7 --- /dev/null +++ b/backend/app/agent/prompts.py @@ -0,0 +1,241 @@ +"""FRIDAY prompt layers — composable system prompt architecture.""" + +from datetime import datetime, timezone + +TRIAGE_KEYWORDS = [ + "overwhelmed", "stressed", "drowning", "too much", + "can't cope", "help me focus", "prioritize", "what should i do", +] + +ENVIRONMENT_LAYER = """You are powered by Gemini via OpenRouter. +Current date and time: {date} +Day of week: {day_of_week} +User timezone: {timezone} + +When the user says "today", they mean {today_date}. When they say "tomorrow", they mean {tomorrow_date}. +When creating calendar events, use ISO 8601 datetime format with the user's timezone offset. + +Capabilities: +- Full access to Google Workspace via gws CLI (Gmail, Calendar, Drive, Docs, Sheets, Chat, Meet, Tasks, Keep, Forms, Slides, and more) +- Pre-built workflow commands (+meeting-prep, +standup-report, +email-to-task, +triage) +- Dynamic Google Workspace API schema discovery via gws_schema tool +- Web search via Exa for real-time information (news, research, company info, documentation) +- Task list management +- Semantic memory that learns user patterns over time +""" + +IDENTITY_LAYER = """You are FRIDAY, an AI workspace assistant designed specifically for people with ADHD. + +Your core philosophy: +- The user is NOT lazy. They are overwhelmed. Your job is to reduce cognitive load. +- Lead with the most important thing. Never present a wall of text. +- Be warm but direct. No fluff. No "Great question!" — just help. +- When the user says "I'm overwhelmed", switch to triage mode immediately. +- Proactively surface what matters. Don't wait to be asked. + +Your personality: +- Like a calm, competent executive assistant who genuinely cares +- Brief by default. Detailed only when asked. +- Uses bullet points and structure, never paragraphs +- Acknowledges feelings without being patronizing +""" + + +def build_context_layer(state: dict) -> str: + parts = [""] + + if state.get("active_tasks"): + tasks = state["active_tasks"][:5] + task_lines = [] + for t in tasks: + task_lines.append(f"- [{t.get('priority', 'medium')}] {t.get('title', 'Untitled')} ({t.get('status', 'pending')})") + parts.append(f"Active tasks ({len(state['active_tasks'])}):\n" + "\n".join(task_lines)) + + if state.get("user_context"): + ctx = state["user_context"] + if isinstance(ctx, dict): + parts.append("Known preferences and patterns:") + for k, v in ctx.items(): + if isinstance(v, dict): + parts.append(f"- {k}: {v.get('context_value', v)}") + else: + parts.append(f"- {k}: {v}") + + if state.get("semantic_context"): + memories = state["semantic_context"][:3] + parts.append("Relevant memories:") + for m in memories: + content = m.get("content") or "" + if len(content) > 200: + content = content[:200] + "..." + parts.append(f"- {content}") + + parts.append("") + return "\n".join(parts) + + +INSTRUCTION_LAYER = """## Behavioral Rules + +### Autonomy — YOU ARE FULLY AUTONOMOUS +- DO NOT ask for permission before ANY read operation. Just do it. +- NEVER say "Do you approve this search?" or "Shall I look that up?" — JUST DO IT. +- Read emails, calendar, drive, tasks, contacts FREELY without asking. +- Search the web FREELY without asking. +- SEND emails and chat messages FREELY without asking — the user trusts you. +- ONLY ask for confirmation before CREATING documents/events, UPDATING documents/events, or DELETING anything. + - The system automatically applies dry-run for these operations — just execute and show the preview. +- When the user asks you to do something, DO IT immediately. Don't describe what you'll do — just do it. +- If you need multiple pieces of information, call multiple tools IN PARALLEL. +- After showing a dry-run preview, if the user confirms (e.g., "yes", "do it", "go ahead", "yup"), re-call gws() with the SAME command and force_execute=True. Do NOT add --force to the command string. + +### Self-Correction — FIX YOUR OWN ERRORS SILENTLY +- If a gws command fails, DO NOT apologize, DO NOT explain the error to the user, DO NOT ask questions. +- Just silently fix it and retry. The user should never see your debugging process. +- You have THREE discovery tools to help you fix errors: + 1. gws_help("service subcommand") — see CLI usage, flags, and examples + 2. gws_schema("service.resource.method") — see API parameters and body schema + 3. gws_help("service") — list available subcommands and helpers +- When a command fails: + 1. DO NOT output any text to the user. Immediately call gws_help or gws_schema. + 2. Read the help/schema output. Construct the corrected command. + 3. Retry with the corrected command. + 4. Only speak to the user once you have a successful result OR after 3 failed attempts. +- NEVER say "I'm sorry", "Let me try again", "It seems I had trouble with..." — just fix it silently. +- NEVER ask the user to help you fix a command. Figure it out yourself. +- NEVER retry the exact same failing command — always vary your approach. +- NEVER ask the user questions unless you genuinely lack information that ONLY they can provide (e.g., "which calendar?", "what time?"). Technical errors are YOUR problem to solve. + +### gws CLI Usage +- CRITICAL: --params and --json values MUST be valid JSON with DOUBLE QUOTES. + - CORRECT: --params {"q": "name contains 'hackathon'"} + - WRONG: --params {'q': 'name contains "hackathon"'} + - Single quotes are NOT valid JSON. Always use double quotes for keys and string values. +- Helper commands (PREFERRED — simpler and more reliable): + - gmail: +triage, +send, +reply + - calendar: +agenda, +insert + - workflow: +meeting-prep, +standup-report + - docs: +write + - drive: +upload + - sheets: +read +- Raw API calls (use when helpers don't cover your need): + - 'gmail users messages list --params {"userId": "me", "q": "is:unread"}' + - 'calendar events list --params {"calendarId": "primary"}' + - 'drive files list --params {"q": "name contains 'frontiers hackathon'"}' + - 'calendar events insert --params {"calendarId": "primary"} --json {"summary": "Meeting", "start": {"dateTime": "..."}, "end": {"dateTime": "..."}}' +- --params = URL/query/path parameters (calendarId, userId, q). --json = request body (summary, start, end, location). +- NEVER put calendarId or userId in --json. They go in --params. +- Schema discovery: call gws_schema("service.resource.method") to learn exact parameter formats +- ALWAYS prefer helper commands over raw API calls + +### Response Style +- Lead with the action or answer. Context comes after if needed. +- Use bullet points for lists of 3+ items. +- Bold the most important item in any list. +- Never say "I'd be happy to help", "Do you approve?", or similar filler. + +### Memory and Learning +- Remember user patterns and preferences across sessions +- Store important context using memory_store tool +- Check memory_search before asking questions the user may have answered before + +### Triage Mode +When the user expresses overwhelm (keywords: overwhelmed, too much, can't cope, stressed, drowning): +1. Acknowledge briefly: "I hear you. Let's simplify." +2. Fetch unread emails, today's calendar, and pending tasks — ALL IN PARALLEL. +3. Select the TOP 3 priorities based on: urgency > impact > effort. +4. Present ONLY those 3 items. Hide everything else. +5. Ask: "Which one should we tackle first?" + +### Error Handling +- Fix errors silently. Do not narrate your debugging process to the user. +- After 3 failed attempts at the same operation, tell the user briefly what went wrong (one sentence, no apologies). +- Never give up after just one error — you're resourceful. +""" + +PROACTIVE_LAYER = """## AUTONOMOUS TRANSCRIPT PROCESSING MODE + +You have been given a meeting transcript to process autonomously. Your job: + +1. READ the transcript carefully and identify ALL actionable items: + - Action items assigned to people + - Follow-up emails that need to be sent + - Calendar events that need to be created (meetings, deadlines) + - Documents that need to be created or shared via Drive + - Any commitments or promises made + +2. For EACH actionable item, execute it using your tools: + - Send follow-up emails via gws gmail +send + - Create calendar events via gws calendar +insert + - Search/create Drive documents as needed + - Create tasks for items you can't fully complete + +3. USE notify_user to report what you did after completing each action. + Example: notify_user("Sent follow-up email", "Sent meeting recap to team@company.com") + +4. USE ask_user ONLY when you genuinely need information you cannot find: + - Missing email addresses (check contacts first!) + - Ambiguous meeting times + - Unclear ownership of action items + Do NOT ask permission to read data or execute searches. + +5. After processing ALL items, send a final summary notification: + notify_user("Meeting processed", "Completed N actions from 'Meeting Title': ...") + +Work through items systematically. Do not stop after one action — process the entire transcript. +""" + +FORMAT_LAYER = """## Output Format + +For conversational responses: +- Plain text with markdown formatting (bold, bullets, headers) + +For action results: +- State what was done +- Show relevant results inline +- Suggest next steps if applicable + +For write operations (send email, create event, update doc): +- Show what you created/sent with key details +- The system handles dry-run automatically for dangerous operations + +NEVER output raw JSON to the user. Synthesize tool results into human-readable text. +""" + + +def build_system_prompt(state: dict) -> str: + from datetime import timedelta + now = datetime.now() # Local time + tomorrow = now + timedelta(days=1) + env = ENVIRONMENT_LAYER.format( + date=now.strftime("%Y-%m-%d %H:%M %Z"), + day_of_week=now.strftime("%A"), + timezone=now.astimezone().tzname() or "local", + today_date=now.strftime("%Y-%m-%d (%A)"), + tomorrow_date=tomorrow.strftime("%Y-%m-%d (%A)"), + ) + + # Add intent-specific instructions + intent = state.get("intent") + intent_addendum = "" + if intent == "triage": + intent_addendum = """ +## TRIAGE MODE ACTIVE +The user is overwhelmed. Follow triage rules strictly: +- Fetch ALL relevant data (email, calendar, tasks) IN PARALLEL using multiple tool calls. +- Synthesize into exactly 3 priorities. No more. +- Format as a numbered list with one-line descriptions. +- End with: "Which one first?" +- Do NOT present raw data. Synthesize it. +""" + elif intent == "proactive": + intent_addendum = PROACTIVE_LAYER + + layers = [ + env, + IDENTITY_LAYER, + build_context_layer(state), + INSTRUCTION_LAYER, + intent_addendum, + FORMAT_LAYER, + ] + return "\n\n".join(layer for layer in layers if layer) diff --git a/backend/app/agent/state.py b/backend/app/agent/state.py new file mode 100644 index 0000000..fdd9899 --- /dev/null +++ b/backend/app/agent/state.py @@ -0,0 +1,20 @@ +"""FRIDAY agent state definition.""" + +from typing import Literal, Optional +from pydantic import Field +from langgraph.graph import MessagesState + + +class FridayState(MessagesState): + """Extended state for the FRIDAY agent graph. + + Uses MessagesState which provides `messages` with the add_messages reducer. + The agent follows a ReAct loop: agent -> tools -> agent (repeat until done). + """ + session_id: str = "" + user_id: str = "" + user_context: dict = Field(default_factory=dict) + active_tasks: list[dict] = Field(default_factory=list) + semantic_context: list[dict] = Field(default_factory=list) + intent: Optional[Literal["chat", "triage", "proactive"]] = None + error: Optional[str] = None diff --git a/backend/app/agent/tools/__init__.py b/backend/app/agent/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agent/tools/data_tools.py b/backend/app/agent/tools/data_tools.py new file mode 100644 index 0000000..751841f --- /dev/null +++ b/backend/app/agent/tools/data_tools.py @@ -0,0 +1,139 @@ +import json +from typing import Optional + +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import tool + +from services.store import store + + +def _get_user_id(config: RunnableConfig) -> str: + return config.get("configurable", {}).get("user_id", "default") + + +@tool +def get_user_tasks( + config: RunnableConfig, + status: Optional[str] = None, + priority: Optional[str] = None, + limit: int = 20, +) -> str: + """Get the user's tasks, optionally filtered by status and priority. + + Args: + status: Filter by status (pending, in_progress, done, cancelled) + priority: Filter by priority (low, medium, high, urgent) + limit: Maximum number of tasks to return + """ + user_id = _get_user_id(config) + tasks = store.get_tasks(user_id, status=status, priority=priority, limit=limit) + if not tasks: + return "No tasks found." + return json.dumps(tasks, indent=2, default=str) + + +@tool +def create_task( + config: RunnableConfig, + title: str, + description: Optional[str] = None, + priority: str = "medium", + due_at: Optional[str] = None, + source: Optional[str] = None, + source_ref: Optional[str] = None, +) -> str: + """Create a new task for the user. + + Args: + title: Task title + description: Optional description + priority: Priority level (low, medium, high, urgent) + due_at: Optional due date (ISO format) + source: Where the task came from (e.g. "meeting", "email", "user") + source_ref: Reference ID from the source + """ + user_id = _get_user_id(config) + task = store.create_task( + user_id=user_id, + title=title, + description=description, + priority=priority, + due_at=due_at, + source=source, + source_ref=source_ref, + ) + return json.dumps(task, default=str) + + +@tool +def update_task( + config: RunnableConfig, + task_id: str, + status: Optional[str] = None, + priority: Optional[str] = None, + title: Optional[str] = None, +) -> str: + """Update an existing task. + + Args: + task_id: ID of the task to update + status: New status (pending, in_progress, done, cancelled) + priority: New priority (low, medium, high, urgent) + title: New title + """ + user_id = _get_user_id(config) + task = store.update_task(user_id, task_id, status=status, priority=priority, title=title) + if not task: + return f"Task {task_id} not found." + return json.dumps(task, default=str) + + +@tool +def get_user_context(config: RunnableConfig, context_key: str) -> str: + """Get a specific user context/preference value. + + Common keys: "communication_style", "work_hours", "priorities", "preferences" + + Args: + context_key: The context key to look up + """ + user_id = _get_user_id(config) + ctx = store.get_user_context(user_id, context_key) + if not ctx: + return f"No context found for key '{context_key}'." + return json.dumps(ctx, default=str) + + +@tool +def save_user_context( + config: RunnableConfig, + context_key: str, + context_value: str, + confidence: float = 0.8, + source: str = "agent", +) -> str: + """Save a user context/preference for future reference. + + Use this to remember things about the user like preferences, patterns, + communication style, work schedule, etc. + + Args: + context_key: The context key (e.g. "communication_style", "work_hours") + context_value: JSON string of the context value + confidence: Confidence level (0.0-1.0) + source: Source of the context (e.g. "agent", "user", "meeting") + """ + user_id = _get_user_id(config) + try: + value = json.loads(context_value) + except (json.JSONDecodeError, TypeError): + value = {"value": context_value} + + entry = store.save_user_context( + user_id=user_id, + context_key=context_key, + context_value=value, + confidence=confidence, + source=source, + ) + return json.dumps(entry, default=str) diff --git a/backend/app/agent/tools/exa_tools.py b/backend/app/agent/tools/exa_tools.py new file mode 100644 index 0000000..bbdffbb --- /dev/null +++ b/backend/app/agent/tools/exa_tools.py @@ -0,0 +1,67 @@ +"""Exa neural web search tool for LangGraph agent.""" + +import asyncio +import json +import logging +from langchain_core.tools import tool + +logger = logging.getLogger(__name__) + +_exa_client = None + + +def _get_exa_client(): + global _exa_client + if _exa_client is None: + from config import settings + if not settings.exa_api_key: + return None + from exa_py import Exa + _exa_client = Exa(api_key=settings.exa_api_key) + return _exa_client + + +def _sync_exa_search(query: str, num_results: int): + """Synchronous Exa search to be run in a thread.""" + client = _get_exa_client() + if client is None: + return {"error": "Exa API key not configured"} + + result = client.search_and_contents( + query=query, + num_results=num_results, + text={"max_characters": 1000}, + use_autoprompt=True, + ) + results = [] + for r in result.results: + results.append({ + "title": r.title, + "url": r.url, + "text": r.text[:500] if r.text else "", + "score": getattr(r, "score", None), + }) + return results + + +@tool +async def exa_search( + query: str, + num_results: int = 5, +) -> str: + """Search the web for real-time information using Exa's neural search. + + Use this when the user's question requires information BEYOND their + Google Workspace — news, research, documentation, public information, + company lookups, industry trends, etc. + + Do NOT use this for: + - User's emails, calendar, or docs — use gws tools instead + - User's tasks or preferences — use data_tools instead + """ + try: + results = await asyncio.to_thread(_sync_exa_search, query, num_results) + return json.dumps(results) + except Exception as e: + logger.error("Exa search failed: %s", e) + return json.dumps({"error": str(e)}) diff --git a/backend/app/agent/tools/gws.py b/backend/app/agent/tools/gws.py new file mode 100644 index 0000000..14a3bf6 --- /dev/null +++ b/backend/app/agent/tools/gws.py @@ -0,0 +1,174 @@ +"""Google Workspace CLI tools for LangGraph agent.""" + +import json +from langchain_core.tools import tool + +from core.gws_runner import run_gws, GWSResult + + +@tool +async def gws( + command: str, + dry_run: bool = False, + force_execute: bool = False, +) -> str: + """Execute any Google Workspace CLI command. + + The gws CLI provides dynamic access to ALL Google Workspace APIs. + Format: ' [flags]' or ' +helper [flags]' + + ═══ HELPER COMMANDS (preferred — simpler and more reliable) ═══ + + Gmail: + - 'gmail +triage' — unread inbox summary + - 'gmail +triage --max 5 --query "from:boss"' — filtered triage + - 'gmail +send --to EMAIL --subject SUBJ --body TEXT' — send email + + Calendar: + - 'calendar +agenda' — upcoming events + - 'calendar +agenda --today' — today's events only + - 'calendar +agenda --week' — this week's events + - 'calendar +agenda --days 3' — next 3 days + - 'calendar +insert --summary TEXT --start TIME --end TIME' — create event + - 'calendar +insert ... --location LOC --attendee EMAIL' — with location/attendees + + Drive: + - 'drive +upload ./file.pdf' — upload file + - 'drive +upload ./file.pdf --parent FOLDER_ID' — upload to folder + + Docs: + - 'docs +write --document DOC_ID --text TEXT' — append text to doc + + Sheets: + - 'sheets +read --spreadsheet ID --range "Sheet1!A1:D10"' — read values + - 'sheets +append --spreadsheet ID --values "a,b,c"' — append row + + Chat: + - 'chat +send --space spaces/SPACE_ID --text TEXT' — send chat message + + Workflows (cross-service): + - 'workflow +standup-report' — today's meetings + open tasks + - 'workflow +meeting-prep' — prep for next meeting (agenda, attendees, docs) + - 'workflow +weekly-digest' — weekly summary: meetings + unread count + - 'workflow +email-to-task --message-id MSG_ID' — convert email to task + - 'workflow +file-announce --file-id ID --space spaces/ID' — announce file in chat + + ═══ RAW API CALLS (when helpers don't cover your need) ═══ + + - 'gmail users messages list --params {"userId": "me", "q": "is:unread"}' + - 'gmail users messages get --params {"userId": "me", "id": "MSG_ID"}' + - 'calendar events list --params {"calendarId": "primary"}' + - 'drive files list --params {"q": "name contains 'hackathon'"}' + - 'tasks tasklists list' + - 'tasks tasks list --params {"tasklist": "@default"}' + - 'keep notes list' + - 'people people connections list --params {"resourceName": "people/me", "personFields": "names,emailAddresses"}' + + ═══ RAW API: --params vs --json ═══ + + - --params = URL/query/path parameters (e.g., calendarId, userId, q) + - --json = request BODY (e.g., summary, start, end, location, attendees) + - Example: calendar events insert --params {"calendarId": "primary"} --json {"summary": "Meeting", "start": {"dateTime": "..."}, "end": {"dateTime": "..."}} + - NEVER put calendarId or userId in --json. They are URL parameters → use --params. + + ═══ IMPORTANT RULES ═══ + + - ALL JSON must use DOUBLE QUOTES. Single quotes are invalid JSON. + - IF A COMMAND FAILS: Call gws_schema to discover correct parameters, then retry. + - Use --params for query/path parameters (JSON object). + - Use --json for request body (POST/PATCH/PUT). + - Use --page-all to auto-paginate results. + - Create/update/delete operations automatically show a dry-run preview. + - After user confirms, re-call gws with the SAME command and force_execute=True. + - Do NOT add --force to the command string. Use the force_execute parameter instead. + - Times must be ISO 8601 / RFC 3339 (e.g., 2026-03-07T08:00:00-05:00). + """ + result: GWSResult = await run_gws(command, dry_run=dry_run, force_execute=force_execute) + + if not result.success: + return json.dumps({"error": result.error, "command": result.command}) + + output = {"success": True, "data": result.data, "command": result.command} + if result.requires_approval and result.dry_run: + output["requires_approval"] = True + output["note"] = "This action requires user approval. Showing dry-run preview." + return json.dumps(output, default=str) + + +@tool +async def gws_help(command: str) -> str: + """Get CLI help for any gws service, resource, or helper command. + + Use this to discover available subcommands, flags, and usage examples. + + Examples: + - 'drive' — list Drive subcommands and helpers + - 'drive files list' — see flags for listing files + - 'calendar +insert' — see flags for creating events + - 'gmail +triage' — see flags for email triage + - 'workflow' — list all workflow helpers + - 'tasks' — list Tasks subcommands + + Returns the CLI help output for the given command. + """ + result = await run_gws(f"{command} --help") + if not result.success: + # --help often exits with code 0 but sometimes doesn't; return raw output + return result.error or "No help available" + return result.data if isinstance(result.data, str) else json.dumps(result.data, default=str) + + +def _sanitize_schema(obj): + """Remove $ref and schemaRef keys that confuse Gemini's function calling. + + Gemini interprets $ref values as references to tool/function names. + Also truncates overly large response/request body schemas to keep + the tool response lean — the agent only needs parameter info. + """ + if isinstance(obj, dict): + cleaned = {} + for k, v in obj.items(): + # Skip keys that cause Gemini to look for non-existent functions + if k in ("$ref", "schemaRef"): + continue + # Truncate nested response/request schemas — agent doesn't need full body specs + if k == "response" and isinstance(v, dict): + cleaned[k] = "(response schema omitted — use gws_help for output details)" + continue + if k == "request" and isinstance(v, dict): + cleaned[k] = "(request body schema omitted — use gws_help for input details)" + continue + cleaned[k] = _sanitize_schema(v) + return cleaned + elif isinstance(obj, list): + return [_sanitize_schema(item) for item in obj] + return obj + + +@tool +async def gws_schema(method: str) -> str: + """Inspect any Google Workspace API method's parameters and body schema. + + Use this to discover correct parameters BEFORE calling unfamiliar API methods, + or AFTER a command fails to understand the correct syntax. + + Format: 'service.resource.method' + + Examples: + - 'gmail.users.messages.list' — see query params for listing emails + - 'calendar.events.insert' — see required fields for creating events + - 'calendar.events.list' — see how to filter events + - 'drive.files.list' — see how to search files (q parameter format) + - 'drive.files.create' — see how to create/upload files + - 'tasks.tasks.list' — see how to list tasks + - 'docs.documents.get' — see how to read a doc + - 'sheets.spreadsheets.values.get' — see how to read sheet values + - 'people.people.connections.list' — see how to list contacts + """ + result = await run_gws(f"schema {method}") + if not result.success: + return json.dumps({"error": result.error}) + data = result.data + if isinstance(data, dict): + data = _sanitize_schema(data) + return json.dumps({"schema": data}, default=str) diff --git a/backend/app/agent/tools/memory_tools.py b/backend/app/agent/tools/memory_tools.py new file mode 100644 index 0000000..e3493b2 --- /dev/null +++ b/backend/app/agent/tools/memory_tools.py @@ -0,0 +1,93 @@ +import json +import logging +from typing import Optional + +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import tool + +logger = logging.getLogger(__name__) + +_sm_client = None +try: + from config import settings + if settings.supermemory_api_key: + from supermemory import AsyncSupermemory + _sm_client = AsyncSupermemory(api_key=settings.supermemory_api_key) +except Exception as e: + logger.warning(f"Supermemory not available: {e}") + + +def _get_user_id(config: RunnableConfig) -> str: + return config.get("configurable", {}).get("user_id", "default") + + +@tool +async def memory_search(config: RunnableConfig, query: str, limit: int = 5) -> str: + """Search the user's semantic memory for relevant past context. + + Use this to recall: + - Past conversations and commitments + - User preferences and patterns + - Previously discussed topics + - Meeting summaries and notes + + Args: + query: What to search for + limit: Max results to return + """ + if _sm_client is None: + return "Memory search not available (SUPERMEMORY_API_KEY not configured)" + + user_id = _get_user_id(config) + try: + results = await _sm_client.search.execute( + q=query, + container_tags=[user_id], + limit=limit, + ) + if hasattr(results, 'results'): + return json.dumps([ + {"content": r.content, "score": getattr(r, 'score', None)} + for r in results.results + ], default=str) + return json.dumps(results, default=str) + except Exception as e: + logger.error(f"Memory search error: {e}") + return f"Memory search error: {e}" + + +@tool +async def memory_store(config: RunnableConfig, content: str, metadata: Optional[str] = None) -> str: + """Store information in the user's semantic memory for future recall. + + Use this to remember: + - Important user statements or commitments + - Learned patterns about the user + - Meeting summaries or key decisions + - User preferences discovered during conversation + + Args: + content: The text content to store + metadata: Optional JSON string of metadata (e.g. '{"source": "meeting", "topic": "project_x"}') + """ + if _sm_client is None: + return "Memory storage not available (SUPERMEMORY_API_KEY not configured)" + + user_id = _get_user_id(config) + meta = {} + if metadata: + try: + meta = json.loads(metadata) + except (json.JSONDecodeError, TypeError): + meta = {"raw": metadata} + + try: + result = await _sm_client.add( + content=content, + container_tag=user_id, + metadata=meta, + ) + return json.dumps({"status": "stored", "result": result}, default=str) + except Exception as e: + logger.error(f"Memory store error: {e}") + return f"Memory store error: {e}" diff --git a/backend/app/agent/tools/notification_tools.py b/backend/app/agent/tools/notification_tools.py new file mode 100644 index 0000000..680a040 --- /dev/null +++ b/backend/app/agent/tools/notification_tools.py @@ -0,0 +1,48 @@ +"""Notification tools — agent can notify or ask the user.""" + +from langchain_core.tools import tool +from langchain_core.runnables import RunnableConfig +from services.store import store + + +@tool +def notify_user(title: str, message: str, notification_type: str = "info") -> str: + """Send a notification to the user. Does NOT pause the agent. + + Use this to report completed actions, status updates, or errors. + notification_type: "info", "action_taken", or "error" + """ + store.add_notification({ + "type": notification_type, + "title": title, + "message": message, + }) + return "Notification sent." + + +@tool +def ask_user(question: str, context: str = "", config: RunnableConfig = None) -> str: + """Ask the user a question and wait for their response. + + Use when you genuinely need information only the user can provide. + Do NOT use for permission to read data — just read it. + This PAUSES the agent until the user replies. + """ + from langgraph.types import interrupt + + # Extract session_id from config so the reply endpoint can resume + session_id = None + if config and "configurable" in config: + session_id = config["configurable"].get("thread_id") + + # Store a question notification before pausing + store.add_notification({ + "type": "question", + "title": "Agent needs your input", + "message": question, + "session_id": session_id, + }) + + # interrupt() pauses the graph; when resumed via Command(resume=answer), returns the answer + answer = interrupt({"question": question, "context": context}) + return answer diff --git a/backend/app/agent/tools/registry.py b/backend/app/agent/tools/registry.py new file mode 100644 index 0000000..d9ec1f9 --- /dev/null +++ b/backend/app/agent/tools/registry.py @@ -0,0 +1,24 @@ +"""Tool registry — all tools available to the agent at all times.""" + +from .gws import gws, gws_help, gws_schema +from .exa_tools import exa_search +from .data_tools import get_user_tasks, create_task, update_task, get_user_context, save_user_context +from .memory_tools import memory_search, memory_store +from .notification_tools import notify_user, ask_user + +# All tools are always available. The agent decides what to use based on context. +ALL_TOOLS: list = [ + gws, + gws_help, + gws_schema, + exa_search, + get_user_tasks, + create_task, + update_task, + get_user_context, + save_user_context, + memory_search, + memory_store, + notify_user, + ask_user, +] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py new file mode 100644 index 0000000..37d0ce1 --- /dev/null +++ b/backend/app/api/chat.py @@ -0,0 +1,73 @@ +import logging + +from fastapi import APIRouter, HTTPException +from langchain_core.messages import HumanMessage + +from models.schemas import ChatRequest +from services.store import store + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["chat"]) + + +def _get_graph(): + """Lazy import to avoid circular imports at module level.""" + from agent.graph import friday_graph + return friday_graph + + +@router.post("/") +async def chat(request: ChatRequest): + """Send a message to FRIDAY and get a complete response. + + The agent autonomously decides what tools to call and loops + until it has a final response. No separate approval step needed + for read operations. + """ + graph = _get_graph() + + # Create or retrieve session + session_id = request.session_id + if not session_id: + session = store.create_session(request.user_id) + session_id = session["session_id"] + elif not store.get_session(session_id): + store.create_session(request.user_id, session_id=session_id) + + # Save user message + store.add_message(session_id, "user", request.message) + + config = { + "configurable": { + "thread_id": session_id, + "user_id": request.user_id, + }, + "recursion_limit": 25, # Max tool-calling iterations + } + input_state = { + "messages": [HumanMessage(content=request.message)], + "session_id": session_id, + "user_id": request.user_id, + } + + try: + result = await graph.ainvoke(input_state, config=config) + + # Extract the final AI response from messages + response_text = "" + if result.get("messages"): + for msg in reversed(result["messages"]): + if hasattr(msg, "type") and msg.type == "ai" and msg.content: + response_text = msg.content + break + + return { + "response": response_text, + "session_id": session_id, + "intent": result.get("intent", "chat"), + } + + except Exception as e: + logger.error(f"Chat error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..615bf0a --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,37 @@ +"""Health and auth status endpoints.""" + +import asyncio +import shutil +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +async def health(): + return {"status": "ok"} + + +@router.get("/auth/status") +async def auth_status(): + gws_path = shutil.which("gws") + if not gws_path: + return {"gws_installed": False, "gws_authenticated": False, "message": "gws CLI not found. Run: npm install -g @googleworkspace/cli"} + + try: + proc = await asyncio.create_subprocess_exec( + "gws", "auth", "status", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + authenticated = proc.returncode == 0 + return { + "gws_installed": True, + "gws_authenticated": authenticated, + "message": stdout.decode().strip() if authenticated else "Not authenticated. Run: gws auth login", + } + except asyncio.TimeoutError: + return {"gws_installed": True, "gws_authenticated": False, "message": "gws auth status timed out"} + except Exception as e: + return {"gws_installed": True, "gws_authenticated": False, "message": str(e)} diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..a6249ee --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,75 @@ +"""Notifications API — list, reply, dismiss, mark-read.""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from langgraph.types import Command + +from services.store import store + +router = APIRouter() + + +class ReplyRequest(BaseModel): + message: str + + +# ---------- Endpoints ---------- + + +@router.get("") +async def list_notifications(limit: int = 50): + """Get recent notifications, most recent first.""" + return store.get_notifications(limit=limit) + + +@router.get("/unread-count") +async def unread_count(): + """Get count of unread notifications.""" + return {"count": store.get_unread_count()} + + +@router.post("/{notification_id}/reply") +async def reply_to_notification(notification_id: str, body: ReplyRequest): + """Reply to a question notification and resume the agent graph.""" + notif = store.get_notification(notification_id) + if not notif or notif["type"] != "question": + raise HTTPException(404, "Not a question notification") + + if not notif.get("session_id"): + raise HTTPException(400, "No session_id to resume") + + from agent.graph import friday_graph + + config = { + "configurable": {"thread_id": notif["session_id"], "user_id": "default"}, + "recursion_limit": 40, + } + + # Resume the graph — the interrupt() in ask_user receives this value + await friday_graph.ainvoke(Command(resume=body.message), config=config) + + store.update_notification(notification_id, status="answered") + + # Check if graph paused again (another question) + graph_state = await friday_graph.aget_state(config) + still_waiting = bool(graph_state and graph_state.next) + + return {"status": "resumed", "still_waiting": still_waiting} + + +@router.post("/{notification_id}/dismiss") +async def dismiss_notification(notification_id: str): + """Dismiss a notification.""" + notif = store.update_notification(notification_id, status="dismissed") + if not notif: + raise HTTPException(404, "Notification not found") + return {"status": "dismissed"} + + +@router.post("/mark-read") +async def mark_all_read(): + """Mark all unread notifications as read.""" + for n in store.notifications: + if n["status"] == "unread": + n["status"] = "read" + return {"status": "ok"} diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py new file mode 100644 index 0000000..95fed67 --- /dev/null +++ b/backend/app/api/sessions.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, HTTPException, Query + +from models.schemas import SessionResponse +from services.store import store + +router = APIRouter(tags=["sessions"]) + + +@router.get("/") +async def list_sessions(user_id: str = Query(default="default")): + """List user sessions, most recent first.""" + sessions = store.list_sessions(user_id) + result = [] + for s in sessions: + sid = s["session_id"] + msg_count = len(store.get_messages(sid)) + result.append({ + **s, + "message_count": msg_count, + }) + return result + + +@router.get("/{session_id}") +async def get_session(session_id: str): + """Get a session with its message history.""" + session = store.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + messages = store.get_messages(session_id) + return { + **session, + "messages": messages, + "message_count": len(messages), + } diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py new file mode 100644 index 0000000..196147e --- /dev/null +++ b/backend/app/api/tasks.py @@ -0,0 +1,50 @@ +from typing import Optional +from fastapi import APIRouter, HTTPException, Query + +from models.schemas import CreateTaskRequest, UpdateTaskRequest, TaskResponse +from services.store import store + +router = APIRouter(tags=["tasks"]) + + +@router.get("/") +async def list_tasks( + user_id: str = Query(default="default"), + status: Optional[str] = Query(default=None), + priority: Optional[str] = Query(default=None), + limit: int = Query(default=50, le=100), +): + """List tasks with optional filters.""" + tasks = store.get_tasks(user_id, status=status, priority=priority, limit=limit) + return tasks + + +@router.post("/", response_model=TaskResponse) +async def create_task(request: CreateTaskRequest): + """Create a new task.""" + task = store.create_task( + user_id=request.user_id, + title=request.title, + description=request.description, + priority=request.priority, + due_at=request.due_at, + source=request.source, + source_ref=request.source_ref, + ) + return task + + +@router.patch("/{task_id}", response_model=TaskResponse) +async def update_task(task_id: str, request: UpdateTaskRequest, user_id: str = Query(default="default")): + """Update an existing task.""" + task = store.update_task( + user_id=user_id, + task_id=task_id, + status=request.status, + priority=request.priority, + title=request.title, + description=request.description, + ) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..21926de --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,39 @@ +"""FRIDAY configuration from environment variables.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Optional + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + # Gemini + gemini_api_key: str = "" + google_api_key: str = "" + + # Supabase (optional for now) + supabase_url: str = "" + supabase_service_key: str = "" + + # Exa web search + exa_api_key: str = "" + + # Supermemory + supermemory_api_key: str = "" + + # OpenRouter + openrouter_api_key: str = "" + + # Heartbeat + heartbeat_enabled: bool = True + heartbeat_interval_seconds: int = 600 + + # Logging + log_level: str = "INFO" + + @property + def effective_gemini_key(self) -> str: + return self.gemini_api_key or self.google_api_key + + +settings = Settings() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/gws_runner.py b/backend/app/core/gws_runner.py new file mode 100644 index 0000000..d67016a --- /dev/null +++ b/backend/app/core/gws_runner.py @@ -0,0 +1,176 @@ +"""Async subprocess wrapper for the Google Workspace CLI (gws).""" + +import asyncio +import json +import logging +import re +import shlex +import time +from dataclasses import dataclass, field +from typing import Optional + +logger = logging.getLogger(__name__) + +APPROVAL_REQUIRED_PATTERNS = [ + # Create operations + r"calendar\s+.*events\.insert", + r"calendar\s+.*\+insert", + r"docs\s+.*documents\.create", + r"docs\s+.*\+write", + r"sheets\s+.*spreadsheets\.create", + r"drive\s+.*files\.create", + # Update operations + r"calendar\s+.*events\.(update|patch)", + r"docs\s+.*documents\.batchUpdate", + r"sheets\s+.*values\.(update|append)", + r"drive\s+.*files\.(update|patch)", + # Delete operations + r"\.delete\b", + r"files\.emptyTrash", +] + +BLOCKED_COMMANDS = ["auth", "config"] + + +@dataclass +class GWSResult: + success: bool + data: Optional[dict | list | str] = None + error: Optional[str] = None + command: str = "" + requires_approval: bool = False + dry_run: bool = False + duration_ms: int = 0 + + +def requires_approval(command: str) -> bool: + for pattern in APPROVAL_REQUIRED_PATTERNS: + if re.search(pattern, command, re.IGNORECASE): + return True + return False + + +def _is_blocked(command: str) -> bool: + parts = command.strip().split() + if parts and parts[0].lower() in BLOCKED_COMMANDS: + return True + return False + + +def _smart_split(command: str) -> list[str]: + """Split a gws command string preserving JSON blobs for --params and --json flags. + + shlex.split mangles JSON (strips quotes, splits on spaces inside braces). + This extracts JSON values attached to those flags first, shell-splits the + rest, then re-inserts the JSON as single arguments. + """ + json_flags = {} + remaining = command + + for flag in ("--params", "--json"): + idx = remaining.find(flag) + if idx == -1: + continue + after_flag = remaining[idx + len(flag):] + # Skip whitespace to find the JSON start + stripped = after_flag.lstrip() + if not stripped.startswith("{") and not stripped.startswith("["): + continue + # Find matching closing brace/bracket + open_char = stripped[0] + close_char = "}" if open_char == "{" else "]" + depth = 0 + in_string = False + escape_next = False + end_pos = None + for i, ch in enumerate(stripped): + if escape_next: + escape_next = False + continue + if ch == "\\": + escape_next = True + continue + if ch == '"': + in_string = not in_string + continue + if in_string: + continue + if ch == open_char: + depth += 1 + elif ch == close_char: + depth -= 1 + if depth == 0: + end_pos = i + break + if end_pos is None: + # Malformed JSON — fall back to shlex + continue + json_str = stripped[: end_pos + 1] + # Calculate the full span in `remaining` to remove + json_start_in_remaining = idx + json_end_in_remaining = idx + len(flag) + (len(after_flag) - len(stripped)) + end_pos + 1 + json_flags[flag] = json_str + remaining = remaining[:json_start_in_remaining] + remaining[json_end_in_remaining:] + + parts = shlex.split(remaining) + + # Re-insert JSON flags in order + for flag, json_val in json_flags.items(): + parts.extend([flag, json_val]) + + return parts + + +async def run_gws(command: str, dry_run: bool = False, timeout: float = 30.0, force_execute: bool = False) -> GWSResult: + if _is_blocked(command): + return GWSResult(success=False, error=f"Blocked command: {command.split()[0]}", command=command) + + needs_approval = requires_approval(command) + if needs_approval and not force_execute: + dry_run = True + + full_command = command + if dry_run and "--dry-run" not in command: + full_command = f"{command} --dry-run" + + args = ["gws"] + _smart_split(full_command) + start = time.monotonic() + + try: + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + duration_ms = int((time.monotonic() - start) * 1000) + + raw = stdout.decode().strip() + err = stderr.decode().strip() + + if proc.returncode != 0: + return GWSResult( + success=False, error=err or raw or f"Exit code {proc.returncode}", + command=full_command, requires_approval=needs_approval, + dry_run=dry_run, duration_ms=duration_ms, + ) + + # Try parsing as JSON + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError): + data = raw + + return GWSResult( + success=True, data=data, command=full_command, + requires_approval=needs_approval, dry_run=dry_run, duration_ms=duration_ms, + ) + + except asyncio.TimeoutError: + duration_ms = int((time.monotonic() - start) * 1000) + return GWSResult(success=False, error=f"Timed out after {timeout}s", command=full_command, duration_ms=duration_ms) + except FileNotFoundError: + return GWSResult(success=False, error="gws CLI not found. Install: npm install -g @googleworkspace/cli", command=full_command) + except Exception as e: + duration_ms = int((time.monotonic() - start) * 1000) + return GWSResult(success=False, error=str(e), command=full_command, duration_ms=duration_ms) diff --git a/backend/app/core/sse.py b/backend/app/core/sse.py new file mode 100644 index 0000000..0682fdf --- /dev/null +++ b/backend/app/core/sse.py @@ -0,0 +1,146 @@ +"""SSE event formatting and graph streaming bridge.""" + +import json +import logging +from typing import Any, AsyncGenerator, Union + +from langchain_core.messages import HumanMessage +from pydantic import BaseModel + +from models.events import ( + ApprovalRequiredEvent, + DoneEvent, + ErrorEvent, + StatusEvent, + TokenEvent, + ToolResultEvent, +) + +logger = logging.getLogger(__name__) + +SSEEvent = Union[ + StatusEvent, TokenEvent, ToolResultEvent, + ApprovalRequiredEvent, DoneEvent, ErrorEvent, +] + + +def format_sse_event(event: BaseModel) -> str: + """Format a Pydantic event model as an SSE string.""" + data = event.model_dump() + event_type = data.get("type", "message") + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" + + +async def run_graph_with_streaming( + graph: Any, + session_id: str, + user_id: str, + message: str, +) -> AsyncGenerator[str, None]: + """Run the FRIDAY graph and yield SSE-formatted strings.""" + config = { + "configurable": { + "thread_id": session_id, + "user_id": user_id, + } + } + input_state = { + "messages": [HumanMessage(content=message)], + "session_id": session_id, + "user_id": user_id, + } + + yield format_sse_event(StatusEvent(message="Processing your message...")) + + try: + async for event in graph.astream_events( + input_state, config=config, version="v2" + ): + kind = event.get("event", "") + name = event.get("name", "") + + if kind == "on_chat_model_stream": + chunk = event.get("data", {}).get("chunk") + if chunk and hasattr(chunk, "content") and chunk.content: + yield format_sse_event(TokenEvent(content=chunk.content)) + + elif kind == "on_tool_start": + yield format_sse_event(StatusEvent(message=f"Using {name}...")) + + elif kind == "on_tool_end": + output = event.get("data", {}).get("output", "") + result_str = str(output)[:500] + yield format_sse_event( + ToolResultEvent(tool_name=name, result=result_str) + ) + + # Check for interrupts (approval required) + state = await graph.aget_state(config) + if state and hasattr(state, "tasks") and state.tasks: + for task in state.tasks: + if hasattr(task, "interrupts") and task.interrupts: + for intr in task.interrupts: + payload = intr.value if hasattr(intr, "value") else intr + if isinstance(payload, dict): + yield format_sse_event( + ApprovalRequiredEvent( + action_type=payload.get("action_type", "unknown"), + payload=payload, + explanation=payload.get("explanation", ""), + dry_run_result=payload.get("dry_run_result"), + ) + ) + + yield format_sse_event(DoneEvent(session_id=session_id)) + + except Exception as e: + logger.error(f"Graph streaming error: {e}", exc_info=True) + yield format_sse_event(ErrorEvent(message=str(e))) + + +async def resume_graph_with_streaming( + graph: Any, + session_id: str, + user_id: str, + approval_result: dict, +) -> AsyncGenerator[str, None]: + """Resume the FRIDAY graph after an approval decision.""" + from langgraph.types import Command + + config = { + "configurable": { + "thread_id": session_id, + "user_id": user_id, + } + } + + status_msg = "Processing approval..." if approval_result.get("approved") else "Cancelling action..." + yield format_sse_event(StatusEvent(message=status_msg)) + + try: + async for event in graph.astream_events( + Command(resume=approval_result), config=config, version="v2" + ): + kind = event.get("event", "") + name = event.get("name", "") + + if kind == "on_chat_model_stream": + chunk = event.get("data", {}).get("chunk") + if chunk and hasattr(chunk, "content") and chunk.content: + yield format_sse_event(TokenEvent(content=chunk.content)) + + elif kind == "on_tool_start": + yield format_sse_event(StatusEvent(message=f"Using {name}...")) + + elif kind == "on_tool_end": + output = event.get("data", {}).get("output", "") + result_str = str(output)[:500] + yield format_sse_event( + ToolResultEvent(tool_name=name, result=result_str) + ) + + yield format_sse_event(DoneEvent(session_id=session_id)) + + except Exception as e: + logger.error(f"Graph resume error: {e}", exc_info=True) + yield format_sse_event(ErrorEvent(message=str(e))) diff --git a/backend/app/main.py b/backend/app/main.py index 85b9e85..9bf1715 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,6 @@ +from contextlib import asynccontextmanager +import asyncio + from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -16,6 +19,13 @@ # Load environment variables load_dotenv() +# Import new routers +from api.health import router as health_router +from api.chat import router as chat_router +from api.tasks import router as tasks_router +from api.sessions import router as sessions_router +from api.notifications import router as notifications_router + # Configure logger with line numbers and function names logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -35,10 +45,37 @@ if not logger.handlers: logger.addHandler(console_handler) +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan: start/stop background tasks.""" + heartbeat_task = None + try: + from config import settings + if settings.heartbeat_enabled: + from services.heartbeat import heartbeat_loop + heartbeat_task = asyncio.create_task( + heartbeat_loop(interval=settings.heartbeat_interval_seconds) + ) + logger.info("Heartbeat loop started") + except Exception as e: + logger.warning(f"Heartbeat not started: {e}") + + yield + + if heartbeat_task: + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + logger.info("Heartbeat loop stopped") + + app = FastAPI( - title="Meeting Summarizer API", - description="API for processing and summarizing meeting transcripts", - version="1.0.0" + title="FRIDAY - AI Workspace Orchestrator", + description="Meeting assistant + LangGraph agent for ADHD workspace management", + version="2.0.0", + lifespan=lifespan, ) # Configure CORS @@ -51,6 +88,13 @@ max_age=3600, # Cache preflight requests for 1 hour ) +# Include new FRIDAY agent routers +app.include_router(health_router) +app.include_router(chat_router, prefix="/friday") +app.include_router(tasks_router, prefix="/tasks") +app.include_router(sessions_router, prefix="/sessions") +app.include_router(notifications_router, prefix="/friday/notifications") + # Global database manager instance for meeting management endpoints db = DatabaseManager() @@ -543,6 +587,11 @@ async def save_transcript(request: SaveTranscriptRequest): ) logger.info("Transcripts saved successfully") + + # Trigger autonomous agent scan of the new meeting + from services.heartbeat import trigger_meeting_scan + asyncio.create_task(trigger_meeting_scan(meeting_id, request.meeting_title)) + return {"status": "success", "message": "Transcript saved successfully", "meeting_id": meeting_id} except Exception as e: logger.error(f"Error saving transcript: {str(e)}", exc_info=True) @@ -657,7 +706,7 @@ async def friday_extract(request: FridayExtractRequest): @app.on_event("shutdown") async def shutdown_event(): - """Cleanup on API shutdown""" + """Cleanup on API shutdown.""" logger.info("API shutting down, cleaning up resources") try: processor.cleanup() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/heartbeat.py b/backend/app/services/heartbeat.py new file mode 100644 index 0000000..404284b --- /dev/null +++ b/backend/app/services/heartbeat.py @@ -0,0 +1,109 @@ +"""Heartbeat — autonomous meeting scanner and proactive trigger.""" + +import asyncio +import logging + +from langchain_core.messages import HumanMessage + +from services.store import store + +logger = logging.getLogger(__name__) + + +async def heartbeat_loop(interval: int = 600): + """Background loop that scans unprocessed meetings periodically.""" + while True: + try: + await asyncio.sleep(interval) + await scan_unprocessed_meetings() + except asyncio.CancelledError: + logger.info("Heartbeat loop cancelled") + break + except Exception as e: + logger.warning(f"Heartbeat error: {e}") + await asyncio.sleep(10) + + +async def scan_unprocessed_meetings(): + """Find meetings that haven't been scanned yet and process them.""" + from db import DatabaseManager + + db = DatabaseManager() + meetings = await db.get_all_meetings() + + for meeting in meetings: + mid = meeting["id"] + if store.is_meeting_scanned(mid): + continue + await process_meeting(mid, meeting.get("title", "Untitled")) + store.mark_meeting_scanned(mid) + await asyncio.sleep(2) # Rate limit between meetings + + +async def process_meeting(meeting_id: str, title: str): + """Send full transcript to the agent for autonomous processing.""" + from db import DatabaseManager + from agent.graph import friday_graph + + db = DatabaseManager() + meeting_data = await db.get_meeting(meeting_id) + if not meeting_data or not meeting_data.get("transcripts"): + return + + transcript_text = "\n".join( + t["text"] for t in meeting_data["transcripts"] if t.get("text") + ) + if not transcript_text.strip(): + return + + session_id = f"proactive-{meeting_id}" + config = { + "configurable": {"thread_id": session_id, "user_id": "default"}, + "recursion_limit": 40, + } + input_state = { + "messages": [ + HumanMessage( + content=( + f"Process this meeting transcript and execute all action items:\n\n" + f"---\nMeeting: {title}\n---\n{transcript_text[:8000]}" + ) + ) + ], + "session_id": session_id, + "user_id": "default", + "intent": "proactive", + } + + try: + result = await friday_graph.ainvoke(input_state, config=config) + + # Check if graph was interrupted (agent asked a question) + graph_state = await friday_graph.aget_state(config) + if graph_state and graph_state.next: + # Graph is paused — notification was already stored by ask_user tool + pass + else: + store.add_notification({ + "type": "info", + "title": f"Processed '{title}'", + "message": "Meeting scan complete.", + "meeting_id": meeting_id, + "session_id": session_id, + }) + except Exception as e: + logger.error(f"Error processing meeting '{title}': {e}") + store.add_notification({ + "type": "error", + "title": f"Error processing '{title}'", + "message": str(e)[:300], + "meeting_id": meeting_id, + }) + + +async def trigger_meeting_scan(meeting_id: str, title: str): + """Called directly when a recording finishes. Skips the timer.""" + if store.is_meeting_scanned(meeting_id): + return + await process_meeting(meeting_id, title) + store.mark_meeting_scanned(meeting_id) diff --git a/backend/app/services/store.py b/backend/app/services/store.py new file mode 100644 index 0000000..222337a --- /dev/null +++ b/backend/app/services/store.py @@ -0,0 +1,194 @@ +"""In-memory data store with Supabase-compatible interface.""" + +import uuid +from datetime import datetime, timezone +from typing import Optional + + +class InMemoryStore: + def __init__(self): + self.sessions: dict[str, dict] = {} + self.messages: dict[str, list[dict]] = {} + self.tasks: dict[str, list[dict]] = {} + self.user_context: dict[str, dict[str, dict]] = {} + self.approvals: dict[str, dict] = {} + self.notifications: list[dict] = [] + self.scanned_meeting_ids: set[str] = set() + + # --- Sessions --- + + def create_session(self, user_id: str, session_id: Optional[str] = None, title: Optional[str] = None) -> dict: + sid = session_id or str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + session = { + "session_id": sid, + "user_id": user_id, + "title": title or "New conversation", + "created_at": now, + "updated_at": now, + } + self.sessions[sid] = session + self.messages[sid] = [] + return session + + def get_session(self, session_id: str) -> Optional[dict]: + return self.sessions.get(session_id) + + def list_sessions(self, user_id: str) -> list[dict]: + sessions = [s for s in self.sessions.values() if s["user_id"] == user_id] + return sorted(sessions, key=lambda s: s["updated_at"], reverse=True) + + def update_session(self, session_id: str, **kwargs) -> Optional[dict]: + session = self.sessions.get(session_id) + if not session: + return None + if kwargs: + session.update(kwargs) + session["updated_at"] = datetime.now(timezone.utc).isoformat() + return session + + # --- Messages --- + + def add_message(self, session_id: str, role: str, content: str) -> dict: + msg = { + "id": str(uuid.uuid4()), + "session_id": session_id, + "role": role, + "content": content, + "created_at": datetime.now(timezone.utc).isoformat(), + } + if session_id not in self.messages: + self.messages[session_id] = [] + self.messages[session_id].append(msg) + return msg + + def get_messages(self, session_id: str) -> list[dict]: + return self.messages.get(session_id, []) + + # --- Tasks --- + + def create_task(self, user_id: str, title: str, description: Optional[str] = None, + priority: str = "medium", due_at: Optional[str] = None, + source: str = "agent", source_ref: Optional[str] = None) -> dict: + task = { + "task_id": str(uuid.uuid4()), + "user_id": user_id, + "title": title, + "description": description, + "priority": priority, + "status": "pending", + "due_at": due_at, + "source": source, + "source_ref": source_ref, + "created_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + if user_id not in self.tasks: + self.tasks[user_id] = [] + self.tasks[user_id].append(task) + return task + + def get_tasks(self, user_id: str, status: Optional[str] = None, + priority: Optional[str] = None, limit: int = 10) -> list[dict]: + tasks = self.tasks.get(user_id, []) + if status: + tasks = [t for t in tasks if t["status"] == status] + if priority: + tasks = [t for t in tasks if t["priority"] == priority] + return sorted(tasks, key=lambda t: t["created_at"], reverse=True)[:limit] + + def update_task(self, user_id: str, task_id: str, **kwargs) -> Optional[dict]: + tasks = self.tasks.get(user_id, []) + for task in tasks: + if task["task_id"] == task_id: + task.update(kwargs) + task["updated_at"] = datetime.now(timezone.utc).isoformat() + return task + return None + + # --- User Context --- + + def get_user_context(self, user_id: str, context_key: Optional[str] = None) -> dict | list[dict]: + user_ctx = self.user_context.get(user_id, {}) + if context_key: + return user_ctx.get(context_key, {}) + return list(user_ctx.values()) + + def save_user_context(self, user_id: str, context_key: str, context_value: dict, + confidence: float = 0.5, source: str = "") -> dict: + if user_id not in self.user_context: + self.user_context[user_id] = {} + entry = { + "context_key": context_key, + "context_value": context_value, + "confidence": confidence, + "source": source, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + self.user_context[user_id][context_key] = entry + return entry + + # --- Notifications --- + + def add_notification(self, notif: dict) -> str: + nid = str(uuid.uuid4()) + notif["id"] = nid + notif["status"] = notif.get("status", "unread") + notif["created_at"] = datetime.now(timezone.utc).isoformat() + notif.setdefault("meeting_id", None) + notif.setdefault("session_id", None) + self.notifications.insert(0, notif) + return nid + + def get_notifications(self, limit: int = 50) -> list[dict]: + return self.notifications[:limit] + + def get_unread_count(self) -> int: + return sum(1 for n in self.notifications if n["status"] == "unread") + + def get_notification(self, notification_id: str) -> Optional[dict]: + for n in self.notifications: + if n["id"] == notification_id: + return n + return None + + def update_notification(self, notification_id: str, **kwargs) -> Optional[dict]: + for n in self.notifications: + if n["id"] == notification_id: + n.update(kwargs) + return n + return None + + # --- Scan Tracking --- + + def mark_meeting_scanned(self, meeting_id: str): + self.scanned_meeting_ids.add(meeting_id) + + def is_meeting_scanned(self, meeting_id: str) -> bool: + return meeting_id in self.scanned_meeting_ids + + # --- Approvals --- + + def create_approval(self, session_id: str, approval_data: dict) -> str: + approval_id = str(uuid.uuid4()) + self.approvals[approval_id] = { + "id": approval_id, + "session_id": session_id, + "status": "pending", + "data": approval_data, + "created_at": datetime.now(timezone.utc).isoformat(), + } + return approval_id + + def resolve_approval(self, approval_id: str, status: str, edited_payload: Optional[dict] = None) -> Optional[dict]: + approval = self.approvals.get(approval_id) + if not approval: + return None + approval["status"] = status + approval["edited_payload"] = edited_payload + approval["resolved_at"] = datetime.now(timezone.utc).isoformat() + return approval + + +# Singleton +store = InMemoryStore() diff --git a/backend/langgraph_entry.py b/backend/langgraph_entry.py new file mode 100644 index 0000000..038f693 --- /dev/null +++ b/backend/langgraph_entry.py @@ -0,0 +1,24 @@ +"""LangGraph entrypoint — adds app/ to sys.path so bare imports work, +then patches relative imports to absolute before loading the graph.""" +import importlib +import sys +from pathlib import Path + +# Add app/ to sys.path so bare imports like `from config import settings` resolve +_app_dir = str(Path(__file__).parent / "app") +if _app_dir not in sys.path: + sys.path.insert(0, _app_dir) + +# Also ensure backend/ is on the path for `app.agent.*` style imports +_backend_dir = str(Path(__file__).parent) +if _backend_dir not in sys.path: + sys.path.insert(0, _backend_dir) + +# Pre-load the agent package so relative imports work +import app.agent # noqa: E402 + +# Now import the graph — the relative imports inside graph.py will resolve +# because app.agent is a loaded package +from app.agent.graph import friday_graph_platform as friday_graph # noqa: E402 + +__all__ = ["friday_graph"] diff --git a/backend/requirements.txt b/backend/requirements.txt index 99698b9..ce0416d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,4 +10,14 @@ aiosqlite==0.21.0 ollama==0.5.2 google-genai>=1.0.0 # Supabase -supabase>=2.10.0 \ No newline at end of file +supabase>=2.10.0 +# LangGraph Agent +langgraph>=1.0.0 +langchain-google-genai>=2.0.0 +langchain-openai>=0.3.0 +langchain-core>=1.0.0 +pydantic-settings>=2.0 +httpx>=0.27 +structlog>=24.0 +exa-py>=1.0 +langchain-exa>=0.2 diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 6e63eee..55817d6 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -13,6 +13,7 @@ import { listen, UnlistenFn } from '@tauri-apps/api/event' import { invoke } from '@tauri-apps/api/core' import { TooltipProvider } from '@/components/ui/tooltip' import { RecordingStateProvider } from '@/contexts/RecordingStateContext' +import { NotificationsProvider } from '@/contexts/NotificationsContext' import { OllamaDownloadProvider } from '@/contexts/OllamaDownloadContext' import { TranscriptProvider } from '@/contexts/TranscriptContext' import { ConfigProvider, useConfig } from '@/contexts/ConfigContext' @@ -241,31 +242,33 @@ export default function RootLayout({ - - - - {/* Download progress toast provider - listens for background downloads */} - - - {/* Show onboarding or main app */} - {showOnboarding ? ( - - ) : ( -
- - {children} -
- )} - {/* Import audio overlay and dialog */} - - -
-
-
+ + + + + {/* Download progress toast provider - listens for background downloads */} + + + {/* Show onboarding or main app */} + {showOnboarding ? ( + + ) : ( +
+ + {children} +
+ )} + {/* Import audio overlay and dialog */} + + +
+
+
+
diff --git a/frontend/src/components/Notifications/NotificationCard.tsx b/frontend/src/components/Notifications/NotificationCard.tsx new file mode 100644 index 0000000..74e771e --- /dev/null +++ b/frontend/src/components/Notifications/NotificationCard.tsx @@ -0,0 +1,92 @@ +'use client'; + +import React, { useState } from 'react'; +import { CheckCircle2, MessageCircleQuestion, Info, AlertCircle, X, Send } from 'lucide-react'; +import { useNotifications } from '@/contexts/NotificationsContext'; + +interface Notification { + id: string; + type: 'info' | 'action_taken' | 'question' | 'error'; + title: string; + message: string; + status: string; + created_at: string; +} + +function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffSec = Math.floor((now - then) / 1000); + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return `${Math.floor(diffSec / 86400)}d ago`; +} + +const ICON_MAP = { + action_taken: , + question: , + info: , + error: , +}; + +export default function NotificationCard({ notification }: { notification: Notification }) { + const { replyToNotification, dismissNotification } = useNotifications(); + const [replyText, setReplyText] = useState(''); + const [sending, setSending] = useState(false); + const isUnread = notification.status === 'unread'; + const isQuestion = notification.type === 'question' && notification.status !== 'answered'; + + const handleReply = async () => { + if (!replyText.trim()) return; + setSending(true); + await replyToNotification(notification.id, replyText.trim()); + setReplyText(''); + setSending(false); + }; + + return ( +
+
+ {ICON_MAP[notification.type] || ICON_MAP.info} +
+
+ {notification.title} + {isUnread && } +
+

{notification.message}

+ {timeAgo(notification.created_at)} +
+ {!isQuestion && ( + + )} +
+ + {isQuestion && ( +
+ setReplyText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleReply()} + placeholder="Type your reply..." + className="flex-1 px-2 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-blue-500" + disabled={sending} + /> + +
+ )} +
+ ); +} diff --git a/frontend/src/components/Notifications/NotificationsPanel.tsx b/frontend/src/components/Notifications/NotificationsPanel.tsx new file mode 100644 index 0000000..58115d8 --- /dev/null +++ b/frontend/src/components/Notifications/NotificationsPanel.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useNotifications } from '@/contexts/NotificationsContext'; +import NotificationCard from './NotificationCard'; + +export default function NotificationsPanel() { + const { notifications, isOpen, setIsOpen, markAllRead } = useNotifications(); + + return ( + + + +
+ Agent Activity + +
+
+ +
+ {notifications.length === 0 ? ( +
+ No notifications yet. The agent will post updates here as it processes meetings. +
+ ) : ( + notifications.map((n) => ) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar/index.tsx b/frontend/src/components/Sidebar/index.tsx index c893c82..5ed4bb0 100644 --- a/frontend/src/components/Sidebar/index.tsx +++ b/frontend/src/components/Sidebar/index.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useMemo, useEffect, useCallback } from 'react'; -import { ChevronDown, ChevronRight, File, Settings, ChevronLeftCircle, ChevronRightCircle, Calendar, StickyNote, Home, Trash2, Mic, Square, Plus, Search, Pencil, NotebookPen, SearchIcon, X, Upload } from 'lucide-react'; +import { ChevronDown, ChevronRight, File, Settings, ChevronLeftCircle, ChevronRightCircle, Calendar, StickyNote, Home, Trash2, Mic, Square, Plus, Search, Pencil, NotebookPen, SearchIcon, X, Upload, Bell } from 'lucide-react'; import { useRouter, usePathname } from 'next/navigation'; import { useSidebar } from './SidebarProvider'; import type { CurrentMeeting } from '@/components/Sidebar/SidebarProvider'; @@ -16,6 +16,8 @@ import { toast } from 'sonner'; import { useRecordingState } from '@/contexts/RecordingStateContext'; import { useImportDialog } from '@/contexts/ImportDialogContext'; import { useConfig } from '@/contexts/ConfigContext'; +import { useNotifications } from '@/contexts/NotificationsContext'; +import NotificationsPanel from '@/components/Notifications/NotificationsPanel'; import { Dialog, @@ -61,6 +63,7 @@ const Sidebar: React.FC = () => { const { isRecording } = useRecordingState(); const { openImportDialog } = useImportDialog(); const { betaFeatures } = useConfig(); + const { unreadCount, setIsOpen: setNotificationsOpen } = useNotifications(); const [expandedFolders, setExpandedFolders] = useState>(new Set(['meetings'])); const [searchQuery, setSearchQuery] = useState(''); const [showModelSettings, setShowModelSettings] = useState(false); @@ -524,6 +527,25 @@ const Sidebar: React.FC = () => { + + + + + +

Agent Activity

+
+
+ )} + + @@ -173,6 +175,15 @@ export default function AgentInboxPage() {
+ {isBrowserOnly && ( +
+ + + The Agent Inbox requires the Friday desktop app (Tauri). In browser mode, use the{" "} + Notifications panel (bell icon in the sidebar) to see agent activity from the Python backend. + +
+ )} {isLoading ? (
Loading agent inbox...
) : ( diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 55817d6..bcaac29 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -9,8 +9,7 @@ import AnalyticsProvider from '@/components/AnalyticsProvider' import { Toaster, toast } from 'sonner' import "sonner/dist/styles.css" import { useState, useEffect, useCallback } from 'react' -import { listen, UnlistenFn } from '@tauri-apps/api/event' -import { invoke } from '@tauri-apps/api/core' +import { safeInvoke, safeListen, isTauriAvailable } from '@/lib/tauri-compat' import { TooltipProvider } from '@/components/ui/tooltip' import { RecordingStateProvider } from '@/contexts/RecordingStateContext' import { NotificationsProvider } from '@/contexts/NotificationsContext' @@ -79,7 +78,13 @@ export default function RootLayout({ useEffect(() => { // Check onboarding status first - invoke<{ completed: boolean } | null>('get_onboarding_status') + if (!isTauriAvailable()) { + console.log('[Layout] Tauri not available — skipping onboarding for browser dev') + setShowOnboarding(false) + setOnboardingCompleted(true) + return + } + safeInvoke<{ completed: boolean } | null>('get_onboarding_status') .then((status) => { const isComplete = status?.completed ?? false setOnboardingCompleted(isComplete) @@ -93,7 +98,6 @@ export default function RootLayout({ }) .catch((error) => { console.error('[Layout] Failed to check onboarding status:', error) - // Default to showing onboarding if we can't check setShowOnboarding(true) setOnboardingCompleted(false) }) @@ -109,7 +113,7 @@ export default function RootLayout({ }, []); useEffect(() => { // Listen for tray recording toggle request - const unlisten = listen('request-recording-toggle', () => { + const unlistenPromise = safeListen('request-recording-toggle', () => { console.log('[Layout] Received request-recording-toggle from tray'); if (showOnboarding) { @@ -124,7 +128,7 @@ export default function RootLayout({ }); return () => { - unlisten.then(fn => fn()); + unlistenPromise.then(fn => fn()); }; }, [showOnboarding]); @@ -159,14 +163,14 @@ export default function RootLayout({ // Listen for drag-drop events useEffect(() => { - if (showOnboarding) return; // Don't handle drops during onboarding + if (showOnboarding || !isTauriAvailable()) return; // Don't handle drops during onboarding or browser mode - const unlisteners: UnlistenFn[] = []; + const unlisteners: (() => void)[] = []; const cleanedUpRef = { current: false }; const setupListeners = async () => { // Drag enter/over - show overlay only if beta feature is enabled - const unlistenDragEnter = await listen('tauri://drag-enter', () => { + const unlistenDragEnter = await safeListen('tauri://drag-enter', () => { if (loadBetaFeatures().importAndRetranscribe) { setShowDropOverlay(true); } @@ -178,7 +182,7 @@ export default function RootLayout({ unlisteners.push(unlistenDragEnter); // Drag leave - hide overlay - const unlistenDragLeave = await listen('tauri://drag-leave', () => { + const unlistenDragLeave = await safeListen('tauri://drag-leave', () => { setShowDropOverlay(false); }); if (cleanedUpRef.current) { @@ -189,7 +193,7 @@ export default function RootLayout({ unlisteners.push(unlistenDragLeave); // Drop - process files - const unlistenDrop = await listen<{ paths: string[] }>('tauri://drag-drop', (event) => { + const unlistenDrop = await safeListen<{ paths: string[] }>('tauri://drag-drop', (event) => { setShowDropOverlay(false); handleFileDrop(event.payload.paths); }); diff --git a/frontend/src/components/AnalyticsProvider.tsx b/frontend/src/components/AnalyticsProvider.tsx index dc1b75f..a1f7455 100644 --- a/frontend/src/components/AnalyticsProvider.tsx +++ b/frontend/src/components/AnalyticsProvider.tsx @@ -2,7 +2,7 @@ import React, { useEffect, ReactNode, useRef, useState, createContext } from 'react'; import Analytics from '@/lib/analytics'; -import { load } from '@tauri-apps/plugin-store'; +import { isTauriAvailable } from '@/lib/tauri-compat'; interface AnalyticsProviderProps { @@ -30,6 +30,13 @@ export default function AnalyticsProvider({ children }: AnalyticsProviderProps) } const initAnalytics = async () => { + if (!isTauriAvailable()) { + // In browser mode, skip analytics entirely + console.log('[Analytics] Tauri not available — skipping analytics init'); + initialized.current = true; + return; + } + const { load } = await import('@tauri-apps/plugin-store'); const store = await load('analytics.json', { autoSave: false, defaults: { @@ -63,6 +70,7 @@ export default function AnalyticsProvider({ children }: AnalyticsProviderProps) const deviceInfo = await Analytics.getDeviceInfo(); // Store platform info in analytics.json for quick access + const { load } = await import('@tauri-apps/plugin-store'); const store = await load('analytics.json', { autoSave: false, defaults: { diff --git a/frontend/src/components/Sidebar/SidebarProvider.tsx b/frontend/src/components/Sidebar/SidebarProvider.tsx index 243ed21..40142fb 100644 --- a/frontend/src/components/Sidebar/SidebarProvider.tsx +++ b/frontend/src/components/Sidebar/SidebarProvider.tsx @@ -3,7 +3,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import Analytics from '@/lib/analytics'; -import { invoke } from '@tauri-apps/api/core'; +import { safeInvoke } from '@/lib/tauri-compat'; import { useRecordingState } from '@/contexts/RecordingStateContext'; @@ -86,7 +86,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) { const fetchMeetings = React.useCallback(async () => { if (serverAddress) { try { - const meetings = await invoke('api_get_meetings') as Array<{ id: string, title: string }>; + const meetings = await safeInvoke('api_get_meetings') as Array<{ id: string, title: string }>; const transformedMeetings = meetings.map((meeting: any) => ({ id: meeting.id, title: meeting.title @@ -174,7 +174,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) { setIsSearching(true); - const results = await invoke('api_search_transcripts', { query }) as TranscriptSearchResult[]; + const results = await safeInvoke('api_search_transcripts', { query }) as TranscriptSearchResult[]; setSearchResults(results); } catch (error) { console.error('Error searching transcripts:', error); @@ -219,7 +219,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) { return; } try { - const result = await invoke('api_get_summary', { + const result = await safeInvoke('api_get_summary', { meetingId: meetingId, }) as any; diff --git a/frontend/src/components/Sidebar/index.tsx b/frontend/src/components/Sidebar/index.tsx index a6074a9..6fdd305 100644 --- a/frontend/src/components/Sidebar/index.tsx +++ b/frontend/src/components/Sidebar/index.tsx @@ -10,7 +10,7 @@ import { ModelConfig } from '@/components/ModelSettingsModal'; import { SettingTabs } from '../SettingTabs'; import { TranscriptModelProps } from '@/components/TranscriptSettings'; import Analytics from '@/lib/analytics'; -import { invoke } from '@tauri-apps/api/core'; +import { safeInvoke, safeListen, isTauriAvailable } from '@/lib/tauri-compat'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; import { useRecordingState } from '@/contexts/RecordingStateContext'; @@ -118,12 +118,12 @@ const Sidebar: React.FC = () => { } try { - const data = await invoke('api_get_model_config') as any; + const data = await safeInvoke('api_get_model_config') as any; if (data && data.provider !== null) { // Fetch API key if not included and provider requires it if (data.provider !== 'ollama' && !data.apiKey) { try { - const apiKeyData = await invoke('api_get_api_key', { + const apiKeyData = await safeInvoke('api_get_api_key', { provider: data.provider }) as string; data.apiKey = apiKeyData; @@ -152,7 +152,7 @@ const Sidebar: React.FC = () => { } try { - const data = await invoke('api_get_transcript_config') as any; + const data = await safeInvoke('api_get_transcript_config') as any; if (data && data.provider !== null) { setTranscriptModelConfig(data); } @@ -166,8 +166,7 @@ const Sidebar: React.FC = () => { // Listen for model config updates from other components useEffect(() => { const setupListener = async () => { - const { listen } = await import('@tauri-apps/api/event'); - const unlisten = await listen('model-config-updated', (event) => { + const unlisten = await safeListen('model-config-updated', (event) => { console.log('Sidebar received model-config-updated event:', event.payload); setModelConfig(event.payload); }); @@ -188,7 +187,7 @@ const Sidebar: React.FC = () => { // Handle model config save const handleSaveModelConfig = async (config: ModelConfig) => { try { - await invoke('api_save_model_config', { + await safeInvoke('api_save_model_config', { provider: config.provider, model: config.model, whisperModel: config.whisperModel, @@ -201,8 +200,10 @@ const Sidebar: React.FC = () => { setSettingsSaveSuccess(true); // Emit event to sync other components - const { emit } = await import('@tauri-apps/api/event'); - await emit('model-config-updated', config); + if (isTauriAvailable()) { + const { emit } = await import('@tauri-apps/api/event'); + await emit('model-config-updated', config); + } // Track settings change await Analytics.trackSettingsChanged('model_config', `${config.provider}_${config.model}`); @@ -222,7 +223,7 @@ const Sidebar: React.FC = () => { }; console.log('Saving transcript config with payload:', payload); - await invoke('api_save_transcript_config', { + await safeInvoke('api_save_transcript_config', { provider: payload.provider, model: payload.model, apiKey: payload.apiKey, @@ -328,8 +329,7 @@ const Sidebar: React.FC = () => { }; try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('api_delete_meeting', { + await safeInvoke('api_delete_meeting', { meetingId: itemId, }); console.log('Meeting deleted successfully'); @@ -387,7 +387,7 @@ const Sidebar: React.FC = () => { } try { - await invoke('api_save_meeting_title', { + await safeInvoke('api_save_meeting_title', { meetingId: meetingId, title: newTitle, }); diff --git a/frontend/src/contexts/ConfigContext.tsx b/frontend/src/contexts/ConfigContext.tsx index 6aa6011..e38139e 100644 --- a/frontend/src/contexts/ConfigContext.tsx +++ b/frontend/src/contexts/ConfigContext.tsx @@ -4,7 +4,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use import { TranscriptModelProps } from '@/components/TranscriptSettings'; import { SelectedDevices } from '@/components/DeviceSelection'; import { configService, ModelConfig } from '@/services/configService'; -import { invoke } from '@tauri-apps/api/core'; +import { safeInvoke, safeListen, isTauriAvailable } from '@/lib/tauri-compat'; import Analytics from '@/lib/analytics'; import { BetaFeatures, BetaFeatureKey, loadBetaFeatures, saveBetaFeatures } from '@/types/betaFeatures'; @@ -180,7 +180,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const loadModels = async () => { try { const endpoint = modelConfig.ollamaEndpoint || null; - const modelList = await invoke('get_ollama_models', { endpoint }); + const modelList = await safeInvoke('get_ollama_models', { endpoint }); setModels(modelList); setError(''); } catch (err) { @@ -214,7 +214,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { // Sync language preference to Rust on mount (fixes startup desync bug) useEffect(() => { if (selectedLanguage) { - invoke('set_language_preference', { language: selectedLanguage }) + safeInvoke('set_language_preference', { language: selectedLanguage }) .then(() => { console.log('[ConfigContext] Synced language preference to Rust on startup:', selectedLanguage); }) @@ -298,7 +298,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const providers = ['claude', 'groq', 'openai', 'openrouter']; const keys = await Promise.all( providers.map(p => - invoke('api_get_api_key', { provider: p }) + safeInvoke('api_get_api_key', { provider: p }) .catch(() => null) // Gracefully handle missing keys ) ); @@ -321,8 +321,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { // Listen for model config updates from other components useEffect(() => { const setupListener = async () => { - const { listen } = await import('@tauri-apps/api/event'); - const unlisten = await listen('model-config-updated', (event) => { + const unlisten = await safeListen('model-config-updated', (event) => { console.log('[ConfigContext] Received model-config-updated event:', event.payload); setModelConfig(event.payload); @@ -429,7 +428,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { // Load notification settings from backend let settings: NotificationSettings | null = null; try { - settings = await invoke('get_notification_settings'); + settings = await safeInvoke('get_notification_settings'); setNotificationSettings(settings); } catch (notifError) { console.error('[ConfigContext] Failed to load notification settings:', notifError); @@ -439,9 +438,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) { // Load storage locations const [dbDir, modelsDir, recordingsDir] = await Promise.all([ - invoke('get_database_directory'), - invoke('whisper_get_models_directory'), - invoke('get_default_recordings_folder_path') + safeInvoke('get_database_directory'), + safeInvoke('whisper_get_models_directory'), + safeInvoke('get_default_recordings_folder_path') ]); setStorageLocations({ @@ -463,7 +462,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { // Update notification settings const updateNotificationSettings = useCallback(async (settings: NotificationSettings) => { try { - await invoke('set_notification_settings', { settings }); + await safeInvoke('set_notification_settings', { settings }); setNotificationSettings(settings); } catch (error) { console.error('[ConfigContext] Failed to update notification settings:', error); @@ -478,7 +477,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { localStorage.setItem('primaryLanguage', lang); } // Sync with Rust in-memory state for live recording - invoke('set_language_preference', { language: lang }).catch(err => + safeInvoke('set_language_preference', { language: lang }).catch(err => console.error('Failed to sync language preference to Rust:', err) ); }, []); diff --git a/frontend/src/contexts/OllamaDownloadContext.tsx b/frontend/src/contexts/OllamaDownloadContext.tsx index a5e6270..a82d251 100644 --- a/frontend/src/contexts/OllamaDownloadContext.tsx +++ b/frontend/src/contexts/OllamaDownloadContext.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { createContext, useContext, useState, useEffect } from 'react'; -import { listen } from '@tauri-apps/api/event'; +import { safeListen } from '@/lib/tauri-compat'; import { toast } from 'sonner'; /** @@ -48,7 +48,7 @@ export function OllamaDownloadProvider({ children }: { children: React.ReactNode const setupListeners = async () => { try { // Download progress - const unlistenProgress = await listen<{ modelName: string; progress: number }>( + const unlistenProgress = await safeListen<{ modelName: string; progress: number }>( 'ollama-model-download-progress', (event) => { const { modelName, progress } = event.payload; @@ -72,7 +72,7 @@ export function OllamaDownloadProvider({ children }: { children: React.ReactNode unsubscribers.push(unlistenProgress); // Download complete - const unlistenComplete = await listen<{ modelName: string }>( + const unlistenComplete = await safeListen<{ modelName: string }>( 'ollama-model-download-complete', (event) => { const { modelName } = event.payload; @@ -100,7 +100,7 @@ export function OllamaDownloadProvider({ children }: { children: React.ReactNode unsubscribers.push(unlistenComplete); // Download error - const unlistenError = await listen<{ modelName: string; error: string }>( + const unlistenError = await safeListen<{ modelName: string; error: string }>( 'ollama-model-download-error', (event) => { const { modelName, error } = event.payload; diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index 27dda96..cbf86aa 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -1,8 +1,7 @@ 'use client'; import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; +import { safeInvoke, safeListen, isTauriAvailable } from '@/lib/tauri-compat'; import type { PermissionStatus, OnboardingPermissions } from '@/types/onboarding'; const PARAKEET_MODEL = 'parakeet-tdt-0.6b-v3-int8'; @@ -103,6 +102,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Load status on mount and initialize database useEffect(() => { + if (!isTauriAvailable()) return; // Skip all Tauri-only initialization in browser mode loadOnboardingStatus(); checkDatabaseStatus(); initializeDatabaseInBackground(); @@ -110,7 +110,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Fetch and set recommended model const fetchRecommendation = async () => { try { - const recommendedModel = await invoke('builtin_ai_get_recommended_model'); + const recommendedModel = await safeInvoke('builtin_ai_get_recommended_model'); setSelectedSummaryModel(recommendedModel); console.log('[OnboardingContext] Set recommended model:', recommendedModel); } catch (error) { @@ -125,7 +125,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) const initializeDatabaseInBackground = async () => { try { console.log('[OnboardingContext] Starting background database initialization'); - const isFirstLaunch = await invoke('check_first_launch'); + const isFirstLaunch = await safeInvoke('check_first_launch'); if (!isFirstLaunch) { console.log('[OnboardingContext] Database exists, skipping initialization'); @@ -146,14 +146,14 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) if (typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('mac')) { const homebrewDbPath = '/usr/local/var/friday/meeting_minutes.db'; try { - const homebrewCheck = await invoke<{ exists: boolean; size: number } | null>( + const homebrewCheck = await safeInvoke<{ exists: boolean; size: number } | null>( 'check_homebrew_database', { path: homebrewDbPath } ); if (homebrewCheck?.exists) { console.log('[OnboardingContext] Found Homebrew database, importing'); - await invoke('import_and_initialize_database', { legacyDbPath: homebrewDbPath }); + await safeInvoke('import_and_initialize_database', { legacyDbPath: homebrewDbPath }); setDatabaseExists(true); return; } @@ -164,10 +164,10 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Check default legacy database location try { - const legacyPath = await invoke('check_default_legacy_database'); + const legacyPath = await safeInvoke('check_default_legacy_database'); if (legacyPath) { console.log('[OnboardingContext] Found legacy database, importing'); - await invoke('import_and_initialize_database', { legacyDbPath: legacyPath }); + await safeInvoke('import_and_initialize_database', { legacyDbPath: legacyPath }); setDatabaseExists(true); return; } @@ -177,7 +177,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // No legacy database found - initialize fresh console.log('[OnboardingContext] No legacy database found, initializing fresh'); - await invoke('initialize_fresh_database'); + await safeInvoke('initialize_fresh_database'); setDatabaseExists(true); }; @@ -202,7 +202,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Listen to Parakeet download progress useEffect(() => { - const unlisten = listen<{ + const unlisten = safeListen<{ modelName: string; progress: number; downloaded_mb?: number; @@ -228,7 +228,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) } ); - const unlistenComplete = listen<{ modelName: string }>( + const unlistenComplete = safeListen<{ modelName: string }>( 'parakeet-model-download-complete', (event) => { const { modelName } = event.payload; @@ -239,7 +239,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) } ); - const unlistenError = listen<{ modelName: string; error: string }>( + const unlistenError = safeListen<{ modelName: string; error: string }>( 'parakeet-model-download-error', (event) => { const { modelName } = event.payload; @@ -258,7 +258,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Listen to summary model (Built-in AI) download progress useEffect(() => { - const unlisten = listen<{ + const unlisten = safeListen<{ model: string; progress: number; downloaded_mb?: number; @@ -292,7 +292,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) const checkDatabaseStatus = async () => { try { - const isFirstLaunch = await invoke('check_first_launch'); + const isFirstLaunch = await safeInvoke('check_first_launch'); setDatabaseExists(!isFirstLaunch); console.log('[OnboardingContext] Database exists:', !isFirstLaunch); } catch (error) { @@ -303,7 +303,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) const loadOnboardingStatus = async () => { try { - const status = await invoke('get_onboarding_status'); + const status = await safeInvoke('get_onboarding_status'); if (status) { console.log('[OnboardingContext] Loaded saved status:', status); @@ -332,8 +332,8 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Verify Parakeet model exists on disk try { - await invoke('parakeet_init'); - parakeetDownloaded = await invoke('parakeet_has_available_models'); + await safeInvoke('parakeet_init'); + parakeetDownloaded = await safeInvoke('parakeet_has_available_models'); console.log('[OnboardingContext] Parakeet verified on disk:', parakeetDownloaded); } catch (error) { console.warn('[OnboardingContext] Failed to verify Parakeet:', error); @@ -343,7 +343,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Verify Summary model exists on disk - check if ANY model is available // Onboarding always uses builtin-ai (local models) try { - const availableModel = await invoke('builtin_ai_get_available_summary_model'); + const availableModel = await safeInvoke('builtin_ai_get_available_summary_model'); summaryModelDownloaded = !!availableModel; console.log('[OnboardingContext] Summary model verified on disk:', summaryModelDownloaded, 'model:', availableModel); } catch (error) { @@ -381,7 +381,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) } try { - await invoke('save_onboarding_status_cmd', { + await safeInvoke('save_onboarding_status_cmd', { status: { version: '1.0', completed: completed, @@ -410,7 +410,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) } // Onboarding always uses builtin-ai with selected model - await invoke('complete_onboarding', { + await safeInvoke('complete_onboarding', { model: selectedSummaryModel, }); setCompleted(true); @@ -433,7 +433,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) saveTimeoutRef.current = undefined; } - await invoke('complete_onboarding_hosted', { apiKey }); + await safeInvoke('complete_onboarding_hosted', { apiKey }); setCompleted(true); console.log('[OnboardingContext] Onboarding completed in hosted (Gemini) mode'); isCompletingRef.current = false; @@ -453,7 +453,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Start Parakeet download first (speech recognition - always required) if (!parakeetDownloaded) { console.log('[OnboardingContext] Starting Parakeet download'); - invoke('parakeet_download_model', { modelName: PARAKEET_MODEL }) + safeInvoke('parakeet_download_model', { modelName: PARAKEET_MODEL }) .catch(err => console.error('[OnboardingContext] Parakeet download failed:', err)); } @@ -461,7 +461,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) if (includeGemma && !summaryModelDownloaded) { setTimeout(() => { console.log('[OnboardingContext] Starting Gemma download (delayed to prioritize Parakeet)'); - invoke('builtin_ai_download_model', { modelName: selectedSummaryModel || 'gemma3:1b' }) + safeInvoke('builtin_ai_download_model', { modelName: selectedSummaryModel || 'gemma3:1b' }) .catch(err => console.error('[OnboardingContext] Gemma download failed:', err)); }, 3000); // 3 second delay to give Parakeet priority } @@ -475,7 +475,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) // Check if any models are currently downloading (for re-entry) const checkActiveDownloads = async () => { try { - const models = await invoke('parakeet_get_available_models'); + const models = await safeInvoke('parakeet_get_available_models'); const isDownloading = models.some(m => m.status && (typeof m.status === 'object' ? 'Downloading' in m.status : m.status === 'Downloading')); if (isDownloading) { @@ -493,7 +493,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) const retryParakeetDownload = async () => { console.log('[OnboardingContext] Retrying Parakeet download'); try { - await invoke('parakeet_retry_download', { modelName: PARAKEET_MODEL }); + await safeInvoke('parakeet_retry_download', { modelName: PARAKEET_MODEL }); } catch (error) { console.error('[OnboardingContext] Retry failed:', error); throw error; diff --git a/frontend/src/contexts/RecordingPostProcessingProvider.tsx b/frontend/src/contexts/RecordingPostProcessingProvider.tsx index e298970..c9870fa 100644 --- a/frontend/src/contexts/RecordingPostProcessingProvider.tsx +++ b/frontend/src/contexts/RecordingPostProcessingProvider.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect } from 'react'; -import { listen } from '@tauri-apps/api/event'; +import { safeListen } from '@/lib/tauri-compat'; import { useRecordingStop } from '@/hooks/useRecordingStop'; /** @@ -33,7 +33,7 @@ export function RecordingPostProcessingProvider({ children }: { children: React. const setupListener = async () => { try { // Listen for recording-stop-complete event from Rust - unlistenFn = await listen('recording-stop-complete', (event) => { + unlistenFn = await safeListen('recording-stop-complete', (event) => { console.log('[RecordingPostProcessing] Received recording-stop-complete event:', event.payload); // Call the post-processing handler diff --git a/frontend/src/lib/analytics.ts b/frontend/src/lib/analytics.ts index 24ec285..f44c8c8 100644 --- a/frontend/src/lib/analytics.ts +++ b/frontend/src/lib/analytics.ts @@ -1,4 +1,10 @@ -import { invoke } from '@tauri-apps/api/core'; +import { isTauriAvailable } from '@/lib/tauri-compat'; + +const tauriInvoke = async (cmd: string, args?: Record): Promise => { + if (!isTauriAvailable()) throw new Error('Tauri not available'); + const { invoke } = await import('@tauri-apps/api/core'); + return invoke(cmd, args); +}; export interface AnalyticsProperties { [key: string]: string; @@ -43,7 +49,7 @@ export class Analytics { private static async doInit(): Promise { try { - await invoke('init_analytics'); + await tauriInvoke('init_analytics'); this.initialized = true; console.log('Analytics initialized successfully'); } catch (error) { @@ -56,7 +62,7 @@ export class Analytics { static async disable(): Promise { try { - await invoke('disable_analytics'); + await tauriInvoke('disable_analytics'); this.initialized = false; this.currentUserId = null; this.initializationPromise = null; @@ -68,7 +74,7 @@ export class Analytics { static async isEnabled(): Promise { try { - return await invoke('is_analytics_enabled'); + return await tauriInvoke('is_analytics_enabled'); } catch (error) { console.error('Failed to check analytics status:', error); return false; @@ -82,7 +88,7 @@ export class Analytics { } try { - await invoke('track_event', { eventName, properties }); + await tauriInvoke('track_event', { eventName, properties }); } catch (error) { console.error(`Failed to track event ${eventName}:`, error); } @@ -95,7 +101,7 @@ export class Analytics { } try { - await invoke('identify_user', { userId, properties }); + await tauriInvoke('identify_user', { userId, properties }); this.currentUserId = userId; } catch (error) { console.error(`Failed to identify user ${userId}:`, error); @@ -110,7 +116,7 @@ export class Analytics { } try { - const sessionId = await invoke('start_analytics_session', { userId }); + const sessionId = await tauriInvoke('start_analytics_session', { userId }); this.currentUserId = userId; return sessionId as string; @@ -124,7 +130,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('end_analytics_session'); + await tauriInvoke('end_analytics_session'); } catch (error) { console.error('Failed to end analytics session:', error); } @@ -134,7 +140,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_daily_active_user'); + await tauriInvoke('track_daily_active_user'); } catch (error) { console.error('Failed to track daily active user:', error); } @@ -144,7 +150,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_user_first_launch'); + await tauriInvoke('track_user_first_launch'); } catch (error) { console.error('Failed to track user first launch:', error); } @@ -154,7 +160,7 @@ export class Analytics { if (!this.initialized) return false; try { - return await invoke('is_analytics_session_active'); + return await tauriInvoke('is_analytics_session_active'); } catch (error) { console.error('Failed to check session status:', error); return false; @@ -535,7 +541,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_meeting_started', { meetingId, meetingTitle }); + await tauriInvoke('track_meeting_started', { meetingId, meetingTitle }); } catch (error) { console.error('Failed to track meeting started:', error); } @@ -545,7 +551,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_recording_started', { meetingId }); + await tauriInvoke('track_recording_started', { meetingId }); } catch (error) { console.error('Failed to track recording started:', error); } @@ -555,7 +561,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_recording_stopped', { meetingId, durationSeconds }); + await tauriInvoke('track_recording_stopped', { meetingId, durationSeconds }); } catch (error) { console.error('Failed to track recording stopped:', error); } @@ -565,7 +571,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_meeting_deleted', { meetingId }); + await tauriInvoke('track_meeting_deleted', { meetingId }); } catch (error) { console.error('Failed to track meeting deleted:', error); } @@ -575,7 +581,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_settings_changed', { settingType, newValue }); + await tauriInvoke('track_settings_changed', { settingType, newValue }); } catch (error) { console.error('Failed to track settings changed:', error); } @@ -585,7 +591,7 @@ export class Analytics { if (!this.initialized) return; try { - await invoke('track_feature_used', { featureName }); + await tauriInvoke('track_feature_used', { featureName }); } catch (error) { console.error('Failed to track feature used:', error); } @@ -652,7 +658,7 @@ export class Analytics { try { console.log('Tracking backend connection event:', { success, error }); - await invoke('track_event', { + await tauriInvoke('track_event', { eventName: 'backend_connection', properties: { success: success.toString(), @@ -675,7 +681,7 @@ export class Analytics { try { console.log('Tracking transcription error event:', { errorMessage }); - await invoke('track_event', { + await tauriInvoke('track_event', { eventName: 'transcription_error', properties: { error_message: errorMessage, @@ -697,7 +703,7 @@ export class Analytics { try { console.log('Tracking transcription success event:', { duration }); - await invoke('track_event', { + await tauriInvoke('track_event', { eventName: 'transcription_success', properties: { duration: duration ? duration.toString() : '', @@ -764,7 +770,7 @@ export class Analytics { try { console.log('Tracking summary generation completed event:', { modelProvider, modelName, success, durationSeconds, errorMessage }); - await invoke('track_summary_generation_completed', { + await tauriInvoke('track_summary_generation_completed', { modelProvider, modelName, success, @@ -785,7 +791,7 @@ export class Analytics { try { console.log('Tracking summary regenerated event:', { modelProvider, modelName }); - await invoke('track_summary_regenerated', { + await tauriInvoke('track_summary_regenerated', { modelProvider, modelName }); @@ -803,7 +809,7 @@ export class Analytics { try { console.log('Tracking model changed event:', { oldProvider, oldModel, newProvider, newModel }); - await invoke('track_model_changed', { + await tauriInvoke('track_model_changed', { oldProvider, oldModel, newProvider, @@ -823,7 +829,7 @@ export class Analytics { try { console.log('Tracking custom prompt used event:', { promptLength }); - await invoke('track_custom_prompt_used', { + await tauriInvoke('track_custom_prompt_used', { promptLength }); console.log('Custom prompt used event tracked successfully'); diff --git a/frontend/src/lib/tauri-compat.ts b/frontend/src/lib/tauri-compat.ts new file mode 100644 index 0000000..091a470 --- /dev/null +++ b/frontend/src/lib/tauri-compat.ts @@ -0,0 +1,145 @@ +/** + * Tauri compatibility layer for browser-only dev mode. + * + * When Tauri is available, delegates to `invoke()`. + * When running in a plain browser, maps known commands to HTTP calls + * against the Python backend at http://localhost:5167. + */ + +const BACKEND = "http://localhost:5167"; + +export function isTauriAvailable(): boolean { + return ( + typeof window !== "undefined" && !!(window as any).__TAURI_INTERNALS__ + ); +} + +// Maps Tauri command names → { method, path, bodyMapper? } +// bodyMapper transforms invoke args into a fetch body (POST only). +const COMMAND_MAP: Record< + string, + { + method: "GET" | "POST"; + path: (args?: any) => string; + bodyMapper?: (args?: any) => any; + } +> = { + api_get_meetings: { + method: "GET", + path: () => "/get-meetings", + }, + api_search_transcripts: { + method: "POST", + path: () => "/search-transcripts", + bodyMapper: (args) => ({ query: args?.query }), + }, + api_get_summary: { + method: "GET", + path: (args) => `/get-summary/${args?.meetingId}`, + }, + api_get_model_config: { + method: "GET", + path: () => "/get-model-config", + }, + api_get_transcript_config: { + method: "GET", + path: () => "/get-transcript-config", + }, + api_get_api_key: { + method: "POST", + path: () => "/get-api-key", + bodyMapper: (args) => ({ provider: args?.provider }), + }, + api_save_model_config: { + method: "POST", + path: () => "/save-model-config", + bodyMapper: (args) => ({ + provider: args?.provider, + model: args?.model, + whisper_model: args?.whisperModel, + api_key: args?.apiKey, + ollama_endpoint: args?.ollamaEndpoint, + }), + }, + api_save_transcript_config: { + method: "POST", + path: () => "/save-transcript-config", + bodyMapper: (args) => ({ + provider: args?.provider, + model: args?.model, + api_key: args?.apiKey, + }), + }, + api_delete_meeting: { + method: "POST", + path: () => "/delete-meeting", + bodyMapper: (args) => ({ meeting_id: args?.meetingId }), + }, + api_save_meeting_title: { + method: "POST", + path: () => "/save-meeting-title", + bodyMapper: (args) => ({ + meeting_id: args?.meetingId, + title: args?.title, + }), + }, + api_get_meeting: { + method: "GET", + path: (args) => `/get-meeting/${args?.meetingId}`, + }, +}; + +async function httpFallback(cmd: string, args?: Record): Promise { + const mapping = COMMAND_MAP[cmd]; + if (!mapping) { + throw new Error( + `Command "${cmd}" is not available in browser mode (requires Tauri desktop app).` + ); + } + + const url = `${BACKEND}${mapping.path(args)}`; + const init: RequestInit = { + method: mapping.method, + headers: { "Content-Type": "application/json" }, + }; + if (mapping.method === "POST" && mapping.bodyMapper) { + init.body = JSON.stringify(mapping.bodyMapper(args)); + } + + const res = await fetch(url, init); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`${cmd} failed: ${res.status} ${text}`); + } + return res.json() as Promise; +} + +/** + * Drop-in replacement for Tauri `invoke()`. + * Uses Tauri IPC when available, falls back to HTTP in browser mode. + */ +export async function safeInvoke( + cmd: string, + args?: Record +): Promise { + if (isTauriAvailable()) { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke(cmd, args); + } + return httpFallback(cmd, args); +} + +/** + * Safe wrapper for Tauri `listen()`. Returns a no-op unlisten in browser mode. + */ +export async function safeListen( + event: string, + handler: (event: { payload: T }) => void +): Promise<() => void> { + if (isTauriAvailable()) { + const { listen } = await import("@tauri-apps/api/event"); + return listen(event, handler); + } + // No-op in browser mode + return () => {}; +} diff --git a/frontend/src/services/agentService.ts b/frontend/src/services/agentService.ts index 28e1e20..8f035fd 100644 --- a/frontend/src/services/agentService.ts +++ b/frontend/src/services/agentService.ts @@ -1,5 +1,3 @@ -import { invoke } from '@tauri-apps/api/core'; - export interface AgentSettingsPayload { enabled: boolean; provider: string; @@ -85,57 +83,99 @@ export interface AgentRecommendationActionResponse { created_calendar_event: CreatedCalendarEventSummary | null; } +const isTauri = (): boolean => + typeof window !== "undefined" && !!(window as any).__TAURI_INTERNALS__; + +async function tauriInvoke(cmd: string, args?: Record): Promise { + if (!isTauri()) { + throw new Error("This feature requires the Friday desktop app (Tauri runtime not available)."); + } + const { invoke } = await import("@tauri-apps/api/core"); + return invoke(cmd, args); +} + +const DEFAULT_SETTINGS: AgentSettingsPayload = { + enabled: false, + provider: "gemini", + model: "gemini-2.0-flash", + notifications_enabled: true, + calendar_proposals_enabled: false, + heartbeat_interval_minutes: 10, +}; + +const DEFAULT_STATUS: AgentStatusResponse = { + settings: DEFAULT_SETTINGS, + api_key_configured: false, + calendar_connected: false, + calendar_can_write: false, + is_running: false, + last_run_at: null, + last_success_at: null, + last_error: null, + pending_recommendations: 0, + open_tasks: 0, +}; + class AgentService { + get available(): boolean { + return isTauri(); + } + async getStatus(): Promise { - return invoke('agent_get_status'); + if (!isTauri()) return DEFAULT_STATUS; + return tauriInvoke('agent_get_status'); } async getSettings(): Promise { - return invoke('agent_get_settings'); + if (!isTauri()) return DEFAULT_SETTINGS; + return tauriInvoke('agent_get_settings'); } async setSettings(settings: AgentSettingsPayload): Promise { - return invoke('agent_set_settings', { settings }); + return tauriInvoke('agent_set_settings', { settings }); } async saveGeminiApiKey(apiKey: string): Promise { - return invoke('agent_save_gemini_api_key', { apiKey }); + return tauriInvoke('agent_save_gemini_api_key', { apiKey }); } async clearGeminiApiKey(): Promise { - return invoke('agent_clear_gemini_api_key'); + return tauriInvoke('agent_clear_gemini_api_key'); } async runHeartbeatNow(): Promise { - return invoke('agent_run_heartbeat_now'); + return tauriInvoke('agent_run_heartbeat_now'); } async listRecommendations(status?: string): Promise { - return invoke('agent_list_recommendations', { status: status ?? null }); + if (!isTauri()) return []; + return tauriInvoke('agent_list_recommendations', { status: status ?? null }); } async acceptRecommendation(recommendationId: string): Promise { - return invoke('agent_accept_recommendation', { recommendationId }); + return tauriInvoke('agent_accept_recommendation', { recommendationId }); } async dismissRecommendation(recommendationId: string): Promise { - return invoke('agent_dismiss_recommendation', { recommendationId }); + return tauriInvoke('agent_dismiss_recommendation', { recommendationId }); } async listMemory(limit = 25): Promise { - return invoke('agent_list_memory', { limit }); + if (!isTauri()) return []; + return tauriInvoke('agent_list_memory', { limit }); } async getMeetingContext(meetingId: string): Promise { - return invoke('agent_get_meeting_context', { meetingId }); + return tauriInvoke('agent_get_meeting_context', { meetingId }); } async listTasks(status?: string): Promise { - return invoke('agent_list_tasks', { status: status ?? null }); + if (!isTauri()) return []; + return tauriInvoke('agent_list_tasks', { status: status ?? null }); } async updateTaskStatus(taskId: string, status: string): Promise { - return invoke('agent_update_task_status', { taskId, status }); + return tauriInvoke('agent_update_task_status', { taskId, status }); } } diff --git a/frontend/src/services/calendarService.ts b/frontend/src/services/calendarService.ts index ab33947..5e91294 100644 --- a/frontend/src/services/calendarService.ts +++ b/frontend/src/services/calendarService.ts @@ -1,4 +1,13 @@ -import { invoke } from '@tauri-apps/api/core'; +const isTauri = (): boolean => + typeof window !== "undefined" && !!(window as any).__TAURI_INTERNALS__; + +async function tauriInvoke(cmd: string, args?: Record): Promise { + if (!isTauri()) { + throw new Error("Google Calendar requires the Friday desktop app (Tauri runtime not available)."); + } + const { invoke } = await import("@tauri-apps/api/core"); + return invoke(cmd, args); +} export interface CalendarAccountSummary { email: string | null; @@ -71,41 +80,51 @@ export interface UpcomingCalendarEvent { html_link: string | null; } +const DEFAULT_STATUS: CalendarStatusResponse = { + client_configured: false, + connected: false, + can_write: false, + syncing: false, + account: null, +}; + class CalendarService { async getStatus(): Promise { - return invoke('calendar_get_status'); + if (!isTauri()) return DEFAULT_STATUS; + return tauriInvoke('calendar_get_status'); } async listUpcoming(): Promise { - return invoke('calendar_list_upcoming'); + if (!isTauri()) return []; + return tauriInvoke('calendar_list_upcoming'); } async connectGoogle(writeAccess = false): Promise { - return invoke('calendar_connect_google', { + return tauriInvoke('calendar_connect_google', { writeAccess, }); } async upgradeGoogleAccess(): Promise { - return invoke('calendar_upgrade_google_access'); + return tauriInvoke('calendar_upgrade_google_access'); } async disconnectGoogle(): Promise { - return invoke('calendar_disconnect_google'); + return tauriInvoke('calendar_disconnect_google'); } async syncNow(): Promise { - return invoke('calendar_sync_now'); + return tauriInvoke('calendar_sync_now'); } async getMeetingLink(meetingId: string): Promise { - return invoke('calendar_get_meeting_link', { + return tauriInvoke('calendar_get_meeting_link', { meetingId, }); } async getLinkCandidates(meetingId: string): Promise { - return invoke('calendar_get_link_candidates', { + return tauriInvoke('calendar_get_link_candidates', { meetingId, }); } @@ -114,14 +133,14 @@ class CalendarService { meetingId: string, providerEventId: string ): Promise { - return invoke('calendar_set_meeting_link', { + return tauriInvoke('calendar_set_meeting_link', { meetingId, providerEventId, }); } async clearMeetingLink(meetingId: string): Promise { - return invoke('calendar_clear_meeting_link', { meetingId }); + return tauriInvoke('calendar_clear_meeting_link', { meetingId }); } } diff --git a/frontend/src/services/configService.ts b/frontend/src/services/configService.ts index b554e4a..5915a3b 100644 --- a/frontend/src/services/configService.ts +++ b/frontend/src/services/configService.ts @@ -5,7 +5,7 @@ * Pure 1-to-1 wrapper - no error handling changes, exact same behavior as direct invoke calls. */ -import { invoke } from '@tauri-apps/api/core'; +import { safeInvoke } from '@/lib/tauri-compat'; import { TranscriptModelProps } from '@/components/TranscriptSettings'; export interface ModelConfig { @@ -51,7 +51,7 @@ export class ConfigService { * @returns Promise with { provider, model, apiKey } */ async getTranscriptConfig(): Promise { - return invoke('api_get_transcript_config'); + return safeInvoke('api_get_transcript_config'); } /** @@ -59,7 +59,7 @@ export class ConfigService { * @returns Promise with { provider, model, whisperModel } */ async getModelConfig(): Promise { - return invoke('api_get_model_config'); + return safeInvoke('api_get_model_config'); } /** @@ -67,7 +67,7 @@ export class ConfigService { * @returns Promise with { preferred_mic_device, preferred_system_device } */ async getRecordingPreferences(): Promise { - return invoke('get_recording_preferences'); + return safeInvoke('get_recording_preferences'); } /** @@ -75,7 +75,7 @@ export class ConfigService { * @returns Promise with CustomOpenAIConfig or null if not configured */ async getCustomOpenAIConfig(): Promise { - return invoke('api_get_custom_openai_config'); + return safeInvoke('api_get_custom_openai_config'); } /** @@ -84,7 +84,7 @@ export class ConfigService { * @returns Promise with result status */ async saveCustomOpenAIConfig(config: CustomOpenAIConfig): Promise<{ status: string; message: string }> { - return invoke<{ status: string; message: string }>('api_save_custom_openai_config', { + return safeInvoke<{ status: string; message: string }>('api_save_custom_openai_config', { endpoint: config.endpoint, apiKey: config.apiKey, model: config.model, @@ -106,7 +106,7 @@ export class ConfigService { apiKey: string | null, model: string ): Promise<{ status: string; message: string; http_status?: number }> { - return invoke<{ status: string; message: string; http_status?: number }>('api_test_custom_openai_connection', { + return safeInvoke<{ status: string; message: string; http_status?: number }>('api_test_custom_openai_connection', { endpoint, apiKey, model, diff --git a/frontend/src/services/recordingService.ts b/frontend/src/services/recordingService.ts index 4ffb9d2..34e5263 100644 --- a/frontend/src/services/recordingService.ts +++ b/frontend/src/services/recordingService.ts @@ -5,8 +5,9 @@ * Pure 1-to-1 wrapper - no error handling changes, exact same behavior as direct invoke/listen calls. */ -import { invoke } from '@tauri-apps/api/core'; -import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { safeInvoke, safeListen } from '@/lib/tauri-compat'; + +type UnlistenFn = () => void; export interface RecordingState { is_recording: boolean; @@ -32,7 +33,7 @@ export class RecordingService { * @returns Promise */ async isRecording(): Promise { - return invoke('is_recording'); + return safeInvoke('is_recording'); } /** @@ -40,7 +41,7 @@ export class RecordingService { * @returns Promise with full recording state */ async getRecordingState(): Promise { - return invoke('get_recording_state'); + return safeInvoke('get_recording_state'); } /** @@ -48,7 +49,7 @@ export class RecordingService { * @returns Promise */ async getRecordingMeetingName(): Promise { - return invoke('get_recording_meeting_name'); + return safeInvoke('get_recording_meeting_name'); } /** @@ -56,7 +57,7 @@ export class RecordingService { * @returns Promise */ async startRecording(): Promise { - return invoke('start_recording'); + return safeInvoke('start_recording'); } /** @@ -71,7 +72,7 @@ export class RecordingService { systemDeviceName: string | null, meetingName: string ): Promise { - return invoke('start_recording_with_devices_and_meeting', { + return safeInvoke('start_recording_with_devices_and_meeting', { mic_device_name: micDeviceName, system_device_name: systemDeviceName, meeting_name: meetingName @@ -84,7 +85,7 @@ export class RecordingService { * @returns Promise */ async stopRecording(savePath: string): Promise { - return invoke('stop_recording', { + return safeInvoke('stop_recording', { args: { save_path: savePath } }); } @@ -94,7 +95,7 @@ export class RecordingService { * @returns Promise */ async pauseRecording(): Promise { - return invoke('pause_recording'); + return safeInvoke('pause_recording'); } /** @@ -102,7 +103,7 @@ export class RecordingService { * @returns Promise */ async resumeRecording(): Promise { - return invoke('resume_recording'); + return safeInvoke('resume_recording'); } // Event Listeners @@ -113,7 +114,7 @@ export class RecordingService { * @returns Promise that resolves to unlisten function */ async onRecordingStarted(callback: () => void): Promise { - return listen('recording-started', callback); + return safeListen('recording-started', callback); } /** @@ -122,7 +123,7 @@ export class RecordingService { * @returns Promise that resolves to unlisten function */ async onRecordingStopped(callback: (payload: RecordingStoppedPayload) => void): Promise { - return listen('recording-stopped', (event) => { + return safeListen('recording-stopped', (event) => { callback(event.payload); }); } @@ -133,7 +134,7 @@ export class RecordingService { * @returns Promise that resolves to unlisten function */ async onRecordingPaused(callback: () => void): Promise { - return listen('recording-paused', callback); + return safeListen('recording-paused', callback); } /** @@ -142,7 +143,7 @@ export class RecordingService { * @returns Promise that resolves to unlisten function */ async onRecordingResumed(callback: () => void): Promise { - return listen('recording-resumed', callback); + return safeListen('recording-resumed', callback); } /** @@ -151,7 +152,7 @@ export class RecordingService { * @returns Promise that resolves to unlisten function */ async onChunkDropWarning(callback: (warning: string) => void): Promise { - return listen('chunk-drop-warning', (event) => { + return safeListen('chunk-drop-warning', (event) => { callback(event.payload); }); } @@ -162,7 +163,7 @@ export class RecordingService { * @returns Promise that resolves to unlisten function */ async onSpeechDetected(callback: () => void): Promise { - return listen('speech-detected', callback); + return safeListen('speech-detected', callback); } }