@effectnode/media 0.1.0

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.
Files changed (133) hide show
  1. package/bin/effectnode-media +2 -0
  2. package/dist/backend/index.d.ts +8 -0
  3. package/dist/backend/index.js +13 -0
  4. package/dist/backend/movie-backend/agent/agent-backend.d.ts +16 -0
  5. package/dist/backend/movie-backend/agent/agent-backend.js +703 -0
  6. package/dist/backend/movie-backend/agent/storyprompt.d.ts +1 -0
  7. package/dist/backend/movie-backend/agent/storyprompt.js +90 -0
  8. package/dist/backend/movie-backend/agent/tools/edit-image.d.ts +3 -0
  9. package/dist/backend/movie-backend/agent/tools/edit-image.js +192 -0
  10. package/dist/backend/movie-backend/agent/tools/get-time.d.ts +3 -0
  11. package/dist/backend/movie-backend/agent/tools/get-time.js +7 -0
  12. package/dist/backend/movie-backend/agent/tools/grep-files.d.ts +3 -0
  13. package/dist/backend/movie-backend/agent/tools/grep-files.js +59 -0
  14. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.d.ts +3 -0
  15. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.js +160 -0
  16. package/dist/backend/movie-backend/agent/tools/index.d.ts +7 -0
  17. package/dist/backend/movie-backend/agent/tools/index.js +59 -0
  18. package/dist/backend/movie-backend/agent/tools/list-files.d.ts +3 -0
  19. package/dist/backend/movie-backend/agent/tools/list-files.js +17 -0
  20. package/dist/backend/movie-backend/agent/tools/read-file.d.ts +3 -0
  21. package/dist/backend/movie-backend/agent/tools/read-file.js +28 -0
  22. package/dist/backend/movie-backend/agent/tools/remove-file.d.ts +3 -0
  23. package/dist/backend/movie-backend/agent/tools/remove-file.js +22 -0
  24. package/dist/backend/movie-backend/agent/tools/rename-file.d.ts +3 -0
  25. package/dist/backend/movie-backend/agent/tools/rename-file.js +37 -0
  26. package/dist/backend/movie-backend/agent/tools/show-image.d.ts +3 -0
  27. package/dist/backend/movie-backend/agent/tools/show-image.js +28 -0
  28. package/dist/backend/movie-backend/agent/tools/stitch-videos.d.ts +3 -0
  29. package/dist/backend/movie-backend/agent/tools/stitch-videos.js +96 -0
  30. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.d.ts +3 -0
  31. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.js +125 -0
  32. package/dist/backend/movie-backend/agent/tools/types.d.ts +15 -0
  33. package/dist/backend/movie-backend/agent/tools/types.js +1 -0
  34. package/dist/backend/movie-backend/agent/tools/update-file.d.ts +3 -0
  35. package/dist/backend/movie-backend/agent/tools/update-file.js +27 -0
  36. package/dist/backend/movie-backend/agent/tools/write-file.d.ts +3 -0
  37. package/dist/backend/movie-backend/agent/tools/write-file.js +26 -0
  38. package/dist/backend/movie-backend/agent/workspace.d.ts +25 -0
  39. package/dist/backend/movie-backend/agent/workspace.js +124 -0
  40. package/dist/backend/movie-backend/core.d.ts +4 -0
  41. package/dist/backend/movie-backend/core.js +1167 -0
  42. package/dist/backend/movie-backend/generation-queue.d.ts +24 -0
  43. package/dist/backend/movie-backend/generation-queue.js +766 -0
  44. package/dist/backend/movie-backend/process.d.ts +21 -0
  45. package/dist/backend/movie-backend/process.js +98 -0
  46. package/dist/backend/movie-backend/render-media.d.ts +48 -0
  47. package/dist/backend/movie-backend/render-media.js +3311 -0
  48. package/dist/backend/routes/health.d.ts +1 -0
  49. package/dist/backend/routes/health.js +9 -0
  50. package/dist/backend/routes/index.d.ts +1 -0
  51. package/dist/backend/routes/index.js +11 -0
  52. package/dist/backend/routes/media.d.ts +1 -0
  53. package/dist/backend/routes/media.js +13 -0
  54. package/dist/backend/routes/projects.d.ts +8 -0
  55. package/dist/backend/routes/projects.js +68 -0
  56. package/dist/backend/routes/voice.d.ts +1 -0
  57. package/dist/backend/routes/voice.js +39 -0
  58. package/dist/backend/routes.d.ts +1 -0
  59. package/dist/backend/routes.js +20 -0
  60. package/dist/backend/server.d.ts +1 -0
  61. package/dist/backend/server.js +23 -0
  62. package/dist/backend/workspace.d.ts +5 -0
  63. package/dist/backend/workspace.js +27 -0
  64. package/dist/backend/ws/index.d.ts +3 -0
  65. package/dist/backend/ws/index.js +27 -0
  66. package/dist/backend/ws/messages.d.ts +8 -0
  67. package/dist/backend/ws/messages.js +21 -0
  68. package/dist/backend/ws.d.ts +3 -0
  69. package/dist/backend/ws.js +29 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +80 -0
  72. package/frontend/index.html +22 -0
  73. package/frontend/public/ffmpeg/ffmpeg-core.js +16 -0
  74. package/frontend/public/ffmpeg/ffmpeg-core.wasm +0 -0
  75. package/frontend/src/index.css +46 -0
  76. package/frontend/src/main.tsx +13 -0
  77. package/frontend/src/movie-app/AppRouter.tsx +14 -0
  78. package/frontend/src/movie-app/MediaStudio.tsx +9 -0
  79. package/frontend/src/movie-app/SetupPage.tsx +293 -0
  80. package/frontend/src/movie-app/components/AgentWorkspace.tsx +574 -0
  81. package/frontend/src/movie-app/components/ChatUI/ChatUI.tsx +655 -0
  82. package/frontend/src/movie-app/components/ChatUI/MarkdownMessage.tsx +267 -0
  83. package/frontend/src/movie-app/components/EditorTabs/AgentTab.tsx +61 -0
  84. package/frontend/src/movie-app/components/EditorTabs/BatchImageToVideoTab.tsx +927 -0
  85. package/frontend/src/movie-app/components/EditorTabs/BatchVideoTab.tsx +777 -0
  86. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +913 -0
  87. package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +234 -0
  88. package/frontend/src/movie-app/components/EditorTabs/CharactersTab.tsx +664 -0
  89. package/frontend/src/movie-app/components/EditorTabs/CropTool.tsx +251 -0
  90. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +305 -0
  91. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +249 -0
  92. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +555 -0
  93. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +722 -0
  94. package/frontend/src/movie-app/components/EditorTabs/LlmServerTab.tsx +40 -0
  95. package/frontend/src/movie-app/components/EditorTabs/MlxVlmServerPanel.tsx +490 -0
  96. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +938 -0
  97. package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +881 -0
  98. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +267 -0
  99. package/frontend/src/movie-app/components/EditorTabs/StoryWriterTab.tsx +51 -0
  100. package/frontend/src/movie-app/components/EditorTabs/TaskQueuePanel.tsx +246 -0
  101. package/frontend/src/movie-app/components/EditorTabs/TerminalLogPanel.tsx +49 -0
  102. package/frontend/src/movie-app/components/EditorTabs/TextToImageTab.tsx +574 -0
  103. package/frontend/src/movie-app/components/EditorTabs/VoiceChatPanel.tsx +320 -0
  104. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +636 -0
  105. package/frontend/src/movie-app/components/ProjectManager.tsx +381 -0
  106. package/frontend/src/movie-app/index.css +46 -0
  107. package/frontend/src/movie-app/index.html +16 -0
  108. package/frontend/src/movie-app/lambobo.png +0 -0
  109. package/frontend/src/movie-app/lib/batchImageToVideoStorage.ts +69 -0
  110. package/frontend/src/movie-app/lib/batchVideoStorage.ts +68 -0
  111. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +75 -0
  112. package/frontend/src/movie-app/lib/ffmpeg.ts +42 -0
  113. package/frontend/src/movie-app/lib/referencesToVideoStorage.ts +64 -0
  114. package/frontend/src/movie-app/lib/textToImageStorage.ts +61 -0
  115. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.js +16 -0
  116. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.wasm +0 -0
  117. package/frontend/src/movie-app/stores/batchImageToVideoStore.ts +736 -0
  118. package/frontend/src/movie-app/stores/batchVideoStore.ts +670 -0
  119. package/frontend/src/movie-app/stores/batchVoiceStore.ts +990 -0
  120. package/frontend/src/movie-app/stores/characterStore.ts +109 -0
  121. package/frontend/src/movie-app/stores/chatStore.ts +459 -0
  122. package/frontend/src/movie-app/stores/generationStore.ts +2140 -0
  123. package/frontend/src/movie-app/stores/logStore.ts +25 -0
  124. package/frontend/src/movie-app/stores/movieStudioStore.ts +709 -0
  125. package/frontend/src/movie-app/stores/projectStore.ts +124 -0
  126. package/frontend/src/movie-app/stores/queueStore.ts +214 -0
  127. package/frontend/src/movie-app/stores/referencesToVideoStore.ts +467 -0
  128. package/frontend/src/movie-app/stores/sceneVisualStore.ts +251 -0
  129. package/frontend/src/movie-app/stores/voiceChatStore.ts +170 -0
  130. package/frontend/src/movie-app/stores/workspaceStore.ts +158 -0
  131. package/frontend/src/vite-env.d.ts +1 -0
  132. package/frontend/tsconfig.json +19 -0
  133. package/package.json +65 -0
