CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/chat/message.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { Badge, Button, Col, Popconfirm, Row, Space, Tooltip } from "antd";
7
import { List, Map } from "immutable";
8
import { CSSProperties, useEffect, useLayoutEffect } from "react";
9
import { useIntl } from "react-intl";
10
11
import { Avatar } from "@cocalc/frontend/account/avatar/avatar";
12
import {
13
CSS,
14
redux,
15
useMemo,
16
useRef,
17
useState,
18
useTypedRedux,
19
} from "@cocalc/frontend/app-framework";
20
import { Gap, Icon, TimeAgo, Tip } from "@cocalc/frontend/components";
21
import MostlyStaticMarkdown from "@cocalc/frontend/editors/slate/mostly-static-markdown";
22
import { IS_TOUCH } from "@cocalc/frontend/feature";
23
import { modelToName } from "@cocalc/frontend/frame-editors/llm/llm-selector";
24
import { labels } from "@cocalc/frontend/i18n";
25
import { CancelText } from "@cocalc/frontend/i18n/components";
26
import { User } from "@cocalc/frontend/users";
27
import { isLanguageModelService } from "@cocalc/util/db-schema/llm-utils";
28
import { plural, unreachable } from "@cocalc/util/misc";
29
import { COLORS } from "@cocalc/util/theme";
30
import { ChatActions } from "./actions";
31
import { getUserName } from "./chat-log";
32
import { History, HistoryFooter, HistoryTitle } from "./history";
33
import ChatInput from "./input";
34
import { LLMCostEstimationChat } from "./llm-cost-estimation";
35
import { FeedbackLLM } from "./llm-msg-feedback";
36
import { RegenerateLLM } from "./llm-msg-regenerate";
37
import { SummarizeThread } from "./llm-msg-summarize";
38
import { Name } from "./name";
39
import { Time } from "./time";
40
import { ChatMessageTyped, Mode, SubmitMentionsFn } from "./types";
41
import {
42
getThreadRootDate,
43
is_editing,
44
message_colors,
45
newest_content,
46
sender_is_viewer,
47
} from "./utils";
48
49
const DELETE_BUTTON = false;
50
51
const BLANK_COLUMN = (xs) => <Col key={"blankcolumn"} xs={xs}></Col>;
52
53
const MARKDOWN_STYLE = undefined;
54
55
const BORDER = "2px solid #ccc";
56
57
const SHOW_EDIT_BUTTON_MS = 15000;
58
59
const TRHEAD_STYLE_SINGLE: CSS = {
60
marginLeft: "15px",
61
marginRight: "15px",
62
paddingLeft: "15px",
63
} as const;
64
65
const THREAD_STYLE: CSS = {
66
...TRHEAD_STYLE_SINGLE,
67
borderLeft: BORDER,
68
borderRight: BORDER,
69
} as const;
70
71
const THREAD_STYLE_BOTTOM: CSS = {
72
...THREAD_STYLE,
73
borderBottomLeftRadius: "10px",
74
borderBottomRightRadius: "10px",
75
borderBottom: BORDER,
76
marginBottom: "10px",
77
} as const;
78
79
const THREAD_STYLE_TOP: CSS = {
80
...THREAD_STYLE,
81
borderTop: BORDER,
82
borderTopLeftRadius: "10px",
83
borderTopRightRadius: "10px",
84
marginTop: "10px",
85
} as const;
86
87
const THREAD_STYLE_FOLDED: CSS = {
88
...THREAD_STYLE_TOP,
89
...THREAD_STYLE_BOTTOM,
90
} as const;
91
92
const MARGIN_TOP_VIEWER = "17px";
93
94
const AVATAR_MARGIN_LEFTRIGHT = "15px";
95
96
interface Props {
97
index: number;
98
actions?: ChatActions;
99
get_user_name: (account_id?: string) => string;
100
messages;
101
message: ChatMessageTyped;
102
account_id: string;
103
user_map?: Map<string, any>;
104
project_id?: string; // improves relative links if given
105
path?: string;
106
font_size: number;
107
is_prev_sender?: boolean;
108
show_avatar?: boolean;
109
mode: Mode;
110
selectedHashtags?: Set<string>;
111
112
scroll_into_view?: () => void; // call to scroll this message into view
113
114
// if true, include a reply button - this should only be for messages
115
// that don't have an existing reply to them already.
116
allowReply?: boolean;
117
118
is_thread?: boolean; // if true, there is a thread starting in a reply_to message
119
is_folded?: boolean; // if true, only show the reply_to root message
120
is_thread_body: boolean;
121
122
costEstimate;
123
124
selected?: boolean;
125
126
// for the root of a folded thread, optionally give this number of a
127
// more informative message to the user.
128
numChildren?: number;
129
}
130
131
export default function Message({
132
index,
133
actions,
134
get_user_name,
135
messages,
136
message,
137
account_id,
138
user_map,
139
project_id,
140
path,
141
font_size,
142
is_prev_sender,
143
show_avatar,
144
mode,
145
selectedHashtags,
146
scroll_into_view,
147
allowReply,
148
is_thread,
149
is_folded,
150
is_thread_body,
151
costEstimate,
152
selected,
153
numChildren,
154
}: Props) {
155
const intl = useIntl();
156
157
const showAISummarize = redux
158
.getStore("projects")
159
.hasLanguageModelEnabled(project_id, "chat-summarize");
160
161
const hideTooltip =
162
useTypedRedux("account", "other_settings").get("hide_file_popovers") ??
163
false;
164
165
const [edited_message, set_edited_message] = useState<string>(
166
newest_content(message),
167
);
168
// We have to use a ref because of trickiness involving
169
// stale closures when submitting the message.
170
const edited_message_ref = useRef(edited_message);
171
172
const [show_history, set_show_history] = useState(false);
173
174
const new_changes = useMemo(
175
() => edited_message !== newest_content(message),
176
[message] /* note -- edited_message is a function of message */,
177
);
178
179
// date as ms since epoch or 0
180
const date: number = useMemo(() => {
181
return message?.get("date")?.valueOf() ?? 0;
182
}, [message.get("date")]);
183
184
const generating = message.get("generating");
185
186
const history_size = useMemo(() => message.get("history").size, [message]);
187
188
const isEditing = useMemo(
189
() => is_editing(message, account_id),
190
[message, account_id],
191
);
192
193
const editor_name = useMemo(() => {
194
return get_user_name(message.get("history")?.first()?.get("author_id"));
195
}, [message]);
196
197
const reverseRowOrdering =
198
!is_thread_body && sender_is_viewer(account_id, message);
199
200
const submitMentionsRef = useRef<SubmitMentionsFn>();
201
202
const [replying, setReplying] = useState<boolean>(() => {
203
if (!allowReply) {
204
return false;
205
}
206
const replyDate = -getThreadRootDate({ date, messages });
207
const draft = actions?.syncdb?.get_one({
208
event: "draft",
209
sender_id: account_id,
210
date: replyDate,
211
});
212
if (draft == null) {
213
return false;
214
}
215
if (draft.get("active") <= 1720071100408) {
216
// before this point in time, drafts never ever got deleted when sending replies! So there's a massive
217
// clutter of reply drafts sitting in chats, and we don't want to resurrect them.
218
return false;
219
}
220
return true;
221
});
222
useEffect(() => {
223
if (!allowReply) {
224
setReplying(false);
225
}
226
}, [allowReply]);
227
228
const [autoFocusReply, setAutoFocusReply] = useState<boolean>(false);
229
const [autoFocusEdit, setAutoFocusEdit] = useState<boolean>(false);
230
231
const replyMessageRef = useRef<string>("");
232
const replyMentionsRef = useRef<SubmitMentionsFn>();
233
234
const is_viewers_message = sender_is_viewer(account_id, message);
235
const verb = show_history ? "Hide" : "Show";
236
237
const isLLMThread = useMemo(
238
() => actions?.isLanguageModelThread(message.get("date")),
239
[message, actions != null],
240
);
241
242
const msgWrittenByLLM = useMemo(() => {
243
const author_id = message.get("history")?.first()?.get("author_id");
244
return typeof author_id === "string" && isLanguageModelService(author_id);
245
}, [message]);
246
247
useLayoutEffect(() => {
248
if (replying) {
249
scroll_into_view?.();
250
}
251
}, [replying]);
252
253
function editing_status(is_editing: boolean) {
254
let text;
255
256
let other_editors = // @ts-ignore -- keySeq *is* a method of TypedMap
257
message.get("editing")?.remove(account_id).keySeq() ?? List();
258
if (is_editing) {
259
if (other_editors.size === 1) {
260
// This user and someone else is also editing
261
text = (
262
<>
263
{`WARNING: ${get_user_name(
264
other_editors.first(),
265
)} is also editing this! `}
266
<b>Simultaneous editing of messages is not supported.</b>
267
</>
268
);
269
} else if (other_editors.size > 1) {
270
// Multiple other editors
271
text = `${other_editors.size} other users are also editing this!`;
272
} else if (history_size !== message.get("history").size && new_changes) {
273
text = `${editor_name} has updated this message. Esc to discard your changes and see theirs`;
274
} else {
275
if (IS_TOUCH) {
276
text = "You are now editing ...";
277
} else {
278
text = "You are now editing ... Shift+Enter to submit changes.";
279
}
280
}
281
} else {
282
if (other_editors.size === 1) {
283
// One person is editing
284
text = `${get_user_name(
285
other_editors.first(),
286
)} is editing this message`;
287
} else if (other_editors.size > 1) {
288
// Multiple editors
289
text = `${other_editors.size} people are editing this message`;
290
} else if (newest_content(message).trim() === "") {
291
text = `Deleted by ${editor_name}`;
292
}
293
}
294
295
if (text == null) {
296
text = `Last edit by ${editor_name}`;
297
}
298
299
if (
300
!is_editing &&
301
other_editors.size === 0 &&
302
newest_content(message).trim() !== ""
303
) {
304
const edit = "Last edit ";
305
const name = ` by ${editor_name}`;
306
const msg_date = message.get("history").first()?.get("date");
307
return (
308
<div
309
style={{
310
color: COLORS.GRAY_M,
311
fontSize: "14px" /* matches Reply button */,
312
}}
313
>
314
{edit}{" "}
315
{msg_date != null ? (
316
<TimeAgo date={new Date(msg_date)} />
317
) : (
318
"unknown time"
319
)}{" "}
320
{name}
321
</div>
322
);
323
}
324
return (
325
<div style={{ color: COLORS.GRAY_M }}>
326
{text}
327
{is_editing ? (
328
<span style={{ margin: "10px 10px 0 10px", display: "inline-block" }}>
329
<Button onClick={on_cancel}>Cancel</Button>
330
<Gap />
331
<Button onClick={saveEditedMessage} type="primary">
332
Save (shift+enter)
333
</Button>
334
</span>
335
) : undefined}
336
</div>
337
);
338
}
339
340
function edit_message() {
341
if (project_id == null || path == null || actions == null) {
342
// no editing functionality or not in a project with a path.
343
return;
344
}
345
actions.setEditing(message, true);
346
setAutoFocusEdit(true);
347
scroll_into_view?.();
348
}
349
350
function avatar_column() {
351
const sender_id = message.get("sender_id");
352
let style: CSSProperties = {};
353
if (!is_prev_sender) {
354
style.marginTop = "22px";
355
} else {
356
style.marginTop = "5px";
357
}
358
359
if (!is_thread_body) {
360
if (sender_is_viewer(account_id, message)) {
361
style.marginLeft = AVATAR_MARGIN_LEFTRIGHT;
362
} else {
363
style.marginRight = AVATAR_MARGIN_LEFTRIGHT;
364
}
365
}
366
367
return (
368
<Col key={0} xs={2}>
369
<div style={style}>
370
{sender_id != null && show_avatar ? (
371
<Avatar size={40} account_id={sender_id} />
372
) : undefined}
373
</div>
374
</Col>
375
);
376
}
377
378
function contentColumn() {
379
let marginTop;
380
let value = newest_content(message);
381
382
const { background, color, lighten, message_class } = message_colors(
383
account_id,
384
message,
385
);
386
387
if (!is_prev_sender && is_viewers_message) {
388
marginTop = MARGIN_TOP_VIEWER;
389
} else {
390
marginTop = "5px";
391
}
392
393
const message_style: CSSProperties = {
394
color,
395
background,
396
wordWrap: "break-word",
397
borderRadius: "5px",
398
marginTop,
399
fontSize: `${font_size}px`,
400
// no padding on bottom, since message itself is markdown, hence
401
// wrapped in <p>'s, which have a big 10px margin on their bottoms
402
// already.
403
padding: selected ? "6px 6px 0 6px" : "9px 9px 0 9px",
404
...(mode === "sidechat"
405
? { marginLeft: "5px", marginRight: "5px" }
406
: undefined),
407
...(selected ? { border: "3px solid #66bb6a" } : undefined),
408
maxHeight: is_folded ? "100px" : undefined,
409
overflowY: is_folded ? "auto" : undefined,
410
} as const;
411
412
const mainXS = mode === "standalone" ? 20 : 22;
413
const showEditButton = Date.now() - date < SHOW_EDIT_BUTTON_MS;
414
const feedback = message.getIn(["feedback", account_id]);
415
const otherFeedback =
416
isLLMThread && msgWrittenByLLM ? 0 : (message.get("feedback")?.size ?? 0);
417
const showOtherFeedback = otherFeedback > 0;
418
419
const editControlRow = () => {
420
if (isEditing) {
421
return null;
422
}
423
const showDeleteButton =
424
DELETE_BUTTON && newest_content(message).trim().length > 0;
425
const showEditingStatus =
426
(message.get("history")?.size ?? 0) > 1 ||
427
(message.get("editing")?.size ?? 0) > 0;
428
const showHistory = (message.get("history")?.size ?? 0) > 1;
429
const showLLMFeedback = isLLMThread && msgWrittenByLLM;
430
431
// Show the bottom line of the message -- this uses a LOT of extra
432
// vertical space, so only do it if there is a good reason to.
433
// Getting rid of this might be nice.
434
const show =
435
showEditButton ||
436
showDeleteButton ||
437
showEditingStatus ||
438
showHistory ||
439
showLLMFeedback;
440
if (!show) {
441
// important to explicitly check this before rendering below, since otherwise we get a big BLANK space.
442
return null;
443
}
444
445
return (
446
<div style={{ width: "100%", textAlign: "center" }}>
447
<Space direction="horizontal" size="small" wrap>
448
{showEditButton ? (
449
<Tooltip
450
title={
451
<>
452
Edit this message. You can edit <b>any</b> past message at
453
any time by double clicking on it. Fix other people's typos.
454
All versions are stored.
455
</>
456
}
457
placement="left"
458
>
459
<Button
460
disabled={replying}
461
style={{
462
color: is_viewers_message ? "white" : "#555",
463
}}
464
type="text"
465
size="small"
466
onClick={() => actions?.setEditing(message, true)}
467
>
468
<Icon name="pencil" /> Edit
469
</Button>
470
</Tooltip>
471
) : undefined}
472
{showDeleteButton && (
473
<Tooltip
474
title="Delete this message. You can delete any past message by anybody. The deleted message can be view in history."
475
placement="left"
476
>
477
<Popconfirm
478
title="Delete this message"
479
description="Are you sure you want to delete this message?"
480
onConfirm={() => {
481
actions?.setEditing(message, true);
482
setTimeout(() => actions?.sendEdit(message, ""), 1);
483
}}
484
>
485
<Button
486
disabled={replying}
487
style={{
488
color: is_viewers_message ? "white" : "#555",
489
}}
490
type="text"
491
size="small"
492
>
493
<Icon name="trash" /> Delete
494
</Button>
495
</Popconfirm>
496
</Tooltip>
497
)}
498
{showEditingStatus && editing_status(isEditing)}
499
{showHistory && (
500
<Button
501
style={{
502
marginLeft: "5px",
503
color: is_viewers_message ? "white" : "#555",
504
}}
505
type="text"
506
size="small"
507
icon={<Icon name="history" />}
508
onClick={() => {
509
set_show_history(!show_history);
510
scroll_into_view?.();
511
}}
512
>
513
<Tip
514
title="Message History"
515
tip={`${verb} history of editing of this message. Any collaborator can edit any message by double clicking on it.`}
516
>
517
{verb} History
518
</Tip>
519
</Button>
520
)}
521
{showLLMFeedback && (
522
<>
523
<RegenerateLLM
524
actions={actions}
525
date={date}
526
model={isLLMThread}
527
/>
528
<FeedbackLLM actions={actions} message={message} />
529
</>
530
)}
531
</Space>
532
</div>
533
);
534
};
535
536
return (
537
<Col key={1} xs={mainXS}>
538
<div
539
style={{ display: "flex" }}
540
onClick={() => {
541
actions?.setFragment(message.get("date"));
542
}}
543
>
544
{!is_prev_sender &&
545
!is_viewers_message &&
546
message.get("sender_id") ? (
547
<Name sender_name={get_user_name(message.get("sender_id"))} />
548
) : undefined}
549
{generating === true && actions ? (
550
<Button
551
style={{ color: COLORS.GRAY_M }}
552
onClick={() => {
553
actions?.languageModelStopGenerating(new Date(date));
554
}}
555
>
556
<Icon name="square" /> Stop Generating
557
</Button>
558
) : undefined}
559
</div>
560
<div
561
style={message_style}
562
className="smc-chat-message"
563
onDoubleClick={edit_message}
564
>
565
{!isEditing && (
566
<span style={lighten}>
567
<Time message={message} edit={edit_message} />
568
{!isLLMThread && (
569
<Tooltip
570
title={
571
!showOtherFeedback
572
? undefined
573
: () => {
574
return (
575
<div>
576
{Object.keys(
577
message.get("feedback")?.toJS() ?? {},
578
).map((account_id) => (
579
<div
580
key={account_id}
581
style={{ marginBottom: "2px" }}
582
>
583
<Avatar size={24} account_id={account_id} />{" "}
584
<User account_id={account_id} />
585
</div>
586
))}
587
</div>
588
);
589
}
590
}
591
>
592
<Button
593
style={{
594
marginRight: "5px",
595
float: "right",
596
marginTop: "-4px",
597
color: !feedback && is_viewers_message ? "white" : "#888",
598
fontSize: "12px",
599
}}
600
size="small"
601
type={feedback ? "dashed" : "text"}
602
onClick={() => {
603
actions?.feedback(message, feedback ? null : "positive");
604
}}
605
>
606
{showOtherFeedback ? (
607
<Badge
608
count={otherFeedback}
609
color="darkblue"
610
size="small"
611
/>
612
) : (
613
""
614
)}
615
<Tooltip
616
title={showOtherFeedback ? undefined : "Like this"}
617
>
618
<Icon
619
name="thumbs-up"
620
style={{
621
color: showOtherFeedback ? "darkblue" : undefined,
622
}}
623
/>
624
</Tooltip>
625
</Button>
626
</Tooltip>
627
)}{" "}
628
<Tooltip title="Select message. Copy URL to link to this message.">
629
<Button
630
onClick={() => {
631
actions?.setFragment(message.get("date"));
632
}}
633
size="small"
634
type={"text"}
635
style={{
636
float: "right",
637
marginTop: "-4px",
638
color: is_viewers_message ? "white" : "#888",
639
fontSize: "12px",
640
}}
641
>
642
<Icon name="link" />
643
</Button>
644
</Tooltip>
645
</span>
646
)}
647
{!isEditing && (
648
<MostlyStaticMarkdown
649
style={MARKDOWN_STYLE}
650
value={value}
651
className={message_class}
652
selectedHashtags={selectedHashtags}
653
toggleHashtag={
654
selectedHashtags != null && actions != null
655
? (tag) =>
656
actions?.setHashtagState(
657
tag,
658
selectedHashtags?.has(tag) ? undefined : 1,
659
)
660
: undefined
661
}
662
/>
663
)}
664
{isEditing && renderEditMessage()}
665
{editControlRow()}
666
</div>
667
{show_history && (
668
<div>
669
<HistoryTitle />
670
<History history={message.get("history")} user_map={user_map} />
671
<HistoryFooter />
672
</div>
673
)}
674
{replying ? renderComposeReply() : undefined}
675
</Col>
676
);
677
}
678
679
function saveEditedMessage(): void {
680
if (actions == null) return;
681
const mesg =
682
submitMentionsRef.current?.({ chat: `${date}` }) ??
683
edited_message_ref.current;
684
const value = newest_content(message);
685
if (mesg !== value) {
686
set_edited_message(mesg);
687
actions.sendEdit(message, mesg);
688
} else {
689
actions.setEditing(message, false);
690
}
691
}
692
693
function on_cancel(): void {
694
set_edited_message(newest_content(message));
695
if (actions == null) return;
696
actions.setEditing(message, false);
697
actions.deleteDraft(date);
698
}
699
700
function renderEditMessage() {
701
if (project_id == null || path == null || actions?.syncdb == null) {
702
// should never get into this position
703
// when null.
704
return;
705
}
706
return (
707
<div>
708
<ChatInput
709
fontSize={font_size}
710
autoFocus={autoFocusEdit}
711
cacheId={`${path}${project_id}${date}`}
712
input={newest_content(message)}
713
submitMentionsRef={submitMentionsRef}
714
on_send={saveEditedMessage}
715
height={"auto"}
716
syncdb={actions.syncdb}
717
date={date}
718
onChange={(value) => {
719
edited_message_ref.current = value;
720
}}
721
/>
722
<div style={{ marginTop: "10px", display: "flex" }}>
723
<Button
724
style={{ marginRight: "5px" }}
725
onClick={() => {
726
actions?.setEditing(message, false);
727
actions?.deleteDraft(date);
728
}}
729
>
730
{intl.formatMessage(labels.cancel)}
731
</Button>
732
<Button type="primary" onClick={saveEditedMessage}>
733
<Icon name="save" /> Save Edited Message
734
</Button>
735
</div>
736
</div>
737
);
738
}
739
740
function sendReply(reply?: string) {
741
if (actions == null) return;
742
setReplying(false);
743
if (!reply && !replyMentionsRef.current?.(undefined, true)) {
744
reply = replyMessageRef.current;
745
}
746
actions.sendReply({
747
message: message.toJS(),
748
reply,
749
submitMentionsRef: replyMentionsRef,
750
});
751
actions.scrollToIndex(index);
752
}
753
754
function renderComposeReply() {
755
if (project_id == null || path == null || actions?.syncdb == null) {
756
// should never get into this position
757
// when null.
758
return;
759
}
760
const replyDate = -getThreadRootDate({ date, messages });
761
let input;
762
let moveCursorToEndOfLine = false;
763
if (isLLMThread) {
764
input = "";
765
} else {
766
const replying_to = message.get("history")?.first()?.get("author_id");
767
if (!replying_to || replying_to == account_id) {
768
input = "";
769
} else {
770
input = `<span class="user-mention" account-id=${replying_to} >@${editor_name}</span> `;
771
moveCursorToEndOfLine = autoFocusReply;
772
}
773
}
774
return (
775
<div style={{ marginLeft: mode === "standalone" ? "30px" : "0" }}>
776
<ChatInput
777
fontSize={font_size}
778
autoFocus={autoFocusReply}
779
moveCursorToEndOfLine={moveCursorToEndOfLine}
780
style={{
781
borderRadius: "8px",
782
height: "auto" /* for some reason the default 100% breaks things */,
783
}}
784
cacheId={`${path}${project_id}${date}-reply`}
785
input={input}
786
submitMentionsRef={replyMentionsRef}
787
on_send={sendReply}
788
height={"auto"}
789
syncdb={actions.syncdb}
790
date={replyDate}
791
onChange={(value) => {
792
replyMessageRef.current = value;
793
// replyMentionsRef does not submit mentions, only gives us the value
794
const input = replyMentionsRef.current?.(undefined, true) ?? value;
795
actions?.llmEstimateCost({
796
date: replyDate,
797
input,
798
message: message.toJS(),
799
});
800
}}
801
placeholder={"Reply to the above message..."}
802
/>
803
<div style={{ margin: "5px 0", display: "flex" }}>
804
<Button
805
style={{ marginRight: "5px" }}
806
onClick={() => {
807
setReplying(false);
808
actions?.deleteDraft(replyDate);
809
}}
810
>
811
<CancelText />
812
</Button>
813
<Button
814
onClick={() => {
815
sendReply();
816
}}
817
type="primary"
818
>
819
<Icon name="reply" /> Reply (shift+enter)
820
</Button>
821
{costEstimate?.get("date") == replyDate && (
822
<LLMCostEstimationChat
823
costEstimate={costEstimate?.toJS()}
824
compact={false}
825
style={{ display: "inline-block", marginLeft: "10px" }}
826
/>
827
)}
828
</div>
829
</div>
830
);
831
}
832
833
function getStyleBase(): CSS {
834
if (!is_thread_body) {
835
if (is_thread) {
836
if (is_folded) {
837
return THREAD_STYLE_FOLDED;
838
} else {
839
return THREAD_STYLE_TOP;
840
}
841
} else {
842
return TRHEAD_STYLE_SINGLE;
843
}
844
} else if (allowReply) {
845
return THREAD_STYLE_BOTTOM;
846
} else {
847
return THREAD_STYLE;
848
}
849
}
850
851
function getStyle(): CSS {
852
switch (mode) {
853
case "standalone":
854
return getStyleBase();
855
case "sidechat":
856
return {
857
...getStyleBase(),
858
marginLeft: "5px",
859
marginRight: "5px",
860
paddingLeft: "0",
861
};
862
default:
863
unreachable(mode);
864
return getStyleBase();
865
}
866
}
867
868
function renderReplyRow() {
869
if (replying || generating || !allowReply || is_folded || actions == null) {
870
return;
871
}
872
873
return (
874
<div style={{ textAlign: "center", width: "100%" }}>
875
<Tooltip
876
title={
877
isLLMThread
878
? `Reply to ${modelToName(
879
isLLMThread,
880
)}, sending the thread as context.`
881
: "Reply to this thread."
882
}
883
>
884
<Button
885
type="text"
886
onClick={() => {
887
setReplying(true);
888
setAutoFocusReply(true);
889
}}
890
style={{ color: COLORS.GRAY_M }}
891
>
892
<Icon name="reply" /> Reply
893
{isLLMThread ? ` to ${modelToName(isLLMThread)}` : ""}
894
{isLLMThread ? (
895
<Avatar
896
account_id={isLLMThread}
897
size={16}
898
style={{ top: "-5px" }}
899
/>
900
) : undefined}
901
</Button>
902
</Tooltip>
903
{showAISummarize && is_thread ? (
904
<SummarizeThread message={message} actions={actions} />
905
) : undefined}
906
</div>
907
);
908
}
909
910
function renderFoldedRow() {
911
if (!is_folded || !is_thread || is_thread_body) {
912
return;
913
}
914
915
let label;
916
if (numChildren) {
917
label = (
918
<>
919
{numChildren} {plural(numChildren, "Reply", "Replies")}
920
</>
921
);
922
} else {
923
label = "View Replies";
924
}
925
926
return (
927
<Col xs={24}>
928
<div style={{ textAlign: "center" }}>
929
<Button
930
onClick={() =>
931
actions?.toggleFoldThread(message.get("date"), index)
932
}
933
type="link"
934
style={{ color: "darkblue" }}
935
>
936
{label}
937
</Button>
938
</div>
939
</Col>
940
);
941
}
942
943
function getThreadfoldOrBlank() {
944
const xs = 2;
945
if (is_thread_body || (!is_thread_body && !is_thread)) {
946
return BLANK_COLUMN(xs);
947
} else {
948
const style: CSS =
949
mode === "standalone"
950
? {
951
color: "#666",
952
marginTop: MARGIN_TOP_VIEWER,
953
marginLeft: "5px",
954
marginRight: "5px",
955
}
956
: {
957
color: "#666",
958
marginTop: "5px",
959
width: "100%",
960
textAlign: "center",
961
};
962
const iconname = is_folded
963
? mode === "standalone"
964
? reverseRowOrdering
965
? "right-circle-o"
966
: "left-circle-o"
967
: "right-circle-o"
968
: "down-circle-o";
969
const button = (
970
<Button
971
type="text"
972
style={style}
973
onClick={() => actions?.toggleFoldThread(message.get("date"), index)}
974
icon={
975
<Icon
976
name={iconname}
977
style={{ fontSize: mode === "standalone" ? "22px" : "18px" }}
978
/>
979
}
980
/>
981
);
982
return (
983
<Col
984
xs={xs}
985
key={"blankcolumn"}
986
style={{ textAlign: reverseRowOrdering ? "left" : "right" }}
987
>
988
{hideTooltip ? (
989
button
990
) : (
991
<Tooltip
992
title={
993
is_folded ? (
994
<>
995
Unfold this thread{" "}
996
{numChildren
997
? ` to show ${numChildren} ${plural(
998
numChildren,
999
"reply",
1000
"replies",
1001
)}`
1002
: ""}
1003
</>
1004
) : (
1005
"Fold this thread to hide replies"
1006
)
1007
}
1008
>
1009
{button}
1010
</Tooltip>
1011
)}
1012
</Col>
1013
);
1014
}
1015
}
1016
1017
function renderCols(): JSX.Element[] | JSX.Element {
1018
// these columns should be filtered in the first place, this here is just an extra check
1019
if (is_thread && is_folded && is_thread_body) {
1020
return <></>;
1021
}
1022
1023
switch (mode) {
1024
case "standalone":
1025
const cols = [avatar_column(), contentColumn(), getThreadfoldOrBlank()];
1026
if (reverseRowOrdering) {
1027
cols.reverse();
1028
}
1029
return cols;
1030
1031
case "sidechat":
1032
return [getThreadfoldOrBlank(), contentColumn()];
1033
1034
default:
1035
unreachable(mode);
1036
return contentColumn();
1037
}
1038
}
1039
1040
return (
1041
<Row style={getStyle()}>
1042
{renderCols()}
1043
{renderFoldedRow()}
1044
{renderReplyRow()}
1045
</Row>
1046
);
1047
}
1048
1049
// Used for exporting chat to markdown file
1050
export function message_to_markdown(message): string {
1051
let value = newest_content(message);
1052
const user_map = redux.getStore("users").get("user_map");
1053
const sender = getUserName(user_map, message.get("sender_id"));
1054
const date = message.get("date").toString();
1055
return `*From:* ${sender} \n*Date:* ${date} \n\n${value}`;
1056
}
1057
1058