diff --git a/public/chat/export_conversation_template.md b/public/chat/export_conversation_template.md new file mode 100644 index 0000000..00c2983 --- /dev/null +++ b/public/chat/export_conversation_template.md @@ -0,0 +1,7 @@ +# From: {role} + +{timestamp} + +{content} + +--- diff --git a/src/App.css b/src/App.css index c64ba48..8ce0d16 100644 --- a/src/App.css +++ b/src/App.css @@ -669,9 +669,9 @@ body { color: #e0e0e0; } -.export-dropdown .quant-dropdown { - width: 200px; - left: 0; +.chatbox-export-dropdown .quant-dropdown { + width: 250px; + left: -200px; } .key-signature-dropdown .quant-dropdown { @@ -1039,6 +1039,10 @@ body { flex-shrink: 0; } +.chatbox.is-hidden { + display: none; +} + /* Instrument Selection Panel */ .instrument-selection { display: flex; @@ -1197,6 +1201,25 @@ body { border-radius: 3px; } +/* ChatBox export button wrapper and dropdown positioning */ +.chatbox-export-wrapper { + position: relative; + display: inline-block; +} + +.chatbox-export-btn { + display: flex; + align-items: center; + gap: 4px; +} + +.chatbox-export-dropdown-anchor { + position: absolute; + top: 100%; + left: 0; + z-index: 10000; +} + .chatbox-header h3 { color: #e0e0e0; font-size: 12px; diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 20b4e2b..ef77ce4 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; -import { FaPlus, FaBan } from 'react-icons/fa'; +import { FaPlus, FaBan, FaDownload } from 'react-icons/fa'; import { UserMessage, AssistantMessage } from './chat'; import { AgentCore } from '../agent/core/AgentCore'; import { OpenAIProvider } from '../agent/llm/OpenAIProvider'; @@ -14,6 +14,10 @@ import { processUserMessage } from '../util/messageFilter/UserMessageFilter'; import { useStreamProcessor } from '../hooks/useStreamProcessor'; import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils'; import { extractActionableTools, executeAllTools } from '../utils/toolExecutionUtils'; +import { formatLocalDateTime } from '../util/timeUtil'; +import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil'; +import { wrapXmlBlocksInContent } from '../util/xmlUtil'; +import KGDropdown from './common/KGDropdown'; import type { ChatMessage } from '../types/projectTypes'; @@ -60,6 +64,65 @@ const ChatBox: React.FC = ({ isVisible }) => { // Track if this is the first message (for system prompt logging) const [isFirstMessage, setIsFirstMessage] = useState(true); + // Export dropdown state and options + const [showExportDropdown, setShowExportDropdown] = useState(false); + const exportOptions = [ + 'Export conversation as JSON', + 'Export conversation as Markdown' + ]; + + const handleExportOptionSelect = (option: string) => { + if (option === 'Export conversation as JSON') { + try { + const messages = AgentCore.instance().getAgentState().getMessages(); + const exportMessages = messages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + timestamp: formatLocalDateTime(new Date(m.timestamp)) + })); + const json = JSON.stringify(exportMessages, null, 2); + const filename = `kgstudio-conversation-${buildTimestampSuffix()}.json`; + downloadBlob(json, 'application/json', filename); + } catch (err) { + console.error('Failed to export conversation as JSON:', err); + } + } else if (option === 'Export conversation as Markdown') { + (async () => { + try { + const messages = AgentCore.instance().getAgentState().getMessages(); + const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`; + const res = await fetch(templateUrl); + const template = await res.text(); + + const isAutomatedUserMessage = (content: string): boolean => { + return /^tool:\s.*\nsuccess:\s*(true|false)/i.test(content); + }; + + const sections = messages.map((m) => { + const isAutomaticUserMessage = isAutomatedUserMessage(m.content); + const roleLabel = m.role === 'assistant' ? 'Assistant' : (isAutomaticUserMessage ? 'User (Automatic)' : 'User'); + const ts = formatLocalDateTime(new Date(m.timestamp)); + const contentWithXml = isAutomaticUserMessage ? "```\n" + m.content + "\n```" : wrapXmlBlocksInContent(m.content); + return template + .replace('{role}', roleLabel) + .replace('{timestamp}', ts) + .replace('{content}', contentWithXml); + }); + + const markdown = sections.join('\n'); + const filename = `kgstudio-conversation-${buildTimestampSuffix()}.md`; + downloadBlob(markdown, 'text/markdown', filename); + } catch (err) { + console.error('Failed to export conversation as Markdown:', err); + } + })(); + } else { + console.log('Chat export selected:', option); + } + setShowExportDropdown(false); + }; + // Message update callbacks for stream processor const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => { setMessages(prev => prev.map(msg => msg.id === messageId ? updater(msg) : msg)); @@ -323,7 +386,7 @@ const ChatBox: React.FC = ({ isVisible }) => { }, [isProcessing, isExecutingTools]); return ( -
+

K.G.Studio Musician Assistant

@@ -337,6 +400,28 @@ const ChatBox: React.FC = ({ isVisible }) => { )} +
+ +
+ +
+