human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
294
frontend/src/components/HandoffSettings.jsx
Normal file
294
frontend/src/components/HandoffSettings.jsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { MoreHorizontal, Plus, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createHandoffRule,
|
||||
deleteHandoffRule,
|
||||
listHandoffRules,
|
||||
testHandoffRule,
|
||||
updateHandoffRule,
|
||||
} from "../api";
|
||||
import AgentSpark from "./AgentSpark";
|
||||
import HumanHandoffDrawer from "./HumanHandoffDrawer";
|
||||
import { buildRuleCardSummary, EMAIL_TYPE_OPTIONS } from "../utils/handoffUtils";
|
||||
|
||||
function typeLabel(id) {
|
||||
return EMAIL_TYPE_OPTIONS.find((item) => item.id === id)?.title || id;
|
||||
}
|
||||
|
||||
export default function HandoffSettings() {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [rules, setRules] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState(null);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const [testDraft, setTestDraft] = useState(null);
|
||||
const [testSubject, setTestSubject] = useState("");
|
||||
const [testBody, setTestBody] = useState("");
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listHandoffRules(token);
|
||||
setRules(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingRule(null);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(rule) {
|
||||
setEditingRule(rule);
|
||||
setDrawerOpen(true);
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
|
||||
async function handleSave(payload) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editingRule) {
|
||||
await updateHandoffRule(editingRule.id, payload, token);
|
||||
setNotice("Human Handoff updated.");
|
||||
} else {
|
||||
await createHandoffRule(payload, token);
|
||||
setNotice("Human Handoff created.");
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setEditingRule(null);
|
||||
await loadRules();
|
||||
window.setTimeout(() => setNotice(""), 2500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(rule) {
|
||||
if (!window.confirm("Delete this handoff rule?")) return;
|
||||
try {
|
||||
await deleteHandoffRule(rule.id, token);
|
||||
setMenuOpenId(null);
|
||||
await loadRules();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openTest(draft) {
|
||||
setTestDraft(draft);
|
||||
setTestSubject("");
|
||||
setTestBody("");
|
||||
setTestResult(null);
|
||||
setTestOpen(true);
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (!testDraft) return;
|
||||
setTesting(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await testHandoffRule({
|
||||
rule_id: editingRule?.id,
|
||||
owner_email: testDraft.owner_email,
|
||||
labels: testDraft.labels,
|
||||
email_types: testDraft.email_types,
|
||||
instructions: testDraft.instructions,
|
||||
email: {
|
||||
subject: testSubject,
|
||||
snippet: testBody,
|
||||
body_text: testBody,
|
||||
},
|
||||
}, token);
|
||||
setTestResult(result);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="handoff-settings">
|
||||
<div className="handoff-settings-intro">
|
||||
<p>
|
||||
<strong>Your Agent handles email. You step in when needed.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Human Handoff sends selected conversations to the Agent's Human Owner. The owner can
|
||||
reply from their normal inbox, and Gentic carries that response back to the original sender
|
||||
without exposing or mixing the private Agent ↔ Human conversation.
|
||||
</p>
|
||||
<div className="handoff-flow-diagram" aria-hidden="true">
|
||||
<span>External Sender</span>
|
||||
<span className="handoff-flow-arrow">↕</span>
|
||||
<span>Gentic Agent</span>
|
||||
<span className="handoff-flow-arrow">↕</span>
|
||||
<span>Human Owner</span>
|
||||
</div>
|
||||
<small>
|
||||
The external conversation and Human Handoff remain separate and independently threaded.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="handoff-settings-toolbar">
|
||||
<button type="button" className="handoff-btn primary" onClick={openCreate}>
|
||||
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
{notice && <p className="security-notice" role="status">{notice}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="people-table-empty">Loading settings…</p>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="human-handoff-empty">
|
||||
<div className="human-handoff-empty-icon" aria-hidden="true">
|
||||
<UserRound size={24} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h2>No handoff rules yet</h2>
|
||||
<p>
|
||||
Configure when your Agent should hand conversations to the Human Owner.
|
||||
Handoff transport messages stay out of your normal Inbox and Sent folders.
|
||||
</p>
|
||||
<button type="button" className="handoff-btn primary" onClick={openCreate}>
|
||||
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="human-handoff-list">
|
||||
{rules.map((rule) => (
|
||||
<article key={rule.id} className="human-handoff-card">
|
||||
<div className="human-handoff-card-top">
|
||||
<span className="human-handoff-card-avatar" aria-hidden="true">
|
||||
<UserRound size={18} strokeWidth={1.75} />
|
||||
</span>
|
||||
<div className="human-handoff-card-meta">
|
||||
<strong>{rule.owner_name}</strong>
|
||||
<small>{rule.owner_email}</small>
|
||||
</div>
|
||||
<span className={`human-handoff-status${rule.active ? " active" : ""}`}>
|
||||
{rule.active ? "Active" : "Paused"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="human-handoff-card-summary">{buildRuleCardSummary(rule)}</p>
|
||||
{rule.instructions && (
|
||||
<blockquote className="human-handoff-card-quote">“{rule.instructions}”</blockquote>
|
||||
)}
|
||||
<div className="human-handoff-card-actions">
|
||||
<button type="button" className="handoff-btn subtle" onClick={() => openEdit(rule)}>Edit</button>
|
||||
<div className="handoff-card-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="handoff-btn icon"
|
||||
aria-label="More actions"
|
||||
onClick={() => setMenuOpenId((current) => (current === rule.id ? null : rule.id))}
|
||||
>
|
||||
<MoreHorizontal size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
{menuOpenId === rule.id && (
|
||||
<div className="handoff-card-menu">
|
||||
<button type="button" onClick={() => openTest({
|
||||
owner_email: rule.owner_email,
|
||||
labels: rule.labels,
|
||||
email_types: rule.email_types,
|
||||
instructions: rule.instructions,
|
||||
})}>Test with an email</button>
|
||||
<button type="button" onClick={() => handleDelete(rule)}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HumanHandoffDrawer
|
||||
open={drawerOpen}
|
||||
rule={editingRule}
|
||||
saving={saving}
|
||||
onClose={() => { setDrawerOpen(false); setEditingRule(null); }}
|
||||
onSave={handleSave}
|
||||
onTest={(draft) => openTest(draft)}
|
||||
/>
|
||||
|
||||
{testOpen && (
|
||||
<div className="handoff-drawer-backdrop" role="presentation" onClick={() => setTestOpen(false)}>
|
||||
<aside className="handoff-test-modal" role="dialog" aria-labelledby="handoff-test-title" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="handoff-drawer-head">
|
||||
<div>
|
||||
<h2 id="handoff-test-title">Test Human Handoff</h2>
|
||||
<p>Check whether your current rules would cause a particular email to be handed to you.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="handoff-drawer-body">
|
||||
<label className="handoff-field-label" htmlFor="handoff-test-subject">Subject</label>
|
||||
<input
|
||||
id="handoff-test-subject"
|
||||
className="handoff-text-input"
|
||||
value={testSubject}
|
||||
onChange={(event) => setTestSubject(event.target.value)}
|
||||
placeholder="Payment approval required"
|
||||
/>
|
||||
<label className="handoff-field-label" htmlFor="handoff-test-body">Email content</label>
|
||||
<textarea
|
||||
id="handoff-test-body"
|
||||
className="handoff-instructions"
|
||||
rows={6}
|
||||
value={testBody}
|
||||
onChange={(event) => setTestBody(event.target.value)}
|
||||
placeholder="Paste or describe the email you want to test…"
|
||||
/>
|
||||
{testResult && (
|
||||
<div className="handoff-test-result">
|
||||
<h3>Handoff test</h3>
|
||||
<ul>
|
||||
<li className={testResult.label_matched ? "pass" : "fail"}>
|
||||
{testResult.label_matched ? "✓" : "✕"} Label matched
|
||||
{testResult.matched_labels?.length ? ` — ${testResult.matched_labels.join(", ")}` : ""}
|
||||
</li>
|
||||
<li className={testResult.type_matched ? "pass" : "fail"}>
|
||||
{testResult.type_matched ? "✓" : "✕"} Type matched
|
||||
{testResult.matched_types?.length ? ` — ${testResult.matched_types.map(typeLabel).join(", ")}` : ""}
|
||||
</li>
|
||||
<li className={testResult.instruction_matched ? "pass" : "fail"}>
|
||||
{testResult.instruction_matched ? "✓" : "✕"} Handoff instruction matched
|
||||
</li>
|
||||
</ul>
|
||||
<p><strong>Result:</strong> {testResult.result_path}</p>
|
||||
<p><strong>Reason:</strong> {testResult.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="handoff-drawer-foot">
|
||||
<button type="button" className="handoff-btn subtle" onClick={() => setTestOpen(false)}>Close</button>
|
||||
<button type="button" className="handoff-btn primary" disabled={testing} onClick={runTest}>
|
||||
<AgentSpark /> {testing ? "Testing…" : "Run test"}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue