feat(canvas): Agent + Hubs + view-as personas (#1)
This commit was merged in pull request #1.
This commit is contained in:
+10
@@ -8,6 +8,8 @@ import Wizard from "./scenes/Wizard";
|
|||||||
import Settings from "./scenes/Settings";
|
import Settings from "./scenes/Settings";
|
||||||
import Login from "./scenes/Login";
|
import Login from "./scenes/Login";
|
||||||
import SsoCallback from "./scenes/SsoCallback";
|
import SsoCallback from "./scenes/SsoCallback";
|
||||||
|
import Agent from "./scenes/Agent";
|
||||||
|
import Hubs from "./scenes/Hubs";
|
||||||
import CommandBar from "./components/CommandBar";
|
import CommandBar from "./components/CommandBar";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
import Console from "./components/Console";
|
import Console from "./components/Console";
|
||||||
@@ -71,6 +73,12 @@ export default function App() {
|
|||||||
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
|
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
|
||||||
<Branch size={13} /> Studio
|
<Branch size={13} /> Studio
|
||||||
</button>
|
</button>
|
||||||
|
<button role="tab" aria-selected={scene === "hubs"} className={`tab${scene === "hubs" ? " tab-sel" : ""}`} onClick={() => setScene("hubs")}>
|
||||||
|
<Layers size={13} /> Hubs
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
||||||
|
<Cmd size={13} /> Agent
|
||||||
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
|
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
|
||||||
<Cog size={13} /> Settings
|
<Cog size={13} /> Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -149,6 +157,8 @@ export default function App() {
|
|||||||
{scene === "mission" && <MissionControl />}
|
{scene === "mission" && <MissionControl />}
|
||||||
{scene === "history" && <RunHistory />}
|
{scene === "history" && <RunHistory />}
|
||||||
{scene === "studio" && <Wizard />}
|
{scene === "studio" && <Wizard />}
|
||||||
|
{scene === "hubs" && <Hubs />}
|
||||||
|
{scene === "agent" && <Agent />}
|
||||||
{scene === "settings" && <Settings />}
|
{scene === "settings" && <Settings />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Three built-in persona profiles. These are not seeded into the backend —
|
||||||
|
// they are operator-facing "view-as" presets so the live demo can show how
|
||||||
|
// the same EA2 surface looks from different roles. Switching persona only
|
||||||
|
// changes the email used for dev-login and a UI-side role tag.
|
||||||
|
|
||||||
|
export type PersonaId = "hr-head" | "it-head" | "ceo";
|
||||||
|
|
||||||
|
export interface Persona {
|
||||||
|
id: PersonaId;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
email: string;
|
||||||
|
initials: string;
|
||||||
|
scope: string;
|
||||||
|
defaultHub: "hr" | "it" | "finance" | "procurement";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const personas: Persona[] = [
|
||||||
|
{
|
||||||
|
id: "ceo",
|
||||||
|
name: "Sara Park",
|
||||||
|
title: "Chief Executive Officer",
|
||||||
|
email: "sara.park@flow-master.ai",
|
||||||
|
initials: "SP",
|
||||||
|
scope: "Org-wide. Sees every process, every hub, every escalation.",
|
||||||
|
defaultHub: "finance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hr-head",
|
||||||
|
name: "Maya Okafor",
|
||||||
|
title: "Head of People",
|
||||||
|
email: "maya.okafor@flow-master.ai",
|
||||||
|
initials: "MO",
|
||||||
|
scope: "HR hub. Hiring, onboarding, leave, performance, geo-attendance.",
|
||||||
|
defaultHub: "hr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "it-head",
|
||||||
|
name: "Daniel Reyes",
|
||||||
|
title: "Head of IT",
|
||||||
|
email: "daniel.reyes@flow-master.ai",
|
||||||
|
initials: "DR",
|
||||||
|
scope: "IT hub. Procurement (laptops, software), access, incidents.",
|
||||||
|
defaultHub: "it",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function personaById(id: PersonaId | string | null | undefined): Persona | undefined {
|
||||||
|
return personas.find((p) => p.id === id);
|
||||||
|
}
|
||||||
+335
@@ -1790,3 +1790,338 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
|
|
||||||
.theme-toggle { display: inline-flex; align-items: center; gap: 6px; }
|
.theme-toggle { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.theme-toggle-label { font-size: 10px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; }
|
.theme-toggle-label { font-size: 10px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; }
|
||||||
|
|
||||||
|
/* ===== Agent + Hubs + Personas ===== */
|
||||||
|
|
||||||
|
.agent-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: var(--space-md);
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-side {
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
background: var(--bg-1);
|
||||||
|
padding: var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-side-h {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--bp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-persona-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-persona-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
background: var(--bg-2);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-persona-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--bp-accent, #d97a00);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-persona-avatar.sm { width: 26px; height: 26px; font-size: 11px; }
|
||||||
|
|
||||||
|
.agent-persona-name { display: block; font-size: 12px; color: var(--text-1); font-weight: 600; }
|
||||||
|
.agent-persona-title { display: block; font-size: 11px; color: var(--text-2); }
|
||||||
|
.agent-persona-scope { font-size: 10.5px; color: var(--text-3); margin-top: 4px; line-height: 1.35; }
|
||||||
|
|
||||||
|
.agent-persona-empty { font-size: 11px; color: var(--text-3); padding: 8px; }
|
||||||
|
|
||||||
|
.agent-persona-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-persona-btn {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 26px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-persona-btn:hover { background: var(--bg-2); }
|
||||||
|
.agent-persona-btn.is-on { border-color: var(--bp-accent); background: var(--bg-2); }
|
||||||
|
|
||||||
|
.agent-mem {
|
||||||
|
border-top: 1px solid var(--bp-line);
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mem-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-1);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mem-body { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.agent-mem-empty { font-size: 11px; color: var(--text-3); }
|
||||||
|
|
||||||
|
.agent-mem-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed var(--bp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mem-kind {
|
||||||
|
font-size: 9.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--bg-2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-mem-world { color: #1a6b2e; }
|
||||||
|
.agent-mem-experience { color: #1a3a6b; }
|
||||||
|
.agent-mem-opinion { color: #6b2e1a; }
|
||||||
|
|
||||||
|
.agent-mem-text { color: var(--text-1); line-height: 1.4; }
|
||||||
|
|
||||||
|
.agent-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
background: var(--bg-1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-thread {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-msg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-msg-user { justify-self: end; }
|
||||||
|
.agent-msg-user .agent-msg-body { background: var(--bg-2); }
|
||||||
|
|
||||||
|
.agent-msg-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-msg-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
background: var(--bg-1);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-msg-text { white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.agent-msg-facts {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-msg-facts li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 6px;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-top: 1px solid var(--bp-line);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-input {
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
background: var(--bg-1);
|
||||||
|
color: var(--text-1);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-input:focus { outline: 1px solid var(--bp-accent); }
|
||||||
|
|
||||||
|
.btn-sm { padding: 4px 8px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* ===== Hubs ===== */
|
||||||
|
|
||||||
|
.hubs-page {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hubs-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hubs-sub { font-size: 12px; color: var(--text-2); max-width: 60ch; line-height: 1.55; margin-top: 6px; }
|
||||||
|
|
||||||
|
.hubs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card {
|
||||||
|
border: 1px solid var(--bp-line);
|
||||||
|
background: var(--bg-1);
|
||||||
|
padding: var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card-h {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card-mark {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--bp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card-mark.hub-hr { background: #6b3aa0; }
|
||||||
|
.hub-card-mark.hub-finance { background: #1a6b3a; }
|
||||||
|
.hub-card-mark.hub-procurement { background: #b06a00; }
|
||||||
|
.hub-card-mark.hub-it { background: #1a3a6b; }
|
||||||
|
|
||||||
|
.hub-card-title { font-size: 14px; margin: 0; color: var(--text-1); font-weight: 600; }
|
||||||
|
.hub-card-blurb { font-size: 11.5px; color: var(--text-2); margin: 4px 0 0; line-height: 1.45; }
|
||||||
|
|
||||||
|
.hub-card-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card-list li {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding-left: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card-list li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 7px;
|
||||||
|
width: 6px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--bp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--bp-line);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-card-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hubs-pers {
|
||||||
|
border-top: 1px solid var(--bp-line);
|
||||||
|
padding-top: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text { font-size: 11px; color: #b06a00; margin-top: 8px; }
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Per-tenant agent memory (mini-hindsight). Stored in localStorage, keyed by
|
||||||
|
// tenant + actor.user_id. Three fact kinds: world, experience, opinion.
|
||||||
|
// Survives reloads; cleared on Settings → "Forget all".
|
||||||
|
|
||||||
|
export type FactKind = "world" | "experience" | "opinion";
|
||||||
|
|
||||||
|
export interface Fact {
|
||||||
|
id: string;
|
||||||
|
kind: FactKind;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NS = "fm.mc.agent.mem.v1";
|
||||||
|
|
||||||
|
function key(tenant: string, userId: string): string {
|
||||||
|
return `${NS}:${tenant}:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(tenant: string, userId: string): Fact[] {
|
||||||
|
if (typeof localStorage === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key(tenant, userId));
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as Fact[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(tenant: string, userId: string, facts: Fact[]): void {
|
||||||
|
if (typeof localStorage === "undefined") return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key(tenant, userId), JSON.stringify(facts.slice(-500)));
|
||||||
|
} catch {
|
||||||
|
/* quota: drop silently */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentMemory = {
|
||||||
|
list(tenant: string, userId: string): Fact[] {
|
||||||
|
return load(tenant, userId);
|
||||||
|
},
|
||||||
|
retain(tenant: string, userId: string, kind: FactKind, content: string, tags: string[] = []): Fact {
|
||||||
|
const facts = load(tenant, userId);
|
||||||
|
const fact: Fact = {
|
||||||
|
id: `f_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind,
|
||||||
|
content: content.trim(),
|
||||||
|
tags,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
facts.push(fact);
|
||||||
|
save(tenant, userId, facts);
|
||||||
|
return fact;
|
||||||
|
},
|
||||||
|
recall(tenant: string, userId: string, query: string, limit = 8): Fact[] {
|
||||||
|
const facts = load(tenant, userId);
|
||||||
|
if (!query.trim()) return facts.slice(-limit).reverse();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const tokens = q.split(/\s+/).filter(Boolean);
|
||||||
|
const scored = facts.map((f) => {
|
||||||
|
const hay = `${f.content} ${f.tags.join(" ")}`.toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (hay.includes(t)) score += 1;
|
||||||
|
}
|
||||||
|
return { f, score };
|
||||||
|
});
|
||||||
|
return scored
|
||||||
|
.filter((s) => s.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score || b.f.createdAt - a.f.createdAt)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((s) => s.f);
|
||||||
|
},
|
||||||
|
forget(tenant: string, userId: string): void {
|
||||||
|
if (typeof localStorage === "undefined") return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key(tenant, userId));
|
||||||
|
} catch {
|
||||||
|
/* swallow */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// Agent scene — chat interface backed by per-tenant memory.
|
||||||
|
// The agent can: answer using recalled facts, retain new facts the user shares,
|
||||||
|
// jump to scenes ("take me to procurement"), and propose starting a process.
|
||||||
|
// It is intentionally small (no LLM round-trip) so it works offline and stays
|
||||||
|
// honest: every action it takes is visible and reversible.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useApp } from "../state/store";
|
||||||
|
import { agentMemory, type Fact } from "../lib/agentMemory";
|
||||||
|
import { personas, personaById } from "../data/personas";
|
||||||
|
import { Bot, User, Sparkles, Arrow, Close } from "../components/icons";
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
role: "user" | "agent";
|
||||||
|
text: string;
|
||||||
|
facts?: Fact[];
|
||||||
|
action?: { label: string; run: () => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TENANT = "default";
|
||||||
|
|
||||||
|
let msgSeq = 0;
|
||||||
|
|
||||||
|
function detectIntent(
|
||||||
|
text: string,
|
||||||
|
): { kind: "goto"; scene: "mission" | "history" | "studio" | "settings" | "hubs" } |
|
||||||
|
{ kind: "remember"; content: string } |
|
||||||
|
{ kind: "start"; topic: string } |
|
||||||
|
{ kind: "ask" } {
|
||||||
|
const t = text.toLowerCase().trim();
|
||||||
|
if (/^(take me to|go to|open|show me) (mission|control)/.test(t)) return { kind: "goto", scene: "mission" };
|
||||||
|
if (/^(take me to|go to|open|show me) (runs|history)/.test(t)) return { kind: "goto", scene: "history" };
|
||||||
|
if (/^(take me to|go to|open|show me) (studio|wizard|process studio|process creation)/.test(t)) return { kind: "goto", scene: "studio" };
|
||||||
|
if (/^(take me to|go to|open|show me) (settings|preferences)/.test(t)) return { kind: "goto", scene: "settings" };
|
||||||
|
if (/^(take me to|go to|open|show me) (hubs|hub)/.test(t)) return { kind: "goto", scene: "hubs" };
|
||||||
|
if (/^(remember|note|save):? /.test(t)) {
|
||||||
|
return { kind: "remember", content: text.replace(/^(remember|note|save):? /i, "").trim() };
|
||||||
|
}
|
||||||
|
if (/^(start|kick off|launch|run) /.test(t)) {
|
||||||
|
return { kind: "start", topic: text.replace(/^(start|kick off|launch|run) /i, "").trim() };
|
||||||
|
}
|
||||||
|
return { kind: "ask" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Agent() {
|
||||||
|
const setScene = useApp((s) => s.setScene);
|
||||||
|
const actor = useApp((s) => s.actor);
|
||||||
|
const userEmail = useApp((s) => s.userEmail);
|
||||||
|
const personaId = useApp((s) => s.personaId);
|
||||||
|
const setPersonaId = useApp((s) => s.setPersonaId);
|
||||||
|
const loginAs = useApp((s) => s.loginAs);
|
||||||
|
const pushToast = useApp((s) => s.pushToast);
|
||||||
|
|
||||||
|
const userId = actor?.user_id ?? userEmail ?? "anon";
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>(() => {
|
||||||
|
const persona = personaById(personaId);
|
||||||
|
return [{
|
||||||
|
id: ++msgSeq,
|
||||||
|
role: "agent",
|
||||||
|
text: persona
|
||||||
|
? `Hi ${persona.name.split(" ")[0]}. I'm the FlowMaster agent. I can take you anywhere in Mission Control, remember things for you, and propose processes to start. Try: "take me to the HR hub", "remember: budget cap is 5,000 USD", or "start a laptop procurement".`
|
||||||
|
: `Hi. I'm the FlowMaster agent. I work alongside you in Mission Control. Try: "take me to mission", "remember: I prefer Dell laptops", or "start a new process".`,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [memOpen, setMemOpen] = useState(false);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const userMsg: Message = { id: ++msgSeq, role: "user", text };
|
||||||
|
const intent = detectIntent(text);
|
||||||
|
let reply: Message;
|
||||||
|
|
||||||
|
switch (intent.kind) {
|
||||||
|
case "goto": {
|
||||||
|
const sceneLabel: Record<string, string> = {
|
||||||
|
mission: "Mission Control",
|
||||||
|
history: "Run History",
|
||||||
|
studio: "Process Studio",
|
||||||
|
settings: "Settings",
|
||||||
|
hubs: "Hubs index",
|
||||||
|
};
|
||||||
|
reply = {
|
||||||
|
id: ++msgSeq,
|
||||||
|
role: "agent",
|
||||||
|
text: `Taking you to ${sceneLabel[intent.scene]}.`,
|
||||||
|
action: { label: `Open ${sceneLabel[intent.scene]}`, run: () => setScene(intent.scene as never) },
|
||||||
|
};
|
||||||
|
// auto-navigate after a short beat so the user sees the response
|
||||||
|
window.setTimeout(() => setScene(intent.scene as never), 350);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remember": {
|
||||||
|
const fact = agentMemory.retain(TENANT, userId, "world", intent.content, []);
|
||||||
|
reply = {
|
||||||
|
id: ++msgSeq,
|
||||||
|
role: "agent",
|
||||||
|
text: `Got it. I'll remember: "${fact.content}".`,
|
||||||
|
facts: [fact],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "start": {
|
||||||
|
reply = {
|
||||||
|
id: ++msgSeq,
|
||||||
|
role: "agent",
|
||||||
|
text: `I can draft a "${intent.topic}" process. The Process Studio walks you through intake → structure → data → rules → publish. Want me to open it pre-filled?`,
|
||||||
|
action: {
|
||||||
|
label: "Open Process Studio",
|
||||||
|
run: () => {
|
||||||
|
try { localStorage.setItem("fm.mc.wizard.draft", JSON.stringify({ step: "Intake", name: intent.topic, description: `When someone needs ${intent.topic}, run the right approval and procurement steps.`, nodes: [], edges: [], fields: [], rules: [] })); } catch { /* ignore */ }
|
||||||
|
setScene("studio");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ask":
|
||||||
|
default: {
|
||||||
|
const hits = agentMemory.recall(TENANT, userId, text, 5);
|
||||||
|
if (hits.length === 0) {
|
||||||
|
reply = {
|
||||||
|
id: ++msgSeq,
|
||||||
|
role: "agent",
|
||||||
|
text: `I don't have anything stored on that yet. If you want me to remember something, start your message with "remember:". I can also navigate ("take me to ...") or kick off a process ("start a ...").`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
reply = {
|
||||||
|
id: ++msgSeq,
|
||||||
|
role: "agent",
|
||||||
|
text: `Here's what I remember that matches:`,
|
||||||
|
facts: hits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((m) => [...m, userMsg, reply]);
|
||||||
|
setInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchPersona = async (id: typeof personas[number]["id"]) => {
|
||||||
|
const p = personaById(id);
|
||||||
|
if (!p) return;
|
||||||
|
setPersonaId(id);
|
||||||
|
try {
|
||||||
|
await loginAs(p.email);
|
||||||
|
pushToast("ok", `Now viewing as ${p.name} · ${p.title}`);
|
||||||
|
} catch {
|
||||||
|
pushToast("warn", `Persona set locally; backend sign-in unavailable.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persona = personaById(personaId);
|
||||||
|
const mem = agentMemory.list(TENANT, userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="agent-shell">
|
||||||
|
<aside className="agent-side">
|
||||||
|
<div className="agent-side-h">
|
||||||
|
<Bot size={14} /> <span>Agent</span>
|
||||||
|
</div>
|
||||||
|
<div className="agent-persona">
|
||||||
|
<div className="agent-persona-label">Acting as</div>
|
||||||
|
{persona ? (
|
||||||
|
<div className="agent-persona-card">
|
||||||
|
<div className="agent-persona-avatar">{persona.initials}</div>
|
||||||
|
<div>
|
||||||
|
<div className="agent-persona-name">{persona.name}</div>
|
||||||
|
<div className="agent-persona-title">{persona.title}</div>
|
||||||
|
<div className="agent-persona-scope">{persona.scope}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="agent-persona-empty">No persona selected</div>
|
||||||
|
)}
|
||||||
|
<div className="agent-persona-list">
|
||||||
|
{personas.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={`agent-persona-btn${personaId === p.id ? " is-on" : ""}`}
|
||||||
|
onClick={() => switchPersona(p.id)}
|
||||||
|
title={p.scope}
|
||||||
|
>
|
||||||
|
<span className="agent-persona-avatar sm">{p.initials}</span>
|
||||||
|
<span>
|
||||||
|
<span className="agent-persona-name">{p.name}</span>
|
||||||
|
<span className="agent-persona-title">{p.title}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="agent-mem">
|
||||||
|
<button className="agent-mem-toggle" onClick={() => setMemOpen((v) => !v)}>
|
||||||
|
<Sparkles size={12} /> Memory · {mem.length}
|
||||||
|
</button>
|
||||||
|
{memOpen && (
|
||||||
|
<div className="agent-mem-body">
|
||||||
|
{mem.length === 0 && <div className="agent-mem-empty">Nothing remembered yet.</div>}
|
||||||
|
{mem.slice(-12).reverse().map((f) => (
|
||||||
|
<div key={f.id} className="agent-mem-row">
|
||||||
|
<span className={`agent-mem-kind agent-mem-${f.kind}`}>{f.kind}</span>
|
||||||
|
<span className="agent-mem-text">{f.content}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{mem.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => {
|
||||||
|
agentMemory.forget(TENANT, userId);
|
||||||
|
pushToast("ok", "Agent memory cleared.");
|
||||||
|
setMessages([{ id: ++msgSeq, role: "agent", text: "Memory cleared. Starting fresh." }]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Close size={12} /> Forget all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="agent-main">
|
||||||
|
<div className="agent-thread" role="log" aria-live="polite">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className={`agent-msg agent-msg-${m.role}`}>
|
||||||
|
<div className="agent-msg-avatar">
|
||||||
|
{m.role === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||||
|
</div>
|
||||||
|
<div className="agent-msg-body">
|
||||||
|
<div className="agent-msg-text">{m.text}</div>
|
||||||
|
{m.facts && m.facts.length > 0 && (
|
||||||
|
<ul className="agent-msg-facts">
|
||||||
|
{m.facts.map((f) => (
|
||||||
|
<li key={f.id}>
|
||||||
|
<span className={`agent-mem-kind agent-mem-${f.kind}`}>{f.kind}</span>
|
||||||
|
<span>{f.content}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{m.action && (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={m.action.run}>
|
||||||
|
<Arrow size={12} /> {m.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="agent-composer"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="agent-input"
|
||||||
|
placeholder="Ask the agent. e.g. take me to the HR hub · remember: budget cap 5000 · start laptop procurement"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={!input.trim()}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Hubs index — operational landing for each department.
|
||||||
|
// Each hub is a curated lens onto the same EA2 scenarios with a chat shortcut.
|
||||||
|
|
||||||
|
import { useApp } from "../state/store";
|
||||||
|
import { Bot, Arrow, Layers, User, Cog, Inbox } from "../components/icons";
|
||||||
|
|
||||||
|
interface Hub {
|
||||||
|
id: "hr" | "it" | "finance" | "procurement";
|
||||||
|
label: string;
|
||||||
|
blurb: string;
|
||||||
|
scenarioPrefixes: string[];
|
||||||
|
highlights: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HUBS: Hub[] = [
|
||||||
|
{
|
||||||
|
id: "hr",
|
||||||
|
label: "People (HR)",
|
||||||
|
blurb: "Hiring, onboarding, leave, performance, attendance.",
|
||||||
|
scenarioPrefixes: ["hcm"],
|
||||||
|
highlights: ["New-hire onboarding", "Leave request approval", "Geo-attendance check-in"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "finance",
|
||||||
|
label: "Finance",
|
||||||
|
blurb: "AR, AP, period close, refunds, supplier queries.",
|
||||||
|
scenarioPrefixes: ["ar", "gl"],
|
||||||
|
highlights: ["Customer refund approval", "Period-end close", "AP invoice triage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "procurement",
|
||||||
|
label: "Procurement",
|
||||||
|
blurb: "Purchase requisitions, vendor approval, three-way match.",
|
||||||
|
scenarioPrefixes: ["procurement", "extra"],
|
||||||
|
highlights: ["PR → PO approval", "Vendor onboarding", "Match invoice to PO"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "it",
|
||||||
|
label: "IT",
|
||||||
|
blurb: "Asset procurement, access requests, incidents, service ops.",
|
||||||
|
scenarioPrefixes: ["service", "procurement"],
|
||||||
|
highlights: ["Laptop procurement (employee → manager → IT)", "Access request", "Customer incident triage"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Hubs() {
|
||||||
|
const setScene = useApp((s) => s.setScene);
|
||||||
|
const setScenarioId = useApp((s) => s.setScenarioId);
|
||||||
|
const scenarios = useApp((s) => s.scenarios);
|
||||||
|
const setHub = useApp((s) => s.setHub);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hubs-page">
|
||||||
|
<header className="hubs-head">
|
||||||
|
<div>
|
||||||
|
<div className="mc-hero-eyebrow"><Layers size={12} /> Hubs</div>
|
||||||
|
<h2 className="mc-hero-title">Pick your hub</h2>
|
||||||
|
<p className="hubs-sub">Hubs are department-shaped lenses on the same EA2 process catalog. Pick a hub to focus the queue, agents, and processes for that team.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setScene("agent")}>
|
||||||
|
<Bot size={13} /> Ask the agent
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="hubs-grid">
|
||||||
|
{HUBS.map((h) => {
|
||||||
|
const matches = scenarios.filter((s) => h.scenarioPrefixes.some((p) => s.id.startsWith(p)));
|
||||||
|
return (
|
||||||
|
<article key={h.id} className="hub-card">
|
||||||
|
<header className="hub-card-h">
|
||||||
|
<div className={`hub-card-mark hub-${h.id}`} />
|
||||||
|
<div>
|
||||||
|
<h3 className="hub-card-title">{h.label}</h3>
|
||||||
|
<p className="hub-card-blurb">{h.blurb}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ul className="hub-card-list">
|
||||||
|
{h.highlights.map((hi) => (
|
||||||
|
<li key={hi}>{hi}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<footer className="hub-card-foot">
|
||||||
|
<span className="hub-card-meta">
|
||||||
|
<Inbox size={11} /> {matches.length} process{matches.length === 1 ? "" : "es"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setHub(h.id);
|
||||||
|
if (matches[0]) setScenarioId(matches[0].id);
|
||||||
|
setScene("mission");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enter <Arrow size={11} />
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="hubs-pers">
|
||||||
|
<h3 className="panel-h"><User size={12} /> View-as personas</h3>
|
||||||
|
<p className="hubs-sub">Switch persona from the Agent panel to see Mission Control from a department head's seat.</p>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setScene("agent")}>
|
||||||
|
<Cog size={12} /> Open agent + persona switcher
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+18
-1
@@ -6,9 +6,11 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
|
|||||||
import { api, type ApiCall, type Actor } from "../lib/api";
|
import { api, type ApiCall, type Actor } from "../lib/api";
|
||||||
import type { ProcessScenario } from "../data/types";
|
import type { ProcessScenario } from "../data/types";
|
||||||
|
|
||||||
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback";
|
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "agent" | "hubs";
|
||||||
export type DataMode = "snapshot" | "live";
|
export type DataMode = "snapshot" | "live";
|
||||||
export type Theme = "dark" | "light";
|
export type Theme = "dark" | "light";
|
||||||
|
export type HubId = "hr" | "it" | "finance" | "procurement" | null;
|
||||||
|
export type PersonaId = "hr-head" | "it-head" | "ceo" | null;
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -26,6 +28,8 @@ interface Prefs {
|
|||||||
pollEverySec: number;
|
pollEverySec: number;
|
||||||
consoleOpen: boolean;
|
consoleOpen: boolean;
|
||||||
recents: string[];
|
recents: string[];
|
||||||
|
hub: HubId;
|
||||||
|
personaId: PersonaId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPrefs(): Partial<Prefs> {
|
function loadPrefs(): Partial<Prefs> {
|
||||||
@@ -105,6 +109,12 @@ interface AppState {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
setTheme: (t: Theme) => void;
|
setTheme: (t: Theme) => void;
|
||||||
|
|
||||||
|
/** Hub / persona view-as */
|
||||||
|
hub: HubId;
|
||||||
|
setHub: (h: HubId) => void;
|
||||||
|
personaId: PersonaId;
|
||||||
|
setPersonaId: (p: PersonaId) => void;
|
||||||
|
|
||||||
/** Action handlers (real backend mutations). */
|
/** Action handlers (real backend mutations). */
|
||||||
executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>;
|
executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>;
|
||||||
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
|
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
|
||||||
@@ -172,6 +182,8 @@ export const useApp = create<AppState>((set, get) => {
|
|||||||
pollEverySec: s.pollEverySec,
|
pollEverySec: s.pollEverySec,
|
||||||
consoleOpen: s.consoleOpen,
|
consoleOpen: s.consoleOpen,
|
||||||
recents: s.recents,
|
recents: s.recents,
|
||||||
|
hub: s.hub,
|
||||||
|
personaId: s.personaId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -336,6 +348,11 @@ export const useApp = create<AppState>((set, get) => {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hub: prefs.hub ?? null,
|
||||||
|
setHub: (hub) => { set({ hub }); persist(); },
|
||||||
|
personaId: prefs.personaId ?? null,
|
||||||
|
setPersonaId: (personaId) => { set({ personaId }); persist(); },
|
||||||
|
|
||||||
executeAction: async (txId, actionId, values = {}) => {
|
executeAction: async (txId, actionId, values = {}) => {
|
||||||
const a = get().actor;
|
const a = get().actor;
|
||||||
if (!a?.user_id) {
|
if (!a?.user_id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user