cue-console 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.
- package/LICENSE +201 -0
- package/README.md +33 -0
- package/bin/cue-console.js +82 -0
- package/components.json +22 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +11 -0
- package/package.json +61 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +304 -0
- package/src/app/layout.tsx +36 -0
- package/src/app/page.tsx +109 -0
- package/src/components/chat-composer.tsx +493 -0
- package/src/components/chat-view.tsx +1463 -0
- package/src/components/conversation-list.tsx +525 -0
- package/src/components/create-group-dialog.tsx +220 -0
- package/src/components/markdown-renderer.tsx +53 -0
- package/src/components/payload-card.tsx +275 -0
- package/src/components/ui/avatar.tsx +53 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +63 -0
- package/src/components/ui/collapsible.tsx +46 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/scroll-area.tsx +59 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/lib/actions.ts +300 -0
- package/src/lib/db.ts +581 -0
- package/src/lib/types.ts +89 -0
- package/src/lib/utils.ts +135 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,1463 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ClipboardEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
13
|
+
import { Button } from "@/components/ui/button";
|
|
14
|
+
import { Badge } from "@/components/ui/badge";
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
} from "@/components/ui/dialog";
|
|
21
|
+
import {
|
|
22
|
+
cn,
|
|
23
|
+
getAgentEmoji,
|
|
24
|
+
formatFullTime,
|
|
25
|
+
getWaitingDuration,
|
|
26
|
+
} from "@/lib/utils";
|
|
27
|
+
import {
|
|
28
|
+
fetchAgentTimeline,
|
|
29
|
+
fetchGroupTimeline,
|
|
30
|
+
fetchGroupMembers,
|
|
31
|
+
fetchAgentDisplayNames,
|
|
32
|
+
setAgentDisplayName,
|
|
33
|
+
setGroupName,
|
|
34
|
+
submitResponse,
|
|
35
|
+
cancelRequest,
|
|
36
|
+
batchRespond,
|
|
37
|
+
type CueRequest,
|
|
38
|
+
type CueResponse,
|
|
39
|
+
type AgentTimelineItem,
|
|
40
|
+
} from "@/lib/actions";
|
|
41
|
+
import { ChevronLeft } from "lucide-react";
|
|
42
|
+
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
|
43
|
+
import { PayloadCard } from "@/components/payload-card";
|
|
44
|
+
import { ChatComposer } from "@/components/chat-composer";
|
|
45
|
+
|
|
46
|
+
type MentionDraft = {
|
|
47
|
+
userId: string;
|
|
48
|
+
start: number;
|
|
49
|
+
length: number;
|
|
50
|
+
display: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
interface ChatViewProps {
|
|
54
|
+
type: "agent" | "group";
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
onBack?: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ChatView({ type, id, name, onBack }: ChatViewProps) {
|
|
61
|
+
const [timeline, setTimeline] = useState<AgentTimelineItem[]>([]);
|
|
62
|
+
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
63
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
64
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
65
|
+
const [members, setMembers] = useState<string[]>([]);
|
|
66
|
+
const [agentNameMap, setAgentNameMap] = useState<Record<string, string>>({});
|
|
67
|
+
const [editingTitle, setEditingTitle] = useState(false);
|
|
68
|
+
const [titleDraft, setTitleDraft] = useState("");
|
|
69
|
+
const [groupTitle, setGroupTitle] = useState(name);
|
|
70
|
+
const [input, setInput] = useState("");
|
|
71
|
+
const [busy, setBusy] = useState(false);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
74
|
+
const [images, setImages] = useState<
|
|
75
|
+
{ mime_type: string; base64_data: string }[]
|
|
76
|
+
>([]);
|
|
77
|
+
const imagesRef = useRef<{ mime_type: string; base64_data: string }[]>([]);
|
|
78
|
+
const [previewImage, setPreviewImage] = useState<
|
|
79
|
+
{ mime_type: string; base64_data: string } | null
|
|
80
|
+
>(null);
|
|
81
|
+
|
|
82
|
+
const [draftMentions, setDraftMentions] = useState<MentionDraft[]>([]);
|
|
83
|
+
const [mentionOpen, setMentionOpen] = useState(false);
|
|
84
|
+
const [mentionQuery, setMentionQuery] = useState("");
|
|
85
|
+
const [mentionActive, setMentionActive] = useState(0);
|
|
86
|
+
const [mentionAtIndex, setMentionAtIndex] = useState<number | null>(null);
|
|
87
|
+
|
|
88
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
89
|
+
const inputWrapRef = useRef<HTMLDivElement>(null);
|
|
90
|
+
const mentionListRef = useRef<HTMLDivElement>(null);
|
|
91
|
+
const mentionPopoverRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
93
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
94
|
+
|
|
95
|
+
const [mentionPos, setMentionPos] = useState<{ left: number; top: number } | null>(
|
|
96
|
+
null
|
|
97
|
+
);
|
|
98
|
+
const prevMentionQueryRef = useRef<string>("");
|
|
99
|
+
const prevMentionOpenRef = useRef<boolean>(false);
|
|
100
|
+
const shouldAutoScrollMentionRef = useRef<boolean>(false);
|
|
101
|
+
const mentionScrollTopRef = useRef<number>(0);
|
|
102
|
+
const pointerInMentionRef = useRef<boolean>(false);
|
|
103
|
+
|
|
104
|
+
const nextCursorRef = useRef<string | null>(null);
|
|
105
|
+
const loadingMoreRef = useRef(false);
|
|
106
|
+
|
|
107
|
+
const PAGE_SIZE = 30;
|
|
108
|
+
|
|
109
|
+
const IMAGE_MAX_DIM = 1600;
|
|
110
|
+
const IMAGE_COMPRESS_QUALITY = 0.82;
|
|
111
|
+
const IMAGE_COMPRESS_THRESHOLD_BYTES = 1_200_000;
|
|
112
|
+
|
|
113
|
+
const readAsDataUrl = (file: Blob) =>
|
|
114
|
+
new Promise<string>((resolve, reject) => {
|
|
115
|
+
const reader = new FileReader();
|
|
116
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
117
|
+
reader.onerror = () => reject(new Error("read failed"));
|
|
118
|
+
reader.readAsDataURL(file);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const fileToImage = (file: File) =>
|
|
122
|
+
new Promise<HTMLImageElement>((resolve, reject) => {
|
|
123
|
+
const url = URL.createObjectURL(file);
|
|
124
|
+
const img = new Image();
|
|
125
|
+
img.onload = () => {
|
|
126
|
+
URL.revokeObjectURL(url);
|
|
127
|
+
resolve(img);
|
|
128
|
+
};
|
|
129
|
+
img.onerror = () => {
|
|
130
|
+
URL.revokeObjectURL(url);
|
|
131
|
+
reject(new Error("image load failed"));
|
|
132
|
+
};
|
|
133
|
+
img.src = url;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const maybeCompressImageFile = async (file: File) => {
|
|
137
|
+
const inputType = (file.type || "").trim();
|
|
138
|
+
const shouldTryCompress =
|
|
139
|
+
file.size >= IMAGE_COMPRESS_THRESHOLD_BYTES ||
|
|
140
|
+
!inputType.startsWith("image/") ||
|
|
141
|
+
inputType === "image/png";
|
|
142
|
+
|
|
143
|
+
if (!shouldTryCompress) return { blob: file as Blob, mime: inputType };
|
|
144
|
+
|
|
145
|
+
const img = await fileToImage(file);
|
|
146
|
+
const w = img.naturalWidth || img.width;
|
|
147
|
+
const h = img.naturalHeight || img.height;
|
|
148
|
+
if (!w || !h) return { blob: file as Blob, mime: inputType };
|
|
149
|
+
|
|
150
|
+
const scale = Math.min(1, IMAGE_MAX_DIM / Math.max(w, h));
|
|
151
|
+
const outW = Math.max(1, Math.round(w * scale));
|
|
152
|
+
const outH = Math.max(1, Math.round(h * scale));
|
|
153
|
+
|
|
154
|
+
const canvas = document.createElement("canvas");
|
|
155
|
+
canvas.width = outW;
|
|
156
|
+
canvas.height = outH;
|
|
157
|
+
const ctx = canvas.getContext("2d");
|
|
158
|
+
if (!ctx) return { blob: file as Blob, mime: inputType };
|
|
159
|
+
ctx.drawImage(img, 0, 0, outW, outH);
|
|
160
|
+
|
|
161
|
+
const outMime = inputType === "image/webp" ? "image/webp" : "image/jpeg";
|
|
162
|
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
163
|
+
canvas.toBlob(
|
|
164
|
+
(b) => (b ? resolve(b) : reject(new Error("toBlob failed"))),
|
|
165
|
+
outMime,
|
|
166
|
+
IMAGE_COMPRESS_QUALITY
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (blob.size >= file.size) return { blob: file as Blob, mime: inputType };
|
|
171
|
+
return { blob, mime: outMime };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const fileToInlineImage = async (file: File) => {
|
|
175
|
+
const { blob, mime } = await maybeCompressImageFile(file);
|
|
176
|
+
const dataUrl = await readAsDataUrl(blob);
|
|
177
|
+
const comma = dataUrl.indexOf(",");
|
|
178
|
+
if (comma < 0) throw new Error("invalid data url");
|
|
179
|
+
const header = dataUrl.slice(0, comma);
|
|
180
|
+
const base64 = dataUrl.slice(comma + 1);
|
|
181
|
+
const m = /data:([^;]+);base64/i.exec(header);
|
|
182
|
+
const rawMime = (m?.[1] || mime || file.type || "").trim();
|
|
183
|
+
const finalMime = rawMime.startsWith("image/") ? rawMime : "image/png";
|
|
184
|
+
if (!base64 || base64.length < 16) throw new Error("empty base64");
|
|
185
|
+
return { mime_type: finalMime, base64_data: base64 };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
imagesRef.current = images;
|
|
190
|
+
}, [images]);
|
|
191
|
+
|
|
192
|
+
const titleDisplay = useMemo(() => {
|
|
193
|
+
if (type === "agent") return agentNameMap[id] || id;
|
|
194
|
+
return groupTitle;
|
|
195
|
+
}, [agentNameMap, groupTitle, id, type]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (type !== "group") return;
|
|
199
|
+
setGroupTitle(name);
|
|
200
|
+
}, [name, type]);
|
|
201
|
+
|
|
202
|
+
const keyForItem = (item: AgentTimelineItem) => {
|
|
203
|
+
return item.item_type === "request"
|
|
204
|
+
? `req:${item.request.request_id}`
|
|
205
|
+
: `resp:${item.response.id}`;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handlePaste = useCallback(
|
|
209
|
+
async (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
210
|
+
const cd = e.clipboardData;
|
|
211
|
+
if (!cd) return;
|
|
212
|
+
|
|
213
|
+
const imageFilesFromItems: File[] = [];
|
|
214
|
+
for (const it of Array.from(cd.items || [])) {
|
|
215
|
+
if (it.kind !== "file") continue;
|
|
216
|
+
const f = it.getAsFile();
|
|
217
|
+
if (!f) continue;
|
|
218
|
+
const itemType = (it.type || "").trim();
|
|
219
|
+
const fileType = (f.type || "").trim();
|
|
220
|
+
const isImage =
|
|
221
|
+
(itemType && itemType.startsWith("image/")) ||
|
|
222
|
+
(fileType && fileType.startsWith("image/"));
|
|
223
|
+
if (isImage) imageFilesFromItems.push(f);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Prefer items over files. Some environments expose the same image in both,
|
|
227
|
+
// and the metadata differs enough that naive dedupe fails.
|
|
228
|
+
const imageFiles: File[] =
|
|
229
|
+
imageFilesFromItems.length > 0
|
|
230
|
+
? imageFilesFromItems
|
|
231
|
+
: Array.from(cd.files || []).filter((f) => (f?.type || "").startsWith("image/"));
|
|
232
|
+
|
|
233
|
+
if (imageFiles.length === 0) {
|
|
234
|
+
setNotice("No pasteable images detected (make sure your clipboard contains an image)");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// If images are present, treat this paste as an image attach.
|
|
239
|
+
// Prevent unexpected text insertion (some browsers may paste placeholder text).
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const nextImages: { mime_type: string; base64_data: string }[] = [];
|
|
244
|
+
const failures: string[] = [];
|
|
245
|
+
const seen = new Set<string>();
|
|
246
|
+
|
|
247
|
+
const fingerprint = (img: { mime_type: string; base64_data: string }) => {
|
|
248
|
+
const head = img.base64_data.slice(0, 64);
|
|
249
|
+
return `${img.mime_type}|${img.base64_data.length}|${head}`;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
for (const f of imageFiles) {
|
|
253
|
+
try {
|
|
254
|
+
const img = await fileToInlineImage(f);
|
|
255
|
+
const key = fingerprint(img);
|
|
256
|
+
if (seen.has(key)) continue;
|
|
257
|
+
seen.add(key);
|
|
258
|
+
nextImages.push(img);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
261
|
+
failures.push(msg || "unknown error");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (nextImages.length > 0) {
|
|
266
|
+
setImages((prev) => [...prev, ...nextImages]);
|
|
267
|
+
if (failures.length > 0) {
|
|
268
|
+
setNotice(
|
|
269
|
+
`Added ${nextImages.length} image(s) from paste; failed ${failures.length}: ${failures[0]}`
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
setNotice(`Added ${nextImages.length} image(s) from paste`);
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
setNotice(`Detected images but failed to parse: ${failures[0] || "unknown error"}`);
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
279
|
+
setNotice(`Failed to read clipboard image: ${msg || "unknown error"}`);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
[fileToInlineImage]
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const beginEditTitle = () => {
|
|
286
|
+
setEditingTitle(true);
|
|
287
|
+
setTitleDraft(type === "agent" ? agentNameMap[id] || id : groupTitle);
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
const el = document.getElementById("chat-title-input");
|
|
290
|
+
if (el instanceof HTMLInputElement) el.focus();
|
|
291
|
+
}, 0);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const commitEditTitle = async () => {
|
|
295
|
+
const next = titleDraft.trim();
|
|
296
|
+
setEditingTitle(false);
|
|
297
|
+
if (!next) return;
|
|
298
|
+
if (type === "agent") {
|
|
299
|
+
if (next === (agentNameMap[id] || id)) return;
|
|
300
|
+
await setAgentDisplayName(id, next);
|
|
301
|
+
setAgentNameMap((prev) => ({ ...prev, [id]: next }));
|
|
302
|
+
window.dispatchEvent(
|
|
303
|
+
new CustomEvent("cuehub:agentDisplayNameUpdated", {
|
|
304
|
+
detail: { agentId: id, displayName: next },
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (next === groupTitle) return;
|
|
310
|
+
await setGroupName(id, next);
|
|
311
|
+
setGroupTitle(next);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
nextCursorRef.current = nextCursor;
|
|
316
|
+
}, [nextCursor]);
|
|
317
|
+
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
loadingMoreRef.current = loadingMore;
|
|
320
|
+
}, [loadingMore]);
|
|
321
|
+
|
|
322
|
+
const requestsById = useMemo(() => {
|
|
323
|
+
const map = new Map<string, CueRequest>();
|
|
324
|
+
for (const item of timeline) {
|
|
325
|
+
if (item.item_type === "request") {
|
|
326
|
+
map.set(item.request.request_id, item.request);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return map;
|
|
330
|
+
}, [timeline]);
|
|
331
|
+
|
|
332
|
+
const pendingRequests = useMemo(() => {
|
|
333
|
+
const list: CueRequest[] = [];
|
|
334
|
+
for (const req of requestsById.values()) {
|
|
335
|
+
if (req.status === "PENDING") {
|
|
336
|
+
list.push(req);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return list;
|
|
340
|
+
}, [requestsById]);
|
|
341
|
+
|
|
342
|
+
const mentionCandidates = useMemo(() => {
|
|
343
|
+
if (type !== "group") return [];
|
|
344
|
+
const q = mentionQuery.trim().toLowerCase();
|
|
345
|
+
|
|
346
|
+
const base = members
|
|
347
|
+
.filter((agentId) => {
|
|
348
|
+
if (!q) return true;
|
|
349
|
+
const label = (agentNameMap[agentId] || agentId).toLowerCase();
|
|
350
|
+
return label.includes(q) || agentId.toLowerCase().includes(q);
|
|
351
|
+
})
|
|
352
|
+
.sort((a, b) => {
|
|
353
|
+
const la = agentNameMap[a] || a;
|
|
354
|
+
const lb = agentNameMap[b] || b;
|
|
355
|
+
return la.localeCompare(lb);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const all = q.length === 0 ? ["all", ...base] : base;
|
|
359
|
+
return all;
|
|
360
|
+
}, [agentNameMap, members, mentionQuery, type]);
|
|
361
|
+
|
|
362
|
+
const mentionScrollable = mentionCandidates.length > 5;
|
|
363
|
+
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
const loadNames = async () => {
|
|
366
|
+
try {
|
|
367
|
+
const ids = type === "group" ? Array.from(new Set([id, ...members])) : [id];
|
|
368
|
+
const map = await fetchAgentDisplayNames(ids);
|
|
369
|
+
setAgentNameMap(map);
|
|
370
|
+
} catch {
|
|
371
|
+
// ignore
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
void loadNames();
|
|
376
|
+
}, [id, members, type]);
|
|
377
|
+
|
|
378
|
+
const closeMention = () => {
|
|
379
|
+
setMentionQuery("");
|
|
380
|
+
setMentionOpen(false);
|
|
381
|
+
setMentionActive(0);
|
|
382
|
+
setMentionPos(null);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const insertMentionAtCursor = (userId: string, name: string) => {
|
|
386
|
+
const el = textareaRef.current;
|
|
387
|
+
if (!el) return;
|
|
388
|
+
const text = input;
|
|
389
|
+
|
|
390
|
+
const cursorStart = el.selectionStart ?? text.length;
|
|
391
|
+
const cursorEnd = el.selectionEnd ?? cursorStart;
|
|
392
|
+
|
|
393
|
+
const display = name === "all" ? "@all" : `@${name}`;
|
|
394
|
+
const insertion = `${display} `;
|
|
395
|
+
|
|
396
|
+
const before = text.slice(0, cursorStart);
|
|
397
|
+
const after = text.slice(cursorEnd);
|
|
398
|
+
const nextText = before + insertion + after;
|
|
399
|
+
const delta = insertion.length - (cursorEnd - cursorStart);
|
|
400
|
+
|
|
401
|
+
const start = cursorStart;
|
|
402
|
+
const mention: MentionDraft = {
|
|
403
|
+
userId,
|
|
404
|
+
start,
|
|
405
|
+
length: display.length,
|
|
406
|
+
display,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
setInput(nextText);
|
|
410
|
+
setDraftMentions((prev) => {
|
|
411
|
+
const shifted = shiftMentions(cursorEnd, delta, prev);
|
|
412
|
+
return [...shifted, mention].sort((a, b) => a.start - b.start);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
requestAnimationFrame(() => {
|
|
416
|
+
const cur = textareaRef.current;
|
|
417
|
+
if (!cur) return;
|
|
418
|
+
const pos = start + insertion.length;
|
|
419
|
+
cur.focus();
|
|
420
|
+
cur.setSelectionRange(pos, pos);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
closeMention();
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const pasteToInput = (text: string, mode: "replace" | "append" = "replace") => {
|
|
427
|
+
const cleaned = (text || "").trim();
|
|
428
|
+
if (!cleaned) return;
|
|
429
|
+
|
|
430
|
+
const next = (() => {
|
|
431
|
+
if (mode !== "append") return cleaned;
|
|
432
|
+
|
|
433
|
+
const lines = input
|
|
434
|
+
.split(/\r?\n/)
|
|
435
|
+
.map((s) => s.trim())
|
|
436
|
+
.filter(Boolean);
|
|
437
|
+
const exists = new Set(lines);
|
|
438
|
+
if (exists.has(cleaned)) return input;
|
|
439
|
+
|
|
440
|
+
const base = input.trim() ? input.replace(/\s+$/, "") : "";
|
|
441
|
+
return base ? base + "\n" + cleaned : cleaned;
|
|
442
|
+
})();
|
|
443
|
+
|
|
444
|
+
setInput(next);
|
|
445
|
+
setDraftMentions((prev) => reconcileMentionsByDisplay(next, prev));
|
|
446
|
+
closeMention();
|
|
447
|
+
|
|
448
|
+
requestAnimationFrame(() => {
|
|
449
|
+
const el = textareaRef.current;
|
|
450
|
+
if (!el) return;
|
|
451
|
+
el.focus();
|
|
452
|
+
const pos = el.value.length;
|
|
453
|
+
el.setSelectionRange(pos, pos);
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const getCaretCoords = (el: HTMLTextAreaElement, pos: number) => {
|
|
458
|
+
const style = window.getComputedStyle(el);
|
|
459
|
+
const div = document.createElement("div");
|
|
460
|
+
div.style.position = "absolute";
|
|
461
|
+
div.style.visibility = "hidden";
|
|
462
|
+
div.style.whiteSpace = "pre-wrap";
|
|
463
|
+
div.style.wordWrap = "break-word";
|
|
464
|
+
|
|
465
|
+
// mirror styles that affect layout
|
|
466
|
+
div.style.font = style.font;
|
|
467
|
+
div.style.letterSpacing = style.letterSpacing;
|
|
468
|
+
div.style.textTransform = style.textTransform;
|
|
469
|
+
div.style.padding = style.padding;
|
|
470
|
+
div.style.border = style.border;
|
|
471
|
+
div.style.boxSizing = style.boxSizing;
|
|
472
|
+
div.style.lineHeight = style.lineHeight;
|
|
473
|
+
div.style.width = style.width;
|
|
474
|
+
|
|
475
|
+
const value = el.value;
|
|
476
|
+
div.textContent = value.substring(0, pos);
|
|
477
|
+
|
|
478
|
+
const span = document.createElement("span");
|
|
479
|
+
span.textContent = value.substring(pos) || ".";
|
|
480
|
+
div.appendChild(span);
|
|
481
|
+
|
|
482
|
+
document.body.appendChild(div);
|
|
483
|
+
const rect = span.getBoundingClientRect();
|
|
484
|
+
const divRect = div.getBoundingClientRect();
|
|
485
|
+
document.body.removeChild(div);
|
|
486
|
+
|
|
487
|
+
return { left: rect.left - divRect.left, top: rect.top - divRect.top };
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const updateMentionPosition = () => {
|
|
491
|
+
const ta = textareaRef.current;
|
|
492
|
+
const wrap = inputWrapRef.current;
|
|
493
|
+
if (!ta || !wrap) return;
|
|
494
|
+
const cursor = ta.selectionStart ?? ta.value.length;
|
|
495
|
+
const caret = getCaretCoords(ta, cursor);
|
|
496
|
+
const taRect = ta.getBoundingClientRect();
|
|
497
|
+
const wrapRect = wrap.getBoundingClientRect();
|
|
498
|
+
const left = taRect.left + caret.left - wrapRect.left;
|
|
499
|
+
const top = taRect.top + caret.top - wrapRect.top;
|
|
500
|
+
setMentionPos({ left, top });
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
if (!mentionOpen) return;
|
|
505
|
+
if (!mentionPos) return;
|
|
506
|
+
|
|
507
|
+
requestAnimationFrame(() => {
|
|
508
|
+
// clamp within container
|
|
509
|
+
const wrap = inputWrapRef.current;
|
|
510
|
+
const pop = mentionPopoverRef.current;
|
|
511
|
+
if (!wrap || !pop) return;
|
|
512
|
+
const wrapW = wrap.clientWidth;
|
|
513
|
+
const wrapH = wrap.clientHeight;
|
|
514
|
+
const popW = pop.offsetWidth;
|
|
515
|
+
const popH = pop.offsetHeight;
|
|
516
|
+
const padding = 12;
|
|
517
|
+
const clampedLeft = Math.min(
|
|
518
|
+
Math.max(mentionPos.left, padding),
|
|
519
|
+
Math.max(padding, wrapW - popW - padding)
|
|
520
|
+
);
|
|
521
|
+
const clampedTop = Math.min(
|
|
522
|
+
Math.max(mentionPos.top, padding),
|
|
523
|
+
Math.max(padding, wrapH - popH - padding)
|
|
524
|
+
);
|
|
525
|
+
if (clampedLeft !== mentionPos.left || clampedTop !== mentionPos.top) {
|
|
526
|
+
setMentionPos((p) =>
|
|
527
|
+
p ? { ...p, left: clampedLeft, top: clampedTop } : p
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}, [mentionOpen, mentionPos]);
|
|
532
|
+
|
|
533
|
+
// Note: we intentionally do NOT restore scrollTop on every render.
|
|
534
|
+
// This avoids "scroll jumping". We only set scrollTop when query changes.
|
|
535
|
+
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
if (mentionOpen) return;
|
|
538
|
+
prevMentionOpenRef.current = false;
|
|
539
|
+
prevMentionQueryRef.current = "";
|
|
540
|
+
}, [mentionOpen]);
|
|
541
|
+
|
|
542
|
+
const updateMentionFromCursor = (text: string) => {
|
|
543
|
+
if (type !== "group") return;
|
|
544
|
+
// While the user is interacting with the mention popover (scrollbar/scroll),
|
|
545
|
+
// do not recompute mention state; it can reset query/active and jump scroll.
|
|
546
|
+
if (pointerInMentionRef.current) return;
|
|
547
|
+
const el = textareaRef.current;
|
|
548
|
+
if (!el) return;
|
|
549
|
+
const cursor = el.selectionStart ?? text.length;
|
|
550
|
+
const at = text.lastIndexOf("@", cursor - 1);
|
|
551
|
+
if (at < 0) {
|
|
552
|
+
closeMention();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const before = at === 0 ? "" : text[at - 1];
|
|
557
|
+
const allowedBefore =
|
|
558
|
+
at === 0 ||
|
|
559
|
+
/\s/.test(before) ||
|
|
560
|
+
/[\(\[\{\<\>\-—_,.,。!?!?:;;“”"'、]/.test(before);
|
|
561
|
+
|
|
562
|
+
// avoid email/identifier like a@b
|
|
563
|
+
if (!allowedBefore) {
|
|
564
|
+
closeMention();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const after = text.slice(at + 1, cursor);
|
|
569
|
+
if (after.includes(" ") || after.includes("\n") || after.includes("\t")) {
|
|
570
|
+
closeMention();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// If nothing changed, don't touch state (prevents scroll reset / active reset)
|
|
575
|
+
if (mentionOpen && mentionAtIndex === at && mentionQuery === after) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// email heuristic: something@something.com while cursor after @ part
|
|
580
|
+
if (/[\w.+-]+@[\w-]+\.[\w.-]+/.test(text.slice(Math.max(0, at - 32), cursor + 32))) {
|
|
581
|
+
closeMention();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
setMentionAtIndex(at);
|
|
586
|
+
setMentionQuery(after);
|
|
587
|
+
setMentionOpen(true);
|
|
588
|
+
setMentionActive(0);
|
|
589
|
+
requestAnimationFrame(() => {
|
|
590
|
+
updateMentionPosition();
|
|
591
|
+
});
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
useEffect(() => {
|
|
595
|
+
if (!mentionOpen) return;
|
|
596
|
+
const el = mentionListRef.current;
|
|
597
|
+
if (!el) return;
|
|
598
|
+
|
|
599
|
+
// Reset only when opening, or when query actually changes (avoid stealing scroll)
|
|
600
|
+
const queryChanged = prevMentionQueryRef.current !== mentionQuery;
|
|
601
|
+
if (queryChanged) {
|
|
602
|
+
// filtering should jump to the first match like WeChat
|
|
603
|
+
shouldAutoScrollMentionRef.current = true;
|
|
604
|
+
setMentionActive(0);
|
|
605
|
+
el.scrollTop = 0;
|
|
606
|
+
mentionScrollTopRef.current = 0;
|
|
607
|
+
}
|
|
608
|
+
prevMentionOpenRef.current = true;
|
|
609
|
+
prevMentionQueryRef.current = mentionQuery;
|
|
610
|
+
}, [mentionOpen, mentionQuery]);
|
|
611
|
+
|
|
612
|
+
const shiftMentions = (from: number, delta: number, list: MentionDraft[]) => {
|
|
613
|
+
return list.map((m) => {
|
|
614
|
+
if (m.start >= from) return { ...m, start: m.start + delta };
|
|
615
|
+
return m;
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const reconcileMentionsByDisplay = (text: string, list: MentionDraft[]) => {
|
|
620
|
+
const used = new Set<number>();
|
|
621
|
+
const next: MentionDraft[] = [];
|
|
622
|
+
for (const m of list) {
|
|
623
|
+
const windowStart = Math.max(0, m.start - 8);
|
|
624
|
+
const windowEnd = Math.min(text.length, m.start + 32);
|
|
625
|
+
const windowText = text.slice(windowStart, windowEnd);
|
|
626
|
+
const localIdx = windowText.indexOf(m.display);
|
|
627
|
+
let idx = -1;
|
|
628
|
+
if (localIdx >= 0) idx = windowStart + localIdx;
|
|
629
|
+
if (idx < 0) idx = text.indexOf(m.display);
|
|
630
|
+
if (idx >= 0 && !used.has(idx)) {
|
|
631
|
+
used.add(idx);
|
|
632
|
+
next.push({ ...m, start: idx, length: m.display.length });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
next.sort((a, b) => a.start - b.start);
|
|
636
|
+
return next;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const insertMention = (userId: string, name: string) => {
|
|
640
|
+
const el = textareaRef.current;
|
|
641
|
+
if (!el) return;
|
|
642
|
+
const text = input;
|
|
643
|
+
const cursor = el.selectionStart ?? text.length;
|
|
644
|
+
const at = mentionAtIndex;
|
|
645
|
+
if (at === null) return;
|
|
646
|
+
|
|
647
|
+
const display = name === "all" ? "@all" : `@${name}`;
|
|
648
|
+
const insertion = `${display} `;
|
|
649
|
+
const before = text.slice(0, at);
|
|
650
|
+
const after = text.slice(cursor);
|
|
651
|
+
const nextText = before + insertion + after;
|
|
652
|
+
const delta = insertion.length - (cursor - at);
|
|
653
|
+
|
|
654
|
+
const start = at;
|
|
655
|
+
const mention: MentionDraft = {
|
|
656
|
+
userId,
|
|
657
|
+
start,
|
|
658
|
+
length: display.length,
|
|
659
|
+
display,
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
setInput(nextText);
|
|
663
|
+
setDraftMentions((prev) => {
|
|
664
|
+
const shifted = shiftMentions(cursor, delta, prev);
|
|
665
|
+
return [...shifted, mention].sort((a, b) => a.start - b.start);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
requestAnimationFrame(() => {
|
|
669
|
+
const cur = textareaRef.current;
|
|
670
|
+
if (!cur) return;
|
|
671
|
+
const pos = start + insertion.length;
|
|
672
|
+
cur.focus();
|
|
673
|
+
cur.setSelectionRange(pos, pos);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
closeMention();
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const fetchPage = async (before: string | null, limit: number) => {
|
|
680
|
+
if (type === "agent") {
|
|
681
|
+
return fetchAgentTimeline(id, before, limit);
|
|
682
|
+
}
|
|
683
|
+
return fetchGroupTimeline(id, before, limit);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const loadInitial = async () => {
|
|
687
|
+
try {
|
|
688
|
+
if (type === "group") {
|
|
689
|
+
const mems = await fetchGroupMembers(id);
|
|
690
|
+
setMembers(mems);
|
|
691
|
+
}
|
|
692
|
+
const { items, nextCursor: cursor } = await fetchPage(null, PAGE_SIZE);
|
|
693
|
+
const asc = [...items].reverse();
|
|
694
|
+
setTimeline(asc);
|
|
695
|
+
setNextCursor(cursor);
|
|
696
|
+
} catch (e) {
|
|
697
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const refreshLatest = async () => {
|
|
702
|
+
try {
|
|
703
|
+
const { items } = await fetchPage(null, PAGE_SIZE);
|
|
704
|
+
const asc = [...items].reverse();
|
|
705
|
+
setTimeline((prev) => {
|
|
706
|
+
const map = new Map<string, AgentTimelineItem>();
|
|
707
|
+
for (const it of prev) map.set(keyForItem(it), it);
|
|
708
|
+
for (const it of asc) map.set(keyForItem(it), it);
|
|
709
|
+
return Array.from(map.values()).sort((a, b) => a.time.localeCompare(b.time));
|
|
710
|
+
});
|
|
711
|
+
} catch (e) {
|
|
712
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
useEffect(() => {
|
|
717
|
+
loadInitial();
|
|
718
|
+
|
|
719
|
+
const tick = () => {
|
|
720
|
+
if (document.visibilityState !== "visible") return;
|
|
721
|
+
void refreshLatest();
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const interval = setInterval(tick, 3000);
|
|
725
|
+
|
|
726
|
+
const onVisibilityChange = () => {
|
|
727
|
+
if (document.visibilityState === "visible") tick();
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
731
|
+
|
|
732
|
+
return () => {
|
|
733
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
734
|
+
clearInterval(interval);
|
|
735
|
+
};
|
|
736
|
+
}, [type, id]);
|
|
737
|
+
|
|
738
|
+
useEffect(() => {
|
|
739
|
+
const el = scrollRef.current;
|
|
740
|
+
if (!el) return;
|
|
741
|
+
|
|
742
|
+
const onScroll = () => {
|
|
743
|
+
const threshold = 60;
|
|
744
|
+
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
|
|
745
|
+
setIsAtBottom(atBottom);
|
|
746
|
+
|
|
747
|
+
// Lazy load: auto-load more when near the top
|
|
748
|
+
if (
|
|
749
|
+
el.scrollTop <= threshold &&
|
|
750
|
+
nextCursorRef.current &&
|
|
751
|
+
!loadingMoreRef.current
|
|
752
|
+
) {
|
|
753
|
+
void loadMore();
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
758
|
+
onScroll();
|
|
759
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
760
|
+
}, []);
|
|
761
|
+
|
|
762
|
+
useEffect(() => {
|
|
763
|
+
const el = scrollRef.current;
|
|
764
|
+
if (!el) return;
|
|
765
|
+
if (!isAtBottom) return;
|
|
766
|
+
el.scrollTop = el.scrollHeight;
|
|
767
|
+
}, [timeline, isAtBottom]);
|
|
768
|
+
|
|
769
|
+
const loadMore = async () => {
|
|
770
|
+
if (!nextCursor) return;
|
|
771
|
+
if (loadingMore) return;
|
|
772
|
+
|
|
773
|
+
const el = scrollRef.current;
|
|
774
|
+
const prevScrollHeight = el?.scrollHeight ?? 0;
|
|
775
|
+
const prevScrollTop = el?.scrollTop ?? 0;
|
|
776
|
+
|
|
777
|
+
setLoadingMore(true);
|
|
778
|
+
try {
|
|
779
|
+
const { items, nextCursor: cursor } = await fetchPage(nextCursor, PAGE_SIZE);
|
|
780
|
+
const asc = [...items].reverse();
|
|
781
|
+
setTimeline((prev) => {
|
|
782
|
+
const merged = [...asc, ...prev];
|
|
783
|
+
const map = new Map<string, AgentTimelineItem>();
|
|
784
|
+
for (const it of merged) map.set(keyForItem(it), it);
|
|
785
|
+
return Array.from(map.values()).sort((a, b) => a.time.localeCompare(b.time));
|
|
786
|
+
});
|
|
787
|
+
setNextCursor(cursor);
|
|
788
|
+
requestAnimationFrame(() => {
|
|
789
|
+
const cur = scrollRef.current;
|
|
790
|
+
if (!cur) return;
|
|
791
|
+
const newScrollHeight = cur.scrollHeight;
|
|
792
|
+
cur.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
|
|
793
|
+
});
|
|
794
|
+
} catch (e) {
|
|
795
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
796
|
+
} finally {
|
|
797
|
+
setLoadingMore(false);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const handleSend = async () => {
|
|
802
|
+
const currentImages = imagesRef.current;
|
|
803
|
+
if (!input.trim() && currentImages.length === 0) return;
|
|
804
|
+
|
|
805
|
+
if (busy) return;
|
|
806
|
+
setBusy(true);
|
|
807
|
+
setError(null);
|
|
808
|
+
|
|
809
|
+
let sent = false;
|
|
810
|
+
|
|
811
|
+
const isPending = (r: CueRequest) => r.status === "PENDING";
|
|
812
|
+
|
|
813
|
+
if (type === "agent") {
|
|
814
|
+
// Direct chat: respond to all pending requests for this agent
|
|
815
|
+
const pendingIds = pendingRequests.filter(isPending).map((r) => r.request_id);
|
|
816
|
+
if (pendingIds.length > 0) {
|
|
817
|
+
const result = await batchRespond(pendingIds, input, currentImages, draftMentions);
|
|
818
|
+
if (!result.success) {
|
|
819
|
+
setError(result.error || "Send failed");
|
|
820
|
+
setBusy(false);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
sent = true;
|
|
824
|
+
}
|
|
825
|
+
} else {
|
|
826
|
+
// Group chat
|
|
827
|
+
const mentionTargets = new Set(
|
|
828
|
+
draftMentions
|
|
829
|
+
.map((m) => m.userId)
|
|
830
|
+
.filter((id) => id && id !== "all")
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
const hasMentions = mentionTargets.size > 0;
|
|
834
|
+
|
|
835
|
+
if (hasMentions) {
|
|
836
|
+
// If there are mentions, only respond to mentioned members
|
|
837
|
+
const targetRequests = pendingRequests.filter(
|
|
838
|
+
(r) => isPending(r) && r.agent_id && mentionTargets.has(r.agent_id)
|
|
839
|
+
);
|
|
840
|
+
if (targetRequests.length > 0) {
|
|
841
|
+
const result = await batchRespond(
|
|
842
|
+
targetRequests.map((r) => r.request_id),
|
|
843
|
+
input,
|
|
844
|
+
images,
|
|
845
|
+
draftMentions
|
|
846
|
+
);
|
|
847
|
+
if (!result.success) {
|
|
848
|
+
setError(result.error || "Send failed");
|
|
849
|
+
setBusy(false);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
sent = true;
|
|
853
|
+
}
|
|
854
|
+
} else {
|
|
855
|
+
// Without mentions, respond to all pending
|
|
856
|
+
const pendingIds = pendingRequests.filter(isPending).map((r) => r.request_id);
|
|
857
|
+
if (pendingIds.length > 0) {
|
|
858
|
+
const result = await batchRespond(pendingIds, input, images, draftMentions);
|
|
859
|
+
if (!result.success) {
|
|
860
|
+
setError(result.error || "Send failed");
|
|
861
|
+
setBusy(false);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
sent = true;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!sent) {
|
|
870
|
+
setError("No pending requests to answer");
|
|
871
|
+
setBusy(false);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
setInput("");
|
|
876
|
+
setImages([]);
|
|
877
|
+
setDraftMentions([]);
|
|
878
|
+
await refreshLatest();
|
|
879
|
+
setBusy(false);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const handleCancel = async (requestId: string) => {
|
|
883
|
+
if (busy) return;
|
|
884
|
+
setBusy(true);
|
|
885
|
+
setError(null);
|
|
886
|
+
const result = await cancelRequest(requestId);
|
|
887
|
+
if (!result.success) {
|
|
888
|
+
setError(result.error || "End failed");
|
|
889
|
+
setBusy(false);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
await refreshLatest();
|
|
893
|
+
setBusy(false);
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const handleReply = async (requestId: string) => {
|
|
897
|
+
const currentImages = imagesRef.current;
|
|
898
|
+
if (!input.trim() && currentImages.length === 0) return;
|
|
899
|
+
if (busy) return;
|
|
900
|
+
setBusy(true);
|
|
901
|
+
setError(null);
|
|
902
|
+
const result = await submitResponse(requestId, input, currentImages, draftMentions);
|
|
903
|
+
if (!result.success) {
|
|
904
|
+
setError(result.error || "Reply failed");
|
|
905
|
+
setBusy(false);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
setInput("");
|
|
909
|
+
setImages([]);
|
|
910
|
+
setDraftMentions([]);
|
|
911
|
+
await refreshLatest();
|
|
912
|
+
setBusy(false);
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
916
|
+
const files = e.target.files;
|
|
917
|
+
if (!files) return;
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
const list = Array.from(files);
|
|
921
|
+
const failures: string[] = [];
|
|
922
|
+
const converted = await Promise.all(
|
|
923
|
+
list.map(async (file) => {
|
|
924
|
+
try {
|
|
925
|
+
return await fileToInlineImage(file);
|
|
926
|
+
} catch (err) {
|
|
927
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
928
|
+
failures.push(msg || "unknown error");
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
})
|
|
932
|
+
);
|
|
933
|
+
const next = converted.filter(Boolean) as { mime_type: string; base64_data: string }[];
|
|
934
|
+
if (next.length > 0) {
|
|
935
|
+
setImages((prev) => [...prev, ...next]);
|
|
936
|
+
if (failures.length > 0) {
|
|
937
|
+
setNotice(`Added ${next.length} image(s); failed ${failures.length}: ${failures[0]}`);
|
|
938
|
+
} else {
|
|
939
|
+
setNotice(`Added ${next.length} image(s)`);
|
|
940
|
+
}
|
|
941
|
+
} else {
|
|
942
|
+
setNotice(`Selected images but failed to parse: ${failures[0] || "unknown error"}`);
|
|
943
|
+
}
|
|
944
|
+
} finally {
|
|
945
|
+
// allow selecting the same file again
|
|
946
|
+
e.target.value = "";
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const hasPendingRequests = pendingRequests.length > 0;
|
|
951
|
+
const canSend =
|
|
952
|
+
!busy &&
|
|
953
|
+
hasPendingRequests &&
|
|
954
|
+
(input.trim().length > 0 || images.length > 0);
|
|
955
|
+
|
|
956
|
+
useEffect(() => {
|
|
957
|
+
if (!notice) return;
|
|
958
|
+
const t = setTimeout(() => setNotice(null), 2200);
|
|
959
|
+
return () => clearTimeout(t);
|
|
960
|
+
}, [notice]);
|
|
961
|
+
|
|
962
|
+
useEffect(() => {
|
|
963
|
+
const el = textareaRef.current;
|
|
964
|
+
if (!el) return;
|
|
965
|
+
// Auto-grow up to ~8 lines; beyond that, keep it scrollable
|
|
966
|
+
el.style.height = "0px";
|
|
967
|
+
const maxPx = 8 * 22; // ~8 lines
|
|
968
|
+
el.style.height = Math.min(el.scrollHeight, maxPx) + "px";
|
|
969
|
+
el.style.overflowY = el.scrollHeight > maxPx ? "auto" : "hidden";
|
|
970
|
+
}, [input]);
|
|
971
|
+
|
|
972
|
+
const formatDivider = (dateStr: string) => {
|
|
973
|
+
const d = new Date(dateStr + "Z");
|
|
974
|
+
return d.toLocaleString("zh-CN", {
|
|
975
|
+
month: "2-digit",
|
|
976
|
+
day: "2-digit",
|
|
977
|
+
hour: "2-digit",
|
|
978
|
+
minute: "2-digit",
|
|
979
|
+
timeZone: "Asia/Shanghai",
|
|
980
|
+
});
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
return (
|
|
984
|
+
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
|
985
|
+
{notice && (
|
|
986
|
+
<div className="pointer-events-none fixed right-5 top-5 z-50">
|
|
987
|
+
<div className="rounded-2xl border bg-background/95 px-3 py-2 text-sm shadow-lg backdrop-blur">
|
|
988
|
+
{notice}
|
|
989
|
+
</div>
|
|
990
|
+
</div>
|
|
991
|
+
)}
|
|
992
|
+
{/* Header */}
|
|
993
|
+
<div className="flex items-center gap-3 border-b border-border/60 px-4 py-3 glass-surface-soft glass-noise">
|
|
994
|
+
{onBack && (
|
|
995
|
+
<Button variant="ghost" size="icon" onClick={onBack}>
|
|
996
|
+
<ChevronLeft className="h-5 w-5" />
|
|
997
|
+
</Button>
|
|
998
|
+
)}
|
|
999
|
+
<span className="text-2xl">
|
|
1000
|
+
{type === "group" ? "👥" : getAgentEmoji(id)}
|
|
1001
|
+
</span>
|
|
1002
|
+
<div className="flex-1">
|
|
1003
|
+
{editingTitle ? (
|
|
1004
|
+
<input
|
|
1005
|
+
id="chat-title-input"
|
|
1006
|
+
value={titleDraft}
|
|
1007
|
+
onChange={(e) => setTitleDraft(e.target.value)}
|
|
1008
|
+
onKeyDown={(e) => {
|
|
1009
|
+
if (e.key === "Enter") {
|
|
1010
|
+
e.preventDefault();
|
|
1011
|
+
void commitEditTitle();
|
|
1012
|
+
}
|
|
1013
|
+
if (e.key === "Escape") {
|
|
1014
|
+
e.preventDefault();
|
|
1015
|
+
setEditingTitle(false);
|
|
1016
|
+
}
|
|
1017
|
+
}}
|
|
1018
|
+
onBlur={() => {
|
|
1019
|
+
void commitEditTitle();
|
|
1020
|
+
}}
|
|
1021
|
+
className="w-60 max-w-full rounded-xl border border-white/45 bg-white/55 px-2.5 py-1.5 text-sm font-semibold outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
|
1022
|
+
/>
|
|
1023
|
+
) : (
|
|
1024
|
+
<h2
|
|
1025
|
+
className={cn("font-semibold", "cursor-text")}
|
|
1026
|
+
onDoubleClick={beginEditTitle}
|
|
1027
|
+
title="Double-click to rename"
|
|
1028
|
+
>
|
|
1029
|
+
{titleDisplay}
|
|
1030
|
+
</h2>
|
|
1031
|
+
)}
|
|
1032
|
+
{type === "group" && members.length > 0 && (
|
|
1033
|
+
<p className="text-xs text-muted-foreground">
|
|
1034
|
+
{members.length} member{members.length === 1 ? "" : "s"}
|
|
1035
|
+
</p>
|
|
1036
|
+
)}
|
|
1037
|
+
</div>
|
|
1038
|
+
{type === "group" && (
|
|
1039
|
+
<span className="hidden sm:inline text-[11px] text-muted-foreground select-none mr-1" title="Type @ to mention members">
|
|
1040
|
+
@ mention
|
|
1041
|
+
</span>
|
|
1042
|
+
)}
|
|
1043
|
+
</div>
|
|
1044
|
+
|
|
1045
|
+
{/* Messages */}
|
|
1046
|
+
<ScrollArea
|
|
1047
|
+
className={cn(
|
|
1048
|
+
"flex-1 min-h-0 p-2 sm:p-4",
|
|
1049
|
+
"bg-transparent"
|
|
1050
|
+
)}
|
|
1051
|
+
ref={scrollRef}
|
|
1052
|
+
>
|
|
1053
|
+
<div className="mx-auto flex w-full max-w-230 flex-col gap-6 pb-36 overflow-x-hidden">
|
|
1054
|
+
{loadingMore && (
|
|
1055
|
+
<div className="flex justify-center py-1">
|
|
1056
|
+
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground shadow-sm">
|
|
1057
|
+
Loading...
|
|
1058
|
+
</span>
|
|
1059
|
+
</div>
|
|
1060
|
+
)}
|
|
1061
|
+
{nextCursor && (
|
|
1062
|
+
<div className="flex justify-center">
|
|
1063
|
+
<Button
|
|
1064
|
+
variant="outline"
|
|
1065
|
+
size="sm"
|
|
1066
|
+
onClick={loadMore}
|
|
1067
|
+
disabled={loadingMore}
|
|
1068
|
+
>
|
|
1069
|
+
{loadingMore ? "Loading..." : "Load more"}
|
|
1070
|
+
</Button>
|
|
1071
|
+
</div>
|
|
1072
|
+
)}
|
|
1073
|
+
|
|
1074
|
+
{/* Timeline: all messages sorted by time (paged) */}
|
|
1075
|
+
{timeline.map((item, idx) => {
|
|
1076
|
+
const prev = idx > 0 ? timeline[idx - 1] : null;
|
|
1077
|
+
|
|
1078
|
+
const curTime = item.time;
|
|
1079
|
+
const prevTime = prev?.time;
|
|
1080
|
+
const showDivider = (() => {
|
|
1081
|
+
if (!prevTime) return true;
|
|
1082
|
+
const a = new Date(prevTime + "Z").getTime();
|
|
1083
|
+
const b = new Date(curTime + "Z").getTime();
|
|
1084
|
+
return b - a > 5 * 60 * 1000;
|
|
1085
|
+
})();
|
|
1086
|
+
|
|
1087
|
+
const divider = showDivider ? (
|
|
1088
|
+
<div key={`div-${curTime}-${idx}`} className="flex justify-center py-1">
|
|
1089
|
+
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground shadow-sm">
|
|
1090
|
+
{formatDivider(curTime)}
|
|
1091
|
+
</span>
|
|
1092
|
+
</div>
|
|
1093
|
+
) : null;
|
|
1094
|
+
|
|
1095
|
+
if (item.item_type === "request") {
|
|
1096
|
+
const prevSameSender =
|
|
1097
|
+
prev?.item_type === "request" &&
|
|
1098
|
+
prev.request.agent_id === item.request.agent_id;
|
|
1099
|
+
|
|
1100
|
+
const prevWasRequest = prev?.item_type === "request";
|
|
1101
|
+
const compact = prevWasRequest && prevSameSender;
|
|
1102
|
+
|
|
1103
|
+
return (
|
|
1104
|
+
<div key={`wrap-req-${item.request.request_id}`} className={cn(compact ? "-mt-1" : "")}>
|
|
1105
|
+
{divider}
|
|
1106
|
+
<MessageBubble
|
|
1107
|
+
request={item.request}
|
|
1108
|
+
showAgent={type === "group"}
|
|
1109
|
+
agentNameMap={agentNameMap}
|
|
1110
|
+
showName={!prevSameSender}
|
|
1111
|
+
showAvatar={!prevSameSender}
|
|
1112
|
+
compact={compact}
|
|
1113
|
+
disabled={busy}
|
|
1114
|
+
currentInput={input}
|
|
1115
|
+
isGroup={type === "group"}
|
|
1116
|
+
onPasteChoice={pasteToInput}
|
|
1117
|
+
onMentionAgent={(agentId) => insertMentionAtCursor(agentId, agentId)}
|
|
1118
|
+
onReply={() => handleReply(item.request.request_id)}
|
|
1119
|
+
onCancel={() => handleCancel(item.request.request_id)}
|
|
1120
|
+
/>
|
|
1121
|
+
</div>
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const prevIsResp = prev?.item_type === "response";
|
|
1126
|
+
const compactResp = prevIsResp;
|
|
1127
|
+
|
|
1128
|
+
return (
|
|
1129
|
+
<div key={`wrap-resp-${item.response.id}`} className={cn(compactResp ? "-mt-1" : "")}>
|
|
1130
|
+
{divider}
|
|
1131
|
+
<UserResponseBubble
|
|
1132
|
+
response={item.response}
|
|
1133
|
+
showAvatar={!compactResp}
|
|
1134
|
+
compact={compactResp}
|
|
1135
|
+
onPreview={setPreviewImage}
|
|
1136
|
+
/>
|
|
1137
|
+
</div>
|
|
1138
|
+
);
|
|
1139
|
+
})}
|
|
1140
|
+
|
|
1141
|
+
{timeline.length === 0 && (
|
|
1142
|
+
<div className="flex h-40 items-center justify-center text-muted-foreground">
|
|
1143
|
+
No messages yet
|
|
1144
|
+
</div>
|
|
1145
|
+
)}
|
|
1146
|
+
</div>
|
|
1147
|
+
</ScrollArea>
|
|
1148
|
+
|
|
1149
|
+
{error && (
|
|
1150
|
+
<div className="border-t bg-background px-3 py-2 text-sm text-destructive">
|
|
1151
|
+
{error}
|
|
1152
|
+
</div>
|
|
1153
|
+
)}
|
|
1154
|
+
|
|
1155
|
+
<ChatComposer
|
|
1156
|
+
type={type}
|
|
1157
|
+
onBack={onBack}
|
|
1158
|
+
busy={busy}
|
|
1159
|
+
canSend={canSend}
|
|
1160
|
+
hasPendingRequests={hasPendingRequests}
|
|
1161
|
+
input={input}
|
|
1162
|
+
setInput={setInput}
|
|
1163
|
+
images={images}
|
|
1164
|
+
setImages={setImages}
|
|
1165
|
+
setNotice={setNotice}
|
|
1166
|
+
setPreviewImage={setPreviewImage}
|
|
1167
|
+
handleSend={handleSend}
|
|
1168
|
+
handlePaste={handlePaste}
|
|
1169
|
+
handleImageUpload={handleImageUpload}
|
|
1170
|
+
textareaRef={textareaRef}
|
|
1171
|
+
fileInputRef={fileInputRef}
|
|
1172
|
+
inputWrapRef={inputWrapRef}
|
|
1173
|
+
mentionOpen={mentionOpen}
|
|
1174
|
+
mentionPos={mentionPos}
|
|
1175
|
+
mentionCandidates={mentionCandidates}
|
|
1176
|
+
mentionActive={mentionActive}
|
|
1177
|
+
setMentionActive={setMentionActive}
|
|
1178
|
+
mentionScrollable={mentionScrollable}
|
|
1179
|
+
mentionPopoverRef={mentionPopoverRef}
|
|
1180
|
+
mentionListRef={mentionListRef}
|
|
1181
|
+
pointerInMentionRef={pointerInMentionRef}
|
|
1182
|
+
mentionScrollTopRef={mentionScrollTopRef}
|
|
1183
|
+
closeMention={closeMention}
|
|
1184
|
+
insertMention={insertMention}
|
|
1185
|
+
updateMentionFromCursor={updateMentionFromCursor}
|
|
1186
|
+
draftMentions={draftMentions}
|
|
1187
|
+
setDraftMentions={setDraftMentions}
|
|
1188
|
+
agentNameMap={agentNameMap}
|
|
1189
|
+
setAgentNameMap={setAgentNameMap}
|
|
1190
|
+
/>
|
|
1191
|
+
|
|
1192
|
+
<Dialog open={!!previewImage} onOpenChange={(open) => !open && setPreviewImage(null)}>
|
|
1193
|
+
<DialogContent className="max-w-3xl glass-surface glass-noise">
|
|
1194
|
+
<DialogHeader>
|
|
1195
|
+
<DialogTitle>Preview</DialogTitle>
|
|
1196
|
+
</DialogHeader>
|
|
1197
|
+
{previewImage && (
|
|
1198
|
+
<div className="flex items-center justify-center">
|
|
1199
|
+
<img
|
|
1200
|
+
src={`data:${previewImage.mime_type};base64,${previewImage.base64_data}`}
|
|
1201
|
+
alt=""
|
|
1202
|
+
className="max-h-[70vh] rounded-lg"
|
|
1203
|
+
/>
|
|
1204
|
+
</div>
|
|
1205
|
+
)}
|
|
1206
|
+
</DialogContent>
|
|
1207
|
+
</Dialog>
|
|
1208
|
+
</div>
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function MessageBubble({
|
|
1213
|
+
request,
|
|
1214
|
+
showAgent,
|
|
1215
|
+
agentNameMap,
|
|
1216
|
+
isHistory,
|
|
1217
|
+
showName,
|
|
1218
|
+
showAvatar,
|
|
1219
|
+
compact,
|
|
1220
|
+
disabled,
|
|
1221
|
+
currentInput,
|
|
1222
|
+
isGroup,
|
|
1223
|
+
onPasteChoice,
|
|
1224
|
+
onMentionAgent,
|
|
1225
|
+
onReply,
|
|
1226
|
+
onCancel,
|
|
1227
|
+
}: {
|
|
1228
|
+
request: CueRequest;
|
|
1229
|
+
showAgent?: boolean;
|
|
1230
|
+
agentNameMap?: Record<string, string>;
|
|
1231
|
+
isHistory?: boolean;
|
|
1232
|
+
showName?: boolean;
|
|
1233
|
+
showAvatar?: boolean;
|
|
1234
|
+
compact?: boolean;
|
|
1235
|
+
disabled?: boolean;
|
|
1236
|
+
currentInput?: string;
|
|
1237
|
+
isGroup?: boolean;
|
|
1238
|
+
onPasteChoice?: (text: string, mode?: "replace" | "append") => void;
|
|
1239
|
+
onMentionAgent?: (agentId: string) => void;
|
|
1240
|
+
onReply?: () => void;
|
|
1241
|
+
onCancel?: () => void;
|
|
1242
|
+
}) {
|
|
1243
|
+
const isPending = request.status === "PENDING";
|
|
1244
|
+
|
|
1245
|
+
const selectedLines = useMemo(() => {
|
|
1246
|
+
const text = (currentInput || "").trim();
|
|
1247
|
+
if (!text) return new Set<string>();
|
|
1248
|
+
return new Set(
|
|
1249
|
+
text
|
|
1250
|
+
.split(/\r?\n/)
|
|
1251
|
+
.map((s) => s.trim())
|
|
1252
|
+
.filter(Boolean)
|
|
1253
|
+
);
|
|
1254
|
+
}, [currentInput]);
|
|
1255
|
+
|
|
1256
|
+
const rawId = request.agent_id || "";
|
|
1257
|
+
const displayName = (agentNameMap && rawId ? agentNameMap[rawId] || rawId : rawId) || "";
|
|
1258
|
+
const cardMaxWidth = (showAvatar ?? true) ? "calc(100% - 3rem)" : "100%";
|
|
1259
|
+
|
|
1260
|
+
return (
|
|
1261
|
+
<div
|
|
1262
|
+
className={cn(
|
|
1263
|
+
"flex max-w-full min-w-0 items-start gap-3",
|
|
1264
|
+
compact && "gap-2",
|
|
1265
|
+
isHistory && "opacity-60"
|
|
1266
|
+
)}
|
|
1267
|
+
>
|
|
1268
|
+
{(showAvatar ?? true) ? (
|
|
1269
|
+
<span
|
|
1270
|
+
className={cn(
|
|
1271
|
+
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg",
|
|
1272
|
+
isGroup && request.agent_id && onMentionAgent && "cursor-pointer"
|
|
1273
|
+
)}
|
|
1274
|
+
title={
|
|
1275
|
+
isGroup && request.agent_id && onMentionAgent
|
|
1276
|
+
? "Double-click avatar to @mention"
|
|
1277
|
+
: undefined
|
|
1278
|
+
}
|
|
1279
|
+
onDoubleClick={() => {
|
|
1280
|
+
if (!isGroup) return;
|
|
1281
|
+
const agentId = request.agent_id;
|
|
1282
|
+
if (!agentId) return;
|
|
1283
|
+
onMentionAgent?.(agentId);
|
|
1284
|
+
}}
|
|
1285
|
+
>
|
|
1286
|
+
{getAgentEmoji(request.agent_id || "")}
|
|
1287
|
+
</span>
|
|
1288
|
+
) : (
|
|
1289
|
+
<span className="h-9 w-9 shrink-0" />
|
|
1290
|
+
)}
|
|
1291
|
+
<div className="flex-1 min-w-0 overflow-hidden">
|
|
1292
|
+
{(showName ?? true) && (showAgent || displayName) && (
|
|
1293
|
+
<p className="mb-1 text-xs text-muted-foreground truncate">{displayName}</p>
|
|
1294
|
+
)}
|
|
1295
|
+
<div
|
|
1296
|
+
className={cn(
|
|
1297
|
+
"rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 overflow-hidden",
|
|
1298
|
+
"glass-surface-soft glass-noise",
|
|
1299
|
+
isPending ? "ring-1 ring-ring/25" : "ring-1 ring-white/25"
|
|
1300
|
+
)}
|
|
1301
|
+
style={{ clipPath: "inset(0 round 1rem)", maxWidth: cardMaxWidth }}
|
|
1302
|
+
>
|
|
1303
|
+
<div className="text-sm wrap-anywhere overflow-hidden min-w-0">
|
|
1304
|
+
<MarkdownRenderer>{request.prompt || ""}</MarkdownRenderer>
|
|
1305
|
+
</div>
|
|
1306
|
+
<PayloadCard
|
|
1307
|
+
raw={request.payload}
|
|
1308
|
+
disabled={disabled}
|
|
1309
|
+
onPasteChoice={onPasteChoice}
|
|
1310
|
+
selectedLines={selectedLines}
|
|
1311
|
+
/>
|
|
1312
|
+
</div>
|
|
1313
|
+
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
1314
|
+
<span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
|
|
1315
|
+
{isPending && (
|
|
1316
|
+
<>
|
|
1317
|
+
<Badge variant="default" className="text-xs shrink-0">
|
|
1318
|
+
Pending
|
|
1319
|
+
</Badge>
|
|
1320
|
+
<Badge variant="outline" className="text-xs shrink-0">
|
|
1321
|
+
Waiting {getWaitingDuration(request.created_at || "")}
|
|
1322
|
+
</Badge>
|
|
1323
|
+
{onReply && (
|
|
1324
|
+
<Button
|
|
1325
|
+
variant="link"
|
|
1326
|
+
size="sm"
|
|
1327
|
+
className="h-auto p-0 text-xs"
|
|
1328
|
+
onClick={onReply}
|
|
1329
|
+
disabled={disabled}
|
|
1330
|
+
>
|
|
1331
|
+
Reply
|
|
1332
|
+
</Button>
|
|
1333
|
+
)}
|
|
1334
|
+
{onCancel && (
|
|
1335
|
+
<Button
|
|
1336
|
+
variant="link"
|
|
1337
|
+
size="sm"
|
|
1338
|
+
className="h-auto p-0 text-xs text-destructive"
|
|
1339
|
+
onClick={onCancel}
|
|
1340
|
+
disabled={disabled}
|
|
1341
|
+
>
|
|
1342
|
+
End
|
|
1343
|
+
</Button>
|
|
1344
|
+
)}
|
|
1345
|
+
</>
|
|
1346
|
+
)}
|
|
1347
|
+
{request.status === "COMPLETED" && (
|
|
1348
|
+
<Badge variant="secondary" className="text-xs shrink-0">
|
|
1349
|
+
Replied
|
|
1350
|
+
</Badge>
|
|
1351
|
+
)}
|
|
1352
|
+
{request.status === "CANCELLED" && (
|
|
1353
|
+
<Badge variant="destructive" className="text-xs shrink-0">
|
|
1354
|
+
Ended
|
|
1355
|
+
</Badge>
|
|
1356
|
+
)}
|
|
1357
|
+
</div>
|
|
1358
|
+
</div>
|
|
1359
|
+
</div>
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function UserResponseBubble({
|
|
1364
|
+
response,
|
|
1365
|
+
showAvatar = true,
|
|
1366
|
+
compact = false,
|
|
1367
|
+
onPreview,
|
|
1368
|
+
}: {
|
|
1369
|
+
response: CueResponse;
|
|
1370
|
+
showAvatar?: boolean;
|
|
1371
|
+
compact?: boolean;
|
|
1372
|
+
onPreview?: (img: { mime_type: string; base64_data: string }) => void;
|
|
1373
|
+
}) {
|
|
1374
|
+
const parsed = JSON.parse(response.response_json || "{}") as {
|
|
1375
|
+
text?: string;
|
|
1376
|
+
images?: { mime_type: string; base64_data: string }[];
|
|
1377
|
+
mentions?: { userId: string; start: number; length: number; display: string }[];
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
const renderTextWithMentions = (
|
|
1381
|
+
text: string,
|
|
1382
|
+
mentions?: { start: number; length: number }[]
|
|
1383
|
+
) => {
|
|
1384
|
+
if (!mentions || mentions.length === 0) return text;
|
|
1385
|
+
const safe = [...mentions]
|
|
1386
|
+
.filter((m) => m.start >= 0 && m.length > 0 && m.start + m.length <= text.length)
|
|
1387
|
+
.sort((a, b) => a.start - b.start);
|
|
1388
|
+
|
|
1389
|
+
const nodes: ReactNode[] = [];
|
|
1390
|
+
let cursor = 0;
|
|
1391
|
+
for (const m of safe) {
|
|
1392
|
+
if (m.start < cursor) continue;
|
|
1393
|
+
if (m.start > cursor) {
|
|
1394
|
+
nodes.push(text.slice(cursor, m.start));
|
|
1395
|
+
}
|
|
1396
|
+
const seg = text.slice(m.start, m.start + m.length);
|
|
1397
|
+
nodes.push(
|
|
1398
|
+
<span key={`m-${m.start}`} className="text-emerald-900/90 dark:text-emerald-950 font-semibold">
|
|
1399
|
+
{seg}
|
|
1400
|
+
</span>
|
|
1401
|
+
);
|
|
1402
|
+
cursor = m.start + m.length;
|
|
1403
|
+
}
|
|
1404
|
+
if (cursor < text.length) nodes.push(text.slice(cursor));
|
|
1405
|
+
return nodes;
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
if (response.cancelled) {
|
|
1409
|
+
return (
|
|
1410
|
+
<div className="flex justify-end gap-3 max-w-full min-w-0">
|
|
1411
|
+
<div
|
|
1412
|
+
className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
|
|
1413
|
+
style={{
|
|
1414
|
+
clipPath: "inset(0 round 1rem)",
|
|
1415
|
+
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
1416
|
+
}}
|
|
1417
|
+
>
|
|
1418
|
+
<p className="text-sm text-muted-foreground italic">Conversation ended</p>
|
|
1419
|
+
<p className="text-xs text-muted-foreground mt-1">{formatFullTime(response.created_at)}</p>
|
|
1420
|
+
</div>
|
|
1421
|
+
</div>
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
return (
|
|
1426
|
+
<div className={cn("flex justify-end gap-3 max-w-full min-w-0", compact && "gap-2")}>
|
|
1427
|
+
<div
|
|
1428
|
+
className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
|
|
1429
|
+
style={{
|
|
1430
|
+
clipPath: "inset(0 round 1rem)",
|
|
1431
|
+
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
1432
|
+
}}
|
|
1433
|
+
>
|
|
1434
|
+
{parsed.text && (
|
|
1435
|
+
<p className="whitespace-pre-wrap text-sm wrap-anywhere">
|
|
1436
|
+
{renderTextWithMentions(parsed.text, parsed.mentions)}
|
|
1437
|
+
</p>
|
|
1438
|
+
)}
|
|
1439
|
+
{parsed.images && parsed.images.length > 0 && (
|
|
1440
|
+
<div className="flex flex-wrap gap-2 mt-2 max-w-full">
|
|
1441
|
+
{parsed.images.map((img, i) => (
|
|
1442
|
+
<img
|
|
1443
|
+
key={i}
|
|
1444
|
+
src={`data:${img.mime_type};base64,${img.base64_data}`}
|
|
1445
|
+
alt=""
|
|
1446
|
+
className="max-h-32 max-w-full h-auto rounded cursor-pointer"
|
|
1447
|
+
onClick={() => onPreview?.(img)}
|
|
1448
|
+
/>
|
|
1449
|
+
))}
|
|
1450
|
+
</div>
|
|
1451
|
+
)}
|
|
1452
|
+
<p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
|
|
1453
|
+
</div>
|
|
1454
|
+
{showAvatar ? (
|
|
1455
|
+
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
|
|
1456
|
+
👤
|
|
1457
|
+
</span>
|
|
1458
|
+
) : (
|
|
1459
|
+
<span className="h-9 w-9 shrink-0" />
|
|
1460
|
+
)}
|
|
1461
|
+
</div>
|
|
1462
|
+
);
|
|
1463
|
+
}
|