CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

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

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