diff --git a/apps/shinkai-desktop/src/pages/chat/chat-conversation.tsx b/apps/shinkai-desktop/src/pages/chat/chat-conversation.tsx index ad9a64511..64eab3a0a 100644 --- a/apps/shinkai-desktop/src/pages/chat/chat-conversation.tsx +++ b/apps/shinkai-desktop/src/pages/chat/chat-conversation.tsx @@ -258,6 +258,27 @@ const ChatConversation = () => { }, }); + const regenerateMessage = async (content: string, parentHash: string) => { + setMessageContent(''); // trick to clear the ws stream message + if (!auth) return; + const decodedInboxId = decodeURIComponent(inboxId); + const jobId = extractJobIdFromInbox(decodedInboxId); + await sendMessageToJob({ + nodeAddress: auth.node_address, + jobId, + message: content, + files_inbox: '', + parent: parentHash, + shinkaiIdentity: auth.shinkai_identity, + profile: auth.profile, + my_device_encryption_sk: auth.my_device_encryption_sk, + my_device_identity_sk: auth.my_device_identity_sk, + node_encryption_pk: auth.node_encryption_pk, + profile_encryption_sk: auth.profile_encryption_sk, + profile_identity_sk: auth.profile_identity_sk, + }); + }; + const onSubmit = async (data: ChatMessageFormSchema) => { setMessageContent(''); // trick to clear the ws stream message if (!auth || data.message.trim() === '') return; @@ -346,6 +367,7 @@ const ChatConversation = () => { lastMessageContent={messageContent} noMoreMessageLabel={t('chat.allMessagesLoaded')} paginatedMessages={data} + regenerateMessage={regenerateMessage} /> {isLimitReachedErrorLastMessage && ( @@ -410,6 +432,7 @@ const ChatConversation = () => { { const [isJobProcessingFile, setIsJobProcessingFile] = useState(false); + const regenerateMessage = async (content: string, parentHash: string) => { + setMessageContent(''); // trick to clear the ws stream message + if (!auth) return; + const decodedInboxId = decodeURIComponent(inboxId); + const jobId = extractJobIdFromInbox(decodedInboxId); + await sendMessageToJob({ + nodeAddress: auth.node_address, + jobId, + message: content, + files_inbox: '', + parent: parentHash, + shinkaiIdentity: auth.shinkai_identity, + profile: auth.profile, + my_device_encryption_sk: auth.my_device_encryption_sk, + my_device_identity_sk: auth.my_device_identity_sk, + node_encryption_pk: auth.node_encryption_pk, + profile_encryption_sk: auth.profile_encryption_sk, + profile_identity_sk: auth.profile_identity_sk, + }); + }; + const onSubmit = async (data: ChatMessageFormSchema) => { setMessageContent(''); // trick to clear the ws stream message if (!auth || data.message.trim() === '') return; @@ -429,6 +450,7 @@ export const Inbox = () => { lastMessageContent={messageContent} noMoreMessageLabel="All previous messages have been loaded ✅" paginatedMessages={data} + regenerateMessage={regenerateMessage} /> {isJobProcessingFile && ( @@ -519,7 +541,7 @@ export const Inbox = () => { } disabled={isLoadingMessage} - isLoading={isLoadingMessage} + // isLoading={isLoadingMessage} onChange={field.onChange} onSubmit={chatForm.handleSubmit(onSubmit)} topAddons={ diff --git a/libs/shinkai-i18n/locales/en-US.json b/libs/shinkai-i18n/locales/en-US.json index 77059b2cc..1226645c4 100644 --- a/libs/shinkai-i18n/locales/en-US.json +++ b/libs/shinkai-i18n/locales/en-US.json @@ -62,6 +62,9 @@ "notFound": "No archived conversations found.", "success": "Your conversation has been archived", "error": "Error archiving job" + }, + "editMessage": { + "warning": "This will restart your conversation from here." } }, "aiFilesSearch": { @@ -278,6 +281,8 @@ "search": "Search", "next": "Next", "restore": "Restore", + "retry": "Retry", + "copy": "Copy", "reset": "Reset", "clickToUpload": "Click to upload or drag and drop", "upload": "Upload", @@ -300,6 +305,8 @@ "soon": "soon", "back": "Back", "edit": "Edit", + "send": "Send", + "editMessage": "Edit Message", "delete": "Delete", "update": "Update", "moreOptions": "More Options", diff --git a/libs/shinkai-i18n/locales/es-ES.json b/libs/shinkai-i18n/locales/es-ES.json index 8b5f68ecc..3a6dd3cc0 100644 --- a/libs/shinkai-i18n/locales/es-ES.json +++ b/libs/shinkai-i18n/locales/es-ES.json @@ -37,6 +37,9 @@ "title": "Contexto de la Conversación" }, "create": "Crear Chat de IA", + "editMessage": { + "warning": "Esto reiniciará tu conversación desde aquí." + }, "emptyStateDescription": "Prueba con “Cómo hacer una solicitud HTTP en JavaScript”, “Dame las 10 mejores canciones de rock de los 80”, “Explícame cómo funciona internet”", "emptyStateTitle": "Pregunta a Shinkai IA", "enterMessage": "Introducir Mensaje", @@ -69,10 +72,12 @@ "comingSoon": "Próximamente - Principios de Julio", "connect": "Conectar", "continue": "Continuar", + "copy": "Copiar", "delete": "Eliminar", "disconnect": "Desconectar", "done": "Hecho", "edit": "Editar", + "editMessage": "Editar mensaje", "file": "Archivo", "fileWithCount_one": "{{count}} Archivo", "fileWithCount_other": "{{count}} Archivos", @@ -99,10 +104,12 @@ "reset": "Restablecer", "resetFilters": "Restablecer filtros", "restore": "Restaurar", + "retry": "Reintentar", "save": "Guardar", "search": "Buscar", "searchPlaceholder": "Buscar...", "seeOptions": "Ver Opciones", + "send": "Enviar", "shinkaiPrivate": "Shinkai Privado (Local)", "signUpShinkaiHosting": "Registrarse en Shinkai Hosting", "soon": "pronto", diff --git a/libs/shinkai-i18n/locales/id-ID.json b/libs/shinkai-i18n/locales/id-ID.json index 6d35b8f51..fee6ebb6e 100644 --- a/libs/shinkai-i18n/locales/id-ID.json +++ b/libs/shinkai-i18n/locales/id-ID.json @@ -37,6 +37,9 @@ "title": "Konteks Percakapan" }, "create": "Buat Obrolan AI", + "editMessage": { + "warning": "Ini akan memulai ulang percakapan Anda dari sini." + }, "emptyStateDescription": "Coba “Bagaimana cara membuat permintaan HTTP dalam JavaScript”, “Beri saya 10 musik rock teratas di tahun 80an”, “Jelaskan bagaimana internet bekerja”", "emptyStateTitle": "Tanyakan kepada Shinkai AI", "enterMessage": "Masukkan Pesan", @@ -69,10 +72,12 @@ "comingSoon": "Segera hadir - Awal Juli", "connect": "Hubungkan", "continue": "Lanjutkan", + "copy": "Salin", "delete": "Hapus", "disconnect": "Putuskan Koneksi", "done": "Selesai", "edit": "Edit", + "editMessage": "Edit Pesan", "file": "Berkas", "fileWithCount_one": "{{count}} Berkas", "fileWithCount_other": "{{count}} Berkas", @@ -99,10 +104,12 @@ "reset": "Reset", "resetFilters": "Reset Filter", "restore": "Pulihkan", + "retry": "Coba lagi", "save": "Simpan", "search": "Cari", "searchPlaceholder": "Cari...", "seeOptions": "Lihat Opsi", + "send": "Kirim", "shinkaiPrivate": "Shinkai Pribadi (Lokal)", "signUpShinkaiHosting": "Daftar untuk Hosting Shinkai", "soon": "segera", diff --git a/libs/shinkai-i18n/locales/ja-JP.json b/libs/shinkai-i18n/locales/ja-JP.json index b028fb4b0..b149927d5 100644 --- a/libs/shinkai-i18n/locales/ja-JP.json +++ b/libs/shinkai-i18n/locales/ja-JP.json @@ -37,6 +37,9 @@ "title": "会話コンテキスト" }, "create": "AIチャットを作成", + "editMessage": { + "warning": "ここから会話をやり直します。" + }, "emptyStateDescription": "「JavaScriptでHTTPリクエストを作成する方法」、「80年代のロック音楽のトップ10を教えて」、「インターネットの仕組みを説明して」などをお試しください", "emptyStateTitle": "Shinkai AIに質問してみてください", "enterMessage": "メッセージを入力", @@ -69,10 +72,12 @@ "comingSoon": "近日公開 - 7月上旬", "connect": "接続", "continue": "続行", + "copy": "コピー", "delete": "削除", "disconnect": "切断", "done": "完了", "edit": "編集", + "editMessage": "メッセージを編集", "file": "ファイル", "fileWithCount_one": "{{count}} ファイル", "fileWithCount_other": "{{count}} ファイル", @@ -99,10 +104,12 @@ "reset": "リセット", "resetFilters": "フィルターをリセット", "restore": "復元", + "retry": "再試行", "save": "保存", "search": "検索", "searchPlaceholder": "検索...", "seeOptions": "オプションを表示", + "send": "送信", "shinkaiPrivate": "Shinkaiプライベート(ローカル)", "signUpShinkaiHosting": "Shinkai Hostingにサインアップ", "soon": "近日公開", diff --git a/libs/shinkai-i18n/locales/zh-CN.json b/libs/shinkai-i18n/locales/zh-CN.json index dcb2ea7c0..7dfe136c5 100644 --- a/libs/shinkai-i18n/locales/zh-CN.json +++ b/libs/shinkai-i18n/locales/zh-CN.json @@ -37,6 +37,9 @@ "title": "对话上下文" }, "create": "创建 AI 聊天", + "editMessage": { + "warning": "这将重新开始您的对话。" + }, "emptyStateDescription": "尝试 “如何在 JavaScript 中进行 HTTP 请求”, “给我 80 年代十大摇滚音乐”, “解释互联网的工作原理”", "emptyStateTitle": "询问 Shinkai AI", "enterMessage": "输入消息", @@ -69,10 +72,12 @@ "comingSoon": "即将到来 - 七月初", "connect": "连接", "continue": "继续", + "copy": "复制", "delete": "删除", "disconnect": "断开连接", "done": "完成", "edit": "编辑", + "editMessage": "编辑消息", "file": "文件", "fileWithCount_one": "{{count}} 个文件", "fileWithCount_other": "{{count}} 个文件", @@ -99,10 +104,12 @@ "reset": "重置", "resetFilters": "重置筛选器", "restore": "恢复", + "retry": "重试", "save": "保存", "search": "搜索", "searchPlaceholder": "搜索...", "seeOptions": "查看选项", + "send": "发送", "shinkaiPrivate": "Shinkai私有(本地)", "signUpShinkaiHosting": "注册Shinkai Hosting", "soon": "即将", diff --git a/libs/shinkai-i18n/src/lib/default/index.ts b/libs/shinkai-i18n/src/lib/default/index.ts index ff05fe2c2..21ce2954f 100644 --- a/libs/shinkai-i18n/src/lib/default/index.ts +++ b/libs/shinkai-i18n/src/lib/default/index.ts @@ -68,6 +68,9 @@ export default { success: 'Your conversation has been archived', error: 'Error archiving job', }, + editMessage: { + warning: 'This will restart your conversation from here.', + }, }, aiFilesSearch: { label: 'AI Files Content Search', @@ -296,6 +299,8 @@ export default { search: 'Search', next: 'Next', restore: 'Restore', + retry: 'Retry', + copy: 'Copy', reset: 'Reset', clickToUpload: 'Click to upload or drag and drop', upload: 'Upload', @@ -318,6 +323,8 @@ export default { soon: 'soon', back: 'Back', edit: 'Edit', + send: 'Send', + editMessage: 'Edit Message', delete: 'Delete', update: 'Update', moreOptions: 'More Options', diff --git a/libs/shinkai-message-ts/src/api/methods.ts b/libs/shinkai-message-ts/src/api/methods.ts index 4e4828dd4..02377236b 100644 --- a/libs/shinkai-message-ts/src/api/methods.ts +++ b/libs/shinkai-message-ts/src/api/methods.ts @@ -1572,3 +1572,36 @@ export const addOllamaModels = async ( const data = response.data; return data; }; + +export const getLastMessagesFromInboxWithBranches = async ( + nodeAddress: string, + inbox: string, + count: number, + lastKey: string | undefined, + setupDetailsState: LastMessagesFromInboxCredentialsPayload, +) => { + const messageStr = ShinkaiMessageBuilderWrapper.get_last_messages_from_inbox( + setupDetailsState.profile_encryption_sk, + setupDetailsState.profile_identity_sk, + setupDetailsState.node_encryption_pk, + inbox, + count, + lastKey, + setupDetailsState.shinkai_identity, + setupDetailsState.profile, + setupDetailsState.shinkai_identity, + ); + + const message = JSON.parse(messageStr); + + const response = await httpClient.post( + urlJoin(nodeAddress, '/v1/last_messages_from_inbox_with_branches'), + message, + + { + responseType: 'json', + }, + ); + const data = response.data.data; + return data; +}; diff --git a/libs/shinkai-node-state/src/lib/constants.ts b/libs/shinkai-node-state/src/lib/constants.ts index f8c9df11b..278f1831e 100644 --- a/libs/shinkai-node-state/src/lib/constants.ts +++ b/libs/shinkai-node-state/src/lib/constants.ts @@ -5,6 +5,7 @@ export enum FunctionKey { GET_INBOXES = 'GET_INBOXES', GET_CHAT_CONVERSATION = 'GET_CHAT_CONVERSATION', GET_CHAT_CONVERSATION_PAGINATION = 'GET_CHAT_CONVERSATION_PAGINATION', + GET_CHAT_CONVERSATION_BRANCHES = 'GET_CHAT_CONVERSATION_BRANCHES', GET_NODE_FILES = 'GET_NODE_FILES', GET_VR_FILES = 'GET_VR_FILES', GET_VR_FILES_SEARCH = 'GET_VR_FILES_SEARCH', diff --git a/libs/shinkai-node-state/src/lib/queries/getChatConversation/index.ts b/libs/shinkai-node-state/src/lib/queries/getChatConversation/index.ts index c4a388cb3..5733fea33 100644 --- a/libs/shinkai-node-state/src/lib/queries/getChatConversation/index.ts +++ b/libs/shinkai-node-state/src/lib/queries/getChatConversation/index.ts @@ -1,6 +1,6 @@ import { getFileNames, - getLastMessagesFromInbox, + getLastMessagesFromInboxWithBranches, } from '@shinkai_network/shinkai-message-ts/api'; import type { ShinkaiMessage } from '@shinkai_network/shinkai-message-ts/models'; import { @@ -27,7 +27,7 @@ export const getChatConversation = async ({ profile_identity_sk, node_encryption_pk, }: GetChatConversationInput): Promise => { - const data: ShinkaiMessage[] = await getLastMessagesFromInbox( + const data: ShinkaiMessage[][] = await getLastMessagesFromInboxWithBranches( nodeAddress, inboxId, count, @@ -40,8 +40,10 @@ export const getChatConversation = async ({ node_encryption_pk, }, ); + const flattenMessages: ShinkaiMessage[] = data.flat(1); + const transformedMessagePromises: Promise[] = - data.map(async (shinkaiMessage) => { + flattenMessages.map(async (shinkaiMessage) => { const filesInbox = getMessageFilesInbox(shinkaiMessage); const content = getMessageContent(shinkaiMessage); const isLocal = isLocalMessage(shinkaiMessage, shinkaiIdentity, profile); @@ -51,6 +53,11 @@ export const getChatConversation = async ({ ? shinkaiMessage.body.unencrypted.internal_metadata?.node_api_data ?.node_message_hash : '', + parentHash: + shinkaiMessage.body && 'unencrypted' in shinkaiMessage.body + ? shinkaiMessage.body.unencrypted.internal_metadata?.node_api_data + ?.parent_hash + : '', inboxId, content, sender: { @@ -86,5 +93,12 @@ export const getChatConversation = async ({ } return message; }); - return Promise.all(transformedMessagePromises); + + const messages = await Promise.all(transformedMessagePromises); + // filter out messages if a message is repeated by its parent-hash + const uniqueMessages = messages.filter( + (message, index, self) => + index === self.findIndex((t) => t.parentHash === message.parentHash), + ); + return uniqueMessages; }; diff --git a/libs/shinkai-node-state/src/lib/queries/getChatConversation/types.ts b/libs/shinkai-node-state/src/lib/queries/getChatConversation/types.ts index 0c8328a9d..010a9dfa4 100644 --- a/libs/shinkai-node-state/src/lib/queries/getChatConversation/types.ts +++ b/libs/shinkai-node-state/src/lib/queries/getChatConversation/types.ts @@ -12,6 +12,7 @@ export type GetChatConversationInput = JobCredentialsPayload & { export type ChatConversationMessage = { hash: string; + parentHash: string; inboxId: string; scheduledTime: string | undefined; content: string; diff --git a/libs/shinkai-ui/src/components/chat/chat-input-area.tsx b/libs/shinkai-ui/src/components/chat/chat-input-area.tsx index a8a26d566..9caa2d8be 100644 --- a/libs/shinkai-ui/src/components/chat/chat-input-area.tsx +++ b/libs/shinkai-ui/src/components/chat/chat-input-area.tsx @@ -23,6 +23,7 @@ export const ChatInputArea = ({ onSubmit: () => void; setInitialValue?: string; disabled?: boolean; + autoFocus?: boolean; isLoading?: boolean; placeholder?: string; topAddons?: React.ReactNode; @@ -91,8 +92,12 @@ export const ChatInputArea = ({ if (value === '') editor?.commands.setContent(''); }, [value, editor]); + useEffect(() => { + editor?.chain().focus().run(); + }, [editor]); + return ( -
+
{topAddons}
>; + regenerateMessage: (content: string, messageHash: string) => void; containerClassName?: string; lastMessageContent: string; isLoadingMessage: boolean | undefined; @@ -220,7 +222,24 @@ export const MessageList = ({
- {messages.map((message) => { + {messages.map((message, messageIndex) => { + const previousMessage = messages[messageIndex - 1]; + const grandparentHash = previousMessage + ? previousMessage.parentHash + : null; + + const handleRetryMessage = () => { + regenerateMessage( + previousMessage.content, + grandparentHash ?? '', + ); + }; + const handleEditMessage = (message: string) => { + regenerateMessage( + message, + previousMessage?.hash ?? '', + ); + }; return (
- +
); })} @@ -241,10 +264,11 @@ export const MessageList = ({ void; + handleEditMessage?: (message: string) => void; }; -export const Message = ({ message, isPending }: MessageProps) => { +const actionBar = { + rest: { + opacity: 0, + scale: 0.8, + transition: { + type: 'spring', + bounce: 0, + duration: 0.3, + }, + }, + hover: { + opacity: 1, + scale: 1, + transition: { + type: 'spring', + duration: 0.3, + bounce: 0, + }, + }, +}; + +export const editMessageFormSchema = z.object({ + message: z.string().min(1), +}); + +type EditMessageFormSchema = z.infer; + +export const Message = ({ + message, + isPending, + handleRetryMessage, + handleEditMessage, +}: MessageProps) => { + const { t } = useTranslation(); + + const [editing, setEditing] = useState(false); + const editMessageForm = useForm({ + resolver: zodResolver(editMessageFormSchema), + defaultValues: { + message: message.content, + }, + }); + + const { message: currentMessage } = editMessageForm.watch(); + + const onSubmit = async (data: z.infer) => { + handleEditMessage?.(data.message); + setEditing(false); + }; + return ( -
+
{ )} -
-
- svg]:h-3 [&>svg]:w-3', - )} - onCopyClipboard={() => { - copyToClipboard( - extractErrorPropertyOrContent( - message.content, - 'error_message', - ), - ); - }} - string={extractErrorPropertyOrContent( - message.content, - 'error_message', + {editing ? ( +
+ +
+ ( + +
+ + {t('chat.editMessage.warning')} +
+
+ + +
+
+ } + onChange={field.onChange} + onSubmit={editMessageForm.handleSubmit(onSubmit)} + value={field.value} + /> + )} + /> +
+ + + ) : ( + + + {format(new Date(message?.scheduledTime ?? ''), 'p')} + + + {message.isLocal ? ( + + + + + + + +

{t('common.editMessage')}

+
+
+
+
+ ) : ( + + + + + + + +

{t('common.retry')}

+
+
+
+
+ )} + + + + svg]:h-3 [&>svg]:w-3', + )} + onCopyClipboard={() => { + copyToClipboard( + extractErrorPropertyOrContent( + message.content, + 'error_message', + ), + ); + }} + string={extractErrorPropertyOrContent( + message.content, + 'error_message', + )} + /> + + + +

{t('common.copy')}

+
+
+
+
+
+ {message.content ? ( + ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + }} + source={ + isPending + ? extractErrorPropertyOrContent( + message.content, + 'error_message', + ) + ' ...' + : extractErrorPropertyOrContent( + message.content, + 'error_message', + ) + } + /> + ) : ( + )} - /> -
- {message.content ? ( - ( - // eslint-disable-next-line jsx-a11y/anchor-has-content - - ), - }} - source={extractErrorPropertyOrContent( - isPending ? message.content + ' ...' : message.content, - 'error_message', + {!!message.fileInbox?.files?.length && ( + )} - /> - ) : ( - - )} - {!!message.fileInbox?.files?.length && ( - + )} -
+
-
+ ); }; diff --git a/libs/shinkai-ui/src/helpers/date.ts b/libs/shinkai-ui/src/helpers/date.ts index 62e8358cf..e66034ba7 100644 --- a/libs/shinkai-ui/src/helpers/date.ts +++ b/libs/shinkai-ui/src/helpers/date.ts @@ -1,5 +1,6 @@ export type ChatConversationMessage = { hash: string; + parentHash: string; inboxId: string; scheduledTime: string | undefined; content: string; diff --git a/libs/shinkai-ui/src/hooks/index.ts b/libs/shinkai-ui/src/hooks/index.ts index 2319301b1..0b2a2c0ca 100644 --- a/libs/shinkai-ui/src/hooks/index.ts +++ b/libs/shinkai-ui/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-debounce'; export * from './use-map'; +export * from './use-measure'; diff --git a/libs/shinkai-ui/src/hooks/use-measure.ts b/libs/shinkai-ui/src/hooks/use-measure.ts new file mode 100644 index 000000000..144f51ac2 --- /dev/null +++ b/libs/shinkai-ui/src/hooks/use-measure.ts @@ -0,0 +1,42 @@ +import React from 'react'; + +export function useMeasure(): [ + React.RefCallback, + { + width: number | null; + height: number | null; + }, +] { + const [dimensions, setDimensions] = React.useState<{ + width: number | null; + height: number | null; + }>({ + width: null, + height: null, + }); + + const previousObserver = React.useRef(null); + + const customRef = React.useCallback((node: T) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new ResizeObserver(([entry]) => { + if (entry && entry.borderBoxSize) { + const { inlineSize: width, blockSize: height } = + entry.borderBoxSize[0]; + + setDimensions({ width, height }); + } + }); + + observer.observe(node); + previousObserver.current = observer; + } + }, []); + + return [customRef, dimensions]; +}