added export conversation feature.

This commit is contained in:
Xiaohan-Tian
2025-08-21 21:56:22 -07:00
parent c1112f41d5
commit 0c54332401
6 changed files with 181 additions and 8 deletions
@@ -0,0 +1,7 @@
# From: {role}
{timestamp}
{content}
---
+26 -3
View File
@@ -669,9 +669,9 @@ body {
color: #e0e0e0; color: #e0e0e0;
} }
.export-dropdown .quant-dropdown { .chatbox-export-dropdown .quant-dropdown {
width: 200px; width: 250px;
left: 0; left: -200px;
} }
.key-signature-dropdown .quant-dropdown { .key-signature-dropdown .quant-dropdown {
@@ -1039,6 +1039,10 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.chatbox.is-hidden {
display: none;
}
/* Instrument Selection Panel */ /* Instrument Selection Panel */
.instrument-selection { .instrument-selection {
display: flex; display: flex;
@@ -1197,6 +1201,25 @@ body {
border-radius: 3px; 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 { .chatbox-header h3 {
color: #e0e0e0; color: #e0e0e0;
font-size: 12px; font-size: 12px;
+87 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; 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 { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore'; import { AgentCore } from '../agent/core/AgentCore';
import { OpenAIProvider } from '../agent/llm/OpenAIProvider'; import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
@@ -14,6 +14,10 @@ import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
import { useStreamProcessor } from '../hooks/useStreamProcessor'; import { useStreamProcessor } from '../hooks/useStreamProcessor';
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils'; import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
import { extractActionableTools, executeAllTools } from '../utils/toolExecutionUtils'; 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'; import type { ChatMessage } from '../types/projectTypes';
@@ -60,6 +64,65 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
// Track if this is the first message (for system prompt logging) // Track if this is the first message (for system prompt logging)
const [isFirstMessage, setIsFirstMessage] = useState(true); 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 // Message update callbacks for stream processor
const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => { const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
setMessages(prev => prev.map(msg => msg.id === messageId ? updater(msg) : msg)); setMessages(prev => prev.map(msg => msg.id === messageId ? updater(msg) : msg));
@@ -323,7 +386,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
}, [isProcessing, isExecutingTools]); }, [isProcessing, isExecutingTools]);
return ( return (
<div className="chatbox" style={{ display: isVisible ? 'flex' : 'none' }}> <div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
<div className="chatbox-header"> <div className="chatbox-header">
<h3>K.G.Studio Musician Assistant</h3> <h3>K.G.Studio Musician Assistant</h3>
<div className="chatbox-actions"> <div className="chatbox-actions">
@@ -337,6 +400,28 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<FaBan /> <FaBan />
</button> </button>
)} )}
<div className="chatbox-export-wrapper">
<button
type="button"
title="Export"
onClick={() => setShowExportDropdown(!showExportDropdown)}
className="chatbox-action-btn chatbox-export-btn"
>
<FaDownload />
</button>
<div className="chatbox-export-dropdown-anchor">
<KGDropdown
options={exportOptions}
value={exportOptions[0]}
onChange={handleExportOptionSelect}
label="Export"
hideButton={true}
isOpen={showExportDropdown}
onToggle={setShowExportDropdown}
className="chatbox-export-dropdown"
/>
</div>
</div>
<button <button
type="button" type="button"
title="New Chat" title="New Chat"
+24 -1
View File
@@ -66,4 +66,27 @@ export const generateNewRegionName = (trackId: string): string => {
} }
i++; i++;
} }
}; };
/** Create and trigger a download for a text/blob payload */
export function downloadBlob(content: BlobPart, mimeType: string, filename: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/** Pad number with leading zero to 2 digits */
export function pad2(n: number): string {
return n.toString().padStart(2, '0');
}
/** Build timestamped filename suffix YYYY-MM-DD-hh-mm-ss */
export function buildTimestampSuffix(date: Date = new Date()): string {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}-${pad2(date.getHours())}-${pad2(date.getMinutes())}-${pad2(date.getSeconds())}`;
}
+19 -1
View File
@@ -87,4 +87,22 @@ export function beatsToTimeString(
const mmm = milliseconds.toString().padStart(3, '0'); const mmm = milliseconds.toString().padStart(3, '0');
return `${BBB}:${B} | ${mm}:${ss}:${mmm}`; return `${BBB}:${B} | ${mm}:${ss}:${mmm}`;
} }
/**
* Format a Date object into a human-readable local datetime string with timezone.
* Example: 2025/08/21, 19:57:11 GMT-7
*/
export function formatLocalDateTime(date: Date): string {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZoneName: 'short'
};
return date.toLocaleString(undefined, options);
}
+18 -1
View File
@@ -49,4 +49,21 @@ export function extractXMLFromString(input: string): string[] {
} }
return matches; return matches;
} }
/**
* Wrap XML blocks in content with fenced code blocks
* @param content - The input string that may contain XML blocks
* @returns The input string with XML blocks wrapped in fenced code blocks
*/
export const wrapXmlBlocksInContent = (content: string): string => {
if (!content) return content;
const xmlBlocks = extractXMLFromString(content);
if (!xmlBlocks || xmlBlocks.length === 0) return content;
let result = content;
for (const block of xmlBlocks) {
const fenced = '```\n' + block + '\n```';
result = result.split(block).join(fenced);
}
return result;
};