diff --git a/.gitignore b/.gitignore index 17e107f205..f3009a2cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -196,7 +196,9 @@ packages/app-desktop/commands/toggleSafeMode.js packages/app-desktop/commands/toggleTabMovesFocus.js packages/app-desktop/gui/AiDegradedNotice.js packages/app-desktop/gui/Button/Button.js +packages/app-desktop/gui/ChatPanel/ChatHistory.js packages/app-desktop/gui/ChatPanel/ChatMessageItem.js +packages/app-desktop/gui/ChatPanel/ChatPanel.test.js packages/app-desktop/gui/ChatPanel/ChatPanel.js packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js @@ -536,6 +538,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js @@ -1499,6 +1502,9 @@ packages/lib/mimeUtils.test.js packages/lib/models/Alarm.js packages/lib/models/BaseItem.test.js packages/lib/models/BaseItem.js +packages/lib/models/ChatConversation.test.js +packages/lib/models/ChatConversation.js +packages/lib/models/ChatMessage.js packages/lib/models/ConflictNoteState.js packages/lib/models/Folder.publishing.test.js packages/lib/models/Folder.sharing.test.js @@ -1691,6 +1697,7 @@ packages/lib/services/database/migrations/50.js packages/lib/services/database/migrations/51.js packages/lib/services/database/migrations/52.js packages/lib/services/database/migrations/53.js +packages/lib/services/database/migrations/54.js packages/lib/services/database/migrations/index.js packages/lib/services/database/sqlStringToLines.js packages/lib/services/database/types.js diff --git a/.ignore.eslint b/.ignore.eslint index dbf5d2f24c..2548ff263b 100644 --- a/.ignore.eslint +++ b/.ignore.eslint @@ -222,7 +222,9 @@ packages/app-desktop/commands/toggleSafeMode.js packages/app-desktop/commands/toggleTabMovesFocus.js packages/app-desktop/gui/AiDegradedNotice.js packages/app-desktop/gui/Button/Button.js +packages/app-desktop/gui/ChatPanel/ChatHistory.js packages/app-desktop/gui/ChatPanel/ChatMessageItem.js +packages/app-desktop/gui/ChatPanel/ChatPanel.test.js packages/app-desktop/gui/ChatPanel/ChatPanel.js packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js @@ -562,6 +564,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js @@ -1525,6 +1528,9 @@ packages/lib/mimeUtils.test.js packages/lib/models/Alarm.js packages/lib/models/BaseItem.test.js packages/lib/models/BaseItem.js +packages/lib/models/ChatConversation.test.js +packages/lib/models/ChatConversation.js +packages/lib/models/ChatMessage.js packages/lib/models/ConflictNoteState.js packages/lib/models/Folder.publishing.test.js packages/lib/models/Folder.sharing.test.js @@ -1717,6 +1723,7 @@ packages/lib/services/database/migrations/50.js packages/lib/services/database/migrations/51.js packages/lib/services/database/migrations/52.js packages/lib/services/database/migrations/53.js +packages/lib/services/database/migrations/54.js packages/lib/services/database/migrations/index.js packages/lib/services/database/sqlStringToLines.js packages/lib/services/database/types.js diff --git a/packages/app-desktop/app.reducer.test.ts b/packages/app-desktop/app.reducer.test.ts index 8739be4fea..bafd778617 100644 --- a/packages/app-desktop/app.reducer.test.ts +++ b/packages/app-desktop/app.reducer.test.ts @@ -1,9 +1,25 @@ import { ChatRole, ChatToolCall, ChatToolMessage } from '@joplin/lib/services/ai/types'; import { AiChatMessage, AppState, createAppDefaultWindowState } from './app.reducer'; import appReducer, { createAppDefaultState } from './app.reducer'; -import { defaultWindowId } from '@joplin/lib/reducer'; describe('app.reducer', () => { + it('should clear a deleted conversation from matching windows and keep other conversations', () => { + const messages: AiChatMessage[] = [{ id: 'question', createdTime: 1, role: 'user', text: 'Question', raw: [], noteId: '', noteTitle: '' }]; + const state = createAppDefaultState({}); + state.aiChatConversationId = 'deleted'; + state.aiChatMessages = messages; + state.backgroundWindows = { + second: { ...createAppDefaultWindowState(), windowId: 'second', aiChatConversationId: 'deleted', aiChatMessages: messages }, + third: { ...createAppDefaultWindowState(), windowId: 'third', aiChatConversationId: 'other', aiChatMessages: messages }, + }; + const result = appReducer(state, { type: 'AI_CHAT_DELETE', conversationId: 'deleted' }); + expect(result.aiChatConversationId).toBeNull(); + expect(result.aiChatMessages).toEqual([]); + expect(result.backgroundWindows.second.aiChatConversationId).toBeNull(); + expect(result.backgroundWindows.second.aiChatMessages).toEqual([]); + expect(result.backgroundWindows.third.aiChatConversationId).toBe('other'); + expect(result.backgroundWindows.third.aiChatMessages).toEqual(messages); + }); it('should handle DIALOG_OPEN', async () => { const state: AppState = createAppDefaultState({}); @@ -107,11 +123,15 @@ describe('app.reducer', () => { it('should build AI chat history', () => { let state: AppState = { ...createAppDefaultState({}), + aiChatConversationId: 'chat-1', }; let idCounter = 0; const buildMessage = (role: ChatRole.User | ChatRole.Assistant, content: string, toolCalls: ChatToolCall[]) => ({ id: `id-${idCounter++}`, + createdTime: 1, + noteId: 'note-a', + noteTitle: 'A', role, text: content, raw: [ @@ -125,12 +145,12 @@ describe('app.reducer', () => { state = appReducer(state, { type: 'AI_CHAT_APPEND', - windowId: defaultWindowId, + conversationId: 'chat-1', message: buildMessage(ChatRole.User, 'Test', []), }); state = appReducer(state, { type: 'AI_CHAT_APPEND', - windowId: defaultWindowId, + conversationId: 'chat-1', message: buildMessage( ChatRole.Assistant, 'Testing', @@ -139,7 +159,7 @@ describe('app.reducer', () => { }); state = appReducer(state, { type: 'AI_CHAT_ADD_TOOL_RESULT', - windowId: defaultWindowId, + conversationId: 'chat-1', toolCall: ({ role: ChatRole.Tool, toolName: 'testTool', @@ -155,13 +175,5 @@ describe('app.reducer', () => { { id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] }, { id: 'id-1', role: 'assistant', text: 'Testing' }, ]); - - - state = appReducer(state, { - type: 'AI_CHAT_RESET', - windowId: defaultWindowId, - }); - - expect(state.aiChatMessages).toEqual([]); }); }); diff --git a/packages/app-desktop/app.reducer.ts b/packages/app-desktop/app.reducer.ts index b08dda6d70..1ccf8f4f6a 100644 --- a/packages/app-desktop/app.reducer.ts +++ b/packages/app-desktop/app.reducer.ts @@ -5,12 +5,15 @@ import iterateItems from './gui/ResizableLayout/utils/iterateItems'; import { LayoutItem } from './gui/ResizableLayout/utils/types'; import validateLayout from './gui/ResizableLayout/utils/validateLayout'; import Logger from '@joplin/utils/Logger'; -import { ChatMessage } from '@joplin/lib/services/ai/types'; +import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types'; const logger = Logger.create('app.reducer'); export interface AiChatMessage { id: string; + createdTime: number; + noteId: string; + noteTitle: string; role: 'user' | 'assistant' | 'error' | 'separator'; text: string; hide?: boolean; @@ -78,6 +81,7 @@ export interface AppWindowState extends WindowState { // In window state so the conversation survives panel hide/show (the // layout container can swap component types and unmount the panel). aiChatMessages: AiChatMessage[]; + aiChatConversationId: string|null; // Layout for secondary windows secondaryWindowLayout: LayoutItem|null; } @@ -115,6 +119,7 @@ export const createAppDefaultWindowState = (): AppWindowState => { whiteboardForceMarkdown: {}, activeNoteIsWhiteboard: false, aiChatMessages: [], + aiChatConversationId: null, secondaryWindowLayout: null, }; }; @@ -304,43 +309,44 @@ export default function(state: AppState, action: any) { ); break; + case 'AI_CHAT_DELETE': + newState = produce(state, draft => { + for (const windowState of stateUtils.allWindowStates(draft)) { + if (windowState.aiChatConversationId !== action.conversationId) continue; + windowState.aiChatConversationId = null; + windowState.aiChatMessages = []; + } + }); + break; + + case 'AI_CHAT_OPEN': + newState = produce(state, draft => { + const openConversation = action.conversationId && stateUtils.allWindowStates(state) + .find(windowState => windowState.aiChatConversationId === action.conversationId); + const windowState = stateUtils.windowStateById(draft, action.windowId); + windowState.aiChatConversationId = action.conversationId; + windowState.aiChatMessages = openConversation ? openConversation.aiChatMessages : action.messages; + }); + break; + case 'AI_CHAT_APPEND': - newState = withWindowStateUpdated( - state, action.windowId, 'aiChatMessages', messages => [...messages, action.message as AiChatMessage], - ); - break; - case 'AI_CHAT_ADD_TOOL_RESULT': - newState = withWindowStateUpdated( - state, action.windowId, 'aiChatMessages', messages => { - let lastMessage = messages[messages.length - 1]; - if (lastMessage) { - lastMessage = { - ...lastMessage, - raw: [ - ...lastMessage.raw, - action.toolCall, - ], - }; - - return [...messages.slice(0, messages.length - 1), lastMessage]; - } - - return messages; - }, - ); - break; - case 'AI_CHAT_REMOVE': - newState = withWindowStateUpdated( - state, action.windowId, 'aiChatMessages', messages => messages.filter(m => m.id !== action.id), - ); - break; - - case 'AI_CHAT_RESET': - newState = withWindowStateUpdated( - state, action.windowId, 'aiChatMessages', (): AiChatMessage[] => [], - ); + newState = produce(state, draft => { + for (const windowState of stateUtils.allWindowStates(draft)) { + if (windowState.aiChatConversationId !== action.conversationId) continue; + if (action.type === 'AI_CHAT_APPEND') { + windowState.aiChatMessages.push(action.message); + } else if (action.type === 'AI_CHAT_REMOVE') { + windowState.aiChatMessages = windowState.aiChatMessages.filter(message => message.id !== action.id); + } else { + const message = windowState.aiChatMessages.find(message => message.raw.some(entry => + entry.role === ChatRole.Assistant && entry.toolCalls?.some(call => call.callId === action.toolCall.toolCallId), + )); + message?.raw.push(action.toolCall); + } + } + }); break; case 'AI_STATUS_UPDATE': diff --git a/packages/app-desktop/gui/ChatPanel/ChatHistory.scss b/packages/app-desktop/gui/ChatPanel/ChatHistory.scss new file mode 100644 index 0000000000..d476c9fde6 --- /dev/null +++ b/packages/app-desktop/gui/ChatPanel/ChatHistory.scss @@ -0,0 +1,140 @@ +@mixin chat-history-input { + box-sizing: border-box; + font: inherit; + color: var(--joplin-color); + background-color: var(--joplin-background-color); + border: 1px solid var(--joplin-divider-color); + border-radius: 4px; +} + +.chat-history { + position: absolute; + top: 100%; + left: 30px; + right: 30px; + z-index: 10; + display: flex; + flex-direction: column; + max-width: 400px; + padding: 6px 10px 10px; + font-weight: normal; + background-color: var(--joplin-background-color); + border-bottom: 1px solid var(--joplin-divider-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + + > .search { + flex-shrink: 0; + } +} + +.conversation-search { + position: relative; + display: flex; + align-items: center; + + > .icon { + position: absolute; + left: 8px; + color: var(--joplin-color-faded); + pointer-events: none; + } + + > .field { + @include chat-history-input; + width: 100%; + padding: 6px 6px 6px 26px; + } +} + +.conversation-list { + list-style: none; + margin: 6px 0 0; + padding: 0; + max-height: 200px; + overflow-y: auto; +} + +.conversation-row { + display: flex; + align-items: center; + line-height: 16px; + cursor: pointer; + + &.-current { + font-weight: bold; + background-color: var(--joplin-selected-color); + } + + &:hover:not(.-current) { + background-color: var(--joplin-background-color-hover3); + } + + > .open { + flex: 1; + min-width: 0; + } + + > .title-input { + @include chat-history-input; + flex: 1; + min-width: 0; + margin: 6px 20px; + padding: 3px 4px; + } + + > .actions { + flex-shrink: 0; + margin-right: 20px; + } + + &:hover, + &:focus-within { + --chat-history-timestamp-display: none; + } + + &:hover > .actions, + &:focus-within > .actions { + display: flex; + } +} + +.conversation-link { + display: flex; + padding: 10px 20px; + gap: 18px; + text-align: left; + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + + > .title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .timestamp { + display: var(--chat-history-timestamp-display, initial); + flex-shrink: 0; + text-align: right; + color: var(--joplin-color-faded); + } +} + +.conversation-actions { + display: none; + gap: 10px; + align-items: center; + + > button { + display: flex; + color: var(--joplin-color3); + cursor: pointer; + font-size: 16px; + line-height: 1; + } +} diff --git a/packages/app-desktop/gui/ChatPanel/ChatHistory.tsx b/packages/app-desktop/gui/ChatPanel/ChatHistory.tsx new file mode 100644 index 0000000000..d936ddce24 --- /dev/null +++ b/packages/app-desktop/gui/ChatPanel/ChatHistory.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { _ } from '@joplin/lib/locale'; +import { formatMsToRelativeTime } from '@joplin/utils/time'; + +export interface Conversation { + id: string; + title: string; + updated_time: number; +} + +interface Props { + conversations: Conversation[]; + currentConversationId?: string|null; + id: string; + popupRef: React.Ref; + onSearchChange: (search: string)=> void; + onOpen: (conversationId: string)=> void; + onRename: (conversation: Conversation, title: string)=> void; + onDelete: (conversationId: string)=> void; + onClose: ()=> void; +} + +const ChatHistory: React.FC = props => { + const [search, setSearch] = useState(''); + const [editingId, setEditingId] = useState(null); + const [draftTitle, setDraftTitle] = useState(''); + + const startEditing = (conversation: Conversation) => { + setEditingId(conversation.id); + setDraftTitle(conversation.title); + }; + + const stopEditing = () => setEditingId(null); + + const onTitleKeyDown = (event: React.KeyboardEvent, conversation: Conversation) => { + if (event.nativeEvent.isComposing) return; + if (event.key === 'Escape') { + event.stopPropagation(); + stopEditing(); + return; + } + if (event.key !== 'Enter') return; + event.preventDefault(); + props.onRename(conversation, draftTitle); + stopEditing(); + }; + + const onPopupKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.key !== 'Escape') return; + event.stopPropagation(); + props.onClose(); + }; + + return
+
+