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