mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: enhance Gmail draft update functionality
- Improved the update_gmail_draft tool to allow users to review and edit draft content before applying changes. - Added logic to generate draft body content based on user requests and conversation context. - Implemented fetching of existing draft body to facilitate user edits in the approval card. - Updated UI components to support displaying and editing existing draft content, enhancing user experience.
This commit is contained in:
parent
ab6eeaf02e
commit
cb6b687933
6 changed files with 132 additions and 18 deletions
|
|
@ -32,6 +32,12 @@ def create_update_gmail_draft_tool(
|
|||
|
||||
Use when the user asks to modify, edit, or add content to an existing
|
||||
email draft. This replaces the draft content with the new version.
|
||||
The user will be able to review and edit the content before it is applied.
|
||||
|
||||
If the user simply wants to "edit" a draft without specifying exact changes,
|
||||
generate the body yourself using your best understanding of the conversation
|
||||
context. The user will review and can freely edit the content in the approval
|
||||
card before confirming.
|
||||
|
||||
IMPORTANT: This tool is ONLY for Gmail drafts, NOT for Notion pages,
|
||||
calendar events, or any other content type.
|
||||
|
|
@ -39,7 +45,8 @@ def create_update_gmail_draft_tool(
|
|||
Args:
|
||||
draft_subject_or_id: The exact subject line of the draft to update
|
||||
(as it appears in Gmail drafts).
|
||||
body: The full updated body content for the draft.
|
||||
body: The full updated body content for the draft. Generate this
|
||||
yourself based on the user's request and conversation context.
|
||||
to: Optional new recipient email address (keeps original if omitted).
|
||||
subject: Optional new subject line (keeps original if omitted).
|
||||
cc: Optional CC recipient(s), comma-separated.
|
||||
|
|
@ -62,6 +69,7 @@ def create_update_gmail_draft_tool(
|
|||
Examples:
|
||||
- "Update the Kurseong Plan draft with the new itinerary details"
|
||||
- "Edit my draft about the project proposal and change the recipient"
|
||||
- "Let me edit the meeting notes draft" (call with current body content so user can edit in the approval card)
|
||||
"""
|
||||
logger.info(
|
||||
f"update_gmail_draft called: draft_subject_or_id='{draft_subject_or_id}'"
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import asyncio
|
|||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import String, and_, cast, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
|
@ -264,7 +265,7 @@ class GmailToolMetadataService:
|
|||
if auth_expired:
|
||||
await self._persist_auth_expired(connector.id)
|
||||
|
||||
result = {
|
||||
result: dict = {
|
||||
"account": acc_dict,
|
||||
"email": message.to_dict(),
|
||||
}
|
||||
|
|
@ -273,8 +274,111 @@ class GmailToolMetadataService:
|
|||
if meta.get("draft_id"):
|
||||
result["draft_id"] = meta["draft_id"]
|
||||
|
||||
if not auth_expired:
|
||||
existing_body = await self._fetch_draft_body(
|
||||
connector, message.message_id, meta.get("draft_id")
|
||||
)
|
||||
if existing_body is not None:
|
||||
result["existing_body"] = existing_body
|
||||
|
||||
return result
|
||||
|
||||
async def _fetch_draft_body(
|
||||
self,
|
||||
connector: SearchSourceConnector,
|
||||
message_id: str,
|
||||
draft_id: str | None,
|
||||
) -> str | None:
|
||||
"""Fetch the plain-text body of a Gmail draft via the API.
|
||||
|
||||
Tries ``drafts.get`` first (if *draft_id* is available), then falls
|
||||
back to scanning ``drafts.list`` to resolve the draft by *message_id*.
|
||||
Returns ``None`` on any failure so callers can degrade gracefully.
|
||||
"""
|
||||
try:
|
||||
creds = await self._build_credentials(connector)
|
||||
service = build("gmail", "v1", credentials=creds)
|
||||
|
||||
if not draft_id:
|
||||
draft_id = await self._find_draft_id(service, message_id)
|
||||
if not draft_id:
|
||||
return None
|
||||
|
||||
draft = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: service.users()
|
||||
.drafts()
|
||||
.get(userId="me", id=draft_id, format="full")
|
||||
.execute(),
|
||||
)
|
||||
|
||||
payload = draft.get("message", {}).get("payload", {})
|
||||
return self._extract_body_from_payload(payload)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to fetch draft body for message_id=%s",
|
||||
message_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _find_draft_id(self, service: Any, message_id: str) -> str | None:
|
||||
"""Resolve a draft ID from its message ID by scanning drafts.list."""
|
||||
try:
|
||||
page_token = None
|
||||
while True:
|
||||
kwargs: dict[str, Any] = {"userId": "me", "maxResults": 100}
|
||||
if page_token:
|
||||
kwargs["pageToken"] = page_token
|
||||
response = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: service.users().drafts().list(**kwargs).execute(),
|
||||
)
|
||||
for draft in response.get("drafts", []):
|
||||
if draft.get("message", {}).get("id") == message_id:
|
||||
return draft["id"]
|
||||
page_token = response.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to look up draft by message_id=%s", message_id, exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_body_from_payload(payload: dict) -> str | None:
|
||||
"""Extract the plain-text (or html→text) body from a Gmail payload."""
|
||||
import base64
|
||||
|
||||
def _get_parts(p: dict) -> list[dict]:
|
||||
if "parts" in p:
|
||||
parts: list[dict] = []
|
||||
for sub in p["parts"]:
|
||||
parts.extend(_get_parts(sub))
|
||||
return parts
|
||||
return [p]
|
||||
|
||||
parts = _get_parts(payload)
|
||||
text_content = ""
|
||||
for part in parts:
|
||||
mime_type = part.get("mimeType", "")
|
||||
data = part.get("body", {}).get("data", "")
|
||||
if mime_type == "text/plain" and data:
|
||||
text_content += base64.urlsafe_b64decode(data + "===").decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
elif mime_type == "text/html" and data and not text_content:
|
||||
from markdownify import markdownify as md
|
||||
|
||||
raw_html = base64.urlsafe_b64decode(data + "===").decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
text_content = md(raw_html).strip()
|
||||
|
||||
return text_content.strip() if text_content.strip() else None
|
||||
|
||||
async def get_trash_context(
|
||||
self, search_space_id: int, user_id: str, email_ref: str
|
||||
) -> dict:
|
||||
|
|
@ -325,7 +429,7 @@ class GmailToolMetadataService:
|
|||
SearchSourceConnector.user_id == user_id,
|
||||
or_(
|
||||
func.lower(
|
||||
Document.document_metadata["subject"].astext
|
||||
cast(Document.document_metadata["subject"], String)
|
||||
)
|
||||
== func.lower(email_ref),
|
||||
func.lower(Document.title) == func.lower(email_ref),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from datetime import datetime
|
|||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import String, and_, cast, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
|
@ -389,7 +389,7 @@ class GoogleCalendarToolMetadataService:
|
|||
SearchSourceConnector.user_id == user_id,
|
||||
or_(
|
||||
func.lower(
|
||||
Document.document_metadata["event_summary"].astext
|
||||
cast(Document.document_metadata["event_summary"], String)
|
||||
)
|
||||
== func.lower(event_ref),
|
||||
func.lower(Document.title) == func.lower(event_ref),
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ const TOOLS_WITH_UI = new Set([
|
|||
"update_calendar_event",
|
||||
"delete_calendar_event",
|
||||
"create_gmail_draft",
|
||||
"update_gmail_draft",
|
||||
"send_gmail_email",
|
||||
"trash_gmail_email",
|
||||
"execute",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
CornerDownLeftIcon,
|
||||
MailIcon,
|
||||
Pen,
|
||||
SendIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -193,7 +192,6 @@ function ApprovalCard({
|
|||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<SendIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{decided === "reject"
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ interface InterruptResult {
|
|||
account?: GmailAccount;
|
||||
email?: GmailMessage;
|
||||
draft_id?: string;
|
||||
existing_body?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -176,6 +177,7 @@ function ApprovalCard({
|
|||
const account = interruptData.context?.account;
|
||||
const email = interruptData.context?.email;
|
||||
const draftId = interruptData.context?.draft_id;
|
||||
const existingBody = interruptData.context?.existing_body;
|
||||
|
||||
const reviewConfig = interruptData.review_configs[0];
|
||||
const allowedDecisions = reviewConfig?.allowed_decisions ?? [
|
||||
|
|
@ -193,6 +195,7 @@ function ApprovalCard({
|
|||
const currentTo = pendingEdits?.to ?? args.to ?? "";
|
||||
const currentCc = pendingEdits?.cc ?? args.cc ?? "";
|
||||
const currentBcc = pendingEdits?.bcc ?? args.bcc ?? "";
|
||||
const editableBody = currentBody || existingBody || "";
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (decided || isPanelOpen) return;
|
||||
|
|
@ -208,7 +211,7 @@ function ApprovalCard({
|
|||
draft_id: draftId,
|
||||
to: currentTo,
|
||||
subject: currentSubject,
|
||||
body: currentBody,
|
||||
body: editableBody,
|
||||
cc: currentCc,
|
||||
bcc: currentBcc,
|
||||
connector_id: email?.connector_id ?? account?.id,
|
||||
|
|
@ -226,7 +229,7 @@ function ApprovalCard({
|
|||
draftId,
|
||||
pendingEdits,
|
||||
currentSubject,
|
||||
currentBody,
|
||||
editableBody,
|
||||
currentTo,
|
||||
currentCc,
|
||||
currentBcc,
|
||||
|
|
@ -308,10 +311,10 @@ function ApprovalCard({
|
|||
value: currentBcc,
|
||||
},
|
||||
];
|
||||
openHitlEditPanel({
|
||||
title: currentSubject,
|
||||
content: currentBody,
|
||||
toolName: "Gmail Draft",
|
||||
openHitlEditPanel({
|
||||
title: currentSubject,
|
||||
content: editableBody,
|
||||
toolName: "Gmail Draft",
|
||||
extraFields,
|
||||
onSave: (
|
||||
newTitle,
|
||||
|
|
@ -410,8 +413,8 @@ function ApprovalCard({
|
|||
{currentSubject}
|
||||
</p>
|
||||
)}
|
||||
{currentBody != null && (
|
||||
<div
|
||||
{editableBody ? (
|
||||
<div
|
||||
className="mt-2 max-h-[7rem] overflow-hidden text-sm"
|
||||
style={{
|
||||
maskImage:
|
||||
|
|
@ -421,14 +424,14 @@ function ApprovalCard({
|
|||
}}
|
||||
>
|
||||
<PlateEditor
|
||||
markdown={String(currentBody)}
|
||||
markdown={String(editableBody)}
|
||||
readOnly
|
||||
preset="readonly"
|
||||
editorVariant="none"
|
||||
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue