Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/chat/actions.ts
Views: 687
/*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";8import { setDefaultLLM } from "@cocalc/frontend/account/useLanguageModelSetting";9import { Actions, redux } from "@cocalc/frontend/app-framework";10import { History as LanguageModelHistory } from "@cocalc/frontend/client/types";11import type {12HashtagState,13SelectedHashtags,14} from "@cocalc/frontend/editors/task-editor/types";15import {16modelToMention,17modelToName,18} from "@cocalc/frontend/frame-editors/llm/llm-selector";19import { open_new_tab } from "@cocalc/frontend/misc";20import { calcMinMaxEstimation } from "@cocalc/frontend/misc/llm-cost-estimation";21import enableSearchEmbeddings from "@cocalc/frontend/search/embeddings";22import track from "@cocalc/frontend/user-tracking";23import { webapp_client } from "@cocalc/frontend/webapp-client";24import { SyncDB } from "@cocalc/sync/editor/db";25import {26CUSTOM_OPENAI_PREFIX,27LANGUAGE_MODEL_PREFIXES,28OLLAMA_PREFIX,29USER_LLM_PREFIX,30getLLMServiceStatusCheckMD,31isFreeModel,32isLanguageModel,33isLanguageModelService,34model2service,35model2vendor,36service2model,37toCustomOpenAIModel,38toOllamaModel,39type LanguageModel,40} from "@cocalc/util/db-schema/llm-utils";41import { cmp, isValidUUID, uuid } from "@cocalc/util/misc";42import { reuseInFlight } from "@cocalc/util/reuse-in-flight";43import { getSortedDates, getUserName } from "./chat-log";44import { message_to_markdown } from "./message";45import { ChatState, ChatStore } from "./store";46import type {47ChatMessage,48ChatMessageTyped,49Feedback,50MessageHistory,51} from "./types";52import { history_path } from "@cocalc/util/misc";53import { initFromSyncDB, handleSyncDBChange, processSyncDBObj } from "./sync";54import { getReplyToRoot, getThreadRootDate, toMsString } from "./utils";55import Fragment from "@cocalc/frontend/misc/fragment-id";56import type { Actions as CodeEditorActions } from "@cocalc/frontend/frame-editors/code-editor/actions";5758const MAX_CHATSTREAM = 10;5960export class ChatActions extends Actions<ChatState> {61public syncdb?: SyncDB;62public store?: ChatStore;63// We use this to ensure at most once chatgpt output is streaming64// at a time in a given chatroom. I saw a bug where hundreds started65// at once and it really did send them all to openai at once, and66// this prevents that at least.67private chatStreams: Set<string> = new Set([]);68public frameId: string = "";69// this might not be set e.g., for deprecated side chat on sagews:70public frameTreeActions?: CodeEditorActions;7172set_syncdb = (syncdb: SyncDB, store: ChatStore): void => {73this.syncdb = syncdb;74this.store = store;7576enableSearchEmbeddings({77project_id: store.get("project_id")!,78path: store.get("path")!,79syncdb,80transform: (elt) => {81if (elt["event"] != "chat") return;82return {83date: elt["date"],84content: elt["history"]?.[0]?.content,85sender_id: elt["sender_id"],86};87},88primaryKey: "date",89textColumn: "content",90metaColumns: ["sender_id"],91});92};9394// Initialize the state of the store from the contents of the syncdb.95init_from_syncdb = (): void => {96if (this.syncdb == null) {97return;98}99initFromSyncDB({ syncdb: this.syncdb, store: this.store });100};101102syncdbChange = (changes): void => {103if (this.syncdb == null) {104return;105}106handleSyncDBChange({ changes, store: this.store, syncdb: this.syncdb });107};108109toggleFoldThread = (reply_to: Date, messageIndex?: number) => {110if (this.syncdb == null) {111return;112}113const account_id = this.redux.getStore("account").get_account_id();114const cur = this.syncdb.get_one({ event: "chat", date: reply_to });115const folding = cur?.get("folding") ?? List([]);116const folded = folding.includes(account_id);117const next = folded118? folding.filter((x) => x !== account_id)119: folding.push(account_id);120121this.syncdb.set({122folding: next,123date: typeof reply_to === "string" ? reply_to : reply_to.toISOString(),124});125126this.syncdb.commit();127128if (folded && messageIndex != null) {129this.scrollToIndex(messageIndex);130}131};132133feedback = (message: ChatMessageTyped, feedback: Feedback | null) => {134if (this.syncdb == null) return;135const date = message.get("date");136if (!(date instanceof Date)) return;137const account_id = this.redux.getStore("account").get_account_id();138const cur = this.syncdb.get_one({ event: "chat", date });139const feedbacks = cur?.get("feedback") ?? Map({});140const next = feedbacks.set(account_id, feedback);141this.syncdb.set({ feedback: next, date: date.toISOString() });142this.syncdb.commit();143const model = this.isLanguageModelThread(date);144if (isLanguageModel(model)) {145track("llm_feedback", {146project_id: this.store?.get("project_id"),147path: this.store?.get("path"),148msg_date: date.toISOString(),149type: "chat",150model: model2service(model),151feedback,152});153}154};155156// The second parameter is used for sending a message by157// chatgpt, which is currently managed by the frontend158// (not the project). Also the async doesn't finish until159// chatgpt is totally done.160sendChat = ({161input,162sender_id = this.redux.getStore("account").get_account_id(),163reply_to,164tag,165noNotification,166submitMentionsRef,167}: {168input?: string;169sender_id?: string;170reply_to?: Date;171tag?: string;172noNotification?: boolean;173submitMentionsRef?;174}): string => {175if (this.syncdb == null || this.store == null) {176console.warn("attempt to sendChat before chat actions initialized");177// WARNING: give an error or try again later?178return "";179}180const time_stamp: Date = webapp_client.server_time();181const time_stamp_str = time_stamp.toISOString();182if (submitMentionsRef?.current != null) {183input = submitMentionsRef.current?.({ chat: `${time_stamp.valueOf()}` });184}185input = input?.trim();186if (!input) {187// do not send when there is nothing to send.188return "";189}190const message: ChatMessage = {191sender_id,192event: "chat",193history: [194{195author_id: sender_id,196content: input,197date: time_stamp_str,198},199],200date: time_stamp_str,201reply_to: reply_to?.toISOString(),202editing: {},203};204this.syncdb.set(message);205if (!reply_to) {206this.deleteDraft(0);207// NOTE: we also clear search, since it's confusing to send a message and not208// even see it (if it doesn't match search). We do NOT clear the hashtags though,209// since by default the message you are sending has those tags.210// Also, only do this clearing when not replying.211// For replies search find full threads not individual messages.212this.clearAllFilters();213} else {214// when replying we make sure that the thread is expanded, since otherwise215// our reply won't be visible216const messages = this.store.get("messages");217if (218messages219?.getIn([`${reply_to.valueOf()}`, "folding"])220?.includes(sender_id)221) {222this.toggleFoldThread(reply_to);223}224}225226const project_id = this.store?.get("project_id");227const path = this.store?.get("path");228if (!path) {229throw Error("bug -- path must be defined");230}231// set notification saying that we sent an actual chat232let action;233if (234noNotification ||235mentionsLanguageModel(input) ||236this.isLanguageModelThread(reply_to)237) {238// Note: don't mark it is a chat if it is with chatgpt,239// since no point in notifying all collabs of this.240action = "edit";241} else {242action = "chat";243}244webapp_client.mark_file({245project_id,246path,247action,248ttl: 10000,249});250track("send_chat", { project_id, path });251252this.save_to_disk();253(async () => {254await this.processLLM({255message,256reply_to: reply_to ?? time_stamp,257tag,258});259})();260return time_stamp_str;261};262263setEditing = (message: ChatMessageTyped, is_editing: boolean) => {264if (this.syncdb == null) {265// WARNING: give an error or try again later?266return;267}268const author_id = this.redux.getStore("account").get_account_id();269270// "FUTURE" = save edit changes271const editing = message272.get("editing")273.set(author_id, is_editing ? "FUTURE" : null);274275// console.log("Currently Editing:", editing.toJS())276this.syncdb.set({277history: message.get("history").toJS(),278editing: editing.toJS(),279date: message.get("date").toISOString(),280});281// commit now so others users know this user is editing282this.syncdb.commit();283};284285// Used to edit sent messages.286// NOTE: this is inefficient; it assumes287// the number of edits is small, which is reasonable -- nobody makes hundreds of distinct288// edits of a single message.289sendEdit = (message: ChatMessageTyped, content: string): void => {290if (this.syncdb == null) {291// WARNING: give an error or try again later?292return;293}294const author_id = this.redux.getStore("account").get_account_id();295// OPTIMIZATION: send less data over the network?296const date = webapp_client.server_time().toISOString();297298this.syncdb.set({299history: addToHistory(300message.get("history").toJS() as unknown as MessageHistory[],301{302author_id,303content,304date,305},306),307editing: message.get("editing").set(author_id, null).toJS(),308date: message.get("date").toISOString(),309});310this.deleteDraft(message.get("date")?.valueOf());311this.save_to_disk();312};313314saveHistory = (315message: ChatMessage,316content: string,317author_id: string,318generating: boolean = false,319): {320date: string;321prevHistory: MessageHistory[];322} => {323const date: string =324typeof message.date === "string"325? message.date326: message.date?.toISOString();327if (this.syncdb == null) {328return { date, prevHistory: [] };329}330const prevHistory: MessageHistory[] = message.history ?? [];331this.syncdb.set({332history: addToHistory(prevHistory, {333author_id,334content,335}),336date,337generating,338});339return { date, prevHistory };340};341342sendReply = ({343message,344reply,345from,346noNotification,347reply_to,348submitMentionsRef,349}: {350message: ChatMessage;351reply?: string;352from?: string;353noNotification?: boolean;354reply_to?: Date;355submitMentionsRef?;356}): string => {357const store = this.store;358if (store == null) {359return "";360}361// the reply_to field of the message is *always* the root.362// the order of the replies is by timestamp. This is meant363// to make sure chat is just 1 layer deep, rather than a364// full tree structure, which is powerful but too confusing.365const reply_to_value =366reply_to != null367? reply_to.valueOf()368: getThreadRootDate({369date: new Date(message.date).valueOf(),370messages: store.get("messages"),371});372const time_stamp_str = this.sendChat({373input: reply,374submitMentionsRef,375sender_id: from ?? this.redux.getStore("account").get_account_id(),376reply_to: new Date(reply_to_value),377noNotification,378});379// negative date of reply_to root is used for replies.380this.deleteDraft(-reply_to_value);381return time_stamp_str;382};383384deleteDraft = (385date: number,386commit: boolean = true,387sender_id: string | undefined = undefined,388) => {389if (!this.syncdb) return;390sender_id = sender_id ?? this.redux.getStore("account").get_account_id();391this.syncdb.delete({392event: "draft",393sender_id,394date,395});396if (commit) {397this.syncdb.commit();398}399};400401// Make sure everything saved to DISK.402save_to_disk = async (): Promise<void> => {403this.syncdb?.save_to_disk();404};405406private _llmEstimateCost = async ({407input,408date,409message,410}: {411input: string;412// date is as in chat/input.tsx -- so 0 for main input and -ms for reply413date: number;414// in case of reply/edit, so we can get the entire thread415message?: ChatMessage;416}): Promise<void> => {417if (!this.store) {418return;419}420421const is_cocalc_com = this.redux.getStore("customize").get("is_cocalc_com");422if (!is_cocalc_com) {423return;424}425// this is either a new message or in a reply, but mentions an LLM426let model: LanguageModel | null | false = getLanguageModel(input);427input = stripMentions(input);428let history: string[] = [];429const messages = this.store.get("messages");430// message != null means this is a reply or edit and we have to get the whole chat thread431if (!model && message != null && messages != null) {432const root = getReplyToRoot({ message, messages });433model = this.isLanguageModelThread(root);434if (!isFreeModel(model, is_cocalc_com) && root != null) {435for (const msg of this.getLLMHistory(root)) {436history.push(msg.content);437}438}439}440if (model) {441if (isFreeModel(model, is_cocalc_com)) {442this.setCostEstimate({ date, min: 0, max: 0 });443} else {444const llm_markup = this.redux.getStore("customize").get("llm_markup");445// do not import until needed -- it is HUGE!446const { truncateMessage, getMaxTokens, numTokensUpperBound } =447await import("@cocalc/frontend/misc/llm");448const maxTokens = getMaxTokens(model);449const tokens = numTokensUpperBound(450truncateMessage([input, ...history].join("\n"), maxTokens),451maxTokens,452);453const { min, max } = calcMinMaxEstimation(tokens, model, llm_markup);454this.setCostEstimate({ date, min, max });455}456} else {457this.setCostEstimate();458}459};460461llmEstimateCost: typeof this._llmEstimateCost = debounce(462reuseInFlight(this._llmEstimateCost),4631000,464{ leading: true, trailing: true },465);466467private setCostEstimate = (468costEstimate: {469date: number;470min: number;471max: number;472} | null = null,473) => {474this.frameTreeActions?.set_frame_data({475id: this.frameId,476costEstimate,477});478};479480save_scroll_state = (position, height, offset): void => {481if (height == 0) {482// height == 0 means chat room is not rendered483return;484}485this.setState({ saved_position: position, height, offset });486};487488// scroll to the bottom of the chat log489// if date is given, scrolls to the bottom of the chat *thread*490// that starts with that date.491// safe to call after closing actions.492clearScrollRequest = () => {493this.frameTreeActions?.set_frame_data({494id: this.frameId,495scrollToIndex: null,496scrollToDate: null,497});498};499scrollToIndex = (index: number = -1) => {500if (this.syncdb == null) return;501// we first clear, then set it, since scroll to needs to502// work even if it is the same as last time.503// TODO: alternatively, we could get a reference504// to virtuoso and directly control things from here.505this.clearScrollRequest();506setTimeout(() => {507this.frameTreeActions?.set_frame_data({508id: this.frameId,509scrollToIndex: index,510scrollToDate: null,511});512}, 1);513};514515scrollToBottom = () => {516this.scrollToIndex(Number.MAX_SAFE_INTEGER);517};518519// this scrolls the message with given date into view and sets it as the selected message.520scrollToDate = (date) => {521this.clearScrollRequest();522this.frameTreeActions?.set_frame_data({523id: this.frameId,524fragmentId: toMsString(date),525});526this.setFragment(date);527setTimeout(() => {528this.frameTreeActions?.set_frame_data({529id: this.frameId,530// string version of ms since epoch, which is the key531// in the messages immutable Map532scrollToDate: toMsString(date),533scrollToIndex: null,534});535}, 1);536};537538// Scan through all messages and figure out what hashtags are used.539// Of course, at some point we should try to use efficient algorithms540// to make this faster incrementally.541update_hashtags = (): void => {};542543// Exports the currently visible chats to a markdown file and opens it.544export_to_markdown = async (): Promise<void> => {545if (!this.store) return;546const messages = this.store.get("messages");547if (messages == null) return;548const path = this.store.get("path") + ".md";549const project_id = this.store.get("project_id");550if (project_id == null) return;551const account_id = this.redux.getStore("account").get_account_id();552const { dates } = getSortedDates(553messages,554this.store.get("search"),555account_id,556);557const v: string[] = [];558for (const date of dates) {559const message = messages.get(date);560if (message == null) continue;561v.push(message_to_markdown(message));562}563const content = v.join("\n\n---\n\n");564await webapp_client.project_client.write_text_file({565project_id,566path,567content,568});569this.redux570.getProjectActions(project_id)571.open_file({ path, foreground: true });572};573574setHashtagState = (tag: string, state?: HashtagState): void => {575if (!this.store || this.frameTreeActions == null) return;576// similar code in task list.577let selectedHashtags: SelectedHashtags =578this.frameTreeActions._get_frame_data(this.frameId, "selectedHashtags") ??579immutableMap<string, HashtagState>();580selectedHashtags =581state == null582? selectedHashtags.delete(tag)583: selectedHashtags.set(tag, state);584this.setSelectedHashtags(selectedHashtags);585};586587help = () => {588open_new_tab("https://doc.cocalc.com/chat.html");589};590591undo = () => {592this.syncdb?.undo();593};594595redo = () => {596this.syncdb?.redo();597};598599/**600* This checks a thread of messages to see if it is a language model thread and if so, returns it.601*/602isLanguageModelThread = (date?: Date): false | LanguageModel => {603if (date == null) {604return false;605}606const thread = this.getMessagesInThread(date.toISOString());607if (thread == null) {608return false;609}610611// We deliberately start at the last most recent message.612// Why? If we use the LLM regenerate dropdown button to change the LLM, we want to keep it.613for (const message of thread.reverse()) {614const lastHistory = message.get("history")?.first();615// this must be an invalid message, because there is no history616if (lastHistory == null) continue;617const sender_id = lastHistory.get("author_id");618if (isLanguageModelService(sender_id)) {619return service2model(sender_id);620}621const input = lastHistory.get("content")?.toLowerCase();622if (mentionsLanguageModel(input)) {623return getLanguageModel(input);624}625}626627return false;628};629630private processLLM = async ({631message,632reply_to,633tag,634llm,635dateLimit,636}: {637message: ChatMessage;638reply_to?: Date;639tag?: string;640llm?: LanguageModel;641dateLimit?: Date; // only for regenerate, filter history642}) => {643const store = this.store;644if (this.syncdb == null || !store) {645console.warn("processLLM called before chat actions initialized");646return;647}648if (649!tag &&650!reply_to &&651!redux652.getProjectsStore()653.hasLanguageModelEnabled(this.store?.get("project_id"))654) {655// No need to check whether a language model is enabled at all.656// We only do this check if tag is not set, e.g., directly typing @chatgpt657// into the input box. If the tag is set, then the request to use658// an LLM came from some place, e.g., the "Explain" button, so659// we trust that.660// We also do the check when replying.661return;662}663// if an llm is explicitly set, we only allow that for regenerate and we also check if it is enabled and selecable by the user664if (typeof llm === "string") {665if (tag !== "regenerate") {666console.warn(`chat/llm: llm=${llm} is only allowed for tag=regenerate`);667return;668}669}670if (tag !== "regenerate" && !isValidUUID(message.history?.[0]?.author_id)) {671// do NOT respond to a message that an LLM is sending,672// because that would result in an infinite recursion.673// Note: LLMs do not use avalid UUID, but a special string.674// For regenerate, we delete the last message, though…675return;676}677let input = message.history?.[0]?.content as string | undefined;678// if there is no input in the last message, something is really wrong679if (input == null) return;680// there are cases, where there is nothing in the last message – but we want to regenerate it681if (!input && tag !== "regenerate") return;682683let model: LanguageModel | false = false;684if (llm != null) {685// This is a request to regerenate the last message with a specific model.686// The message.tsx/RegenerateLLM component already checked if the LLM is enabled and selectable by the user.687// ATTN: we trust that information!688model = llm;689} else if (!mentionsLanguageModel(input)) {690// doesn't mention a language model explicitly, but might be a reply to something that does:691if (reply_to == null) {692return;693}694model = this.isLanguageModelThread(reply_to);695if (!model) {696// definitely not a language model chat situation697return;698}699} else {700// it mentions a language model -- which one?701model = getLanguageModel(input);702}703704if (model === false) {705return;706}707708// without any mentions, of course:709input = stripMentions(input);710// also important to strip details, since they tend to confuse an LLM:711//input = stripDetails(input);712const sender_id = (function () {713try {714return model2service(model);715} catch {716return model;717}718})();719720const thinking = ":robot: Thinking...";721// prevHistory: in case of regenerate, it's the history *before* we added the "Thinking..." message (which we ignore)722const { date, prevHistory = [] } =723tag === "regenerate"724? this.saveHistory(message, thinking, sender_id, true)725: {726date: this.sendReply({727message,728reply: thinking,729from: sender_id,730noNotification: true,731reply_to,732}),733};734735if (this.chatStreams.size > MAX_CHATSTREAM) {736console.trace(737`processLanguageModel called when ${MAX_CHATSTREAM} streams active`,738);739if (this.syncdb != null) {740// This should never happen in normal use, but could prevent an expensive blowup due to a bug.741this.syncdb.set({742date,743history: [744{745author_id: sender_id,746content: `\n\n<span style='color:#b71c1c'>There are already ${MAX_CHATSTREAM} language model responses being written. Please try again once one finishes.</span>\n\n`,747date,748},749],750event: "chat",751sender_id,752});753this.syncdb.commit();754}755return;756}757758// keep updating when the LLM is doing something:759const project_id = store.get("project_id");760const path = store.get("path");761if (!tag && reply_to) {762tag = "reply";763}764765// record that we're about to submit message to a language model.766track("chatgpt", {767project_id,768path,769type: "chat",770is_reply: !!reply_to,771tag,772model,773});774775// submit question to the given language model776const id = uuid();777this.chatStreams.add(id);778setTimeout(779() => {780this.chatStreams.delete(id);781},7823 * 60 * 1000,783);784785// construct the LLM history for the given thread786const history = reply_to ? this.getLLMHistory(reply_to) : undefined;787788if (tag === "regenerate") {789if (history && history.length >= 2) {790history.pop(); // remove the last LLM message, which is the one we're regenerating791792// if dateLimit is earlier than the last message's date, remove the last two793while (dateLimit != null && history.length >= 2) {794const last = history[history.length - 1];795if (last.date != null && last.date > dateLimit) {796history.pop();797history.pop();798} else {799break;800}801}802803input = stripMentions(history.pop()?.content ?? ""); // the last user message is the input804} else {805console.warn(806`chat/llm: regenerate called without enough history for thread starting at ${reply_to}`,807);808return;809}810}811812const chatStream = webapp_client.openai_client.queryStream({813input,814history,815project_id,816path,817model,818tag,819});820821// The sender_id might change if we explicitly set the LLM model.822if (tag === "regenerate" && llm != null) {823if (!this.store) return;824const messages = this.store.get("messages");825if (!messages) return;826if (message.sender_id !== sender_id) {827// if that happens, create a new message with the existing history and the new sender_id828const cur = this.syncdb.get_one({ event: "chat", date });829if (cur == null) return;830const reply_to = getReplyToRoot({831message: cur.toJS() as any as ChatMessage,832messages,833});834this.syncdb.delete({ event: "chat", date });835this.syncdb.set({836date,837history: cur?.get("history") ?? [],838event: "chat",839sender_id,840reply_to,841});842}843}844845let content: string = "";846let halted = false;847848chatStream.on("token", (token) => {849if (halted || this.syncdb == null) {850return;851}852853// we check if user clicked on the "stop generating" button854const cur = this.syncdb.get_one({ event: "chat", date });855if (cur?.get("generating") === false) {856halted = true;857this.chatStreams.delete(id);858return;859}860861// collect more of the output862if (token != null) {863content += token;864}865866const msg: ChatMessage = {867event: "chat",868sender_id,869date: new Date(date),870history: addToHistory(prevHistory, {871author_id: sender_id,872content,873}),874generating: token != null, // it's generating as token is not null875reply_to: reply_to?.toISOString(),876};877this.syncdb.set(msg);878879// if it was the last output, close this880if (token == null) {881this.chatStreams.delete(id);882this.syncdb.commit();883}884});885886chatStream.on("error", (err) => {887this.chatStreams.delete(id);888if (this.syncdb == null || halted) return;889890if (!model) {891throw new Error(892`bug: No model set, but we're in language model error handler`,893);894}895896const vendor = model2vendor(model);897const statusCheck = getLLMServiceStatusCheckMD(vendor.name);898content += `\n\n<span style='color:#b71c1c'>${err}</span>\n\n---\n\n${statusCheck}`;899const msg: ChatMessage = {900event: "chat",901sender_id,902date: new Date(date),903history: addToHistory(prevHistory, {904author_id: sender_id,905content,906}),907generating: false,908reply_to: reply_to?.toISOString(),909};910this.syncdb.set(msg);911this.syncdb.commit();912});913};914915/**916* @param dateStr - the ISO date of the message to get the thread for917* @returns - the messages in the thread, sorted by date918*/919private getMessagesInThread = (920dateStr: string,921): Seq.Indexed<ChatMessageTyped> | undefined => {922const messages = this.store?.get("messages");923if (messages == null) {924return;925}926927return (928messages // @ts-ignore -- immutablejs typings are wrong (?)929.filter(930(message) =>931message.get("reply_to") == dateStr ||932message.get("date").toISOString() == dateStr,933)934// @ts-ignore -- immutablejs typings are wrong (?)935.valueSeq()936.sort((a, b) => cmp(a.get("date"), b.get("date")))937);938};939940// the input and output for the thread ending in the941// given message, formatted for querying a langauge model, and heuristically942// truncated to not exceed a limit in size.943private getLLMHistory = (reply_to: Date): LanguageModelHistory => {944const history: LanguageModelHistory = [];945// Next get all of the messages with this reply_to or that are the root of this reply chain:946const d = reply_to.toISOString();947const threadMessages = this.getMessagesInThread(d);948if (!threadMessages) return history;949950for (const message of threadMessages) {951const mostRecent = message.get("history")?.first();952// there must be at least one history entry, otherwise the message is broken953if (!mostRecent) continue;954const content = stripMentions(mostRecent.get("content"));955// We take the message's sender ID, not the most recent version from the history956// Why? e.g. a user could have edited an LLM message, which should still count as an LLM message957// otherwise the forth-and-back between AI and human would be broken.958const sender_id = message.get("sender_id");959const role = isLanguageModelService(sender_id) ? "assistant" : "user";960const date = message.get("date");961history.push({ content, role, date });962}963return history;964};965966languageModelStopGenerating = (date: Date) => {967if (this.syncdb == null) return;968this.syncdb.set({969event: "chat",970date: date.toISOString(),971generating: false,972});973this.syncdb.commit();974};975976summarizeThread = async ({977model,978reply_to,979returnInfo,980short,981}: {982model: LanguageModel;983reply_to?: string;984returnInfo?: boolean; // do not send, but return prompt + info}985short: boolean;986}) => {987if (!reply_to) {988return;989}990const user_map = redux.getStore("users").get("user_map");991if (!user_map) {992return;993}994const threadMessages = this.getMessagesInThread(reply_to);995if (!threadMessages) {996return;997}998999const history: { author: string; content: string }[] = [];1000for (const message of threadMessages) {1001const mostRecent = message.get("history")?.first();1002if (!mostRecent) continue;1003const sender_id: string | undefined = message.get("sender_id");1004const author = getUserName(user_map, sender_id);1005const content = stripMentions(mostRecent.get("content"));1006history.push({ author, content });1007}10081009const txtFull = [1010"<details><summary>Chat history</summary>",1011...history.map(({ author, content }) => `${author}:\n${content}`),1012"</details>",1013].join("\n\n");10141015// do not import until needed -- it is HUGE!1016const { truncateMessage, getMaxTokens, numTokensUpperBound } = await import(1017"@cocalc/frontend/misc/llm"1018);1019const maxTokens = getMaxTokens(model);1020const txt = truncateMessage(txtFull, maxTokens);1021const m = returnInfo ? `@${modelToName(model)}` : modelToMention(model);1022const instruction = short1023? `Briefly summarize the provided chat conversation in one paragraph`1024: `Summarize the provided chat conversation. Make a list of all topics, the main conclusions, assigned tasks, and a sentiment score.`;1025const prompt = `${m} ${instruction}:\n\n${txt}`;10261027if (returnInfo) {1028const tokens = numTokensUpperBound(prompt, getMaxTokens(model));1029return { prompt, tokens, truncated: txtFull != txt };1030} else {1031this.sendChat({1032input: prompt,1033tag: `chat:summarize`,1034noNotification: true,1035});1036this.scrollToIndex();1037}1038};10391040regenerateLLMResponse = async (date0: Date, llm?: LanguageModel) => {1041if (this.syncdb == null) return;1042const date = date0.toISOString();1043const obj = this.syncdb.get_one({ event: "chat", date });1044if (obj == null) {1045return;1046}1047const message = processSyncDBObj(obj.toJS() as ChatMessage);1048if (message == null) {1049return;1050}1051const reply_to = message.reply_to;1052if (!reply_to) return;1053await this.processLLM({1054message,1055reply_to: new Date(reply_to),1056tag: "regenerate",1057llm,1058dateLimit: date0,1059});10601061if (llm != null) {1062setDefaultLLM(llm);1063}1064};10651066showTimeTravelInNewTab = () => {1067const store = this.store;1068if (store == null) return;1069redux.getProjectActions(store.get("project_id")!).open_file({1070path: history_path(store.get("path")!),1071foreground: true,1072foreground_project: true,1073});1074};10751076clearAllFilters = () => {1077if (this.frameTreeActions == null) {1078// crappy code just for sage worksheets -- will go away.1079return;1080}1081this.setSearch("");1082this.setFilterRecentH(0);1083this.setSelectedHashtags({});1084};10851086setSearch = (search) => {1087this.frameTreeActions?.set_frame_data({ id: this.frameId, search });1088};10891090setFilterRecentH = (filterRecentH) => {1091this.frameTreeActions?.set_frame_data({ id: this.frameId, filterRecentH });1092};10931094setSelectedHashtags = (selectedHashtags) => {1095this.frameTreeActions?.set_frame_data({1096id: this.frameId,1097selectedHashtags,1098});1099};11001101setFragment = (date?) => {1102if (!date) {1103Fragment.clear();1104} else {1105const fragmentId = toMsString(date);1106Fragment.set({ chat: fragmentId });1107this.frameTreeActions?.set_frame_data({ id: this.frameId, fragmentId });1108}1109};11101111setShowPreview = (showPreview) => {1112this.frameTreeActions?.set_frame_data({1113id: this.frameId,1114showPreview,1115});1116};1117}11181119// We strip out any cased version of the string @chatgpt and also all mentions.1120function stripMentions(value: string): string {1121for (const name of ["@chatgpt4", "@chatgpt"]) {1122while (true) {1123const i = value.toLowerCase().indexOf(name);1124if (i == -1) break;1125value = value.slice(0, i) + value.slice(i + name.length);1126}1127}1128// The mentions looks like this: <span class="user-mention" account-id=openai-... >@ChatGPT</span> ...1129while (true) {1130const i = value.indexOf('<span class="user-mention"');1131if (i == -1) break;1132const j = value.indexOf("</span>", i);1133if (j == -1) break;1134value = value.slice(0, i) + value.slice(j + "</span>".length);1135}1136return value.trim();1137}11381139// not necessary1140// // Remove instances of <details> and </details> from value:1141// function stripDetails(value: string): string {1142// return value.replace(/<details>/g, "").replace(/<\/details>/g, "");1143// }11441145function mentionsLanguageModel(input?: string): boolean {1146const x = input?.toLowerCase() ?? "";11471148// if any of these prefixes are in the input as "account-id=[prefix]", then return true1149const sys = LANGUAGE_MODEL_PREFIXES.some((prefix) =>1150x.includes(`account-id=${prefix}`),1151);1152return sys || x.includes(`account-id=${USER_LLM_PREFIX}`);1153}11541155/**1156* For the given content of a message, this tries to extract a mentioned language model.1157*/1158function getLanguageModel(input?: string): false | LanguageModel {1159if (!input) return false;1160const x = input.toLowerCase();1161if (x.includes("account-id=chatgpt4")) {1162return "gpt-4";1163}1164if (x.includes("account-id=chatgpt")) {1165return "gpt-3.5-turbo";1166}1167// these prefexes should come from util/db-schema/openai::model2service1168for (const vendorprefix of LANGUAGE_MODEL_PREFIXES) {1169const prefix = `account-id=${vendorprefix}`;1170const i = x.indexOf(prefix);1171if (i != -1) {1172const j = x.indexOf(">", i);1173const model = x.slice(i + prefix.length, j).trim() as LanguageModel;1174// for now, ollama must be prefixed – in the future, all model names should have a vendor prefix!1175if (vendorprefix === OLLAMA_PREFIX) {1176return toOllamaModel(model);1177}1178if (vendorprefix === CUSTOM_OPENAI_PREFIX) {1179return toCustomOpenAIModel(model);1180}1181if (vendorprefix === USER_LLM_PREFIX) {1182return `${USER_LLM_PREFIX}${model}`;1183}1184return model;1185}1186}1187return false;1188}11891190/**1191* This uniformly defines how the history of a message is composed.1192* The newest entry is in the front of the array.1193* If the date isn't set (ISO string), we set it to the current time.1194*/1195function addToHistory(1196history: MessageHistory[],1197next: Optional<MessageHistory, "date">,1198): MessageHistory[] {1199const {1200author_id,1201content,1202date = webapp_client.server_time().toISOString(),1203} = next;1204// inserted at the beginning of the history, without modifying the array1205return [{ author_id, content, date }, ...history];1206}120712081209