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