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