mop-agent 0.1.15 → 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.
@@ -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);
@@ -41,8 +41,9 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
41
41
  const pathname = usePathname();
42
42
  const [menuOpen, setMenuOpen] = useState(false);
43
43
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
44
+ const [accountDrawerOpen, setAccountDrawerOpen] = useState(false);
44
45
  const [projects, setProjects] = useState<Project[]>([]);
45
- const [settingsSection, setSettingsSection] = useState<"providers" | "users">("providers");
46
+ const [settingsSection, setSettingsSection] = useState<"providers" | "users" | "apps">("providers");
46
47
  const isAdmin = viewer.role === "owner";
47
48
  const isSettings = pathname.startsWith("/settings");
48
49
  const title = pageTitle(pathname);
@@ -54,18 +55,27 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
54
55
  .catch(() => {});
55
56
 
56
57
  const requested = new URLSearchParams(window.location.search).get("section");
57
- if (requested === "users") setSettingsSection("users");
58
+ if (requested === "users" || requested === "apps") setSettingsSection(requested);
58
59
  setSidebarCollapsed(window.localStorage.getItem("mop-agent-sidebar-collapsed") === "1");
59
60
  }, []);
60
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
+
61
71
  async function logout() {
62
72
  await signOut();
63
73
  window.location.replace("/login");
64
74
  }
65
75
 
66
- function selectSection(section: "providers" | "users") {
76
+ function selectSection(section: "providers" | "users" | "apps") {
67
77
  setSettingsSection(section);
68
- window.history.replaceState(null, "", section === "providers" ? "/settings" : "/settings?section=users");
78
+ window.history.replaceState(null, "", section === "providers" ? "/settings" : `/settings?section=${section}`);
69
79
  }
70
80
 
71
81
  function toggleSidebar() {
@@ -127,20 +137,27 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
127
137
  <span className="mop-nav-icon">♙</span>
128
138
  <span>Users</span>
129
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>
130
144
  </nav>
131
145
  </div>
132
146
  ) : (
133
147
  <>
134
- <nav className="mop-sidebar-primary" aria-label="Workspace">
135
- <a href="/assistant" className={pathname.startsWith("/assistant") || pathname.startsWith("/chat/") ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
136
- <span className="mop-nav-icon">✎</span>
137
- <span>New chat</span>
138
- </a>
139
- <a href="/brain" className={pathname.startsWith("/brain") ? "is-active" : ""} onClick={() => setMenuOpen(false)}>
140
- <span className="mop-nav-icon">◉</span>
141
- <span>Brain</span>
142
- </a>
143
- </nav>
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>
144
161
 
145
162
  {isAdmin && (
146
163
  <div className="mop-nav-section mop-admin-nav">
@@ -162,7 +179,14 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
162
179
  <span>← BACK TO WORKSPACE</span>
163
180
  </a>
164
181
  )}
165
- <button className="mop-account-card" type="button" onClick={logout} title="Sign out">
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
+ >
166
190
  <span className="mop-account-avatar">{viewer.name.slice(0, 1).toUpperCase()}</span>
167
191
  <span className="mop-account-copy">
168
192
  <strong>{viewer.name}</strong>
@@ -173,6 +197,40 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
173
197
  </aside>
174
198
 
175
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
+ )}
176
234
  </div>
177
235
  </MemoryCoreContext.Provider>
178
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
+ }
@@ -0,0 +1,48 @@
1
+ import { decryptSecret, encryptSecret, keyHint } from "../crypto";
2
+ import { getSqlite } from "../db/client";
3
+
4
+ export type AppId = "telegram" | "discord" | "whatsapp" | "slack" | "webhook";
5
+ export type AppPayload = { secret: string; fields: Record<string, string> };
6
+ type AppRow = { owner_id: string; app_id: AppId; config_enc: string; enabled: number; updated_at: number };
7
+
8
+ export function listAppConfigs(ownerId: string) {
9
+ const rows = getSqlite().prepare("SELECT * FROM app_config WHERE owner_id = ? ORDER BY app_id").all(ownerId) as AppRow[];
10
+ return rows.map((row) => {
11
+ let hint = "••••";
12
+ try { hint = keyHint((JSON.parse(decryptSecret(row.config_enc)) as AppPayload).secret); } catch { /* masked */ }
13
+ return { appId: row.app_id, configured: true, enabled: !!row.enabled, keyHint: hint, updatedAt: row.updated_at };
14
+ });
15
+ }
16
+
17
+ export function saveAppConfig(ownerId: string, input: { appId: AppId; secret?: string; fields?: Record<string, string>; enabled: boolean }) {
18
+ const existing = getSqlite().prepare("SELECT * FROM app_config WHERE owner_id = ? AND app_id = ?").get(ownerId, input.appId) as AppRow | undefined;
19
+ let previous: AppPayload | undefined;
20
+ if (existing) {
21
+ try { previous = JSON.parse(decryptSecret(existing.config_enc)) as AppPayload; } catch { /* replace invalid config */ }
22
+ }
23
+ const secret = input.secret?.trim() || previous?.secret;
24
+ if (!secret) throw new Error("missing_secret");
25
+ const payload: AppPayload = { secret, fields: { ...(previous?.fields ?? {}), ...(input.fields ?? {}) } };
26
+ getSqlite().prepare(`
27
+ INSERT INTO app_config(owner_id, app_id, config_enc, enabled, updated_at)
28
+ VALUES (?, ?, ?, ?, ?)
29
+ ON CONFLICT(owner_id, app_id) DO UPDATE SET
30
+ config_enc = excluded.config_enc,
31
+ enabled = excluded.enabled,
32
+ updated_at = excluded.updated_at
33
+ `).run(ownerId, input.appId, encryptSecret(JSON.stringify(payload)), input.enabled ? 1 : 0, Date.now());
34
+ }
35
+
36
+ export function listEnabledAppConfigs(): Array<{ appId: AppId; payload: AppPayload }> {
37
+ const rows = getSqlite().prepare("SELECT * FROM app_config WHERE enabled = 1 ORDER BY updated_at DESC").all() as AppRow[];
38
+ const seen = new Set<AppId>();
39
+ const configs: Array<{ appId: AppId; payload: AppPayload }> = [];
40
+ for (const row of rows) {
41
+ if (seen.has(row.app_id)) continue;
42
+ try {
43
+ configs.push({ appId: row.app_id, payload: JSON.parse(decryptSecret(row.config_enc)) as AppPayload });
44
+ seen.add(row.app_id);
45
+ } catch { /* skip unreadable secrets */ }
46
+ }
47
+ return configs;
48
+ }
@@ -4,14 +4,18 @@
4
4
  */
