一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
618 行
21 KiB
TypeScript
618 行
21 KiB
TypeScript
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<void>((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<HTMLDivElement> = 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> = (props) => {
|
|
const { dispatch, messages } = props;
|
|
const [input, setInput] = useState('');
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
const historyId = useId();
|
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
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<HTMLDivElement>(null);
|
|
const historyButtonRef = useRef<HTMLButtonElement>(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<boolean>(() => {
|
|
try {
|
|
return !!Setting.value(disclosureSetting);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
const lastNoteIdRef = useRef<string | null>(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<HTMLDivElement>(null);
|
|
|
|
const { hasFocus, onFocus, onBlur } = useHasFocus();
|
|
|
|
const { abortControllerRef, cancelRequest } = useCancelCallback();
|
|
|
|
const windowId = useContext(WindowIdContext);
|
|
|
|
const appendMessage = useCallback(async (message: Omit<AiChatMessage, 'noteId' | 'noteTitle' | 'createdTime'>) => {
|
|
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<ChatMessage[]>(() => {
|
|
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<HTMLTextAreaElement>) => {
|
|
// 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 = (
|
|
<div className='disabled-message'>
|
|
<div className='hint'>{props.unavailableHint}</div>
|
|
{props.showToolbarButton && <>
|
|
<div className='hint'>{_('If you do not want to use the AI features, you may hide the toolbar button:')}</div>
|
|
<Button
|
|
level={ButtonLevel.Secondary}
|
|
title={_('Hide the AI Chat button')}
|
|
onClick={handleHideToolbarButton}
|
|
/>
|
|
<div className='hint'>{_('(You can show it again from Settings → AI)')}</div>
|
|
</>}
|
|
</div>
|
|
);
|
|
return { content, showingMessages: false };
|
|
}
|
|
if (props.noteIsEncrypted) {
|
|
const content = (
|
|
<div className='disabled-message'>
|
|
{_('This note is encrypted and cannot be used with AI Chat.')}
|
|
</div>
|
|
);
|
|
return { content, showingMessages: false };
|
|
}
|
|
|
|
const visibleMessages = messages.filter(m => !m.hide);
|
|
|
|
const content = <>
|
|
{props.aiDegraded && <AiDegradedNotice className='degraded-status' />}
|
|
<div className='messages' aria-live={hasFocus ? 'polite' : undefined}>
|
|
{visibleMessages.length === 0 && (
|
|
<div className='empty'>
|
|
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}
|
|
<br/>
|
|
<br/>
|
|
<button
|
|
type='button'
|
|
className='link-button'
|
|
onClick={() => NavService.go('Config', { props: { defaultSection: 'ai.tools' } })}
|
|
>{_('Manage capabilities')}</button>
|
|
</div>
|
|
)}
|
|
{visibleMessages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
<div className='composer'>
|
|
{showDisclosure && (
|
|
<div className='disclosure'>
|
|
{_('Your note will be sent to the configured AI provider (%s).', props.providerType)}
|
|
{' '}
|
|
<a href='#' onClick={(e) => { e.preventDefault(); handleAcknowledgeDisclosure(); }}>{_('Don\'t show again')}</a>
|
|
</div>
|
|
)}
|
|
<div className='input-wrapper'>
|
|
<textarea
|
|
className='input'
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={_('Ask about this note, or request a change…')}
|
|
aria-label={_('Chat message')}
|
|
/>
|
|
<button
|
|
type='button'
|
|
className='send'
|
|
onClick={sending ? handleCancel : handleSend}
|
|
disabled={!sending && !input.trim()}
|
|
aria-label={sendButtonLabel}
|
|
title={sendButtonLabel}
|
|
>
|
|
<i className={sending ? 'fas fa-stop' : 'fas fa-paper-plane'} aria-hidden='true' />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>;
|
|
|
|
return { content, showingMessages: visibleMessages.length > 0 };
|
|
};
|
|
|
|
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
|
|
const closeLabel = _('Close');
|
|
const newChatLabel = _('New chat');
|
|
const headerId = useId();
|
|
const { content, showingMessages } = renderContent();
|
|
|
|
return (
|
|
<div
|
|
className='chat-panel'
|
|
role='region'
|
|
aria-labelledby={headerId}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
>
|
|
<div className='header chat-panel-header'>
|
|
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
|
|
{props.availabilityReason !== AvailabilityReason.Disabled && (
|
|
<button
|
|
type='button'
|
|
className='history toolbar-button'
|
|
ref={historyButtonRef}
|
|
onClick={handleHistoryToggle}
|
|
title={_('Chat history')}
|
|
aria-label={_('Chat history')}
|
|
aria-expanded={historyOpen}
|
|
aria-controls={historyId}
|
|
>
|
|
<i className='toolbar-icon far fa-clock' aria-hidden='true'/>
|
|
</button>
|
|
)}
|
|
{historyOpen && (
|
|
// Placed right after its button so that Tab moves from the button into the popup
|
|
<ChatHistory
|
|
conversations={conversations}
|
|
currentConversationId={props.conversationId}
|
|
id={historyId}
|
|
popupRef={historyPopupRef}
|
|
onSearchChange={loadConversationHistory}
|
|
onOpen={handleOpenConversation}
|
|
onRename={handleRenameConversation}
|
|
onDelete={handleDeleteConversation}
|
|
onClose={closeHistory}
|
|
/>
|
|
)}
|
|
<button type='button' className='reset toolbar-button' onClick={handleNewChat} disabled={!showingMessages} title={newChatLabel} aria-label={newChatLabel}>
|
|
<i className='toolbar-icon fas fa-comment-medical' aria-hidden='true'/>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='close toolbar-button'
|
|
onClick={handleClose}
|
|
title={closeLabel}
|
|
aria-label={closeLabel}
|
|
><i className='toolbar-icon fas fa-times' role='img' aria-hidden={true}/></button>
|
|
</div>
|
|
{content}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface OwnProps {
|
|
windowId: string;
|
|
}
|
|
|
|
const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
|
|
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
|
const noteId = stateUtils.selectedNoteId(windowState);
|
|
const note = noteId ? windowState.notes.find(n => n.id === noteId) : null;
|
|
const availability = chatAvailability();
|
|
return {
|
|
themeId: state.settings.theme,
|
|
available: availability.available,
|
|
availabilityReason: availability.reason,
|
|
unavailableHint: availability.hint ?? '',
|
|
providerType: state.settings['ai.chat.providerType'] || 'openai-compatible',
|
|
noteId,
|
|
noteTitle: note?.title || '',
|
|
noteIsEncrypted: !!note?.encryption_applied,
|
|
messages: windowState.aiChatMessages || [],
|
|
conversationId: windowState.aiChatConversationId,
|
|
aiDegraded: !!state.aiStatus?.degraded,
|
|
showToolbarButton: !!state.settings['ai.chat.showToolbarButton'],
|
|
};
|
|
};
|
|
|
|
const ConversationPanel: React.FC<Props> = props => <ChatPanel key={props.conversationId} {...props} />;
|
|
|
|
export default connect(mapStateToProps)(ConversationPanel);
|