Path: blob/master/src/packages/frontend/chat/actions.ts
1496 views
/*1* This file is part of CoCalc: Copyright © 2020 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,170}: {171input?: string;172sender_id?: string;173reply_to?: Date;174tag?: string;175noNotification?: boolean;176submitMentionsRef?;177}): string => {178if (this.syncdb == null || this.store == null) {179console.warn("attempt to sendChat before chat actions initialized");180// WARNING: give an error or try again later?181return "";182}183const time_stamp: Date = webapp_client.server_time();184const time_stamp_str = time_stamp.toISOString();185if (submitMentionsRef?.current != null) {186input = submitMentionsRef.current?.({ chat: `${time_stamp.valueOf()}` });187}188input = input?.trim();189if (!input) {190// do not send when there is nothing to send.191return "";192}193const message: ChatMessage = {194sender_id,195event: "chat",196history: [197{198author_id: sender_id,199content: input,200date: time_stamp_str,201},202],203date: time_stamp_str,204reply_to: reply_to?.toISOString(),205editing: {},206};207this.syncdb.set(message);208if (!reply_to) {209this.deleteDraft(0);210// NOTE: we also clear search, since it's confusing to send a message and not211// even see it (if it doesn't match search). We do NOT clear the hashtags though,212// since by default the message you are sending has those tags.213// Also, only do this clearing when not replying.214// For replies search find full threads not individual messages.215this.clearAllFilters();216} else {217// when replying we make sure that the thread is expanded, since otherwise218// our reply won't be visible219const messages = this.store.get("messages");220if (221messages222?.getIn([`${reply_to.valueOf()}`, "folding"])223?.includes(sender_id)224) {225this.toggleFoldThread(reply_to);226}227}228229const project_id = this.store?.get("project_id");230const path = this.store?.get("path");231if (!path) {232throw Error("bug -- path must be defined");233}234// set notification saying that we sent an actual chat235let action;236if (237noNotification ||238mentionsLanguageModel(input) ||239this.isLanguageModelThread(reply_to)240) {241// Note: don't mark it is a chat if it is with chatgpt,242// since no point in notifying all collaborators of this.243action = "edit";244} else {245action = "chat";246}247webapp_client.mark_file({248project_id,249path,250action,251ttl: 10000,252});253track("send_chat", { project_id, path });254255this.save_to_disk();256(async () => {257await this.processLLM({258message,259reply_to: reply_to ?? time_stamp,260tag,261});262})();263return time_stamp_str;264};265266setEditing = (message: ChatMessageTyped, is_editing: boolean) => {267if (this.syncdb == null) {268// WARNING: give an error or try again later?269return;270}271const author_id = this.redux.getStore("account").get_account_id();272273// "FUTURE" = save edit changes274const editing = message275.get("editing")276.set(author_id, is_editing ? "FUTURE" : null);277278// console.log("Currently Editing:", editing.toJS())279this.syncdb.set({280history: message.get("history").toJS(),281editing: editing.toJS(),282date: message.get("date").toISOString(),283});284// commit now so others users know this user is editing285this.syncdb.commit();286};287288// Used to edit sent messages.289// NOTE: this is inefficient; it assumes290// the number of edits is small, which is reasonable -- nobody makes hundreds of distinct291// edits of a single message.292sendEdit = (message: ChatMessageTyped, content: string): void => {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();298// OPTIMIZATION: send less data over the network?299const date = webapp_client.server_time().toISOString();300301this.syncdb.set({302history: addToHistory(303message.get("history").toJS() as unknown as MessageHistory[],304{305author_id,306content,307date,308},309),310editing: message.get("editing").set(author_id, null).toJS(),311date: message.get("date").toISOString(),312});313this.deleteDraft(message.get("date")?.valueOf());314this.save_to_disk();315};316317saveHistory = (318message: ChatMessage,319content: string,320author_id: string,321generating: boolean = false,322): {323date: string;324prevHistory: MessageHistory[];325} => {326const date: string =327typeof message.date === "string"328? message.date329: message.date?.toISOString();330if (this.syncdb == null) {331return { date, prevHistory: [] };332}333const prevHistory: MessageHistory[] = message.history ?? [];334this.syncdb.set({335history: addToHistory(prevHistory, {336author_id,337content,338}),339date,340generating,341});342return { date, prevHistory };343};344345sendReply = ({346message,347reply,348from,349noNotification,350reply_to,351submitMentionsRef,352}: {353message: ChatMessage;354reply?: string;355from?: string;356noNotification?: boolean;357reply_to?: Date;358submitMentionsRef?;359}): string => {360const store = this.store;361if (store == null) {362return "";363}364// the reply_to field of the message is *always* the root.365// the order of the replies is by timestamp. This is meant366// to make sure chat is just 1 layer deep, rather than a367// full tree structure, which is powerful but too confusing.368const reply_to_value =369reply_to != null370? reply_to.valueOf()371: getThreadRootDate({372date: new Date(message.date).valueOf(),373messages: store.get("messages"),374});375const time_stamp_str = this.sendChat({376input: reply,377submitMentionsRef,378sender_id: from ?? this.redux.getStore("account").get_account_id(),379reply_to: new Date(reply_to_value),380noNotification,381});382// negative date of reply_to root is used for replies.383this.deleteDraft(-reply_to_value);384return time_stamp_str;385};386387deleteDraft = (388date: number,389commit: boolean = true,390sender_id: string | undefined = undefined,391) => {392if (!this.syncdb) return;393sender_id = sender_id ?? this.redux.getStore("account").get_account_id();394this.syncdb.delete({395event: "draft",396sender_id,397date,398});399if (commit) {400this.syncdb.commit();401}402};403404// Make sure everything saved to DISK.405save_to_disk = async (): Promise<void> => {406this.syncdb?.save_to_disk();407};408409private _llmEstimateCost = async ({410input,411date,412message,413}: {414input: string;415// date is as in chat/input.tsx -- so 0 for main input and -ms for reply416date: number;417// in case of reply/edit, so we can get the entire thread418message?: ChatMessage;419}): Promise<void> => {420if (!this.store) {421return;422}423424const is_cocalc_com = this.redux.getStore("customize").get("is_cocalc_com");425if (!is_cocalc_com) {426return;427}428// this is either a new message or in a reply, but mentions an LLM429let model: LanguageModel | null | false = getLanguageModel(input);430input = stripMentions(input);431let history: string[] = [];432const messages = this.store.get("messages");433// message != null means this is a reply or edit and we have to get the whole chat thread434if (!model && message != null && messages != null) {435const root = getReplyToRoot({ message, messages });436model = this.isLanguageModelThread(root);437if (!isFreeModel(model, is_cocalc_com) && root != null) {438for (const msg of this.getLLMHistory(root)) {439history.push(msg.content);440}441}442}443if (model) {444if (isFreeModel(model, is_cocalc_com)) {445this.setCostEstimate({ date, min: 0, max: 0 });446} else {447const llm_markup = this.redux.getStore("customize").get("llm_markup");448// do not import until needed -- it is HUGE!449const { truncateMessage, getMaxTokens, numTokensUpperBound } =450await import("@cocalc/frontend/misc/llm");451const maxTokens = getMaxTokens(model);452const tokens = numTokensUpperBound(453truncateMessage([input, ...history].join("\n"), maxTokens),454maxTokens,455);456const { min, max } = calcMinMaxEstimation(tokens, model, llm_markup);457this.setCostEstimate({ date, min, max });458}459} else {460this.setCostEstimate();461}462};463464llmEstimateCost: typeof this._llmEstimateCost = debounce(465reuseInFlight(this._llmEstimateCost),4661000,467{ leading: true, trailing: true },468);469470private setCostEstimate = (471costEstimate: {472date: number;473min: number;474max: number;475} | null = null,476) => {477this.frameTreeActions?.set_frame_data({478id: this.frameId,479costEstimate,480});481};482483save_scroll_state = (position, height, offset): void => {484if (height == 0) {485// height == 0 means chat room is not rendered486return;487}488this.setState({ saved_position: position, height, offset });489};490491// scroll to the bottom of the chat log492// if date is given, scrolls to the bottom of the chat *thread*493// that starts with that date.494// safe to call after closing actions.495clearScrollRequest = () => {496this.frameTreeActions?.set_frame_data({497id: this.frameId,498scrollToIndex: null,499scrollToDate: null,500});501};502503scrollToIndex = (index: number = -1) => {504if (this.syncdb == null) return;505// we first clear, then set it, since scroll to needs to506// work even if it is the same as last time.507// TODO: alternatively, we could get a reference508// to virtuoso and directly control things from here.509this.clearScrollRequest();510setTimeout(() => {511this.frameTreeActions?.set_frame_data({512id: this.frameId,513scrollToIndex: index,514scrollToDate: null,515});516}, 1);517};518519scrollToBottom = () => {520this.scrollToIndex(Number.MAX_SAFE_INTEGER);521};522523// this scrolls the message with given date into view and sets it as the selected message.524scrollToDate = (date) => {525this.clearScrollRequest();526this.frameTreeActions?.set_frame_data({527id: this.frameId,528fragmentId: toMsString(date),529});530this.setFragment(date);531setTimeout(() => {532this.frameTreeActions?.set_frame_data({533id: this.frameId,534// string version of ms since epoch, which is the key535// in the messages immutable Map536scrollToDate: toMsString(date),537scrollToIndex: null,538});539}, 1);540};541542// Scan through all messages and figure out what hashtags are used.543// Of course, at some point we should try to use efficient algorithms544// to make this faster incrementally.545update_hashtags = (): void => {};546547// Exports the currently visible chats to a markdown file and opens it.548export_to_markdown = async (): Promise<void> => {549if (!this.store) return;550const messages = this.store.get("messages");551if (messages == null) return;552const path = this.store.get("path") + ".md";553const project_id = this.store.get("project_id");554if (project_id == null) return;555const account_id = this.redux.getStore("account").get_account_id();556const { dates } = getSortedDates(557messages,558this.store.get("search"),559account_id,560);561const v: string[] = [];562for (const date of dates) {563const message = messages.get(date);564if (message == null) continue;565v.push(message_to_markdown(message));566}567const content = v.join("\n\n---\n\n");568await webapp_client.project_client.write_text_file({569project_id,570path,571content,572});573this.redux574.getProjectActions(project_id)575.open_file({ path, foreground: true });576};577578setHashtagState = (tag: string, state?: HashtagState): void => {579if (!this.store || this.frameTreeActions == null) return;580// similar code in task list.581let selectedHashtags: SelectedHashtags =582this.frameTreeActions._get_frame_data(this.frameId, "selectedHashtags") ??583immutableMap<string, HashtagState>();584selectedHashtags =585state == null586? selectedHashtags.delete(tag)587: selectedHashtags.set(tag, state);588this.setSelectedHashtags(selectedHashtags);589};590591help = () => {592open_new_tab("https://doc.cocalc.com/chat.html");593};594595undo = () => {596this.syncdb?.undo();597};598599redo = () => {600this.syncdb?.redo();601};602603/**604* This checks a thread of messages to see if it is a language model thread and if so, returns it.605*/606isLanguageModelThread = (date?: Date): false | LanguageModel => {607if (date == null) {608return false;609}610const thread = this.getMessagesInThread(date.toISOString());611if (thread == null) {612return false;613}614615// We deliberately start at the last most recent message.616// Why? If we use the LLM regenerate dropdown button to change the LLM, we want to keep it.617for (const message of thread.reverse()) {618const lastHistory = message.get("history")?.first();619// this must be an invalid message, because there is no history620if (lastHistory == null) continue;621const sender_id = lastHistory.get("author_id");622if (isLanguageModelService(sender_id)) {623return service2model(sender_id);624}625const input = lastHistory.get("content")?.toLowerCase();626if (mentionsLanguageModel(input)) {627return getLanguageModel(input);628}629}630631return false;632};633634private processLLM = async ({635message,636reply_to,637tag,638llm,639dateLimit,640}: {641message: ChatMessage;642reply_to?: Date;643tag?: string;644llm?: LanguageModel;645dateLimit?: Date; // only for regenerate, filter history646}) => {647const store = this.store;648if (this.syncdb == null || !store) {649console.warn("processLLM called before chat actions initialized");650return;651}652if (653!tag &&654!reply_to &&655!redux656.getProjectsStore()657.hasLanguageModelEnabled(this.store?.get("project_id"))658) {659// No need to check whether a language model is enabled at all.660// We only do this check if tag is not set, e.g., directly typing @chatgpt661// into the input box. If the tag is set, then the request to use662// an LLM came from some place, e.g., the "Explain" button, so663// we trust that.664// We also do the check when replying.665return;666}667// if an llm is explicitly set, we only allow that for regenerate and we also check if it is enabled and selectable by the user668if (typeof llm === "string") {669if (tag !== "regenerate") {670console.warn(`chat/llm: llm=${llm} is only allowed for tag=regenerate`);671return;672}673}674if (tag !== "regenerate" && !isValidUUID(message.history?.[0]?.author_id)) {675// do NOT respond to a message that an LLM is sending,676// because that would result in an infinite recursion.677// Note: LLMs do not use a valid UUID, but a special string.678// For regenerate, we delete the last message, though…679return;680}681let input = message.history?.[0]?.content as string | undefined;682// if there is no input in the last message, something is really wrong683if (input == null) return;684// there are cases, where there is nothing in the last message – but we want to regenerate it685if (!input && tag !== "regenerate") return;686687let model: LanguageModel | false = false;688if (llm != null) {689// This is a request to regenerate the last message with a specific model.690// The message.tsx/RegenerateLLM component already checked if the LLM is enabled and selectable by the user.691// ATTN: we trust that information!692model = llm;693} else if (!mentionsLanguageModel(input)) {694// doesn't mention a language model explicitly, but might be a reply to something that does:695if (reply_to == null) {696return;697}698model = this.isLanguageModelThread(reply_to);699if (!model) {700// definitely not a language model chat situation701return;702}703} else {704// it mentions a language model -- which one?705model = getLanguageModel(input);706}707708if (model === false) {709return;710}711712// without any mentions, of course:713input = stripMentions(input);714// also important to strip details, since they tend to confuse an LLM:715//input = stripDetails(input);716const sender_id = (function () {717try {718return model2service(model);719} catch {720return model;721}722})();723724const thinking = ":robot: Thinking...";725// prevHistory: in case of regenerate, it's the history *before* we added the "Thinking..." message (which we ignore)726const { date, prevHistory = [] } =727tag === "regenerate"728? this.saveHistory(message, thinking, sender_id, true)729: {730date: this.sendReply({731message,732reply: thinking,733from: sender_id,734noNotification: true,735reply_to,736}),737};738739if (this.chatStreams.size > MAX_CHAT_STREAM) {740console.trace(741`processLanguageModel called when ${MAX_CHAT_STREAM} streams active`,742);743if (this.syncdb != null) {744// This should never happen in normal use, but could prevent an expensive blowup due to a bug.745this.syncdb.set({746date,747history: [748{749author_id: sender_id,750content: `\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`,751date,752},753],754event: "chat",755sender_id,756});757this.syncdb.commit();758}759return;760}761762// keep updating when the LLM is doing something:763const project_id = store.get("project_id");764const path = store.get("path");765if (!tag && reply_to) {766tag = "reply";767}768769// record that we're about to submit message to a language model.770track("chatgpt", {771project_id,772path,773type: "chat",774is_reply: !!reply_to,775tag,776model,777});778779// submit question to the given language model780const id = uuid();781this.chatStreams.add(id);782setTimeout(783() => {784this.chatStreams.delete(id);785},7863 * 60 * 1000,787);788789// construct the LLM history for the given thread790const history = reply_to ? this.getLLMHistory(reply_to) : undefined;791792if (tag === "regenerate") {793if (history && history.length >= 2) {794history.pop(); // remove the last LLM message, which is the one we're regenerating795796// if dateLimit is earlier than the last message's date, remove the last two797while (dateLimit != null && history.length >= 2) {798const last = history[history.length - 1];799if (last.date != null && last.date > dateLimit) {800history.pop();801history.pop();802} else {803break;804}805}806807input = stripMentions(history.pop()?.content ?? ""); // the last user message is the input808} else {809console.warn(810`chat/llm: regenerate called without enough history for thread starting at ${reply_to}`,811);812return;813}814}815816const chatStream = webapp_client.openai_client.queryStream({817input,818history,819project_id,820path,821model,822tag,823});824825// The sender_id might change if we explicitly set the LLM model.826if (tag === "regenerate" && llm != null) {827if (!this.store) return;828const messages = this.store.get("messages");829if (!messages) return;830if (message.sender_id !== sender_id) {831// if that happens, create a new message with the existing history and the new sender_id832const cur = this.syncdb.get_one({ event: "chat", date });833if (cur == null) return;834const reply_to = getReplyToRoot({835message: cur.toJS() as any as ChatMessage,836messages,837});838this.syncdb.delete({ event: "chat", date });839this.syncdb.set({840date,841history: cur?.get("history") ?? [],842event: "chat",843sender_id,844reply_to,845});846}847}848849let content: string = "";850let halted = false;851852chatStream.on("token", (token) => {853if (halted || this.syncdb == null) {854return;855}856857// we check if user clicked on the "stop generating" button858const cur = this.syncdb.get_one({ event: "chat", date });859if (cur?.get("generating") === false) {860halted = true;861this.chatStreams.delete(id);862return;863}864865// collect more of the output866if (token != null) {867content += token;868}869870const msg: ChatMessage = {871event: "chat",872sender_id,873date: new Date(date),874history: addToHistory(prevHistory, {875author_id: sender_id,876content,877}),878generating: token != null, // it's generating as token is not null879reply_to: reply_to?.toISOString(),880};881this.syncdb.set(msg);882883// if it was the last output, close this884if (token == null) {885this.chatStreams.delete(id);886this.syncdb.commit();887}888});889890chatStream.on("error", (err) => {891this.chatStreams.delete(id);892if (this.syncdb == null || halted) return;893894if (!model) {895throw new Error(896`bug: No model set, but we're in language model error handler`,897);898}899900const vendor = model2vendor(model);901const statusCheck = getLLMServiceStatusCheckMD(vendor.name);902content += `\n\n<span style='color:#b71c1c'>${err}</span>\n\n---\n\n${statusCheck}`;903const msg: ChatMessage = {904event: "chat",905sender_id,906date: new Date(date),907history: addToHistory(prevHistory, {908author_id: sender_id,909content,910}),911generating: false,912reply_to: reply_to?.toISOString(),913};914this.syncdb.set(msg);915this.syncdb.commit();916});917};918919/**920* @param dateStr - the ISO date of the message to get the thread for921* @returns - the messages in the thread, sorted by date922*/923private getMessagesInThread = (924dateStr: string,925): Seq.Indexed<ChatMessageTyped> | undefined => {926const messages = this.store?.get("messages");927if (messages == null) {928return;929}930931return (932messages // @ts-ignore -- immutablejs typings are wrong (?)933.filter(934(message) =>935message.get("reply_to") == dateStr ||936message.get("date").toISOString() == dateStr,937)938// @ts-ignore -- immutablejs typings are wrong (?)939.valueSeq()940.sort((a, b) => cmp(a.get("date"), b.get("date")))941);942};943944// the input and output for the thread ending in the945// given message, formatted for querying a language model, and heuristically946// truncated to not exceed a limit in size.947private getLLMHistory = (reply_to: Date): LanguageModelHistory => {948const history: LanguageModelHistory = [];949// Next get all of the messages with this reply_to or that are the root of this reply chain:950const d = reply_to.toISOString();951const threadMessages = this.getMessagesInThread(d);952if (!threadMessages) return history;953954for (const message of threadMessages) {955const mostRecent = message.get("history")?.first();956// there must be at least one history entry, otherwise the message is broken957if (!mostRecent) continue;958const content = stripMentions(mostRecent.get("content"));959// We take the message's sender ID, not the most recent version from the history960// Why? e.g. a user could have edited an LLM message, which should still count as an LLM message961// otherwise the forth-and-back between AI and human would be broken.962const sender_id = message.get("sender_id");963const role = isLanguageModelService(sender_id) ? "assistant" : "user";964const date = message.get("date");965history.push({ content, role, date });966}967return history;968};969970languageModelStopGenerating = (date: Date) => {971if (this.syncdb == null) return;972this.syncdb.set({973event: "chat",974date: date.toISOString(),975generating: false,976});977this.syncdb.commit();978};979980summarizeThread = async ({981model,982reply_to,983returnInfo,984short,985}: {986model: LanguageModel;987reply_to?: string;988returnInfo?: boolean; // do not send, but return prompt + info}989short: boolean;990}) => {991if (!reply_to) {992return;993}994const user_map = redux.getStore("users").get("user_map");995if (!user_map) {996return;997}998const threadMessages = this.getMessagesInThread(reply_to);999if (!threadMessages) {1000return;1001}10021003const history: { author: string; content: string }[] = [];1004for (const message of threadMessages) {1005const mostRecent = message.get("history")?.first();1006if (!mostRecent) continue;1007const sender_id: string | undefined = message.get("sender_id");1008const author = getUserName(user_map, sender_id);1009const content = stripMentions(mostRecent.get("content"));1010history.push({ author, content });1011}10121013const txtFull = [1014"<details><summary>Chat history</summary>",1015...history.map(({ author, content }) => `${author}:\n${content}`),1016"</details>",1017].join("\n\n");10181019// do not import until needed -- it is HUGE!1020const { truncateMessage, getMaxTokens, numTokensUpperBound } = await import(1021"@cocalc/frontend/misc/llm"1022);1023const maxTokens = getMaxTokens(model);1024const txt = truncateMessage(txtFull, maxTokens);1025const m = returnInfo ? `@${modelToName(model)}` : modelToMention(model);1026const instruction = short1027? `Briefly summarize the provided chat conversation in one paragraph`1028: `Summarize the provided chat conversation. Make a list of all topics, the main conclusions, assigned tasks, and a sentiment score.`;1029const prompt = `${m} ${instruction}:\n\n${txt}`;10301031if (returnInfo) {1032const tokens = numTokensUpperBound(prompt, getMaxTokens(model));1033return { prompt, tokens, truncated: txtFull != txt };1034} else {1035this.sendChat({1036input: prompt,1037tag: `chat:summarize`,1038noNotification: true,1039});1040this.scrollToIndex();1041}1042};10431044regenerateLLMResponse = async (date0: Date, llm?: LanguageModel) => {1045if (this.syncdb == null) return;1046const date = date0.toISOString();1047const obj = this.syncdb.get_one({ event: "chat", date });1048if (obj == null) {1049return;1050}1051const message = processSyncDBObj(obj.toJS() as ChatMessage);1052if (message == null) {1053return;1054}1055const reply_to = message.reply_to;1056if (!reply_to) return;1057await this.processLLM({1058message,1059reply_to: new Date(reply_to),1060tag: "regenerate",1061llm,1062dateLimit: date0,1063});10641065if (llm != null) {1066setDefaultLLM(llm);1067}1068};10691070showTimeTravelInNewTab = () => {1071const store = this.store;1072if (store == null) return;1073redux.getProjectActions(store.get("project_id")!).open_file({1074path: history_path(store.get("path")!),1075foreground: true,1076foreground_project: true,1077});1078};10791080clearAllFilters = () => {1081if (this.frameTreeActions == null) {1082// crappy code just for sage worksheets -- will go away.1083return;1084}1085this.setSearch("");1086this.setFilterRecentH(0);1087this.setSelectedHashtags({});1088};10891090setSearch = (search) => {1091this.frameTreeActions?.set_frame_data({ id: this.frameId, search });1092};10931094setFilterRecentH = (filterRecentH) => {1095this.frameTreeActions?.set_frame_data({ id: this.frameId, filterRecentH });1096};10971098setSelectedHashtags = (selectedHashtags) => {1099this.frameTreeActions?.set_frame_data({1100id: this.frameId,1101selectedHashtags,1102});1103};11041105setFragment = (date?) => {1106if (!date) {1107Fragment.clear();1108} else {1109const fragmentId = toMsString(date);1110Fragment.set({ chat: fragmentId });1111this.frameTreeActions?.set_frame_data({ id: this.frameId, fragmentId });1112}1113};11141115setShowPreview = (showPreview) => {1116this.frameTreeActions?.set_frame_data({1117id: this.frameId,1118showPreview,1119});1120};1121}11221123// We strip out any cased version of the string @chatgpt and also all mentions.1124function stripMentions(value: string): string {1125for (const name of ["@chatgpt4", "@chatgpt"]) {1126while (true) {1127const i = value.toLowerCase().indexOf(name);1128if (i == -1) break;1129value = value.slice(0, i) + value.slice(i + name.length);1130}1131}1132// The mentions looks like this: <span class="user-mention" account-id=openai-... >@ChatGPT</span> ...1133while (true) {1134const i = value.indexOf('<span class="user-mention"');1135if (i == -1) break;1136const j = value.indexOf("</span>", i);1137if (j == -1) break;1138value = value.slice(0, i) + value.slice(j + "</span>".length);1139}1140return value.trim();1141}11421143// not necessary1144// // Remove instances of <details> and </details> from value:1145// function stripDetails(value: string): string {1146// return value.replace(/<details>/g, "").replace(/<\/details>/g, "");1147// }11481149function mentionsLanguageModel(input?: string): boolean {1150const x = input?.toLowerCase() ?? "";11511152// if any of these prefixes are in the input as "account-id=[prefix]", then return true1153const sys = LANGUAGE_MODEL_PREFIXES.some((prefix) =>1154x.includes(`account-id=${prefix}`),1155);1156return sys || x.includes(`account-id=${USER_LLM_PREFIX}`);1157}11581159/**1160* For the given content of a message, this tries to extract a mentioned language model.1161*/1162function getLanguageModel(input?: string): false | LanguageModel {1163if (!input) return false;1164const x = input.toLowerCase();1165if (x.includes("account-id=chatgpt4")) {1166return "gpt-4";1167}1168if (x.includes("account-id=chatgpt")) {1169return "gpt-3.5-turbo";1170}1171// these prefixes should come from util/db-schema/openai::model2service1172for (const vendorPrefix of LANGUAGE_MODEL_PREFIXES) {1173const prefix = `account-id=${vendorPrefix}`;1174const i = x.indexOf(prefix);1175if (i != -1) {1176const j = x.indexOf(">", i);1177const model = x.slice(i + prefix.length, j).trim() as LanguageModel;1178// for now, ollama must be prefixed – in the future, all model names should have a vendor prefix!1179if (vendorPrefix === OLLAMA_PREFIX) {1180return toOllamaModel(model);1181}1182if (vendorPrefix === CUSTOM_OPENAI_PREFIX) {1183return toCustomOpenAIModel(model);1184}1185if (vendorPrefix === USER_LLM_PREFIX) {1186return `${USER_LLM_PREFIX}${model}`;1187}1188return model;1189}1190}1191return false;1192}11931194/**1195* This uniformly defines how the history of a message is composed.1196* The newest entry is in the front of the array.1197* If the date isn't set (ISO string), we set it to the current time.1198*/1199function addToHistory(1200history: MessageHistory[],1201next: Optional<MessageHistory, "date">,1202): MessageHistory[] {1203const {1204author_id,1205content,1206date = webapp_client.server_time().toISOString(),1207} = next;1208// inserted at the beginning of the history, without modifying the array1209return [{ author_id, content, date }, ...history];1210}121112121213