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;
|
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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -67,3 +67,26 @@ 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())}`;
|
||||||
|
}
|
||||||
@@ -88,3 +88,21 @@ export function beatsToTimeString(
|
|||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -50,3 +50,20 @@ 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user