@@ -0,0 +1,655 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useGenerationStore } from "../../stores/generationStore";
3
+ import { useChatStore } from "../../stores/chatStore";
4
+ import { MarkdownMessage } from "./MarkdownMessage";
5
+
6
+ const API_BASE = `http://localhost:${(window as any).PORT}`;
7
+
8
+ /** Extract a readable summary of a tool call's JSON arguments. */
9
+ function summarizeArgs(args?: string): string {
10
+ if (!args) return "";
11
+ try {
12
+ const obj = JSON.parse(args);
13
+ const strings = Object.values(obj).filter((v) => typeof v === "string");
14
+ return strings.join(", ");
15
+ } catch {
16
+ return args;
17
+ }
18
+ }
19
+
20
+ interface Props {
21
+ projectId: string;
22
+ agent?: string;
23
+ }
24
+
25
+ async function uploadToAgentWorkspace(
26
+ projectId: string,
27
+ dataUrl: string,
28
+ filename: string,
29
+ ): Promise<void> {
30
+ try {
31
+ const res = await fetch(`${API_BASE}/api/agent/upload`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ image: dataUrl, filename, projectId }),
35
+ });
36
+ if (!res.ok) {
37
+ console.error("Failed to upload to agent workspace:", await res.text());
38
+ }
39
+ } catch (e) {
40
+ console.error("Failed to upload to agent workspace:", e);
41
+ }
42
+ }
43
+
44
+ export function ChatUI({ projectId, agent = "default" }: Props) {
45
+ const gen = useGenerationStore();
46
+ const chat = useChatStore();
47
+
48
+ const port = gen.agent.port;
49
+ const model = gen.agent.model;
50
+ const serverRunning = gen.agent.serverRunning;
51
+
52
+ const activeSession =
53
+ chat.sessions.find((s) => s.id === chat.activeSessionId) ?? null;
54
+ const messages = activeSession?.messages ?? [];
55
+
56
+ const inputRef = useRef<HTMLTextAreaElement>(null);
57
+ const scrollRef = useRef<HTMLDivElement>(null);
58
+ const fileRef = useRef<HTMLInputElement>(null);
59
+
60
+ const [confirmDeleteSession, setConfirmDeleteSession] = useState<
61
+ string | null
62
+ >(null);
63
+ const [previewImage, setPreviewImage] = useState<string | null>(null);
64
+
65
+ useEffect(() => {
66
+ if (scrollRef.current) {
67
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
68
+ }
69
+ }, [messages, chat.sending]);
70
+
71
+ useEffect(() => {
72
+ chat.loadThreads(projectId, agent);
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [projectId, agent]);
75
+
76
+ useEffect(() => {
77
+ if (!previewImage) return;
78
+ const onKeyDown = (e: KeyboardEvent) => {
79
+ if (e.key === "Escape") setPreviewImage(null);
80
+ };
81
+ window.addEventListener("keydown", onKeyDown);
82
+ return () => window.removeEventListener("keydown", onKeyDown);
83
+ }, [previewImage]);
84
+
85
+ const handleSend = () => {
86
+ const value = inputRef.current?.value ?? "";
87
+ const hasText = value.trim().length > 0;
88
+ const hasImage = Boolean(chat.pendingImage);
89
+ if ((!hasText && !hasImage) || chat.sending || !serverRunning) return;
90
+ if (inputRef.current) inputRef.current.value = "";
91
+ chat.sendMessage(
92
+ value.trim(),
93
+ port,
94
+ model,
95
+ projectId,
96
+ chat.pendingImage ?? undefined,
97
+ );
98
+ };
99
+
100
+ const handleAttach = () => fileRef.current?.click();
101
+
102
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
103
+ const file = e.target.files?.[0];
104
+ if (!file) return;
105
+ const reader = new FileReader();
106
+ reader.onload = () => {
107
+ const dataUrl = reader.result as string;
108
+ chat.setPendingImage(dataUrl);
109
+ uploadToAgentWorkspace(
110
+ projectId,
111
+ dataUrl,
112
+ `chat-${Date.now()}-${file.name}`,
113
+ );
114
+ };
115
+ reader.readAsDataURL(file);
116
+ e.target.value = "";
117
+ };
118
+
119
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
120
+ if (e.key === "Enter" && !e.shiftKey) {
121
+ e.preventDefault();
122
+ handleSend();
123
+ }
124
+ };
125
+
126
+ const confirmDeleteSessionAction = () => {
127
+ if (confirmDeleteSession) {
128
+ chat.deleteSession(confirmDeleteSession);
129
+ setConfirmDeleteSession(null);
130
+ }
131
+ };
132
+
133
+ useEffect(() => {
134
+ if (!confirmDeleteSession) return;
135
+ const onKey = (e: KeyboardEvent) => {
136
+ if (e.key === "Escape") {
137
+ e.preventDefault();
138
+ setConfirmDeleteSession(null);
139
+ } else if (e.key === "Enter") {
140
+ e.preventDefault();
141
+ confirmDeleteSessionAction();
142
+ }
143
+ };
144
+ window.addEventListener("keydown", onKey);
145
+ return () => window.removeEventListener("keydown", onKey);
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, [confirmDeleteSession]);
148
+
149
+ // ========== SVG Icons ==========
150
+
151
+ const SendIcon = (
152
+ <svg
153
+ width="16"
154
+ height="16"
155
+ viewBox="0 0 24 24"
156
+ fill="none"
157
+ stroke="currentColor"
158
+ strokeWidth="2"
159
+ strokeLinecap="round"
160
+ strokeLinejoin="round"
161
+ >
162
+ <line x1="22" y1="2" x2="11" y2="13" />
163
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
164
+ </svg>
165
+ );
166
+
167
+ const BotIcon = (
168
+ <svg
169
+ width="16"
170
+ height="16"
171
+ viewBox="0 0 24 24"
172
+ fill="none"
173
+ stroke="currentColor"
174
+ strokeWidth="2"
175
+ strokeLinecap="round"
176
+ strokeLinejoin="round"
177
+ >
178
+ <rect x="4" y="4" width="16" height="16" rx="2" />
179
+ <circle cx="9" cy="9" r="1" />
180
+ <circle cx="15" cy="9" r="1" />
181
+ <line x1="9" y1="14" x2="15" y2="14" />
182
+ </svg>
183
+ );
184
+
185
+ const WrenchIcon = (
186
+ <svg
187
+ width="12"
188
+ height="12"
189
+ viewBox="0 0 24 24"
190
+ fill="none"
191
+ stroke="currentColor"
192
+ strokeWidth="2"
193
+ strokeLinecap="round"
194
+ strokeLinejoin="round"
195
+ >
196
+ <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
197
+ </svg>
198
+ );
199
+
200
+ const SpinnerIcon = (
201
+ <svg
202
+ className="animate-spin text-tiffany-600"
203
+ width="16"
204
+ height="16"
205
+ viewBox="0 0 24 24"
206
+ fill="none"
207
+ stroke="currentColor"
208
+ strokeWidth="2"
209
+ >
210
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
211
+ <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
212
+ </svg>
213
+ );
214
+
215
+ const AttachIcon = (
216
+ <svg
217
+ width="16"
218
+ height="16"
219
+ viewBox="0 0 24 24"
220
+ fill="none"
221
+ stroke="currentColor"
222
+ strokeWidth="2"
223
+ strokeLinecap="round"
224
+ strokeLinejoin="round"
225
+ >
226
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
227
+ </svg>
228
+ );
229
+
230
+ const RemoveIcon = (
231
+ <svg
232
+ width="14"
233
+ height="14"
234
+ viewBox="0 0 24 24"
235
+ fill="none"
236
+ stroke="currentColor"
237
+ strokeWidth="2"
238
+ strokeLinecap="round"
239
+ strokeLinejoin="round"
240
+ >
241
+ <line x1="18" y1="6" x2="6" y2="18" />
242
+ <line x1="6" y1="6" x2="18" y2="18" />
243
+ </svg>
244
+ );
245
+
246
+ const ResetIcon = (
247
+ <svg
248
+ width="14"
249
+ height="14"
250
+ viewBox="0 0 24 24"
251
+ fill="none"
252
+ stroke="currentColor"
253
+ strokeWidth="2"
254
+ strokeLinecap="round"
255
+ strokeLinejoin="round"
256
+ >
257
+ <polyline points="1 4 1 10 7 10" />
258
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
259
+ </svg>
260
+ );
261
+
262
+ const PlusIcon = (
263
+ <svg
264
+ width="14"
265
+ height="14"
266
+ viewBox="0 0 24 24"
267
+ fill="none"
268
+ stroke="currentColor"
269
+ strokeWidth="2"
270
+ strokeLinecap="round"
271
+ strokeLinejoin="round"
272
+ >
273
+ <line x1="12" y1="5" x2="12" y2="19" />
274
+ <line x1="5" y1="12" x2="19" y2="12" />
275
+ </svg>
276
+ );
277
+
278
+ const TrashIcon = (
279
+ <svg
280
+ width="14"
281
+ height="14"
282
+ viewBox="0 0 24 24"
283
+ fill="none"
284
+ stroke="currentColor"
285
+ strokeWidth="2"
286
+ strokeLinecap="round"
287
+ strokeLinejoin="round"
288
+ >
289
+ <polyline points="3 6 5 6 21 6" />
290
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
291
+ </svg>
292
+ );
293
+
294
+ const StopIcon = (
295
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
296
+ <rect x="5" y="5" width="14" height="14" rx="2" />
297
+ </svg>
298
+ );
299
+
300
+ const CloseIcon = (
301
+ <svg
302
+ width="20"
303
+ height="20"
304
+ viewBox="0 0 24 24"
305
+ fill="none"
306
+ stroke="currentColor"
307
+ strokeWidth="2"
308
+ strokeLinecap="round"
309
+ strokeLinejoin="round"
310
+ >
311
+ <line x1="18" y1="6" x2="6" y2="18" />
312
+ <line x1="6" y1="6" x2="18" y2="18" />
313
+ </svg>
314
+ );
315
+
316
+ const canSend = serverRunning && !chat.sending;
317
+
318
+ return (
319
+ <div className="flex border border-tiffany-200/70 rounded-2xl overflow-hidden bg-white">
320
+ {/* ===== Session sidebar ===== */}
321
+ <div className="w-44 shrink-0 border-r border-tiffany-100 bg-tiffany-50/60 flex flex-col">
322
+ <div className="p-2 border-b border-tiffany-100">
323
+ <button
324
+ onClick={() => chat.createSession()}
325
+ className="flex items-center justify-center gap-1.5 w-full px-2 py-1.5 text-xs font-medium rounded-xl border border-tiffany-500 bg-tiffany-500 text-ink-950 hover:bg-tiffany-600 hover:border-tiffany-600 transition-colors"
326
+ >
327
+ {PlusIcon}
328
+ New Session
329
+ </button>
330
+ </div>
331
+ <div className="flex-1 overflow-y-auto">
332
+ {chat.sessions.map((sess) => {
333
+ const active = sess.id === chat.activeSessionId;
334
+ return (
335
+ <div
336
+ key={sess.id}
337
+ onClick={() => chat.selectSession(sess.id)}
338
+ className={`group flex items-center gap-1 px-2.5 py-2 cursor-pointer text-xs border-b border-tiffany-100 transition-colors ${
339
+ active
340
+ ? "bg-tiffany-100 text-tiffany-900"
341
+ : "text-ink-600 hover:bg-tiffany-50"
342
+ }`}
343
+ >
344
+ <span className="flex-1 truncate">{sess.title}</span>
345
+ <button
346
+ onClick={(e) => {
347
+ e.stopPropagation();
348
+ setConfirmDeleteSession(sess.id);
349
+ }}
350
+ className="opacity-0 group-hover:opacity-100 p-0.5 text-red-400 hover:bg-red-50 rounded transition-opacity"
351
+ title="Delete session"
352
+ >
353
+ {TrashIcon}
354
+ </button>
355
+ </div>
356
+ );
357
+ })}
358
+ </div>
359
+ </div>
360
+
361
+ {/* ===== Chat area ===== */}
362
+ <div className="flex-1 flex flex-col min-w-0">
363
+ {/* Header */}
364
+ <div className="flex items-center gap-2 px-4 py-3 bg-tiffany-600 border-b border-tiffany-700">
365
+ <span className="flex items-center justify-center w-6 h-6 rounded-lg bg-white/15 text-white">
366
+ {BotIcon}
367
+ </span>
368
+ <span className="text-sm font-semibold text-white truncate">
369
+ {activeSession?.title ?? "Agent Chat"}
370
+ </span>
371
+ <span className="ml-auto text-xs font-medium text-white/90">
372
+ {serverRunning ? "Connected" : "Server offline"}
373
+ </span>
374
+ <button
375
+ onClick={() => chat.resetActiveSession()}
376
+ disabled={messages.length === 0}
377
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-xl border border-white/30 text-white hover:bg-white/10 transition-colors disabled:opacity-40"
378
+ title="Reset chat history"
379
+ >
380
+ {ResetIcon}
381
+ Reset
382
+ </button>
383
+ </div>
384
+
385
+ {/* Messages */}
386
+ <div
387
+ ref={scrollRef}
388
+ className="flex flex-col gap-3 p-5 h-[500px] overflow-y-auto bg-tiffany-50/40"
389
+ >
390
+ {messages.length === 0 && (
391
+ <div className="m-auto text-center text-xs text-tiffany-600/70 italic max-w-xs">
392
+ {serverRunning
393
+ ? "Ask the agent anything. It can use tools like get_time or list_projects."
394
+ : "Start the mlx-vlm server above to begin chatting."}
395
+ </div>
396
+ )}
397
+
398
+ {messages.map((m) =>
399
+ m.role === "user" ? (
400
+ <div key={m.id} className="flex justify-end">
401
+ <div className="max-w-[80%] px-3 py-2 rounded-2xl bg-tiffany-500 text-ink-950 text-sm whitespace-pre-wrap font-serif">
402
+ {m.image && (
403
+ <img
404
+ src={m.image}
405
+ alt="uploaded"
406
+ onClick={() => setPreviewImage(m.image!)}
407
+ className="mb-2 max-w-48 rounded-xl cursor-pointer hover:opacity-80 transition-opacity"
408
+ />
409
+ )}
410
+ {m.content}
411
+ </div>
412
+ </div>
413
+ ) : (
414
+ <div key={m.id} className="flex flex-col gap-1.5">
415
+ {m.steps.length > 0 && (
416
+ <div className="flex flex-col gap-1">
417
+ {m.steps.map((step, i) => {
418
+ const argSummary = summarizeArgs(step.args);
419
+ return (
420
+ <div
421
+ key={i}
422
+ className="flex items-start gap-1.5 text-[11px] text-tiffany-700/80"
423
+ >
424
+ <span className="mt-0.5 shrink-0 text-tiffany-500">
425
+ {WrenchIcon}
426
+ </span>
427
+ <span className="truncate">
428
+ <span className="font-medium">
429
+ Tool: {step.text}
430
+ </span>
431
+ {argSummary && (
432
+ <span className="text-tiffany-600/60">
433
+ {" "}
434
+ · {argSummary}
435
+ </span>
436
+ )}
437
+ </span>
438
+ </div>
439
+ );
440
+ })}
441
+ </div>
442
+ )}
443
+
444
+ {m.thinking && (
445
+ <details className="text-[11px] text-tiffany-600/80">
446
+ <summary className="cursor-pointer select-none">
447
+ Thinking
448
+ </summary>
449
+ <p className="mt-1 whitespace-pre-wrap italic">
450
+ {m.thinking}
451
+ </p>
452
+ </details>
453
+ )}
454
+
455
+ {m.notices && m.notices.length > 0 && (
456
+ <div className="flex flex-col gap-1">
457
+ {m.notices.map((n, i) => (
458
+ <div
459
+ key={i}
460
+ className="text-xs text-tiffany-600/70 italic whitespace-pre-wrap"
461
+ >
462
+ {n}
463
+ </div>
464
+ ))}
465
+ </div>
466
+ )}
467
+
468
+ {m.images && m.images.length > 0 && (
469
+ <div className="flex flex-wrap gap-2">
470
+ {m.images.map((img, i) => (
471
+ <img
472
+ key={i}
473
+ src={img}
474
+ alt={`result ${i + 1}`}
475
+ onClick={() => setPreviewImage(img)}
476
+ className="max-w-48 max-h-48 rounded-xl border border-ink-200 object-contain cursor-pointer hover:opacity-80 transition-opacity"
477
+ />
478
+ ))}
479
+ </div>
480
+ )}
481
+
482
+ {m.videos && m.videos.length > 0 && (
483
+ <div className="flex flex-wrap gap-2">
484
+ {m.videos.map((v, i) => (
485
+ <video
486
+ key={i}
487
+ src={v}
488
+ controls
489
+ className="max-w-64 max-h-48 rounded-xl border border-ink-200"
490
+ />
491
+ ))}
492
+ </div>
493
+ )}
494
+
495
+ {m.content ? (
496
+ <div className="flex justify-start">
497
+ <div className="max-w-[80%] px-3 py-2 rounded-2xl bg-white border border-tiffany-100 text-ink-900 text-sm">
498
+ <MarkdownMessage content={m.content} />
499
+ </div>
500
+ </div>
501
+ ) : (
502
+ chat.sending && (
503
+ <div className="flex justify-start">
504
+ <div className="flex items-center gap-2 px-3 py-2 rounded-2xl bg-white border border-tiffany-100 text-tiffany-600 text-sm">
505
+ {SpinnerIcon}
506
+ Thinking...
507
+ </div>
508
+ </div>
509
+ )
510
+ )}
511
+
512
+ {m.trace && (
513
+ <details className="text-[11px] text-tiffany-600/70">
514
+ <summary className="cursor-pointer">
515
+ Trace ({m.trace.length} messages)
516
+ </summary>
517
+ <pre className="mt-1 max-h-48 overflow-auto p-2 bg-ink-50 rounded-xl whitespace-pre-wrap text-ink-600">
518
+ {JSON.stringify(m.trace, null, 2)}
519
+ </pre>
520
+ </details>
521
+ )}
522
+ </div>
523
+ ),
524
+ )}
525
+
526
+ {chat.error && (
527
+ <div className="px-3 py-2 rounded-2xl bg-red-50 border border-red-200 text-red-600 text-xs">
528
+ {chat.error}
529
+ </div>
530
+ )}
531
+ </div>
532
+
533
+ {/* Input */}
534
+ <div className="border-t border-ink-200 bg-white">
535
+ <input
536
+ ref={fileRef}
537
+ type="file"
538
+ accept="image/*"
539
+ onChange={handleFileChange}
540
+ className="hidden"
541
+ />
542
+
543
+ {chat.pendingImage && (
544
+ <div className="flex items-center gap-2 px-3 pt-2">
545
+ <img
546
+ src={chat.pendingImage}
547
+ alt="pending"
548
+ className="w-14 h-14 rounded-xl object-cover border border-ink-200"
549
+ />
550
+ <button
551
+ onClick={() => chat.setPendingImage(null)}
552
+ className="flex items-center justify-center w-6 h-6 rounded-full bg-tiffany-100 text-tiffany-700 hover:bg-tiffany-200 transition-colors"
553
+ title="Remove image"
554
+ >
555
+ {RemoveIcon}
556
+ </button>
557
+ </div>
558
+ )}
559
+
560
+ <div className="flex items-end gap-2 px-3 py-3">
561
+ <button
562
+ onClick={handleAttach}
563
+ disabled={!serverRunning || chat.sending}
564
+ className="flex items-center justify-center w-10 h-10 shrink-0 rounded-2xl border border-ink-200 text-ink-600 hover:border-tiffany-400 hover:text-tiffany-600 disabled:opacity-50 transition-all"
565
+ title="Attach image"
566
+ >
567
+ {AttachIcon}
568
+ </button>
569
+ <textarea
570
+ ref={inputRef}
571
+ rows={2}
572
+ placeholder="Type a message..."
573
+ onKeyDown={handleKeyDown}
574
+ disabled={!serverRunning || chat.sending}
575
+ className="flex-1 px-3 py-2 bg-tiffany-50/40 border border-tiffany-200/60 rounded-2xl text-ink-900 text-sm placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all resize-none disabled:opacity-50"
576
+ />
577
+ {chat.sending ? (
578
+ <button
579
+ onClick={() => {
580
+ chat.stop();
581
+ fetch(`${API_BASE}/api/render/cancel`, {
582
+ method: "POST",
583
+ }).catch(() => {});
584
+ }}
585
+ className="flex items-center justify-center gap-2 px-4 py-2.5 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm"
586
+ >
587
+ {StopIcon}
588
+ Stop
589
+ </button>
590
+ ) : (
591
+ <button
592
+ onClick={handleSend}
593
+ disabled={!canSend}
594
+ className="flex items-center justify-center gap-2 px-4 py-2.5 bg-tiffany-500 hover:bg-tiffany-600 active:bg-tiffany-700 disabled:bg-ink-200 disabled:text-ink-500 text-ink-950 text-sm font-semibold rounded-2xl transition-all duration-150 shadow-glow"
595
+ >
596
+ {SendIcon}
597
+ Send
598
+ </button>
599
+ )}
600
+ </div>
601
+ </div>
602
+ </div>
603
+
604
+ {/* Session delete confirmation modal */}
605
+ {confirmDeleteSession && (
606
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
607
+ <div className="bg-white rounded-3xl shadow-card p-6 w-80">
608
+ <h3 className="text-sm font-semibold text-ink-900 mb-2">
609
+ Delete Session
610
+ </h3>
611
+ <p className="text-xs text-ink-600 mb-4">
612
+ Are you sure you want to delete this chat session?
613
+ </p>
614
+ <div className="flex justify-end gap-2">
615
+ <button
616
+ onClick={() => setConfirmDeleteSession(null)}
617
+ className="px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 transition-colors"
618
+ >
619
+ Cancel
620
+ </button>
621
+ <button
622
+ onClick={confirmDeleteSessionAction}
623
+ className="px-3 py-2 text-xs font-medium rounded-xl bg-red-500 hover:bg-red-600 text-white transition-colors"
624
+ >
625
+ Delete
626
+ </button>
627
+ </div>
628
+ </div>
629
+ </div>
630
+ )}
631
+
632
+ {/* Image preview modal */}
633
+ {previewImage && (
634
+ <div
635
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8"
636
+ onClick={() => setPreviewImage(null)}
637
+ >
638
+ <button
639
+ onClick={() => setPreviewImage(null)}
640
+ className="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-black/40 text-white hover:bg-black/60 transition-colors"
641
+ title="Close"
642
+ >
643
+ {CloseIcon}
644
+ </button>
645
+ <img
646
+ src={previewImage}
647
+ alt="preview"
648
+ onClick={(e) => e.stopPropagation()}
649
+ className="max-w-full max-h-full rounded-xl shadow-2xl object-contain"
650
+ />
651
+ </div>
652
+ )}
653
+ </div>
654
+ );
655
+ }