5
5
  export async function startChannels(): Promise<string[]> {
6
6
  const started: string[] = [];
7
- if (process.env.TELEGRAM_BOT_TOKEN) {
7
+ const { listEnabledAppConfigs } = await import("./config");
8
+ const configs = listEnabledAppConfigs();
9
+ const telegramToken = process.env.TELEGRAM_BOT_TOKEN ?? configs.find((config) => config.appId === "telegram")?.payload.secret;
10
+ const discordToken = process.env.DISCORD_BOT_TOKEN ?? configs.find((config) => config.appId === "discord")?.payload.secret;
11
+ if (telegramToken) {
8
12
  const { startTelegram } = await import("./telegram");
9
- startTelegram(process.env.TELEGRAM_BOT_TOKEN);
13
+ startTelegram(telegramToken);
10
14
  started.push("telegram");
11
15
  }
12
- if (process.env.DISCORD_BOT_TOKEN) {
16
+ if (discordToken) {
13
17
  const { startDiscord } = await import("./discord");
14
- startDiscord(process.env.DISCORD_BOT_TOKEN);
18
+ startDiscord(discordToken);
15
19
  started.push("discord");
16
20
  }
17
21
  return started;
@@ -80,6 +80,15 @@ CREATE TABLE IF NOT EXISTS provider_config (
80
80
  updated_at INTEGER NOT NULL
81
81
  );
82
82
 
83
+ CREATE TABLE IF NOT EXISTS app_config (
84
+ owner_id TEXT NOT NULL,
85
+ app_id TEXT NOT NULL,
86
+ config_enc TEXT NOT NULL,
87
+ enabled INTEGER NOT NULL DEFAULT 0,
88
+ updated_at INTEGER NOT NULL,
89
+ PRIMARY KEY (owner_id, app_id)
90
+ );
91
+
83
92
  CREATE TABLE IF NOT EXISTS skill (
84
93
  id TEXT PRIMARY KEY,
85
94
  name TEXT NOT NULL,
@@ -7,11 +7,30 @@ export function anthropicProvider(apiKey: string, model = "claude-sonnet-4-6"):
7
7
  id: "anthropic",
8
8
  model,
9
9
  async *chat({ system, messages }: ChatOptions) {
10
+ const anthropicMessages: Anthropic.MessageParam[] = messages.map((message) => {
11
+ if (!message.image || message.role !== "user") return { role: message.role, content: message.content };
12
+ const match = message.image.dataUrl.match(/^data:(image\/(?:jpeg|png|gif|webp));base64,(.+)$/s);
13
+ if (!match) return { role: message.role, content: message.content };
14
+ return {
15
+ role: message.role,
16
+ content: [
17
+ {
18
+ type: "image",
19
+ source: {
20
+ type: "base64",
21
+ media_type: match[1] as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
22
+ data: match[2]!,
23
+ },
24
+ },
25
+ { type: "text", text: message.content || "Describe and help with this image." },
26
+ ],
27
+ };
28
+ });
10
29
  const stream = client.messages.stream({
11
30
  model,
12
31
  max_tokens: 4096,
13
32
  system,
14
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
33
+ messages: anthropicMessages,
15
34
  });
16
35
  for await (const ev of stream) {
17
36
  if (ev.type === "content_block_delta" && ev.delta.type === "text_delta") {
@@ -8,12 +8,22 @@ export function openRouterProvider(apiKey: string, model = "anthropic/claude-son
8
8
  id: "openrouter",
9
9
  model,
10
10
  async *chat({ system, messages }: ChatOptions) {
11
+ const openRouterMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = messages.map((message) => {
12
+ if (!message.image || message.role !== "user") return { role: message.role, content: message.content };
13
+ return {
14
+ role: "user",
15
+ content: [
16
+ { type: "text", text: message.content || "Describe and help with this image." },
17
+ { type: "image_url", image_url: { url: message.image.dataUrl } },
18
+ ],
19
+ };
20
+ });
11
21
  const stream = await client.chat.completions.create({
12
22
  model,
13
23
  stream: true,
14
24
  messages: [
15
25
  ...(system ? ([{ role: "system" as const, content: system }]) : []),
16
- ...messages.map((m) => ({ role: m.role, content: m.content })),
26
+ ...openRouterMessages,
17
27
  ],
18
28
  });
19
29
  for await (const chunk of stream) {
@@ -1,5 +1,6 @@
1
1
  /** Provider-neutral chat interface. Adapters stream text deltas. */
2
- export type Msg = { role: "user" | "assistant"; content: string };
2
+ export type ChatImage = { name: string; mimeType: string; dataUrl: string };
3
+ export type Msg = { role: "user" | "assistant"; content: string; image?: ChatImage };
3
4
 
4
5
  export type ChatOptions = {
5
6
  system?: string;
@@ -15,6 +15,9 @@
15
15
  "dependencies": {
16
16
  "@anthropic-ai/sdk": "^0.105.0",
17
17
  "@mop/link-protocol": "*",
18
+ "@radix-ui/react-dialog": "^1.1.17",
19
+ "@radix-ui/react-popover": "^1.1.17",
20
+ "@radix-ui/react-tooltip": "^1.2.10",
18
21
  "@xenova/transformers": "^2.17.2",
19
22
  "better-auth": "^1.6.20",
20
23
  "better-sqlite3": "^12.11.1",
@@ -31,6 +34,7 @@
31
34
  "ws": "^8.18.0"
32
35
  },
33
36
  "devDependencies": {
37
+ "@tailwindcss/postcss": "^4.3.1",
34
38
  "@types/better-sqlite3": "^7.6.13",
35
39
  "@types/node": "^22.0.0",
36
40
  "@types/react": "^19.0.0",
@@ -38,7 +42,9 @@
38
42
  "@types/ws": "^8.5.12",
39
43
  "cross-env": "^7.0.3",
40
44
  "drizzle-kit": "^0.31.10",
45
+ "tailwindcss": "^4.3.1",
41
46
  "tsx": "^4.19.0",
47
+ "tw-animate-css": "^1.4.0",
42
48
  "typescript": "^5.5.0"
43
49
  }
44
50
  }
@@ -0,0 +1,5 @@
1
+ export default {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };