Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/chat/actions.ts
5827 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020-2025 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { List, Map, Seq, Map as immutableMap } from "immutable";
7
import { debounce } from "lodash";
8
import { Optional } from "utility-types";
9
10
import { setDefaultLLM } from "@cocalc/frontend/account/useLanguageModelSetting";
11
import { Actions, redux } from "@cocalc/frontend/app-framework";
12
import { History as LanguageModelHistory } from "@cocalc/frontend/client/types";
13
import type {
14
HashtagState,
15
SelectedHashtags,
16
} from "@cocalc/frontend/editors/task-editor/types";
17
import type { Actions as CodeEditorActions } from "@cocalc/frontend/frame-editors/code-editor/actions";
18
import {
19
modelToMention,
20
modelToName,
21
} from "@cocalc/frontend/frame-editors/llm/llm-selector";
22
import { open_new_tab } from "@cocalc/frontend/misc";
23
import Fragment from "@cocalc/frontend/misc/fragment-id";
24
import { calcMinMaxEstimation } from "@cocalc/frontend/misc/llm-cost-estimation";
25
import track from "@cocalc/frontend/user-tracking";
26
import { webapp_client } from "@cocalc/frontend/webapp-client";
27
import { SyncDB } from "@cocalc/sync/editor/db";
28
import {
29
CUSTOM_OPENAI_PREFIX,
30
LANGUAGE_MODEL_PREFIXES,
31
OLLAMA_PREFIX,
32
USER_LLM_PREFIX,
33
getLLMServiceStatusCheckMD,
34
isFreeModel,
35
isLanguageModel,
36
isLanguageModelService,
37
model2service,
38
model2vendor,
39
service2model,
40
toCustomOpenAIModel,
41
toOllamaModel,
42
type LanguageModel,
43
} from "@cocalc/util/db-schema/llm-utils";
44
import { cmp, history_path, isValidUUID, uuid } from "@cocalc/util/misc";
45
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
46
import { getSortedDates, getUserName } from "./chat-log";
47
import { message_to_markdown } from "./message";
48
import { ChatState, ChatStore } from "./store";
49
import { handleSyncDBChange, initFromSyncDB, processSyncDBObj } from "./sync";
50
import type {
51
ChatMessage,
52
ChatMessageTyped,
53
Feedback,
54
MessageHistory,
55
} from "./types";
56
import { getReplyToRoot, getThreadRootDate, toMsString } from "./utils";
57
58
const MAX_CHAT_STREAM = 10;
59
60
export class ChatActions extends Actions<ChatState> {
61
public syncdb?: SyncDB;
62
public store?: ChatStore;
63
// We use this to ensure at most once chatgpt output is streaming
64
// at a time in a given chatroom. I saw a bug where hundreds started
65
// at once and it really did send them all to openai at once, and
66
// this prevents that at least.
67
private chatStreams: Set<string> = new Set([]);
68
public frameId: string = "";
69
// this might not be set e.g., for deprecated side chat on sagews:
70
public frameTreeActions?: CodeEditorActions;
71
72
set_syncdb = (syncdb: SyncDB, store: ChatStore): void => {
73
this.syncdb = syncdb;
74
this.store = store;
75
};
76
77
// Initialize the state of the store from the contents of the syncdb.
78
init_from_syncdb = (): void => {
79
if (this.syncdb == null) {
80
return;
81
}
82
initFromSyncDB({ syncdb: this.syncdb, store: this.store });
83
};
84
85
syncdbChange = (changes): void => {
86
if (this.syncdb == null) {
87
return;
88
}
89
handleSyncDBChange({ changes, store: this.store, syncdb: this.syncdb });
90
};
91
92
toggleFoldThread = (reply_to: Date, messageIndex?: number) => {
93
if (this.syncdb == null) return;
94
const account_id = this.redux.getStore("account").get_account_id();
95
const cur = this.syncdb.get_one({ event: "chat", date: reply_to });
96
const folding = cur?.get("folding") ?? List([]);
97
const folded = folding.includes(account_id);
98
const next = folded
99
? folding.filter((x) => x !== account_id)
100
: folding.push(account_id);
101
102
this.syncdb.set({
103
folding: next,
104
date: typeof reply_to === "string" ? reply_to : reply_to.toISOString(),
105
});
106
107
this.syncdb.commit();
108
109
if (folded && messageIndex != null) {
110
this.scrollToIndex(messageIndex);
111
}
112
};
113
114
foldAllThreads = (onlyLLM = true) => {
115
if (this.syncdb == null || this.store == null) return;
116
const messages = this.store.get("messages");
117
if (messages == null) return;
118
const account_id = this.redux.getStore("account").get_account_id();
119
for (const [_timestamp, message] of messages) {
120
// ignore replies
121
if (message.get("reply_to") != null) continue;
122
const date = message.get("date");
123
if (!(date instanceof Date)) continue;
124
const isLLMThread = this.isLanguageModelThread(date) !== false;
125
if (onlyLLM && !isLLMThread) continue;
126
const folding = message?.get("folding") ?? List([]);
127
const folded = folding.includes(account_id);
128
if (!folded) {
129
this.syncdb.set({
130
folding: folding.push(account_id),
131
date,
132
});
133
}
134
}
135
};
136
137
feedback = (message: ChatMessageTyped, feedback: Feedback | null) => {
138
if (this.syncdb == null) return;
139
const date = message.get("date");
140
if (!(date instanceof Date)) return;
141
const account_id = this.redux.getStore("account").get_account_id();
142
const cur = this.syncdb.get_one({ event: "chat", date });
143
const feedbacks = cur?.get("feedback") ?? Map({});
144
const next = feedbacks.set(account_id, feedback);
145
this.syncdb.set({ feedback: next, date: date.toISOString() });
146
this.syncdb.commit();
147
const model = this.isLanguageModelThread(date);
148
if (isLanguageModel(model)) {
149
track("llm_feedback", {
150
project_id: this.store?.get("project_id"),
151
path: this.store?.get("path"),
152
msg_date: date.toISOString(),
153
type: "chat",
154
model: model2service(model),
155
feedback,
156
});
157
}
158
};
159
160
// The second parameter is used for sending a message by
161
// chatgpt, which is currently managed by the frontend
162
// (not the project). Also the async doesn't finish until
163
// chatgpt is totally done.
164
sendChat = ({
165
input,
166
sender_id = this.redux.getStore("account").get_account_id(),
167
reply_to,
168
tag,
169
noNotification,
170
submitMentionsRef,
171
extraInput,
172
name,
173
}: {
174
input?: string;
175
sender_id?: string;
176
reply_to?: Date;
177
tag?: string;
178
noNotification?: boolean;
179
submitMentionsRef?;
180
extraInput?: string;
181
// if name is given, rename thread to have that name
182
name?: string;
183
}): string => {
184
if (this.syncdb == null || this.store == null) {
185
console.warn("attempt to sendChat before chat actions initialized");
186
// WARNING: give an error or try again later?
187
return "";
188
}
189
const time_stamp: Date = webapp_client.server_time();
190
const time_stamp_str = time_stamp.toISOString();
191
if (submitMentionsRef?.current != null) {
192
input = submitMentionsRef.current?.({ chat: `${time_stamp.valueOf()}` });
193
}
194
if (extraInput) {
195
input = (input ?? "") + extraInput;
196
}
197
input = input?.trim();
198
if (!input) {
199
// do not send when there is nothing to send.
200
return "";
201
}
202
const trimmedName = name?.trim();
203
const message: ChatMessage = {
204
sender_id,
205
event: "chat",
206
history: [
207
{
208
author_id: sender_id,
209
content: input,
210
date: time_stamp_str,
211
},
212
],
213
date: time_stamp_str,
214
reply_to: reply_to?.toISOString(),
215
editing: {},
216
};
217
if (trimmedName && !reply_to) {
218
(message as any).name = trimmedName;
219
}
220
this.syncdb.set(message);
221
const messagesState = this.store.get("messages");
222
let selectedThreadKey: string;
223
if (!reply_to) {
224
this.deleteDraft(0);
225
// NOTE: we also clear search, since it's confusing to send a message and not
226
// even see it (if it doesn't match search). We do NOT clear the hashtags though,
227
// since by default the message you are sending has those tags.
228
// Also, only do this clearing when not replying.
229
// For replies search find full threads not individual messages.
230
this.clearAllFilters();
231
selectedThreadKey = `${time_stamp.valueOf()}`;
232
} else {
233
// when replying we make sure that the thread is expanded, since otherwise
234
// our reply won't be visible
235
if (
236
messagesState
237
?.getIn([`${reply_to.valueOf()}`, "folding"])
238
?.includes(sender_id)
239
) {
240
this.toggleFoldThread(reply_to);
241
}
242
const root =
243
getThreadRootDate({
244
date: reply_to.valueOf(),
245
messages: messagesState,
246
}) ?? reply_to.valueOf();
247
selectedThreadKey = `${root}`;
248
}
249
if (selectedThreadKey != "0") {
250
this.setSelectedThread(selectedThreadKey);
251
}
252
if (trimmedName && reply_to) {
253
this.renameThread(selectedThreadKey, trimmedName);
254
}
255
256
const project_id = this.store?.get("project_id");
257
const path = this.store?.get("path");
258
if (!path) {
259
throw Error("bug -- path must be defined");
260
}
261
// set notification saying that we sent an actual chat
262
let action;
263
if (
264
noNotification ||
265
mentionsLanguageModel(input) ||
266
this.isLanguageModelThread(reply_to)
267
) {
268
// Note: don't mark it is a chat if it is with chatgpt,
269
// since no point in notifying all collaborators of this.
270
action = "edit";
271
} else {
272
action = "chat";
273
}
274
webapp_client.mark_file({
275
project_id,
276
path,
277
action,
278
ttl: 10000,
279
});
280
track("send_chat", { project_id, path });
281
282
this.save_to_disk();
283
(async () => {
284
await this.processLLM({
285
message,
286
reply_to: reply_to ?? time_stamp,
287
tag,
288
});
289
})();
290
return time_stamp_str;
291
};
292
293
setEditing = (message: ChatMessageTyped, is_editing: boolean) => {
294
if (this.syncdb == null) {
295
// WARNING: give an error or try again later?
296
return;
297
}
298
const author_id = this.redux.getStore("account").get_account_id();
299
300
// "FUTURE" = save edit changes
301
const editing = message
302
.get("editing")
303
.set(author_id, is_editing ? "FUTURE" : null);
304
305
// console.log("Currently Editing:", editing.toJS())
306
this.syncdb.set({
307
history: message.get("history").toJS(),
308
editing: editing.toJS(),
309
date: message.get("date").toISOString(),
310
});
311
// commit now so others users know this user is editing
312
this.syncdb.commit();
313
};
314
315
// Used to edit sent messages.
316
// NOTE: this is inefficient; it assumes
317
// the number of edits is small, which is reasonable -- nobody makes hundreds of distinct
318
// edits of a single message.
319
sendEdit = (message: ChatMessageTyped, content: string): void => {
320
if (this.syncdb == null) {
321
// WARNING: give an error or try again later?
322
return;
323
}
324
const author_id = this.redux.getStore("account").get_account_id();
325
// OPTIMIZATION: send less data over the network?
326
const date = webapp_client.server_time().toISOString();
327
328
this.syncdb.set({
329
history: addToHistory(
330
message.get("history").toJS() as unknown as MessageHistory[],
331
{
332
author_id,
333
content,
334
date,
335
},
336
),
337
editing: message.get("editing").set(author_id, null).toJS(),
338
date: message.get("date").toISOString(),
339
});
340
this.deleteDraft(message.get("date")?.valueOf());
341
this.save_to_disk();
342
};
343
344
saveHistory = (
345
message: ChatMessage,
346
content: string,
347
author_id: string,
348
generating: boolean = false,
349
): {
350
date: string;
351
prevHistory: MessageHistory[];
352
} => {
353
const date: string =
354
typeof message.date === "string"
355
? message.date
356
: message.date?.toISOString();
357
if (this.syncdb == null) {
358
return { date, prevHistory: [] };
359
}
360
const prevHistory: MessageHistory[] = message.history ?? [];
361
this.syncdb.set({
362
history: addToHistory(prevHistory, {
363
author_id,
364
content,
365
}),
366
date,
367
generating,
368
});
369
return { date, prevHistory };
370
};
371
372
sendReply = ({
373
message,
374
reply,
375
from,
376
noNotification,
377
reply_to,
378
submitMentionsRef,
379
}: {
380
message: ChatMessage;
381
reply?: string;
382
from?: string;
383
noNotification?: boolean;
384
reply_to?: Date;
385
submitMentionsRef?;
386
}): string => {
387
const store = this.store;
388
if (store == null) {
389
return "";
390
}
391
// the reply_to field of the message is *always* the root.
392
// the order of the replies is by timestamp. This is meant
393
// to make sure chat is just 1 layer deep, rather than a
394
// full tree structure, which is powerful but too confusing.
395
const reply_to_value =
396
reply_to != null
397
? reply_to.valueOf()
398
: getThreadRootDate({
399
date: new Date(message.date).valueOf(),
400
messages: store.get("messages"),
401
});
402
const time_stamp_str = this.sendChat({
403
input: reply,
404
submitMentionsRef,
405
sender_id: from ?? this.redux.getStore("account").get_account_id(),
406
reply_to: new Date(reply_to_value),
407
noNotification,
408
});
409
// negative date of reply_to root is used for replies.
410
this.deleteDraft(-reply_to_value);
411
return time_stamp_str;
412
};
413
414
deleteDraft = (
415
date: number,
416
commit: boolean = true,
417
sender_id: string | undefined = undefined,
418
) => {
419
if (!this.syncdb) return;
420
sender_id = sender_id ?? this.redux.getStore("account").get_account_id();
421
this.syncdb.delete({
422
event: "draft",
423
sender_id,
424
date,
425
});
426
if (commit) {
427
this.syncdb.commit();
428
}
429
};
430
431
// Make sure everything saved to DISK.
432
save_to_disk = async (): Promise<void> => {
433
this.syncdb?.save_to_disk();
434
};
435
436
private _llmEstimateCost = async ({
437
input,
438
date,
439
message,
440
}: {
441
input: string;
442
// date is as in chat/input.tsx -- so 0 for main input and -ms for reply
443
date: number;
444
// in case of reply/edit, so we can get the entire thread
445
message?: ChatMessage;
446
}): Promise<void> => {
447
if (!this.store) {
448
return;
449
}
450
451
const is_cocalc_com = this.redux.getStore("customize").get("is_cocalc_com");
452
if (!is_cocalc_com) {
453
return;
454
}
455
// this is either a new message or in a reply, but mentions an LLM
456
let model: LanguageModel | null | false = getLanguageModel(input);
457
input = stripMentions(input);
458
let history: string[] = [];
459
const messages = this.store.get("messages");
460
// message != null means this is a reply or edit and we have to get the whole chat thread
461
if (!model && message != null && messages != null) {
462
const root = getReplyToRoot({ message, messages });
463
model = this.isLanguageModelThread(root);
464
if (!isFreeModel(model, is_cocalc_com) && root != null) {
465
for (const msg of this.getLLMHistory(root)) {
466
history.push(msg.content);
467
}
468
}
469
}
470
if (model) {
471
if (isFreeModel(model, is_cocalc_com)) {
472
this.setCostEstimate({ date, min: 0, max: 0 });
473
} else {
474
const llm_markup = this.redux.getStore("customize").get("llm_markup");
475
// do not import until needed -- it is HUGE!
476
const { getMaxTokens, numTokensEstimate } =
477
await import("@cocalc/frontend/misc/llm");
478
const maxTokens = getMaxTokens(model);
479
const tokens = numTokensEstimate(
480
[input, ...history].join("\n"),
481
maxTokens,
482
);
483
const { min, max } = calcMinMaxEstimation(tokens, model, llm_markup);
484
this.setCostEstimate({ date, min, max });
485
}
486
} else {
487
this.setCostEstimate();
488
}
489
};
490
491
llmEstimateCost: typeof this._llmEstimateCost = debounce(
492
reuseInFlight(this._llmEstimateCost),
493
1000,
494
{ leading: true, trailing: true },
495
);
496
497
private setCostEstimate = (
498
costEstimate: {
499
date: number;
500
min: number;
501
max: number;
502
} | null = null,
503
) => {
504
this.frameTreeActions?.set_frame_data({
505
id: this.frameId,
506
costEstimate,
507
});
508
};
509
510
// returns number of deleted messages
511
// threadKey = iso timestamp root of thread.
512
deleteThread = (threadKey: string): number => {
513
if (this.syncdb == null || this.store == null) {
514
return 0;
515
}
516
const messages = this.store.get("messages");
517
if (messages == null) {
518
return 0;
519
}
520
const rootTarget = parseInt(`${threadKey}`);
521
if (!isFinite(rootTarget)) {
522
return 0;
523
}
524
let deleted = 0;
525
for (const [_, message] of messages) {
526
if (message == null) continue;
527
const dateField = message.get("date");
528
let dateValue: number | undefined;
529
let dateIso: string | undefined;
530
if (dateField instanceof Date) {
531
dateValue = dateField.valueOf();
532
dateIso = dateField.toISOString();
533
} else if (typeof dateField === "number") {
534
dateValue = dateField;
535
dateIso = new Date(dateField).toISOString();
536
} else if (typeof dateField === "string") {
537
const t = Date.parse(dateField);
538
dateValue = isNaN(t) ? undefined : t;
539
dateIso = dateField;
540
}
541
if (dateValue == null || dateIso == null) {
542
continue;
543
}
544
const rootDate =
545
getThreadRootDate({ date: dateValue, messages }) || dateValue;
546
if (rootDate !== rootTarget) {
547
continue;
548
}
549
this.syncdb.delete({ event: "chat", date: dateIso });
550
deleted++;
551
}
552
if (deleted > 0) {
553
this.syncdb.commit();
554
}
555
return deleted;
556
};
557
558
renameThread = (threadKey: string, name: string): boolean => {
559
if (this.syncdb == null) {
560
return false;
561
}
562
const entry = this.getThreadRootDoc(threadKey);
563
if (entry == null) {
564
return false;
565
}
566
const trimmed = name.trim();
567
if (trimmed) {
568
entry.doc.name = trimmed;
569
} else {
570
delete entry.doc.name;
571
}
572
this.syncdb.set(entry.doc);
573
this.syncdb.commit();
574
return true;
575
};
576
577
setThreadPin = (threadKey: string, pinned: boolean): boolean => {
578
if (this.syncdb == null) {
579
return false;
580
}
581
const entry = this.getThreadRootDoc(threadKey);
582
if (entry == null) {
583
return false;
584
}
585
if (pinned) {
586
entry.doc.pin = true;
587
} else {
588
entry.doc.pin = false;
589
}
590
this.syncdb.set(entry.doc);
591
this.syncdb.commit();
592
return true;
593
};
594
595
markThreadRead = (
596
threadKey: string,
597
count: number,
598
commit = true,
599
): boolean => {
600
if (this.syncdb == null) {
601
return false;
602
}
603
const account_id = this.redux.getStore("account").get_account_id();
604
if (!account_id || !Number.isFinite(count)) {
605
return false;
606
}
607
const entry = this.getThreadRootDoc(threadKey);
608
if (entry == null) {
609
return false;
610
}
611
entry.doc[`read-${account_id}`] = count;
612
this.syncdb.set(entry.doc);
613
if (commit) {
614
this.syncdb.commit();
615
}
616
return true;
617
};
618
619
private getThreadRootDoc = (
620
threadKey: string,
621
): { doc: any; message: ChatMessageTyped } | null => {
622
if (this.store == null) {
623
return null;
624
}
625
const messages = this.store.get("messages");
626
if (messages == null) {
627
return null;
628
}
629
const normalizedKey = toMsString(threadKey);
630
const fallbackKey = `${parseInt(threadKey, 10)}`;
631
const candidates = [normalizedKey, threadKey, fallbackKey];
632
let message: ChatMessageTyped | undefined;
633
for (const key of candidates) {
634
if (!key) continue;
635
message = messages.get(key);
636
if (message != null) break;
637
}
638
if (message == null) {
639
return null;
640
}
641
const dateField = message.get("date");
642
const dateIso =
643
dateField instanceof Date
644
? dateField.toISOString()
645
: typeof dateField === "string"
646
? dateField
647
: new Date(dateField).toISOString();
648
if (!dateIso) {
649
return null;
650
}
651
const doc = { ...message.toJS(), date: dateIso };
652
return { doc, message };
653
};
654
655
save_scroll_state = (position, height, offset): void => {
656
if (height == 0) {
657
// height == 0 means chat room is not rendered
658
return;
659
}
660
this.setState({ saved_position: position, height, offset });
661
};
662
663
// scroll to the bottom of the chat log
664
// if date is given, scrolls to the bottom of the chat *thread*
665
// that starts with that date.
666
// safe to call after closing actions.
667
clearScrollRequest = () => {
668
this.frameTreeActions?.set_frame_data({
669
id: this.frameId,
670
scrollToIndex: null,
671
scrollToDate: null,
672
});
673
};
674
675
scrollToIndex = (index: number = -1) => {
676
if (this.syncdb == null) return;
677
// we first clear, then set it, since scroll to needs to
678
// work even if it is the same as last time.
679
// TODO: alternatively, we could get a reference
680
// to virtuoso and directly control things from here.
681
this.clearScrollRequest();
682
setTimeout(() => {
683
this.frameTreeActions?.set_frame_data({
684
id: this.frameId,
685
scrollToIndex: index,
686
scrollToDate: null,
687
});
688
}, 1);
689
};
690
691
scrollToBottom = () => {
692
this.scrollToIndex(Number.MAX_SAFE_INTEGER);
693
};
694
695
// this scrolls the message with given date into view and sets it as the selected message.
696
scrollToDate = (date) => {
697
this.clearScrollRequest();
698
this.frameTreeActions?.set_frame_data({
699
id: this.frameId,
700
fragmentId: toMsString(date),
701
});
702
this.setFragment(date);
703
setTimeout(() => {
704
this.frameTreeActions?.set_frame_data({
705
id: this.frameId,
706
// string version of ms since epoch, which is the key
707
// in the messages immutable Map
708
scrollToDate: toMsString(date),
709
scrollToIndex: null,
710
});
711
}, 1);
712
};
713
714
// Scan through all messages and figure out what hashtags are used.
715
// Of course, at some point we should try to use efficient algorithms
716
// to make this faster incrementally.
717
update_hashtags = (): void => {};
718
719
// Exports the currently visible chats to a markdown file and opens it.
720
export_to_markdown = async (): Promise<void> => {
721
if (!this.store) return;
722
const messages = this.store.get("messages");
723
if (messages == null) return;
724
const path = this.store.get("path") + ".md";
725
const project_id = this.store.get("project_id");
726
if (project_id == null) return;
727
const account_id = this.redux.getStore("account").get_account_id();
728
const { dates } = getSortedDates(
729
messages,
730
this.store.get("search"),
731
account_id,
732
);
733
const v: string[] = [];
734
for (const date of dates) {
735
const message = messages.get(date);
736
if (message == null) continue;
737
v.push(message_to_markdown(message));
738
}
739
const content = v.join("\n\n---\n\n");
740
await webapp_client.project_client.write_text_file({
741
project_id,
742
path,
743
content,
744
});
745
this.redux
746
.getProjectActions(project_id)
747
.open_file({ path, foreground: true });
748
};
749
750
setHashtagState = (tag: string, state?: HashtagState): void => {
751
if (!this.store || this.frameTreeActions == null) return;
752
// similar code in task list.
753
let selectedHashtags: SelectedHashtags =
754
this.frameTreeActions._get_frame_data(this.frameId, "selectedHashtags") ??
755
immutableMap<string, HashtagState>();
756
selectedHashtags =
757
state == null
758
? selectedHashtags.delete(tag)
759
: selectedHashtags.set(tag, state);
760
this.setSelectedHashtags(selectedHashtags);
761
};
762
763
help = () => {
764
open_new_tab("https://doc.cocalc.com/chat.html");
765
};
766
767
undo = () => {
768
this.syncdb?.undo();
769
};
770
771
redo = () => {
772
this.syncdb?.redo();
773
};
774
775
/**
776
* This checks a thread of messages to see if it is a language model thread and if so, returns it.
777
*/
778
isLanguageModelThread = (date?: Date): false | LanguageModel => {
779
if (date == null || this.store == null) {
780
return false;
781
}
782
const messages = this.store.get("messages");
783
if (messages == null) {
784
return false;
785
}
786
const rootMs =
787
getThreadRootDate({ date: date.valueOf(), messages }) || date.valueOf();
788
const entry = this.getThreadRootDoc(`${rootMs}`);
789
const rootMessage = entry?.message;
790
if (rootMessage == null) {
791
return false;
792
}
793
794
const thread = this.getMessagesInThread(
795
rootMessage.get("date")?.toISOString?.() ?? `${rootMs}`,
796
);
797
if (thread == null) {
798
return false;
799
}
800
801
const firstMessage = thread.first();
802
if (firstMessage == null) {
803
return false;
804
}
805
const firstHistory = firstMessage.get("history")?.first();
806
if (firstHistory == null) {
807
return false;
808
}
809
const sender_id = firstHistory.get("author_id");
810
if (isLanguageModelService(sender_id)) {
811
return service2model(sender_id);
812
}
813
const input = firstHistory.get("content")?.toLowerCase();
814
if (mentionsLanguageModel(input)) {
815
return getLanguageModel(input);
816
}
817
return false;
818
};
819
820
private processLLM = async ({
821
message,
822
reply_to,
823
tag,
824
llm,
825
dateLimit,
826
}: {
827
message: ChatMessage;
828
reply_to?: Date;
829
tag?: string;
830
llm?: LanguageModel;
831
dateLimit?: Date; // only for regenerate, filter history
832
}) => {
833
const store = this.store;
834
if (this.syncdb == null || !store) {
835
console.warn("processLLM called before chat actions initialized");
836
return;
837
}
838
if (
839
!tag &&
840
!reply_to &&
841
!redux
842
.getProjectsStore()
843
.hasLanguageModelEnabled(this.store?.get("project_id"))
844
) {
845
// No need to check whether a language model is enabled at all.
846
// We only do this check if tag is not set, e.g., directly typing @chatgpt
847
// into the input box. If the tag is set, then the request to use
848
// an LLM came from some place, e.g., the "Explain" button, so
849
// we trust that.
850
// We also do the check when replying.
851
return;
852
}
853
// if an llm is explicitly set, we only allow that for regenerate and we also check if it is enabled and selectable by the user
854
if (typeof llm === "string") {
855
if (tag !== "regenerate") {
856
console.warn(`chat/llm: llm=${llm} is only allowed for tag=regenerate`);
857
return;
858
}
859
}
860
if (tag !== "regenerate" && !isValidUUID(message.history?.[0]?.author_id)) {
861
// do NOT respond to a message that an LLM is sending,
862
// because that would result in an infinite recursion.
863
// Note: LLMs do not use a valid UUID, but a special string.
864
// For regenerate, we delete the last message, though…
865
return;
866
}
867
let input = message.history?.[0]?.content as string | undefined;
868
// if there is no input in the last message, something is really wrong
869
if (input == null) return;
870
// there are cases, where there is nothing in the last message – but we want to regenerate it
871
if (!input && tag !== "regenerate") return;
872
873
let model: LanguageModel | false = false;
874
if (llm != null) {
875
// This is a request to regenerate the last message with a specific model.
876
// The message.tsx/RegenerateLLM component already checked if the LLM is enabled and selectable by the user.
877
// ATTN: we trust that information!
878
model = llm;
879
} else if (!mentionsLanguageModel(input)) {
880
// doesn't mention a language model explicitly, but might be a reply to something that does:
881
if (reply_to == null) {
882
return;
883
}
884
model = this.isLanguageModelThread(reply_to);
885
if (!model) {
886
// definitely not a language model chat situation
887
return;
888
}
889
} else {
890
// it mentions a language model -- which one?
891
model = getLanguageModel(input);
892
}
893
894
if (model === false) {
895
return;
896
}
897
898
// without any mentions, of course:
899
input = stripMentions(input);
900
// also important to strip details, since they tend to confuse an LLM:
901
//input = stripDetails(input);
902
const sender_id = (function () {
903
try {
904
return model2service(model);
905
} catch {
906
return model;
907
}
908
})();
909
910
const thinking = ":robot: Thinking...";
911
// prevHistory: in case of regenerate, it's the history *before* we added the "Thinking..." message (which we ignore)
912
const { date, prevHistory = [] } =
913
tag === "regenerate"
914
? this.saveHistory(message, thinking, sender_id, true)
915
: {
916
date: this.sendReply({
917
message,
918
reply: thinking,
919
from: sender_id,
920
noNotification: true,
921
reply_to,
922
}),
923
};
924
925
if (this.chatStreams.size > MAX_CHAT_STREAM) {
926
console.trace(
927
`processLanguageModel called when ${MAX_CHAT_STREAM} streams active`,
928
);
929
if (this.syncdb != null) {
930
// This should never happen in normal use, but could prevent an expensive blowup due to a bug.
931
this.syncdb.set({
932
date,
933
history: [
934
{
935
author_id: sender_id,
936
content: `\n\n<span style='color:#b71c1c'>There are already ${MAX_CHAT_STREAM} language model responses being written. Please try again once one finishes.</span>\n\n`,
937
date,
938
},
939
],
940
event: "chat",
941
sender_id,
942
});
943
this.syncdb.commit();
944
}
945
return;
946
}
947
948
// keep updating when the LLM is doing something:
949
const project_id = store.get("project_id");
950
const path = store.get("path");
951
if (!tag && reply_to) {
952
tag = "reply";
953
}
954
955
// record that we're about to submit message to a language model.
956
track("chatgpt", {
957
project_id,
958
path,
959
type: "chat",
960
is_reply: !!reply_to,
961
tag,
962
model,
963
});
964
965
// submit question to the given language model
966
const id = uuid();
967
this.chatStreams.add(id);
968
setTimeout(
969
() => {
970
this.chatStreams.delete(id);
971
},
972
3 * 60 * 1000,
973
);
974
975
// construct the LLM history for the given thread
976
const history = reply_to ? this.getLLMHistory(reply_to) : undefined;
977
978
if (tag === "regenerate") {
979
if (history && history.length >= 2) {
980
history.pop(); // remove the last LLM message, which is the one we're regenerating
981
982
// if dateLimit is earlier than the last message's date, remove the last two
983
while (dateLimit != null && history.length >= 2) {
984
const last = history[history.length - 1];
985
if (last.date != null && last.date > dateLimit) {
986
history.pop();
987
history.pop();
988
} else {
989
break;
990
}
991
}
992
993
input = stripMentions(history.pop()?.content ?? ""); // the last user message is the input
994
} else {
995
console.warn(
996
`chat/llm: regenerate called without enough history for thread starting at ${reply_to}`,
997
);
998
return;
999
}
1000
}
1001
1002
const chatStream = webapp_client.openai_client.queryStream({
1003
input,
1004
history,
1005
project_id,
1006
path,
1007
model,
1008
tag,
1009
});
1010
1011
// The sender_id might change if we explicitly set the LLM model.
1012
if (tag === "regenerate" && llm != null) {
1013
if (!this.store) return;
1014
const messages = this.store.get("messages");
1015
if (!messages) return;
1016
if (message.sender_id !== sender_id) {
1017
// if that happens, create a new message with the existing history and the new sender_id
1018
const cur = this.syncdb.get_one({ event: "chat", date });
1019
if (cur == null) return;
1020
const reply_to = getReplyToRoot({
1021
message: cur.toJS() as any as ChatMessage,
1022
messages,
1023
});
1024
this.syncdb.delete({ event: "chat", date });
1025
this.syncdb.set({
1026
date,
1027
history: cur?.get("history") ?? [],
1028
event: "chat",
1029
sender_id,
1030
reply_to,
1031
});
1032
}
1033
}
1034
1035
let content: string = "";
1036
let halted = false;
1037
1038
chatStream.on("token", (token) => {
1039
if (halted || this.syncdb == null) {
1040
return;
1041
}
1042
1043
// we check if user clicked on the "stop generating" button
1044
const cur = this.syncdb.get_one({ event: "chat", date });
1045
if (cur?.get("generating") === false) {
1046
halted = true;
1047
this.chatStreams.delete(id);
1048
return;
1049
}
1050
1051
// collect more of the output
1052
if (token != null) {
1053
content += token;
1054
}
1055
1056
const msg: ChatMessage = {
1057
event: "chat",
1058
sender_id,
1059
date: new Date(date),
1060
history: addToHistory(prevHistory, {
1061
author_id: sender_id,
1062
content,
1063
}),
1064
generating: token != null, // it's generating as token is not null
1065
reply_to: reply_to?.toISOString(),
1066
};
1067
this.syncdb.set(msg);
1068
1069
// if it was the last output, close this
1070
if (token == null) {
1071
this.chatStreams.delete(id);
1072
this.syncdb.commit();
1073
}
1074
});
1075
1076
chatStream.on("error", (err) => {
1077
this.chatStreams.delete(id);
1078
if (this.syncdb == null || halted) return;
1079
1080
if (!model) {
1081
throw new Error(
1082
`bug: No model set, but we're in language model error handler`,
1083
);
1084
}
1085
1086
const vendor = model2vendor(model);
1087
const statusCheck = getLLMServiceStatusCheckMD(vendor.name);
1088
content += `\n\n<span style='color:#b71c1c'>${err}</span>\n\n---\n\n${statusCheck}`;
1089
const msg: ChatMessage = {
1090
event: "chat",
1091
sender_id,
1092
date: new Date(date),
1093
history: addToHistory(prevHistory, {
1094
author_id: sender_id,
1095
content,
1096
}),
1097
generating: false,
1098
reply_to: reply_to?.toISOString(),
1099
};
1100
this.syncdb.set(msg);
1101
this.syncdb.commit();
1102
});
1103
};
1104
1105
/**
1106
* @param dateStr - the ISO date of the message to get the thread for
1107
* @returns - the messages in the thread, sorted by date
1108
*/
1109
private getMessagesInThread = (
1110
dateStr: string,
1111
): Seq.Indexed<ChatMessageTyped> | undefined => {
1112
const messages = this.store?.get("messages");
1113
if (messages == null) {
1114
return;
1115
}
1116
1117
return (
1118
messages // @ts-ignore -- immutablejs typings are wrong (?)
1119
.filter(
1120
(message) =>
1121
message.get("reply_to") == dateStr ||
1122
message.get("date").toISOString() == dateStr,
1123
)
1124
// @ts-ignore -- immutablejs typings are wrong (?)
1125
.valueSeq()
1126
.sort((a, b) => cmp(a.get("date"), b.get("date")))
1127
);
1128
};
1129
1130
// the input and output for the thread ending in the
1131
// given message, formatted for querying a language model, and heuristically
1132
// truncated to not exceed a limit in size.
1133
private getLLMHistory = (reply_to: Date): LanguageModelHistory => {
1134
const history: LanguageModelHistory = [];
1135
// Next get all of the messages with this reply_to or that are the root of this reply chain:
1136
const d = reply_to.toISOString();
1137
const threadMessages = this.getMessagesInThread(d);
1138
if (!threadMessages) return history;
1139
1140
for (const message of threadMessages) {
1141
const mostRecent = message.get("history")?.first();
1142
// there must be at least one history entry, otherwise the message is broken
1143
if (!mostRecent) continue;
1144
const content = stripMentions(mostRecent.get("content"));
1145
// We take the message's sender ID, not the most recent version from the history
1146
// Why? e.g. a user could have edited an LLM message, which should still count as an LLM message
1147
// otherwise the forth-and-back between AI and human would be broken.
1148
const sender_id = message.get("sender_id");
1149
const role = isLanguageModelService(sender_id) ? "assistant" : "user";
1150
const date = message.get("date");
1151
history.push({ content, role, date });
1152
}
1153
return history;
1154
};
1155
1156
languageModelStopGenerating = (date: Date) => {
1157
if (this.syncdb == null) return;
1158
this.syncdb.set({
1159
event: "chat",
1160
date: date.toISOString(),
1161
generating: false,
1162
});
1163
this.syncdb.commit();
1164
};
1165
1166
summarizeThread = async ({
1167
model,
1168
reply_to,
1169
returnInfo,
1170
short,
1171
}: {
1172
model: LanguageModel;
1173
reply_to?: string;
1174
returnInfo?: boolean; // do not send, but return prompt + info}
1175
short: boolean;
1176
}) => {
1177
if (!reply_to) {
1178
return;
1179
}
1180
const user_map = redux.getStore("users").get("user_map");
1181
if (!user_map) {
1182
return;
1183
}
1184
const threadMessages = this.getMessagesInThread(reply_to);
1185
if (!threadMessages) {
1186
return;
1187
}
1188
1189
const history: { author: string; content: string }[] = [];
1190
for (const message of threadMessages) {
1191
const mostRecent = message.get("history")?.first();
1192
if (!mostRecent) continue;
1193
const sender_id: string | undefined = message.get("sender_id");
1194
const author = getUserName(user_map, sender_id);
1195
const content = stripMentions(mostRecent.get("content"));
1196
history.push({ author, content });
1197
}
1198
1199
const txtFull = [
1200
"<details><summary>Chat history</summary>",
1201
...history.map(({ author, content }) => `${author}:\n${content}`),
1202
"</details>",
1203
].join("\n\n");
1204
1205
// do not import until needed -- it is HUGE!
1206
const { truncateMessage, getMaxTokens, numTokensEstimate } =
1207
await import("@cocalc/frontend/misc/llm");
1208
const maxTokens = getMaxTokens(model);
1209
const txt = truncateMessage(txtFull, maxTokens);
1210
const m = returnInfo ? `@${modelToName(model)}` : modelToMention(model);
1211
const instruction = short
1212
? `Briefly summarize the provided chat conversation in one paragraph`
1213
: `Summarize the provided chat conversation. Make a list of all topics, the main conclusions, assigned tasks, and a sentiment score.`;
1214
const prompt = `${m} ${instruction}:\n\n${txt}`;
1215
1216
if (returnInfo) {
1217
const tokens = numTokensEstimate(prompt, getMaxTokens(model));
1218
return { prompt, tokens, truncated: txtFull != txt };
1219
} else {
1220
this.sendChat({
1221
input: prompt,
1222
tag: `chat:summarize`,
1223
noNotification: true,
1224
});
1225
this.scrollToIndex();
1226
}
1227
};
1228
1229
regenerateLLMResponse = async (date0: Date, llm?: LanguageModel) => {
1230
if (this.syncdb == null) return;
1231
const date = date0.toISOString();
1232
const obj = this.syncdb.get_one({ event: "chat", date });
1233
if (obj == null) {
1234
return;
1235
}
1236
const message = processSyncDBObj(obj.toJS() as ChatMessage);
1237
if (message == null) {
1238
return;
1239
}
1240
const reply_to = message.reply_to;
1241
if (!reply_to) return;
1242
await this.processLLM({
1243
message,
1244
reply_to: new Date(reply_to),
1245
tag: "regenerate",
1246
llm,
1247
dateLimit: date0,
1248
});
1249
1250
if (llm != null) {
1251
setDefaultLLM(llm);
1252
}
1253
};
1254
1255
showTimeTravelInNewTab = () => {
1256
const store = this.store;
1257
if (store == null) return;
1258
redux.getProjectActions(store.get("project_id")!).open_file({
1259
path: history_path(store.get("path")!),
1260
foreground: true,
1261
foreground_project: true,
1262
});
1263
};
1264
1265
clearAllFilters = () => {
1266
if (this.frameTreeActions == null) {
1267
// crappy code just for sage worksheets -- will go away.
1268
return;
1269
}
1270
this.setSearch("");
1271
this.setFilterRecentH(0);
1272
this.setSelectedHashtags({});
1273
};
1274
1275
setSearch = (search) => {
1276
this.frameTreeActions?.set_frame_data({ id: this.frameId, search });
1277
};
1278
1279
setFilterRecentH = (filterRecentH) => {
1280
this.frameTreeActions?.set_frame_data({ id: this.frameId, filterRecentH });
1281
};
1282
1283
setSelectedHashtags = (selectedHashtags) => {
1284
this.frameTreeActions?.set_frame_data({
1285
id: this.frameId,
1286
selectedHashtags,
1287
});
1288
};
1289
1290
setFragment = (date?) => {
1291
let fragmentId;
1292
if (!date) {
1293
Fragment.clear();
1294
fragmentId = "";
1295
} else {
1296
fragmentId = toMsString(date);
1297
Fragment.set({ chat: fragmentId });
1298
}
1299
this.frameTreeActions?.set_frame_data({ id: this.frameId, fragmentId });
1300
};
1301
1302
setShowPreview = (showPreview) => {
1303
this.frameTreeActions?.set_frame_data({
1304
id: this.frameId,
1305
showPreview,
1306
});
1307
};
1308
1309
setSelectedThread = (threadKey: string | null) => {
1310
this.frameTreeActions?.set_frame_data({
1311
id: this.frameId,
1312
selectedThreadKey: threadKey,
1313
});
1314
};
1315
}
1316
1317
// We strip out any cased version of the string @chatgpt and also all mentions.
1318
function stripMentions(value: string): string {
1319
for (const name of ["@chatgpt4", "@chatgpt"]) {
1320
while (true) {
1321
const i = value.toLowerCase().indexOf(name);
1322
if (i == -1) break;
1323
value = value.slice(0, i) + value.slice(i + name.length);
1324
}
1325
}
1326
// The mentions looks like this: <span class="user-mention" account-id=openai-... >@ChatGPT</span> ...
1327
while (true) {
1328
const i = value.indexOf('<span class="user-mention"');
1329
if (i == -1) break;
1330
const j = value.indexOf("</span>", i);
1331
if (j == -1) break;
1332
value = value.slice(0, i) + value.slice(j + "</span>".length);
1333
}
1334
return value.trim();
1335
}
1336
1337
// not necessary
1338
// // Remove instances of <details> and </details> from value:
1339
// function stripDetails(value: string): string {
1340
// return value.replace(/<details>/g, "").replace(/<\/details>/g, "");
1341
// }
1342
1343
function mentionsLanguageModel(input?: string): boolean {
1344
const x = input?.toLowerCase() ?? "";
1345
1346
// if any of these prefixes are in the input as "account-id=[prefix]", then return true
1347
const sys = LANGUAGE_MODEL_PREFIXES.some((prefix) =>
1348
x.includes(`account-id=${prefix}`),
1349
);
1350
return sys || x.includes(`account-id=${USER_LLM_PREFIX}`);
1351
}
1352
1353
/**
1354
* For the given content of a message, this tries to extract a mentioned language model.
1355
*/
1356
function getLanguageModel(input?: string): false | LanguageModel {
1357
if (!input) return false;
1358
const x = input.toLowerCase();
1359
if (x.includes("account-id=chatgpt4")) {
1360
return "gpt-4";
1361
}
1362
if (x.includes("account-id=chatgpt")) {
1363
return "gpt-3.5-turbo";
1364
}
1365
// these prefixes should come from util/db-schema/openai::model2service
1366
for (const vendorPrefix of LANGUAGE_MODEL_PREFIXES) {
1367
const prefix = `account-id=${vendorPrefix}`;
1368
const i = x.indexOf(prefix);
1369
if (i != -1) {
1370
const j = x.indexOf(">", i);
1371
const model = x.slice(i + prefix.length, j).trim() as LanguageModel;
1372
// for now, ollama must be prefixed – in the future, all model names should have a vendor prefix!
1373
if (vendorPrefix === OLLAMA_PREFIX) {
1374
return toOllamaModel(model);
1375
}
1376
if (vendorPrefix === CUSTOM_OPENAI_PREFIX) {
1377
return toCustomOpenAIModel(model);
1378
}
1379
if (vendorPrefix === USER_LLM_PREFIX) {
1380
return `${USER_LLM_PREFIX}${model}`;
1381
}
1382
return model;
1383
}
1384
}
1385
return false;
1386
}
1387
1388
/**
1389
* This uniformly defines how the history of a message is composed.
1390
* The newest entry is in the front of the array.
1391
* If the date isn't set (ISO string), we set it to the current time.
1392
*/
1393
function addToHistory(
1394
history: MessageHistory[],
1395
next: Optional<MessageHistory, "date">,
1396
): MessageHistory[] {
1397
const {
1398
author_id,
1399
content,
1400
date = webapp_client.server_time().toISOString(),
1401
} = next;
1402
// inserted at the beginning of the history, without modifying the array
1403
return [{ author_id, content, date }, ...history];
1404
}
1405
1406