feat: add markdown rendering and thinking state UI to chat
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Bot } from '$lib/api';
|
||||
import type { ChatMessage } from '$lib/stores/chatStore';
|
||||
import { parseMarkdown } from '$lib/utils/markdown';
|
||||
|
||||
interface Props {
|
||||
bot: Bot | null;
|
||||
messages: ChatMessage[];
|
||||
isSending?: boolean;
|
||||
isThinking?: boolean;
|
||||
thinkingContent?: string;
|
||||
onSendMessage: (message: string) => void;
|
||||
onSelectBot?: (botId: string) => void;
|
||||
availableBots?: Bot[];
|
||||
@@ -15,7 +17,8 @@
|
||||
let {
|
||||
bot,
|
||||
messages,
|
||||
isSending = false,
|
||||
isThinking = false,
|
||||
thinkingContent = '',
|
||||
onSendMessage,
|
||||
onSelectBot,
|
||||
availableBots = [],
|
||||
@@ -24,11 +27,13 @@
|
||||
|
||||
let messageInput = $state('');
|
||||
let chatContainer: HTMLDivElement;
|
||||
let showThinking = $state(false);
|
||||
|
||||
function handleSend() {
|
||||
if (!messageInput.trim()) return;
|
||||
onSendMessage(messageInput);
|
||||
messageInput = '';
|
||||
showThinking = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -45,6 +50,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleThinking() {
|
||||
showThinking = !showThinking;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (messages.length && chatContainer) {
|
||||
setTimeout(() => {
|
||||
@@ -52,6 +61,17 @@
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for thinking state changes
|
||||
$effect(() => {
|
||||
if (isThinking && thinkingContent) {
|
||||
showThinking = true;
|
||||
}
|
||||
});
|
||||
|
||||
function renderContent(content: string) {
|
||||
return parseMarkdown(content);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-interface">
|
||||
@@ -69,7 +89,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="chat-messages" bind:this={chatContainer}>
|
||||
{#if messages.length === 0}
|
||||
{#if messages.length === 0 && !isThinking}
|
||||
<div class="welcome-message">
|
||||
<p>Welcome to {bot?.name || 'your bot'}! Describe your trading strategy in plain English.</p>
|
||||
<p class="hint">Example: "Buy PEPE when the price drops by 5% within 1 hour"</p>
|
||||
@@ -79,7 +99,27 @@
|
||||
{#each messages as message}
|
||||
<div class="message {message.role}">
|
||||
<div class="message-content">
|
||||
{message.content}
|
||||
{#each renderContent(message.content) as segment}
|
||||
{#if segment.type === 'bold'}
|
||||
<strong>{segment.content}</strong>
|
||||
{:else if segment.type === 'italic'}
|
||||
<em>{segment.content}</em>
|
||||
{:else if segment.type === 'code'}
|
||||
<code class="inline-code">{segment.content}</code>
|
||||
{:else if segment.type === 'codeBlock'}
|
||||
<pre class="code-block"><code>{segment.content}</code></pre>
|
||||
{:else if segment.type === 'link'}
|
||||
<a href={segment.content} target="_blank" rel="noopener noreferrer">{segment.content}</a>
|
||||
{:else if segment.type === 'list' && segment.items}
|
||||
<ul>
|
||||
{#each segment.items as item}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
{segment.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
@@ -87,12 +127,46 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if isSending}
|
||||
<div class="message assistant">
|
||||
<div class="message-content typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
{#if isThinking}
|
||||
<div class="message assistant thinking">
|
||||
<div class="message-content">
|
||||
{#if thinkingContent}
|
||||
<div class="thinking-header">
|
||||
<button class="thinking-toggle" onclick={toggleThinking}>
|
||||
<span class="thinking-icon">{showThinking ? '▼' : '▶'}</span>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if showThinking}
|
||||
<div class="thinking-content">
|
||||
{#each renderContent(thinkingContent) as segment}
|
||||
{#if segment.type === 'bold'}
|
||||
<strong>{segment.content}</strong>
|
||||
{:else if segment.type === 'italic'}
|
||||
<em>{segment.content}</em>
|
||||
{:else if segment.type === 'code'}
|
||||
<code class="inline-code">{segment.content}</code>
|
||||
{:else if segment.type === 'codeBlock'}
|
||||
<pre class="code-block"><code>{segment.content}</code></pre>
|
||||
{:else if segment.type === 'list' && segment.items}
|
||||
<ul>
|
||||
{#each segment.items as item}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
{segment.content}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="typing">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -105,9 +179,9 @@
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Describe your trading strategy..."
|
||||
rows="1"
|
||||
disabled={isSending}
|
||||
disabled={isThinking}
|
||||
></textarea>
|
||||
<button onclick={handleSend} disabled={isSending || !messageInput.trim()}>
|
||||
<button onclick={handleSend} disabled={isThinking || !messageInput.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
@@ -213,6 +287,11 @@
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.message.thinking .message-content {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
@@ -220,10 +299,92 @@
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.thinking-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
||||
92
src/frontend/src/lib/utils/markdown.ts
Normal file
92
src/frontend/src/lib/utils/markdown.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Simple markdown parser for rendering AI responses
|
||||
* Supports: bold, italic, code blocks, inline code, links, lists, headings
|
||||
*/
|
||||
|
||||
interface ParsedSegment {
|
||||
type: 'text' | 'bold' | 'italic' | 'code' | 'codeBlock' | 'link' | 'list';
|
||||
content: string;
|
||||
items?: string[];
|
||||
}
|
||||
|
||||
export function parseMarkdown(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Split by code blocks first (they can contain other markdown)
|
||||
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||
const parts = text.split(codeBlockRegex);
|
||||
const codeBlocks = text.match(codeBlockRegex) || [];
|
||||
|
||||
let partIndex = 0;
|
||||
|
||||
while (partIndex < parts.length) {
|
||||
const part = parts[partIndex];
|
||||
|
||||
if (part) {
|
||||
// Process inline formatting
|
||||
segments.push(...parseInlineMarkdown(part));
|
||||
}
|
||||
|
||||
// Add code block if there's one after this part
|
||||
if (partIndex < codeBlocks.length) {
|
||||
const codeContent = codeBlocks[partIndex].replace(/^```\w*\n?/, '').replace(/```$/, '');
|
||||
segments.push({ type: 'codeBlock', content: codeContent });
|
||||
}
|
||||
|
||||
partIndex++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function parseInlineMarkdown(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
|
||||
// Combined regex for bold, italic, inline code, links
|
||||
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\\\[.*?\]\(.*?\))/g;
|
||||
const parts = text.split(inlineRegex);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
segments.push({ type: 'bold', content: part.slice(2, -2) });
|
||||
} else if (part.startsWith('*') && part.endsWith('*')) {
|
||||
segments.push({ type: 'italic', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||
segments.push({ type: 'code', content: part.slice(1, -1) });
|
||||
} else if (part.startsWith('[') && part.includes('](')) {
|
||||
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
segments.push({ type: 'link', content: linkMatch[1] });
|
||||
}
|
||||
} else if (part.includes('\n')) {
|
||||
// Handle newlines and lists
|
||||
const lines = part.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.match(/^[\-\*]\s/)) {
|
||||
// List item
|
||||
if (segments.length > 0 && segments[segments.length - 1].type === 'list') {
|
||||
segments[segments.length - 1].items?.push(line.slice(2));
|
||||
} else {
|
||||
segments.push({ type: 'list', content: '', items: [line.slice(2)] });
|
||||
}
|
||||
} else if (line.match(/^#{1,6}\s/)) {
|
||||
// Heading
|
||||
segments.push({ type: 'text', content: line });
|
||||
} else if (line) {
|
||||
if (i > 0) {
|
||||
segments.push({ type: 'text', content: '\n' + line });
|
||||
} else {
|
||||
segments.push({ type: 'text', content: line });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
segments.push({ type: 'text', content: part });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -108,8 +108,8 @@
|
||||
<ChatInterface
|
||||
bot={$currentBotStore}
|
||||
messages={$chatStore}
|
||||
{isSending}
|
||||
onSendMessage={handleSendMessage}
|
||||
isThinking={isSending}
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user