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