Skip to content

Commit f5b4f33

Browse files
committed
Merge remote-tracking branch 'origin/master' into cocalc-api-20250927
2 parents 7d39dcb + dfc6618 commit f5b4f33

File tree

15 files changed

+1435
-510
lines changed

15 files changed

+1435
-510
lines changed

src/packages/frontend/chat/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Note: There are a couple of ways to represent a time in Javascript:
1616

1717
The data structures for chat have somehow evolved since that
1818
crazy Sage Days by the Ocean in WA to use all of these at once, which is
19-
confusing and annoying. Be careful!
19+
confusing and annoying. Be careful!
2020

2121
## Overview
2222

src/packages/frontend/chat/actions.ts

Lines changed: 215 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,18 @@ export class ChatActions extends Actions<ChatState> {
168168
tag,
169169
noNotification,
170170
submitMentionsRef,
171+
extraInput,
172+
name,
171173
}: {
172174
input?: string;
173175
sender_id?: string;
174176
reply_to?: Date;
175177
tag?: string;
176178
noNotification?: boolean;
177179
submitMentionsRef?;
180+
extraInput?: string;
181+
// if name is given, rename thread to have that name
182+
name?: string;
178183
}): string => {
179184
if (this.syncdb == null || this.store == null) {
180185
console.warn("attempt to sendChat before chat actions initialized");
@@ -186,11 +191,15 @@ export class ChatActions extends Actions<ChatState> {
186191
if (submitMentionsRef?.current != null) {
187192
input = submitMentionsRef.current?.({ chat: `${time_stamp.valueOf()}` });
188193
}
194+
if (extraInput) {
195+
input = (input ?? "") + extraInput;
196+
}
189197
input = input?.trim();
190198
if (!input) {
191199
// do not send when there is nothing to send.
192200
return "";
193201
}
202+
const trimmedName = name?.trim();
194203
const message: ChatMessage = {
195204
sender_id,
196205
event: "chat",
@@ -205,7 +214,12 @@ export class ChatActions extends Actions<ChatState> {
205214
reply_to: reply_to?.toISOString(),
206215
editing: {},
207216
};
217+
if (trimmedName && !reply_to) {
218+
(message as any).name = trimmedName;
219+
}
208220
this.syncdb.set(message);
221+
const messagesState = this.store.get("messages");
222+
let selectedThreadKey: string;
209223
if (!reply_to) {
210224
this.deleteDraft(0);
211225
// NOTE: we also clear search, since it's confusing to send a message and not
@@ -214,17 +228,29 @@ export class ChatActions extends Actions<ChatState> {
214228
// Also, only do this clearing when not replying.
215229
// For replies search find full threads not individual messages.
216230
this.clearAllFilters();
231+
selectedThreadKey = `${time_stamp.valueOf()}`;
217232
} else {
218233
// when replying we make sure that the thread is expanded, since otherwise
219234
// our reply won't be visible
220-
const messages = this.store.get("messages");
221235
if (
222-
messages
236+
messagesState
223237
?.getIn([`${reply_to.valueOf()}`, "folding"])
224238
?.includes(sender_id)
225239
) {
226240
this.toggleFoldThread(reply_to);
227241
}
242+
const root =
243+
getThreadRootDate({
244+
date: reply_to.valueOf(),
245+
messages: messagesState,
246+
}) ?? reply_to.valueOf();
247+
selectedThreadKey = `${root}`;
248+
}
249+
if (selectedThreadKey != "0") {
250+
this.setSelectedThread(selectedThreadKey);
251+
}
252+
if (trimmedName && reply_to) {
253+
this.renameThread(selectedThreadKey, trimmedName);
228254
}
229255

230256
const project_id = this.store?.get("project_id");
@@ -481,6 +507,151 @@ export class ChatActions extends Actions<ChatState> {
481507
});
482508
};
483509

510+
// returns number of deleted messages
511+
// threadKey = iso timestamp root of thread.
512+
deleteThread = (threadKey: string): number => {
513+
if (this.syncdb == null || this.store == null) {
514+
return 0;
515+
}
516+
const messages = this.store.get("messages");
517+
if (messages == null) {
518+
return 0;
519+
}
520+
const rootTarget = parseInt(`${threadKey}`);
521+
if (!isFinite(rootTarget)) {
522+
return 0;
523+
}
524+
let deleted = 0;
525+
for (const [_, message] of messages) {
526+
if (message == null) continue;
527+
const dateField = message.get("date");
528+
let dateValue: number | undefined;
529+
let dateIso: string | undefined;
530+
if (dateField instanceof Date) {
531+
dateValue = dateField.valueOf();
532+
dateIso = dateField.toISOString();
533+
} else if (typeof dateField === "number") {
534+
dateValue = dateField;
535+
dateIso = new Date(dateField).toISOString();
536+
} else if (typeof dateField === "string") {
537+
const t = Date.parse(dateField);
538+
dateValue = isNaN(t) ? undefined : t;
539+
dateIso = dateField;
540+
}
541+
if (dateValue == null || dateIso == null) {
542+
continue;
543+
}
544+
const rootDate =
545+
getThreadRootDate({ date: dateValue, messages }) || dateValue;
546+
if (rootDate !== rootTarget) {
547+
continue;
548+
}
549+
this.syncdb.delete({ event: "chat", date: dateIso });
550+
deleted++;
551+
}
552+
if (deleted > 0) {
553+
this.syncdb.commit();
554+
}
555+
return deleted;
556+
};
557+
558+
renameThread = (threadKey: string, name: string): boolean => {
559+
if (this.syncdb == null) {
560+
return false;
561+
}
562+
const entry = this.getThreadRootDoc(threadKey);
563+
if (entry == null) {
564+
return false;
565+
}
566+
const trimmed = name.trim();
567+
if (trimmed) {
568+
entry.doc.name = trimmed;
569+
} else {
570+
delete entry.doc.name;
571+
}
572+
this.syncdb.set(entry.doc);
573+
this.syncdb.commit();
574+
return true;
575+
};
576+
577+
setThreadPin = (threadKey: string, pinned: boolean): boolean => {
578+
if (this.syncdb == null) {
579+
return false;
580+
}
581+
const entry = this.getThreadRootDoc(threadKey);
582+
if (entry == null) {
583+
return false;
584+
}
585+
if (pinned) {
586+
entry.doc.pin = true;
587+
} else {
588+
entry.doc.pin = false;
589+
}
590+
this.syncdb.set(entry.doc);
591+
this.syncdb.commit();
592+
return true;
593+
};
594+
595+
markThreadRead = (
596+
threadKey: string,
597+
count: number,
598+
commit = true,
599+
): boolean => {
600+
if (this.syncdb == null) {
601+
return false;
602+
}
603+
const account_id = this.redux.getStore("account").get_account_id();
604+
if (!account_id || !Number.isFinite(count)) {
605+
return false;
606+
}
607+
const entry = this.getThreadRootDoc(threadKey);
608+
if (entry == null) {
609+
return false;
610+
}
611+
entry.doc[`read-${account_id}`] = count;
612+
this.syncdb.set(entry.doc);
613+
if (commit) {
614+
this.syncdb.commit();
615+
}
616+
return true;
617+
};
618+
619+
private getThreadRootDoc = (
620+
threadKey: string,
621+
): { doc: any; message: ChatMessageTyped } | null => {
622+
if (this.store == null) {
623+
return null;
624+
}
625+
const messages = this.store.get("messages");
626+
if (messages == null) {
627+
return null;
628+
}
629+
const normalizedKey = toMsString(threadKey);
630+
const fallbackKey = `${parseInt(threadKey, 10)}`;
631+
const candidates = [normalizedKey, threadKey, fallbackKey];
632+
let message: ChatMessageTyped | undefined;
633+
for (const key of candidates) {
634+
if (!key) continue;
635+
message = messages.get(key);
636+
if (message != null) break;
637+
}
638+
if (message == null) {
639+
return null;
640+
}
641+
const dateField = message.get("date");
642+
const dateIso =
643+
dateField instanceof Date
644+
? dateField.toISOString()
645+
: typeof dateField === "string"
646+
? dateField
647+
: new Date(dateField).toISOString();
648+
if (!dateIso) {
649+
return null;
650+
}
651+
const doc = { ...message.toJS(), date: dateIso };
652+
return { doc, message };
653+
};
654+
484655
save_scroll_state = (position, height, offset): void => {
485656
if (height == 0) {
486657
// height == 0 means chat room is not rendered
@@ -605,30 +776,44 @@ export class ChatActions extends Actions<ChatState> {
605776
* This checks a thread of messages to see if it is a language model thread and if so, returns it.
606777
*/
607778
isLanguageModelThread = (date?: Date): false | LanguageModel => {
608-
if (date == null) {
779+
if (date == null || this.store == null) {
609780
return false;
610781
}
611-
const thread = this.getMessagesInThread(date.toISOString());
612-
if (thread == null) {
782+
const messages = this.store.get("messages");
783+
if (messages == null) {
784+
return false;
785+
}
786+
const rootMs =
787+
getThreadRootDate({ date: date.valueOf(), messages }) || date.valueOf();
788+
const entry = this.getThreadRootDoc(`${rootMs}`);
789+
const rootMessage = entry?.message;
790+
if (rootMessage == null) {
613791
return false;
614792
}
615793

616-
// We deliberately start at the last most recent message.
617-
// Why? If we use the LLM regenerate dropdown button to change the LLM, we want to keep it.
618-
for (const message of thread.reverse()) {
619-
const lastHistory = message.get("history")?.first();
620-
// this must be an invalid message, because there is no history
621-
if (lastHistory == null) continue;
622-
const sender_id = lastHistory.get("author_id");
623-
if (isLanguageModelService(sender_id)) {
624-
return service2model(sender_id);
625-
}
626-
const input = lastHistory.get("content")?.toLowerCase();
627-
if (mentionsLanguageModel(input)) {
628-
return getLanguageModel(input);
629-
}
794+
const thread = this.getMessagesInThread(
795+
rootMessage.get("date")?.toISOString?.() ?? `${rootMs}`,
796+
);
797+
if (thread == null) {
798+
return false;
630799
}
631800

801+
const firstMessage = thread.first();
802+
if (firstMessage == null) {
803+
return false;
804+
}
805+
const firstHistory = firstMessage.get("history")?.first();
806+
if (firstHistory == null) {
807+
return false;
808+
}
809+
const sender_id = firstHistory.get("author_id");
810+
if (isLanguageModelService(sender_id)) {
811+
return service2model(sender_id);
812+
}
813+
const input = firstHistory.get("content")?.toLowerCase();
814+
if (mentionsLanguageModel(input)) {
815+
return getLanguageModel(input);
816+
}
632817
return false;
633818
};
634819

@@ -1104,13 +1289,15 @@ export class ChatActions extends Actions<ChatState> {
11041289
};
11051290

11061291
setFragment = (date?) => {
1292+
let fragmentId;
11071293
if (!date) {
11081294
Fragment.clear();
1295+
fragmentId = "";
11091296
} else {
1110-
const fragmentId = toMsString(date);
1297+
fragmentId = toMsString(date);
11111298
Fragment.set({ chat: fragmentId });
1112-
this.frameTreeActions?.set_frame_data({ id: this.frameId, fragmentId });
11131299
}
1300+
this.frameTreeActions?.set_frame_data({ id: this.frameId, fragmentId });
11141301
};
11151302

11161303
setShowPreview = (showPreview) => {
@@ -1119,6 +1306,13 @@ export class ChatActions extends Actions<ChatState> {
11191306
showPreview,
11201307
});
11211308
};
1309+
1310+
setSelectedThread = (threadKey: string | null) => {
1311+
this.frameTreeActions?.set_frame_data({
1312+
id: this.frameId,
1313+
selectedThreadKey: threadKey,
1314+
});
1315+
};
11221316
}
11231317

11241318
// We strip out any cased version of the string @chatgpt and also all mentions.

0 commit comments

Comments
 (0)