mop-agent 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/apps/web/app/api/apps/route.ts +23 -0
- package/apps/web/app/api/chat/route.ts +26 -4
- package/apps/web/app/api/graph/route.ts +8 -3
- package/apps/web/app/assistant/page.tsx +37 -24
- package/apps/web/app/brain/graph/page.tsx +144 -28
- package/apps/web/app/brain/page.tsx +106 -104
- package/apps/web/app/globals.css +505 -12
- package/apps/web/app/settings/page.tsx +102 -1
- package/apps/web/components/AppShell.tsx +101 -22
- package/apps/web/components/ui/chatgpt-prompt-input.tsx +290 -0
- package/apps/web/components.json +18 -0
- package/apps/web/lib/channels/config.ts +48 -0
- package/apps/web/lib/channels/index.ts +8 -4
- package/apps/web/lib/db/migrate.ts +9 -0
- package/apps/web/lib/providers/anthropic.ts +20 -1
- package/apps/web/lib/providers/openrouter.ts +11 -1
- package/apps/web/lib/providers/types.ts +2 -1
- package/apps/web/package.json +6 -0
- package/apps/web/postcss.config.mjs +5 -0
- package/npm-shrinkwrap.json +1646 -222
- package/package.json +3 -1
|
@@ -6,6 +6,16 @@ import { useMemoryCore } from "@/components/AppShell";
|
|
|
6
6
|
|
|
7
7
|
type Masked = { configured: boolean; provider?: string; model?: string | null; keyHint?: string };
|
|
8
8
|
type Member = { id: string; email: string; name: string; role: string };
|
|
9
|
+
type AppId = "telegram" | "discord" | "whatsapp" | "slack" | "webhook";
|
|
10
|
+
type AppConfig = { appId: AppId; configured: boolean; enabled: boolean; keyHint: string; updatedAt: number };
|
|
11
|
+
|
|
12
|
+
const APP_CATALOG: Array<{ id: AppId; name: string; icon: string; description: string; secretLabel: string; fieldLabel: string; fieldKey: string; runtime: boolean }> = [
|
|
13
|
+
{ id: "telegram", name: "Telegram", icon: "✈", description: "Private bot conversations and project-bound chats.", secretLabel: "Bot token", fieldLabel: "Bot username (optional)", fieldKey: "username", runtime: true },
|
|
14
|
+
{ id: "discord", name: "Discord", icon: "◈", description: "Guild channels, direct messages and project replies.", secretLabel: "Bot token", fieldLabel: "Application ID (optional)", fieldKey: "applicationId", runtime: true },
|
|
15
|
+
{ id: "whatsapp", name: "WhatsApp", icon: "◉", description: "Meta Cloud API account and phone-number configuration.", secretLabel: "Access token", fieldLabel: "Phone number ID", fieldKey: "phoneNumberId", runtime: false },
|
|
16
|
+
{ id: "slack", name: "Slack", icon: "⌗", description: "Workspace bot configuration for future channel routing.", secretLabel: "Bot token", fieldLabel: "App ID", fieldKey: "appId", runtime: false },
|
|
17
|
+
{ id: "webhook", name: "Webhook", icon: "↗", description: "Signed custom automation endpoint configuration.", secretLabel: "Signing secret", fieldLabel: "Endpoint URL", fieldKey: "endpoint", runtime: false },
|
|
18
|
+
];
|
|
9
19
|
|
|
10
20
|
export default function SettingsPage() {
|
|
11
21
|
const { settingsSection: section } = useMemoryCore();
|
|
@@ -124,7 +134,7 @@ export default function SettingsPage() {
|
|
|
124
134
|
</form>
|
|
125
135
|
{providerMsg && <p style={messageStyle}>{providerMsg}</p>}
|
|
126
136
|
</>
|
|
127
|
-
) : (
|
|
137
|
+
) : section === "users" ? (
|
|
128
138
|
<>
|
|
129
139
|
<div style={sectionHeading}>
|
|
130
140
|
<div>
|
|
@@ -165,6 +175,8 @@ export default function SettingsPage() {
|
|
|
165
175
|
{userMsg && <p style={messageStyle}>{userMsg}</p>}
|
|
166
176
|
</div>
|
|
167
177
|
</>
|
|
178
|
+
) : (
|
|
179
|
+
<AppsSettings />
|
|
168
180
|
)}
|
|
169
181
|
</section>
|
|
170
182
|
</div>
|
|
@@ -172,6 +184,95 @@ export default function SettingsPage() {
|
|
|
172
184
|
);
|
|
173
185
|
}
|
|
174
186
|
|
|
187
|
+
function AppsSettings() {
|
|
188
|
+
const [configs, setConfigs] = useState<AppConfig[]>([]);
|
|
189
|
+
const [selected, setSelected] = useState<AppId>("telegram");
|
|
190
|
+
const [secret, setSecret] = useState("");
|
|
191
|
+
const [fieldValue, setFieldValue] = useState("");
|
|
192
|
+
const [enabled, setEnabled] = useState(true);
|
|
193
|
+
const [message, setMessage] = useState("");
|
|
194
|
+
const app = APP_CATALOG.find((item) => item.id === selected)!;
|
|
195
|
+
const current = configs.find((config) => config.appId === selected);
|
|
196
|
+
|
|
197
|
+
function loadApps() {
|
|
198
|
+
fetch("/api/apps").then((response) => response.json()).then((result) => setConfigs(result.apps ?? [])).catch(() => {});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
useEffect(loadApps, []);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
setEnabled(current?.enabled ?? true);
|
|
205
|
+
setSecret("");
|
|
206
|
+
setFieldValue("");
|
|
207
|
+
setMessage("");
|
|
208
|
+
}, [selected, current?.enabled]);
|
|
209
|
+
|
|
210
|
+
async function save(event: React.FormEvent) {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
setMessage("Saving encrypted configuration…");
|
|
213
|
+
const response = await fetch("/api/apps", {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { "content-type": "application/json" },
|
|
216
|
+
body: JSON.stringify({
|
|
217
|
+
appId: selected,
|
|
218
|
+
secret: secret || undefined,
|
|
219
|
+
enabled,
|
|
220
|
+
fields: fieldValue ? { [app.fieldKey]: fieldValue } : undefined,
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
const result = await response.json();
|
|
224
|
+
if (response.ok) {
|
|
225
|
+
setConfigs(result.apps ?? []);
|
|
226
|
+
setSecret("");
|
|
227
|
+
setFieldValue("");
|
|
228
|
+
setMessage(app.runtime ? "Saved. Restart MOP-AGENT to activate this bot runtime." : "Configuration saved securely. Runtime adapter will use it when enabled.");
|
|
229
|
+
} else {
|
|
230
|
+
setMessage(`Unable to save: ${result.error ?? response.status}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
<div style={sectionHeading}>
|
|
237
|
+
<div><p className="mop-page-kicker">CHANNELS & INTEGRATIONS</p><h2 style={titleStyle}>Apps</h2></div>
|
|
238
|
+
<span style={statusBadge}>{configs.filter((config) => config.enabled).length} ENABLED</span>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="mop-apps-layout">
|
|
242
|
+
<div className="mop-app-catalog">
|
|
243
|
+
{APP_CATALOG.map((item) => {
|
|
244
|
+
const config = configs.find((entry) => entry.appId === item.id);
|
|
245
|
+
return (
|
|
246
|
+
<button type="button" key={item.id} className={selected === item.id ? "is-active" : ""} onClick={() => setSelected(item.id)}>
|
|
247
|
+
<span>{item.icon}</span>
|
|
248
|
+
<div><strong>{item.name}</strong><small>{config?.configured ? (config.enabled ? "CONFIGURED · ENABLED" : "CONFIGURED · PAUSED") : "NOT CONFIGURED"}</small></div>
|
|
249
|
+
<i className={config?.enabled ? "is-online" : ""} />
|
|
250
|
+
</button>
|
|
251
|
+
);
|
|
252
|
+
})}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<form className="mop-app-config-panel" onSubmit={save}>
|
|
256
|
+
<header><span>{app.icon}</span><div><h3>{app.name}</h3><p>{app.description}</p></div></header>
|
|
257
|
+
<div className="mop-app-runtime-status">
|
|
258
|
+
<span className={app.runtime ? "is-ready" : ""} />
|
|
259
|
+
{app.runtime ? "Runtime adapter available" : "Configuration registry ready · adapter expansion planned"}
|
|
260
|
+
</div>
|
|
261
|
+
<label style={labelStyle}>{app.secretLabel}
|
|
262
|
+
<input type="password" required={!current?.configured} value={secret} onChange={(event) => setSecret(event.target.value)} placeholder={current?.configured ? `Saved key ${current.keyHint} · leave blank to keep` : `Enter ${app.secretLabel.toLowerCase()}`} style={inputStyle} />
|
|
263
|
+
</label>
|
|
264
|
+
<label style={labelStyle}>{app.fieldLabel}
|
|
265
|
+
<input value={fieldValue} onChange={(event) => setFieldValue(event.target.value)} placeholder={current?.configured ? "Leave blank to keep saved value" : app.fieldLabel} style={inputStyle} />
|
|
266
|
+
</label>
|
|
267
|
+
<label className="mop-app-enabled-toggle"><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} /><span>Enable this integration</span></label>
|
|
268
|
+
<button type="submit" style={primaryButton}>SAVE {app.name.toUpperCase()}</button>
|
|
269
|
+
{message && <p style={messageStyle}>{message}</p>}
|
|
270
|
+
</form>
|
|
271
|
+
</div>
|
|
272
|
+
</>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
175
276
|
const adminBadge: CSSProperties = { padding: "7px 10px", color: "#fef9e1", background: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 10, fontWeight: 900, letterSpacing: ".12em" };
|
|
176
277
|
const sectionHeading: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 20, paddingBottom: 15, borderBottom: "1px solid rgba(45,74,62,.24)" };
|
|
177
278
|
const titleStyle: CSSProperties = { margin: 0, fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 22 };
|
|
@@ -15,8 +15,8 @@ export type Project = { id: string; name: string; status: string };
|
|
|
15
15
|
|
|
16
16
|
interface MemoryCoreContextType {
|
|
17
17
|
projects: Project[];
|
|
18
|
-
settingsSection: "providers" | "users";
|
|
19
|
-
setSettingsSection: (section: "providers" | "users") => void;
|
|
18
|
+
settingsSection: "providers" | "users" | "apps";
|
|
19
|
+
setSettingsSection: (section: "providers" | "users" | "apps") => void;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const MemoryCoreContext = createContext<MemoryCoreContextType | undefined>(undefined);
|
|
@@ -40,8 +40,10 @@ function pageTitle(pathname: string): string {
|
|
|
40
40
|
export function AppShell({ viewer, children }: { viewer: AppViewer; children: ReactNode }) {
|
|
41
41
|
const pathname = usePathname();
|
|
42
42
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
43
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
44
|
+
const [accountDrawerOpen, setAccountDrawerOpen] = useState(false);
|
|
43
45
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
44
|
-
const [settingsSection, setSettingsSection] = useState<"providers" | "users">("providers");
|
|
46
|
+
const [settingsSection, setSettingsSection] = useState<"providers" | "users" | "apps">("providers");
|
|
45
47
|
const isAdmin = viewer.role === "owner";
|
|
46
48
|
const isSettings = pathname.startsWith("/settings");
|
|
47
49
|
const title = pageTitle(pathname);
|
|
@@ -53,27 +55,56 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
|
|
|
53
55
|
.catch(() => {});
|
|
54
56
|
|
|
55
57
|
const requested = new URLSearchParams(window.location.search).get("section");
|
|
56
|
-
if (requested === "users") setSettingsSection(
|
|
58
|
+
if (requested === "users" || requested === "apps") setSettingsSection(requested);
|
|
59
|
+
setSidebarCollapsed(window.localStorage.getItem("mop-agent-sidebar-collapsed") === "1");
|
|
57
60
|
}, []);
|
|
58
61
|
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!accountDrawerOpen) return;
|
|
64
|
+
const closeOnEscape = (event: KeyboardEvent) => {
|
|
65
|
+
if (event.key === "Escape") setAccountDrawerOpen(false);
|
|
66
|
+
};
|
|
67
|
+
window.addEventListener("keydown", closeOnEscape);
|
|
68
|
+
return () => window.removeEventListener("keydown", closeOnEscape);
|
|
69
|
+
}, [accountDrawerOpen]);
|
|
70
|
+
|
|
59
71
|
async function logout() {
|
|
60
72
|
await signOut();
|
|
61
73
|
window.location.replace("/login");
|
|
62
74
|
}
|
|
63
75
|
|
|
64
|
-
function selectSection(section: "providers" | "users") {
|
|
76
|
+
function selectSection(section: "providers" | "users" | "apps") {
|
|
65
77
|
setSettingsSection(section);
|
|
66
|
-
window.history.replaceState(null, "", section === "providers" ? "/settings" :
|
|
78
|
+
window.history.replaceState(null, "", section === "providers" ? "/settings" : `/settings?section=${section}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toggleSidebar() {
|
|
82
|
+
setSidebarCollapsed((collapsed) => {
|
|
83
|
+
window.localStorage.setItem("mop-agent-sidebar-collapsed", collapsed ? "0" : "1");
|
|
84
|
+
return !collapsed;
|
|
85
|
+
});
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
return (
|
|
70
89
|
<MemoryCoreContext.Provider value={{ projects, settingsSection, setSettingsSection }}>
|
|
71
|
-
<div className=
|
|
90
|
+
<div className={`mop-app-frame${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
|
72
91
|
<header className="mop-app-topbar">
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
92
|
+
<div className="mop-app-brand-cell">
|
|
93
|
+
<a className="mop-app-brand" href="/assistant" aria-label="MOP-AGENT home">
|
|
94
|
+
<img src="/icon.svg" alt="" />
|
|
95
|
+
<span>MOP-AGENT</span>
|
|
96
|
+
</a>
|
|
97
|
+
<button
|
|
98
|
+
className="mop-sidebar-collapse-toggle"
|
|
99
|
+
type="button"
|
|
100
|
+
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
101
|
+
aria-expanded={!sidebarCollapsed}
|
|
102
|
+
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
103
|
+
onClick={toggleSidebar}
|
|
104
|
+
>
|
|
105
|
+
{sidebarCollapsed ? "›" : "‹"}
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
77
108
|
<div className="mop-app-topbar-main">
|
|
78
109
|
<button
|
|
79
110
|
className="mop-menu-toggle"
|
|
@@ -106,20 +137,27 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
|
|
|
106
137
|
<span className="mop-nav-icon">♙</span>
|
|
107
138
|
<span>Users</span>
|
|
108
139
|
</button>
|
|
140
|
+
<button className={settingsSection === "apps" ? "is-active" : ""} onClick={() => { selectSection("apps"); setMenuOpen(false); }}>
|
|
141
|
+
<span className="mop-nav-icon">⌘</span>
|
|
142
|
+
<span>Apps</span>
|
|
143
|
+
</button>
|
|
109
144
|
</nav>
|
|
110
145
|
</div>
|
|
111
146
|
) : (
|
|
112
147
|
<>
|
|
113
|
-
<
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
148
|
+
<div className="mop-nav-section mop-workspace-nav">
|
|
149
|
+
<p>WORKSPACE</p>
|
|
150
|
+
<nav className="mop-sidebar-primary" aria-label="Workspace">
|
|
151
|
+
<a href="/assistant" className={pathname.startsWith("/assistant") || pathname.startsWith("/chat/") ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
|
|
152
|
+
<span className="mop-nav-icon">✎</span>
|
|
153
|
+
<span>Chat</span>
|
|
154
|
+
</a>
|
|
155
|
+
<a href="/brain" className={pathname.startsWith("/brain") ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
|
|
156
|
+
<span className="mop-nav-icon">◉</span>
|
|
157
|
+
<span>Brain</span>
|
|
158
|
+
</a>
|
|
159
|
+
</nav>
|
|
160
|
+
</div>
|
|
123
161
|
|
|
124
162
|
{isAdmin && (
|
|
125
163
|
<div className="mop-nav-section mop-admin-nav">
|
|
@@ -141,7 +179,14 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
|
|
|
141
179
|
<span>← BACK TO WORKSPACE</span>
|
|
142
180
|
</a>
|
|
143
181
|
)}
|
|
144
|
-
<button
|
|
182
|
+
<button
|
|
183
|
+
className="mop-account-card"
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => { setAccountDrawerOpen(true); setMenuOpen(false); }}
|
|
186
|
+
title="Open profile"
|
|
187
|
+
aria-controls="mop-account-drawer"
|
|
188
|
+
aria-expanded={accountDrawerOpen}
|
|
189
|
+
>
|
|
145
190
|
<span className="mop-account-avatar">{viewer.name.slice(0, 1).toUpperCase()}</span>
|
|
146
191
|
<span className="mop-account-copy">
|
|
147
192
|
<strong>{viewer.name}</strong>
|
|
@@ -152,6 +197,40 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
|
|
|
152
197
|
</aside>
|
|
153
198
|
|
|
154
199
|
<main className="mop-app-main">{children}</main>
|
|
200
|
+
|
|
201
|
+
{accountDrawerOpen && (
|
|
202
|
+
<>
|
|
203
|
+
<button className="mop-account-drawer-scrim" type="button" aria-label="Close profile drawer" onClick={() => setAccountDrawerOpen(false)} />
|
|
204
|
+
<aside id="mop-account-drawer" className="mop-account-drawer" role="dialog" aria-modal="true" aria-labelledby="mop-account-drawer-title">
|
|
205
|
+
<header className="mop-account-drawer-header">
|
|
206
|
+
<div>
|
|
207
|
+
<span>ACCOUNT</span>
|
|
208
|
+
<strong id="mop-account-drawer-title">Profile</strong>
|
|
209
|
+
</div>
|
|
210
|
+
<button type="button" aria-label="Close profile drawer" title="Close" onClick={() => setAccountDrawerOpen(false)}>×</button>
|
|
211
|
+
</header>
|
|
212
|
+
|
|
213
|
+
<div className="mop-account-drawer-profile">
|
|
214
|
+
<span className="mop-account-drawer-avatar">{viewer.name.slice(0, 1).toUpperCase()}</span>
|
|
215
|
+
<div>
|
|
216
|
+
<strong>{viewer.name}</strong>
|
|
217
|
+
<span>{viewer.email}</span>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<dl className="mop-account-drawer-details">
|
|
222
|
+
<div><dt>Access level</dt><dd>{isAdmin ? "Administrator" : "Member"}</dd></div>
|
|
223
|
+
<div><dt>Workspace</dt><dd>MOP-AGENT</dd></div>
|
|
224
|
+
</dl>
|
|
225
|
+
|
|
226
|
+
<div className="mop-account-drawer-spacer" />
|
|
227
|
+
<button className="mop-account-logout" type="button" onClick={logout}>
|
|
228
|
+
<span>↪</span>
|
|
229
|
+
<strong>Logout</strong>
|
|
230
|
+
</button>
|
|
231
|
+
</aside>
|
|
232
|
+
</>
|
|
233
|
+
)}
|
|
155
234
|
</div>
|
|
156
235
|
</MemoryCoreContext.Provider>
|
|
157
236
|
);
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
6
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
7
|
+
|
|
8
|
+
type ClassValue = string | false | null | undefined;
|
|
9
|
+
function cn(...values: ClassValue[]) {
|
|
10
|
+
return values.filter(Boolean).join(" ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PromptTool = "image" | "web" | "code" | "research" | "think";
|
|
14
|
+
export type PromptImage = { name: string; mimeType: string; dataUrl: string };
|
|
15
|
+
export type PromptSubmit = { message: string; tool: PromptTool | null; image: PromptImage | null };
|
|
16
|
+
|
|
17
|
+
type PromptBoxProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit"> & {
|
|
18
|
+
value: string;
|
|
19
|
+
onValueChange: (value: string) => void;
|
|
20
|
+
onSubmit: (payload: PromptSubmit) => void | boolean | Promise<void | boolean>;
|
|
21
|
+
busy?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const PromptBox = React.forwardRef<HTMLTextAreaElement, PromptBoxProps>(function PromptBox(
|
|
25
|
+
{ value, onValueChange, onSubmit, busy = false, className, placeholder = "Message MOP-AGENT…", ...props },
|
|
26
|
+
forwardedRef,
|
|
27
|
+
) {
|
|
28
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
29
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
30
|
+
const recognitionRef = React.useRef<SpeechRecognitionLike | null>(null);
|
|
31
|
+
const [image, setImage] = React.useState<PromptImage | null>(null);
|
|
32
|
+
const [selectedTool, setSelectedTool] = React.useState<PromptTool | null>(null);
|
|
33
|
+
const [popoverOpen, setPopoverOpen] = React.useState(false);
|
|
34
|
+
const [previewOpen, setPreviewOpen] = React.useState(false);
|
|
35
|
+
const [listening, setListening] = React.useState(false);
|
|
36
|
+
const [voiceError, setVoiceError] = React.useState("");
|
|
37
|
+
|
|
38
|
+
React.useImperativeHandle(forwardedRef, () => textareaRef.current as HTMLTextAreaElement);
|
|
39
|
+
|
|
40
|
+
React.useLayoutEffect(() => {
|
|
41
|
+
const textarea = textareaRef.current;
|
|
42
|
+
if (!textarea) return;
|
|
43
|
+
textarea.style.height = "auto";
|
|
44
|
+
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
45
|
+
}, [value]);
|
|
46
|
+
|
|
47
|
+
React.useEffect(() => () => recognitionRef.current?.stop(), []);
|
|
48
|
+
|
|
49
|
+
async function submit() {
|
|
50
|
+
const message = value.trim();
|
|
51
|
+
if (busy || (!message && !image)) return;
|
|
52
|
+
const accepted = await onSubmit({ message, tool: selectedTool, image });
|
|
53
|
+
if (accepted === false) return;
|
|
54
|
+
setImage(null);
|
|
55
|
+
setSelectedTool(null);
|
|
56
|
+
setVoiceError("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function attachFile(event: React.ChangeEvent<HTMLInputElement>) {
|
|
60
|
+
const file = event.target.files?.[0];
|
|
61
|
+
event.target.value = "";
|
|
62
|
+
if (!file) return;
|
|
63
|
+
if (!file.type.startsWith("image/")) {
|
|
64
|
+
setVoiceError("Only image attachments are supported.");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
68
|
+
setVoiceError("Image must be 5 MB or smaller.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const reader = new FileReader();
|
|
72
|
+
reader.onload = () => {
|
|
73
|
+
setImage({ name: file.name, mimeType: file.type, dataUrl: String(reader.result) });
|
|
74
|
+
setVoiceError("");
|
|
75
|
+
};
|
|
76
|
+
reader.readAsDataURL(file);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toggleVoice() {
|
|
80
|
+
if (listening) {
|
|
81
|
+
recognitionRef.current?.stop();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const SpeechRecognition = getSpeechRecognition();
|
|
85
|
+
if (!SpeechRecognition) {
|
|
86
|
+
setVoiceError("Voice input is not supported by this browser.");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const recognition = new SpeechRecognition();
|
|
90
|
+
recognition.lang = navigator.language || "en-US";
|
|
91
|
+
recognition.interimResults = true;
|
|
92
|
+
recognition.continuous = false;
|
|
93
|
+
const startingValue = value;
|
|
94
|
+
recognition.onresult = (event) => {
|
|
95
|
+
let transcript = "";
|
|
96
|
+
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
|
97
|
+
transcript += event.results[index]?.[0]?.transcript ?? "";
|
|
98
|
+
}
|
|
99
|
+
onValueChange(`${startingValue}${startingValue && transcript ? " " : ""}${transcript}`);
|
|
100
|
+
};
|
|
101
|
+
recognition.onerror = () => {
|
|
102
|
+
setListening(false);
|
|
103
|
+
setVoiceError("Voice input could not be started.");
|
|
104
|
+
};
|
|
105
|
+
recognition.onend = () => setListening(false);
|
|
106
|
+
recognitionRef.current = recognition;
|
|
107
|
+
setVoiceError("");
|
|
108
|
+
setListening(true);
|
|
109
|
+
recognition.start();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const activeTool = toolItems.find((item) => item.id === selectedTool);
|
|
113
|
+
const ActiveToolIcon = activeTool?.icon;
|
|
114
|
+
const canSend = !busy && (!!value.trim() || !!image);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className={cn("mop-prompt-box flex flex-col rounded-[26px] border border-[#2d4a3e]/40 bg-[#fffdf2] p-2 shadow-sm", className)}>
|
|
118
|
+
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={attachFile} />
|
|
119
|
+
|
|
120
|
+
{image && (
|
|
121
|
+
<DialogPrimitive.Root open={previewOpen} onOpenChange={setPreviewOpen}>
|
|
122
|
+
<div className="relative mb-1 ml-1 w-fit">
|
|
123
|
+
<DialogPrimitive.Trigger asChild>
|
|
124
|
+
<button type="button" className="mop-prompt-preview-trigger" title="Preview attachment">
|
|
125
|
+
<img src={image.dataUrl} alt={image.name} className="h-16 w-16 rounded-xl object-cover" />
|
|
126
|
+
</button>
|
|
127
|
+
</DialogPrimitive.Trigger>
|
|
128
|
+
<button type="button" className="mop-prompt-remove-image" aria-label="Remove image" onClick={() => setImage(null)}>
|
|
129
|
+
<XIcon className="h-3.5 w-3.5" />
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
<DialogPrimitive.Portal>
|
|
133
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-[100] bg-black/65 backdrop-blur-sm" />
|
|
134
|
+
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-[101] w-[min(900px,92vw)] -translate-x-1/2 -translate-y-1/2 outline-none">
|
|
135
|
+
<div className="relative rounded-2xl bg-[#fffdf2] p-3 shadow-2xl">
|
|
136
|
+
<img src={image.dataUrl} alt={image.name} className="max-h-[82vh] w-full rounded-xl object-contain" />
|
|
137
|
+
<DialogPrimitive.Close className="absolute right-5 top-5 grid h-8 w-8 place-items-center rounded-full bg-[#2d4a3e] text-[#fef9e1]" aria-label="Close preview">
|
|
138
|
+
<XIcon className="h-4 w-4" />
|
|
139
|
+
</DialogPrimitive.Close>
|
|
140
|
+
</div>
|
|
141
|
+
</DialogPrimitive.Content>
|
|
142
|
+
</DialogPrimitive.Portal>
|
|
143
|
+
</DialogPrimitive.Root>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
<textarea
|
|
147
|
+
{...props}
|
|
148
|
+
ref={textareaRef}
|
|
149
|
+
rows={1}
|
|
150
|
+
value={value}
|
|
151
|
+
disabled={busy}
|
|
152
|
+
placeholder={placeholder}
|
|
153
|
+
className="mop-prompt-textarea min-h-12 w-full resize-none border-0 bg-transparent p-3 text-[#2d4a3e] outline-none placeholder:text-[#2d4a3e]/45"
|
|
154
|
+
onChange={(event) => onValueChange(event.target.value)}
|
|
155
|
+
onKeyDown={(event) => {
|
|
156
|
+
props.onKeyDown?.(event);
|
|
157
|
+
if (event.defaultPrevented) return;
|
|
158
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
void submit();
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
<div className="flex items-center gap-2 px-1 pb-1">
|
|
166
|
+
<Tooltip label="Attach image">
|
|
167
|
+
<button type="button" className="mop-prompt-round-button" onClick={() => fileInputRef.current?.click()} disabled={busy} aria-label="Attach image">
|
|
168
|
+
<PlusIcon className="h-5 w-5" />
|
|
169
|
+
</button>
|
|
170
|
+
</Tooltip>
|
|
171
|
+
|
|
172
|
+
<PopoverPrimitive.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
|
|
173
|
+
<TooltipPrimitive.Provider delayDuration={100}>
|
|
174
|
+
<TooltipPrimitive.Root>
|
|
175
|
+
<TooltipPrimitive.Trigger asChild>
|
|
176
|
+
<PopoverPrimitive.Trigger asChild>
|
|
177
|
+
<button type="button" className="mop-prompt-tool-button" disabled={busy}>
|
|
178
|
+
<SettingsIcon className="h-4 w-4" />
|
|
179
|
+
{!selectedTool && <span>Tools</span>}
|
|
180
|
+
</button>
|
|
181
|
+
</PopoverPrimitive.Trigger>
|
|
182
|
+
</TooltipPrimitive.Trigger>
|
|
183
|
+
<TooltipPortal label="Explore tools" />
|
|
184
|
+
</TooltipPrimitive.Root>
|
|
185
|
+
</TooltipPrimitive.Provider>
|
|
186
|
+
<PopoverPrimitive.Portal>
|
|
187
|
+
<PopoverPrimitive.Content side="top" align="start" sideOffset={9} className="mop-prompt-popover z-[90] w-64 rounded-xl border border-[#2d4a3e]/30 bg-[#fffdf2] p-2 text-[#2d4a3e] shadow-xl outline-none">
|
|
188
|
+
{toolItems.map((tool) => (
|
|
189
|
+
<button
|
|
190
|
+
key={tool.id}
|
|
191
|
+
type="button"
|
|
192
|
+
className="mop-prompt-popover-item"
|
|
193
|
+
onClick={() => { setSelectedTool(tool.id); setPopoverOpen(false); }}
|
|
194
|
+
>
|
|
195
|
+
<tool.icon className="h-4 w-4" />
|
|
196
|
+
<span>{tool.name}</span>
|
|
197
|
+
</button>
|
|
198
|
+
))}
|
|
199
|
+
</PopoverPrimitive.Content>
|
|
200
|
+
</PopoverPrimitive.Portal>
|
|
201
|
+
</PopoverPrimitive.Root>
|
|
202
|
+
|
|
203
|
+
{activeTool && (
|
|
204
|
+
<button type="button" className="mop-prompt-active-tool" onClick={() => setSelectedTool(null)} disabled={busy}>
|
|
205
|
+
{ActiveToolIcon && <ActiveToolIcon className="h-4 w-4" />}
|
|
206
|
+
<span>{activeTool.shortName}</span>
|
|
207
|
+
<XIcon className="h-3.5 w-3.5" />
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
<div className="ml-auto flex items-center gap-2">
|
|
212
|
+
<Tooltip label={listening ? "Stop listening" : "Voice input"}>
|
|
213
|
+
<button type="button" className={cn("mop-prompt-round-button", listening && "is-listening")} onClick={toggleVoice} disabled={busy} aria-label={listening ? "Stop listening" : "Voice input"}>
|
|
214
|
+
<MicIcon className="h-4.5 w-4.5" />
|
|
215
|
+
</button>
|
|
216
|
+
</Tooltip>
|
|
217
|
+
<Tooltip label="Send">
|
|
218
|
+
<button type="button" className="mop-prompt-send" disabled={!canSend} onClick={() => void submit()} aria-label="Send message">
|
|
219
|
+
<SendIcon className="h-5 w-5" />
|
|
220
|
+
</button>
|
|
221
|
+
</Tooltip>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{voiceError && <p className="mop-prompt-error">{voiceError}</p>}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
function Tooltip({ label, children }: { label: string; children: React.ReactElement }) {
|
|
231
|
+
return (
|
|
232
|
+
<TooltipPrimitive.Provider delayDuration={100}>
|
|
233
|
+
<TooltipPrimitive.Root>
|
|
234
|
+
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
|
235
|
+
<TooltipPortal label={label} />
|
|
236
|
+
</TooltipPrimitive.Root>
|
|
237
|
+
</TooltipPrimitive.Provider>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function TooltipPortal({ label }: { label: string }) {
|
|
242
|
+
return (
|
|
243
|
+
<TooltipPrimitive.Portal>
|
|
244
|
+
<TooltipPrimitive.Content side="top" sideOffset={6} className="z-[110] rounded-md bg-[#2d4a3e] px-2 py-1 text-xs text-[#fef9e1] shadow-lg">
|
|
245
|
+
{label}<TooltipPrimitive.Arrow className="fill-[#2d4a3e]" />
|
|
246
|
+
</TooltipPrimitive.Content>
|
|
247
|
+
</TooltipPrimitive.Portal>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type SpeechRecognitionEventLike = { resultIndex: number; results: ArrayLike<{ 0?: { transcript?: string } }> };
|
|
252
|
+
type SpeechRecognitionLike = {
|
|
253
|
+
lang: string;
|
|
254
|
+
interimResults: boolean;
|
|
255
|
+
continuous: boolean;
|
|
256
|
+
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
|
257
|
+
onerror: (() => void) | null;
|
|
258
|
+
onend: (() => void) | null;
|
|
259
|
+
start(): void;
|
|
260
|
+
stop(): void;
|
|
261
|
+
};
|
|
262
|
+
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
|
|
263
|
+
|
|
264
|
+
function getSpeechRecognition(): SpeechRecognitionConstructor | undefined {
|
|
265
|
+
const voiceWindow = window as typeof window & {
|
|
266
|
+
SpeechRecognition?: SpeechRecognitionConstructor;
|
|
267
|
+
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
|
268
|
+
};
|
|
269
|
+
return voiceWindow.SpeechRecognition ?? voiceWindow.webkitSpeechRecognition;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
type IconProps = React.SVGProps<SVGSVGElement>;
|
|
273
|
+
const PlusIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...props}><path d="M12 5v14M5 12h14" /></svg>;
|
|
274
|
+
const SettingsIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" {...props}><path d="M20 7h-9M14 17H5" /><circle cx="7" cy="7" r="3" /><circle cx="17" cy="17" r="3" /></svg>;
|
|
275
|
+
const SendIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="M12 19V5m-7 7 7-7 7 7" /></svg>;
|
|
276
|
+
const XIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="m6 6 12 12M18 6 6 18" /></svg>;
|
|
277
|
+
const GlobeIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" {...props}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c3 3 4 6 4 9s-1 6-4 9c-3-3-4-6-4-9s1-6 4-9Z" /></svg>;
|
|
278
|
+
const PencilIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" {...props}><path d="m4 20 4-1 11-11a2.8 2.8 0 0 0-4-4L4 15l-1 5Z" /><path d="m14 5 5 5" /></svg>;
|
|
279
|
+
const BrushIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" {...props}><path d="m14 4 6 6-9 9c-2 2-6 1-7 1 0-1-1-5 1-7l9-9Z" /><path d="m11 7 6 6M4 20c-1 1-2 1-3 1 1-1 1-2 1-3" /></svg>;
|
|
280
|
+
const TelescopeIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" {...props}><path d="m3 7 13 5 2-5L5 2 3 7Z" /><path d="m11 11-3 10m6-9 3 9M7 21h12" /></svg>;
|
|
281
|
+
const BulbIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" {...props}><path d="M9 18h6m-5 3h4m3-7c1-1 2-3 2-5a7 7 0 1 0-14 0c0 2 1 4 2 5 1 1 2 2 2 4h6c0-2 1-3 2-4Z" /></svg>;
|
|
282
|
+
const MicIcon = (props: IconProps) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" {...props}><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3" /></svg>;
|
|
283
|
+
|
|
284
|
+
const toolItems: Array<{ id: PromptTool; name: string; shortName: string; icon: React.ComponentType<IconProps> }> = [
|
|
285
|
+
{ id: "image", name: "Create an image", shortName: "Image", icon: BrushIcon },
|
|
286
|
+
{ id: "web", name: "Search the web", shortName: "Search", icon: GlobeIcon },
|
|
287
|
+
{ id: "code", name: "Write or code", shortName: "Write", icon: PencilIcon },
|
|
288
|
+
{ id: "research", name: "Run deep research", shortName: "Research", icon: TelescopeIcon },
|
|
289
|
+
{ id: "think", name: "Think for longer", shortName: "Think", icon: BulbIcon },
|
|
290
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": true,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"css": "app/globals.css",
|
|
8
|
+
"baseColor": "neutral",
|
|
9
|
+
"cssVariables": true
|
|
10
|
+
},
|
|
11
|
+
"aliases": {
|
|
12
|
+
"components": "@/components",
|
|
13
|
+
"ui": "@/components/ui",
|
|
14
|
+
"utils": "@/lib/utils",
|
|
15
|
+
"lib": "@/lib",
|
|
16
|
+
"hooks": "@/hooks"
|
|
17
|
+
}
|
|
18
|
+
}
|