added export conversation feature.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# From: {role}
|
||||
|
||||
{timestamp}
|
||||
|
||||
{content}
|
||||
|
||||
---
|
||||
+26
-3
@@ -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;
|
||||
|
||||
@@ -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<ChatBoxProps> = ({ 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<ChatBoxProps> = ({ isVisible }) => {
|
||||
}, [isProcessing, isExecutingTools]);
|
||||
|
||||
return (
|
||||
<div className="chatbox" style={{ display: isVisible ? 'flex' : 'none' }}>
|
||||
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
|
||||
<div className="chatbox-header">
|
||||
<h3>K.G.Studio Musician Assistant</h3>
|
||||
<div className="chatbox-actions">
|
||||
@@ -337,6 +400,28 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
<FaBan />
|
||||
</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
|
||||
type="button"
|
||||
title="New Chat"
|
||||
|
||||
+24
-1
@@ -66,4 +66,27 @@ export const generateNewRegionName = (trackId: string): string => {
|
||||
}
|
||||
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
@@ -87,4 +87,22 @@ export function beatsToTimeString(
|
||||
const mmm = milliseconds.toString().padStart(3, '0');
|
||||
|
||||
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
@@ -49,4 +49,21 @@ export function extractXMLFromString(input: string): string[] {
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user