import * as React from 'react'; import { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { _ } from '@joplin/lib/locale'; import Setting from '@joplin/lib/models/Setting'; import Note from '@joplin/lib/models/Note'; import CommandService from '@joplin/lib/services/CommandService'; import Logger from '@joplin/utils/Logger'; import { stateUtils } from '@joplin/lib/reducer'; import { AiChatMessage, AppState } from '../../app.reducer'; import { runNoteChat } from '@joplin/lib/services/ai/noteChat'; import { AvailabilityReason, chatAvailability } from '@joplin/lib/services/ai/availability'; import { WindowIdContext } from '../NewWindowOrIFrame'; import AiDegradedNotice from '../AiDegradedNotice'; import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types'; import JoplinError from '@joplin/lib/JoplinError'; import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager'; import { Second } from '@joplin/utils/time'; import ChatMessageItem from './ChatMessageItem'; import NavService from '@joplin/lib/services/NavService'; import ChatConversation from '@joplin/lib/models/ChatConversation'; import uuid from '@joplin/lib/uuid'; import { focus } from '@joplin/lib/utils/focusHandler'; import dialogs from '../dialogs'; import ChatHistory, { Conversation } from './ChatHistory'; import Button, { ButtonLevel } from '../Button/Button'; const logger = Logger.create('ChatPanel'); interface Props { themeId: number; available: boolean; availabilityReason?: AvailabilityReason; unavailableHint: string; providerType: string; noteId: string | null; noteTitle: string; noteIsEncrypted: boolean; messages: AiChatMessage[]; conversationId?: string|null; aiDegraded: boolean; showToolbarButton: boolean; dispatch: Dispatch; } const disclosureSetting = 'ai.chat.disclosureAcknowledged'; const makeId = () => uuid.create(); const waitForNextNoteChangeOrTimeout = (noteId: string, timeout: number) => { return new Promise((resolve) => { const listener = (event: ItemChangeEvent) => { if (event.itemId === noteId) { onComplete(); } }; const onComplete = () => { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } eventManager.off(EventName.ItemChange, listener); resolve(); }; eventManager.on(EventName.ItemChange, listener); let timeoutId = setTimeout(() => { onComplete(); }, timeout); }); }; const useCancelCallback = () => { const abortControllerRef = useRef(new AbortController()); const cancelRequest = useCallback(() => { abortControllerRef.current.abort(); abortControllerRef.current = new AbortController(); }, []); // Cancel on Reset / unmount so an in-flight reply can detect it should // abort instead of landing in a cleared or destroyed conversation. const cancelRequestRef = useRef(cancelRequest); cancelRequestRef.current = cancelRequest; useEffect(() => () => { cancelRequestRef.current(); }, []); return { abortControllerRef, cancelRequest }; }; const useHasFocus = () => { const [hasFocus, setHasFocus] = useState(false); const onFocus = useCallback(() => setHasFocus(true), []); const onBlur: React.FocusEventHandler = useCallback((event) => { const chatPanelContainer = event.currentTarget; const newElementWithFocus = event.relatedTarget; const movingFocusOut = !chatPanelContainer.contains(newElementWithFocus); // Avoid quickly toggling hasFocus: onBlur/onFocus are emitted when moving focus between // elements within the container. if (movingFocusOut) { setHasFocus(false); } }, []); return { hasFocus, onFocus, onBlur }; }; // Single-window for v1: mapStateToProps hard-codes defaultWindowId and the // toggle writes to the app-wide layout. A second window would mirror the main. const ChatPanel: React.FC = (props) => { const { dispatch, messages } = props; const [input, setInput] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const historyId = useId(); const [conversations, setConversations] = useState([]); const loadConversationHistory = useCallback(async (search = '') => { try { setConversations(await ChatConversation.history(search)); } catch (error) { logger.error('Could not load chat conversations', error); } }, []); const handleHistoryToggle = useCallback(async () => { setHistoryOpen(!historyOpen); if (historyOpen) return; await loadConversationHistory(); }, [historyOpen, loadConversationHistory]); const historyPopupRef = useRef(null); const historyButtonRef = useRef(null); const closeHistory = useCallback(() => { setHistoryOpen(false); focus('ChatPanel::closeHistory', historyButtonRef.current); }, []); useEffect(() => { if (!historyOpen) return () => {}; const onMouseDown = (event: MouseEvent) => { const target = event.target as Node; if (historyPopupRef.current?.contains(target) || historyButtonRef.current?.contains(target)) return; setHistoryOpen(false); }; const doc = historyButtonRef.current.ownerDocument; doc.addEventListener('mousedown', onMouseDown); return () => doc.removeEventListener('mousedown', onMouseDown); }, [historyOpen]); const [sending, setSending] = useState(false); const archivingRef = useRef(false); const [disclosureShown, setDisclosureShown] = useState(() => { try { return !!Setting.value(disclosureSetting); } catch { return false; } }); const lastNoteIdRef = useRef(props.noteId); const messagesLengthRef = useRef(messages.length); messagesLengthRef.current = messages.length; // Lets async work detect note switches without re-running its closure. const noteIdRef = useRef(props.noteId); noteIdRef.current = props.noteId; const messagesEndRef = useRef(null); const { hasFocus, onFocus, onBlur } = useHasFocus(); const { abortControllerRef, cancelRequest } = useCancelCallback(); const windowId = useContext(WindowIdContext); const appendMessage = useCallback(async (message: Omit) => { const fullMessage: AiChatMessage = { ...message, createdTime: Date.now(), noteId: props.noteId ?? '', noteTitle: props.noteId ? props.noteTitle : '' }; try { await ChatConversation.addMessage(props.conversationId, fullMessage); } catch (error) { logger.error('Could not save chat message:', error); } }, [props.noteId, props.noteTitle, props.conversationId]); const removeMessage = useCallback(async (id: string) => { try { await ChatConversation.removeMessage(props.conversationId, id); } catch (error) { logger.error('Could not remove chat message:', error); } }, [props.conversationId]); const addToolResult = useCallback(async (result: ChatToolMessage) => { try { await ChatConversation.addToolResult(props.conversationId, result); } catch (error) { logger.error('Could not save chat tool result:', error); } }, [props.conversationId]); useEffect(() => { if (props.conversationId) return; dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages }); }, [props.conversationId, dispatch, windowId, messages]); // Drop a separator when the active note changes mid-conversation. Skip // the first ever opened note (no prior context to separate from). useEffect(() => { const prev = lastNoteIdRef.current; lastNoteIdRef.current = props.noteId; if (prev === null || prev === props.noteId || !props.noteId) return; if (messagesLengthRef.current === 0) return; const text = _('— now viewing: %s —', props.noteTitle || _('(untitled)')); void appendMessage({ id: makeId(), role: 'separator', text, raw: [{ role: ChatRole.User, content: _('Switched notes: %s', text) }], }); }, [props.noteId, props.noteTitle, appendMessage]); useEffect(() => { if (messages.length === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); const conversationTurns = useMemo(() => { return messages .map(m => m.raw) .flat(); }, [messages]); // Joplin Cloud is remote but the user already consented via sync setup. const requiresDisclosure = props.providerType !== 'joplin-cloud'; const showDisclosure = requiresDisclosure && !disclosureShown && messages.length === 0; const handleCancel = useCallback(() => { cancelRequest(); void appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] }); }, [cancelRequest, appendMessage]); const handleSend = useCallback(async () => { const text = input.trim(); if (!text || sending) return; if (!props.noteId) { void appendMessage({ id: makeId(), role: 'error', text: _('Open a note to start chatting.'), raw: [], }); return; } const abortController = abortControllerRef.current; const noteIdAtStart = props.noteId; setSending(true); setInput(''); // Captured so we can roll it back on failure — otherwise a retry would // send the prior user turn as history alongside the new prompt. const userTurnId = makeId(); let hadVisibleResponse = false; void appendMessage({ id: userTurnId, role: 'user', text, raw: [{ role: ChatRole.User, content: text }], }); try { const note = await Note.load(props.noteId); if (abortController.signal.aborted) return; if (!note) throw new Error(`Note not found: ${props.noteId}`); const getContext = async () => { let selection = ''; try { selection = await CommandService.instance().executeInWindow( 'selectedText', { windowId, args: [] }, ) as string || ''; } catch { // Editor may not be ready; treat as no selection. } const note = await Note.load(props.noteId); if (!note) throw new Error(`Note not found: ${props.noteId}`); return { body: note.body, title: note.title, selection, noteId: note.id, folderId: note.parent_id, }; }; let lastHistory = [ { role: ChatRole.System, content: 'placeholder' }, ...conversationTurns, ]; const onHistoryChanged = (history: ChatMessage[]) => { if (abortController.signal.aborted) return; for (let i = lastHistory.length; i < history.length; i++) { const entry = history[i]; // User messages are added elsewhere, when the user submits the chat if (entry.role === ChatRole.User) continue; // System messages are not shown in the UI if (entry.role === ChatRole.System) continue; if (entry.role === ChatRole.Tool) { void addToolResult(entry); } else { hadVisibleResponse ||= !entry.hide; void appendMessage({ id: makeId(), role: entry.role, text: entry.content, hide: entry.hide, raw: [entry], }); } } lastHistory = history; }; const assertSameNote = () => { if (noteIdAtStart !== noteIdRef.current) { cancelRequest(); throw new JoplinError( _('You switched notes while the request was running; edits were not applied. Try again.'), 'aiNoteChanged', ); } }; await runNoteChat( getContext, conversationTurns, text, { replaceSelection: async (text, oldText) => { if (text === oldText) return; assertSameNote(); const changeListener = waitForNextNoteChangeOrTimeout(props.noteId, Second); await CommandService.instance().executeInWindow('replaceSelection', { windowId, args: [text], }); await changeListener; }, updateNoteBody: async (newBody, oldBody) => { if (newBody === oldBody) return; assertSameNote(); const changeListener = waitForNextNoteChangeOrTimeout(props.noteId, Second); await CommandService.instance().executeInWindow('editor.setText', { windowId, args: [newBody], }); await changeListener; }, displayError: (message) => { void appendMessage({ id: makeId(), role: 'error', text: message, raw: [] }); }, }, onHistoryChanged, abortController.signal, ); } catch (error) { logger.warn('Chat failed:', error); if (abortController.signal.aborted) return; if (!hadVisibleResponse) { void removeMessage(userTurnId); setInput(text); } void appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] }); } finally { setSending(false); } }, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, removeMessage, cancelRequest, abortControllerRef]); const handleAcknowledgeDisclosure = useCallback(() => { Setting.setValue(disclosureSetting, true); setDisclosureShown(true); }, []); const handleNewChat = useCallback(() => { if (archivingRef.current) return; cancelRequest(); // Only saved once a message is sent so empty chats don't show up in history dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages: [] }); }, [dispatch, windowId, cancelRequest]); const handleOpenConversation = useCallback(async (conversationId: string) => { if (conversationId === props.conversationId || archivingRef.current) return; archivingRef.current = true; cancelRequest(); try { await CommandService.instance().executeInWindow('openAiChatConversation', { windowId, args: [conversationId] }); } catch (error) { logger.error('Could not open chat conversation:', error); } finally { archivingRef.current = false; } }, [windowId, cancelRequest, props.conversationId]); const handleClose = useCallback(() => { void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] }); }, [windowId]); const handleDeleteConversation = useCallback(async (conversationId: string) => { if (archivingRef.current) return; archivingRef.current = true; if (conversationId === props.conversationId) cancelRequest(); try { await ChatConversation.deleteConversation(conversationId); setConversations(current => current.filter(item => item.id !== conversationId)); dispatch({ type: 'AI_CHAT_DELETE', conversationId }); } catch (error) { logger.error('Could not delete chat conversation:', error); await dialogs.alert(_('Could not delete conversation.')); } finally { archivingRef.current = false; } }, [dispatch, cancelRequest, props.conversationId]); const handleRenameConversation = useCallback(async (conversation: Conversation, newTitle: string) => { const title = newTitle.trim(); if (!title || title === conversation.title) return; try { await ChatConversation.renameConversation(conversation.id, title); setConversations(current => current.map(item => item.id === conversation.id ? { ...item, title } : item)); } catch (error) { logger.error('Could not rename chat conversation:', error); await dialogs.alert(_('Could not rename conversation.')); } }, []); const handleHideToolbarButton = useCallback(() => { Setting.setValue('ai.chat.showToolbarButton', false); handleClose(); }, [handleClose]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { // Don't send while an IME composition is in flight — Enter commits // the composition for CJK / accented input. if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (!sending) void handleSend(); } }, [handleSend, sending]); const renderContent = () => { if (!props.available) { const content = (
{props.unavailableHint}
{props.showToolbarButton && <>
{_('If you do not want to use the AI features, you may hide the toolbar button:')}
); return { content, showingMessages: false }; } if (props.noteIsEncrypted) { const content = (
{_('This note is encrypted and cannot be used with AI Chat.')}
); return { content, showingMessages: false }; } const visibleMessages = messages.filter(m => !m.hide); const content = <> {props.aiDegraded && }
{visibleMessages.length === 0 && (
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}

)} {visibleMessages.map(m => )}
{showDisclosure && (
{_('Your note will be sent to the configured AI provider (%s).', props.providerType)} {' '} { e.preventDefault(); handleAcknowledgeDisclosure(); }}>{_('Don\'t show again')}
)}